new rpg mode
This commit is contained in:
+431
-21
@@ -3,9 +3,37 @@ import { useGLTF } from "@react-three/drei";
|
||||
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||||
import {
|
||||
BLOCKBREAKER_BIOMES,
|
||||
blockbreakerBiomeForSeed,
|
||||
type BlockbreakerArenaBiome,
|
||||
type BlockbreakerBiomeFixture,
|
||||
} from "../game/blockbreakerBiomes";
|
||||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
||||
import {
|
||||
HOCKEY_ARENA_CENTER_Z,
|
||||
HOCKEY_ARENA_LENGTH,
|
||||
HOCKEY_ARENA_MAX_X,
|
||||
HOCKEY_ARENA_MAX_Z,
|
||||
HOCKEY_ARENA_MIN_X,
|
||||
HOCKEY_ARENA_MIN_Z,
|
||||
HOCKEY_ARENA_WIDTH,
|
||||
HOCKEY_GOAL_HALF_WIDTH,
|
||||
HOCKEY_HEALER_GOAL_Z,
|
||||
HOCKEY_MIDLINE_Z,
|
||||
HOCKEY_NPC_GOAL_Z,
|
||||
} from "../game/hockeyHealing";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||
import {
|
||||
HOCKEY_PVP_ARENA_MAX_X,
|
||||
HOCKEY_PVP_ARENA_MAX_Z,
|
||||
HOCKEY_PVP_ARENA_MIN_X,
|
||||
HOCKEY_PVP_ARENA_MIN_Z,
|
||||
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
||||
HOCKEY_PVP_GOAL_Z,
|
||||
} from "../game/hockeyHealingPvp";
|
||||
import { RpgRoomPortals } from "./rpgRoguelike/RpgRoomPortals";
|
||||
|
||||
const ROOM_CENTER_Z = ARENA_CENTER[1];
|
||||
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
||||
@@ -170,15 +198,15 @@ function LegacyDungeonAssetInstances({
|
||||
return <DungeonMeshInstances mesh={mesh} {...props} />;
|
||||
}
|
||||
|
||||
function arenaWalls(room: BossRoomDefinition) {
|
||||
return ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||
function arenaWalls(room: BossRoomDefinition, portalOpenings = false) {
|
||||
return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({
|
||||
...fixture,
|
||||
scaleY: room.wallHeight / 4,
|
||||
}));
|
||||
}
|
||||
|
||||
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
|
||||
return (
|
||||
<group>
|
||||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
@@ -196,9 +224,9 @@ function namedMesh(scene: THREE.Object3D, name: string) {
|
||||
throw new Error(`Dungeon kit is missing mesh ${name}.`);
|
||||
}
|
||||
|
||||
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
function DungeonKitArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
|
||||
const meshes = useMemo(() => ({
|
||||
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
|
||||
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
|
||||
@@ -232,9 +260,13 @@ class DungeonAssetErrorBoundary extends Component<{
|
||||
}
|
||||
}
|
||||
|
||||
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
function RoomWallFallback({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
const walls = useRef<THREE.Group>(null);
|
||||
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
||||
const fixtures = useMemo(
|
||||
() => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8),
|
||||
[portalOpenings],
|
||||
);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (!walls.current) return;
|
||||
@@ -252,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
|
||||
return (
|
||||
<group ref={walls}>
|
||||
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
|
||||
{fixtures.map((fixture, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
|
||||
@@ -267,28 +299,28 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return (
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<LegacyArenaArchitecture room={room} />
|
||||
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
|
||||
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return (
|
||||
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<DungeonKitArchitecture room={room} />
|
||||
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} portalOpenings={portalOpenings} />}>
|
||||
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
|
||||
<DungeonKitArchitecture room={room} portalOpenings={portalOpenings} />
|
||||
</Suspense>
|
||||
</DungeonAssetErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return LEGACY_GAME_ASSETS_FORCED
|
||||
? <LegacyRoomWalls room={room} />
|
||||
: <OptimizedRoomWalls room={room} />;
|
||||
? <LegacyRoomWalls room={room} portalOpenings={portalOpenings} />
|
||||
: <OptimizedRoomWalls room={room} portalOpenings={portalOpenings} />;
|
||||
}
|
||||
|
||||
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||
@@ -384,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RoomFloor({ room }: { room: BossRoomDefinition }) {
|
||||
function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
|
||||
@@ -401,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) {
|
||||
</mesh>
|
||||
<RoomMarks room={room} />
|
||||
<RoomScenery room={room} />
|
||||
<RoomWalls room={room} />
|
||||
<RoomWalls room={room} portalOpenings={portalOpenings} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function HockeyGoal({ z, color }: { z: number; color: string }) {
|
||||
const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2;
|
||||
return (
|
||||
<group position={[0, 0, z]}>
|
||||
<mesh position={[-HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
|
||||
<boxGeometry args={[0.18, 2.1, 0.24]} />
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
|
||||
</mesh>
|
||||
<mesh position={[HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
|
||||
<boxGeometry args={[0.18, 2.1, 0.24]} />
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
|
||||
</mesh>
|
||||
<mesh position={[0, 2.03, 0]} castShadow>
|
||||
<boxGeometry args={[goalWidth, 0.16, 0.22]} />
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.2} metalness={0.45} roughness={0.32} />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.05, z === HOCKEY_HEALER_GOAL_Z ? 0.08 : -0.08]}>
|
||||
<planeGeometry args={[goalWidth, 2]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.14} wireframe depthWrite={false} />
|
||||
</mesh>
|
||||
<pointLight color={color} intensity={2.4} distance={7} position={[0, 1.3, 0]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function HockeyHealingRoom() {
|
||||
const goalSideWidth = (HOCKEY_ARENA_WIDTH - HOCKEY_GOAL_HALF_WIDTH * 2) * 0.5;
|
||||
const leftGoalSideX = HOCKEY_ARENA_MIN_X + goalSideWidth * 0.5;
|
||||
const rightGoalSideX = HOCKEY_ARENA_MAX_X - goalSideWidth * 0.5;
|
||||
const wallMaterial = <meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />;
|
||||
return (
|
||||
<group>
|
||||
<color attach="background" args={["#020b11"]} />
|
||||
<fog attach="fog" args={["#061a24", 18, 46]} />
|
||||
<hemisphereLight args={["#80dfff", "#061015", 1.08]} />
|
||||
<directionalLight castShadow position={[6, 12, 8]} intensity={1.8} color="#d6f7ff" shadow-mapSize={[512, 512]} />
|
||||
<pointLight color="#50dfff" intensity={2.2} distance={14} position={[0, 4, HOCKEY_MIDLINE_Z]} />
|
||||
<mesh position={[0, -0.42, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
|
||||
<boxGeometry args={[HOCKEY_ARENA_WIDTH + 1.2, 0.8, HOCKEY_ARENA_LENGTH + 1.2]} />
|
||||
<meshStandardMaterial color="#061117" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.01, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
|
||||
<meshStandardMaterial color="#102731" roughness={0.68} metalness={0.3} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.012, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 0.5, 0.09]} />
|
||||
<meshBasicMaterial color="#64e7ff" transparent opacity={0.62} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.018, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[2.45, 2.55, 48]} />
|
||||
<meshBasicMaterial color="#64e7ff" transparent opacity={0.44} />
|
||||
</mesh>
|
||||
<mesh position={[HOCKEY_ARENA_MIN_X - 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
|
||||
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
|
||||
{wallMaterial}
|
||||
</mesh>
|
||||
<mesh position={[HOCKEY_ARENA_MAX_X + 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
|
||||
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
|
||||
{wallMaterial}
|
||||
</mesh>
|
||||
{[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => (
|
||||
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
|
||||
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
|
||||
<meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />
|
||||
</mesh>
|
||||
)))}
|
||||
<HockeyGoal z={HOCKEY_NPC_GOAL_Z} color="#ff6e5c" />
|
||||
<HockeyGoal z={HOCKEY_HEALER_GOAL_Z} color="#67e8ff" />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const BLOCKBREAKER_BIOME_FIXTURES = [
|
||||
[-12.75, -12.5, 0.94],
|
||||
[-12.75, -1, 1.08],
|
||||
[-12.75, 10.5, 0.9],
|
||||
[12.75, -12.5, 1.02],
|
||||
[12.75, -1, 0.88],
|
||||
[12.75, 10.5, 1.12],
|
||||
] as const;
|
||||
|
||||
function BlockbreakerFixtureGeometry({ fixture }: { fixture: BlockbreakerBiomeFixture }) {
|
||||
if (fixture === "crystal") return <octahedronGeometry args={[1.25, 0]} />;
|
||||
if (fixture === "forge") return <cylinderGeometry args={[0.72, 1.18, 2.8, 6]} />;
|
||||
if (fixture === "spire") return <coneGeometry args={[1.05, 3.7, 5]} />;
|
||||
if (fixture === "monolith") return <boxGeometry args={[1.25, 3.5, 1.25]} />;
|
||||
return <torusGeometry args={[1.08, 0.3, 8, 18]} />;
|
||||
}
|
||||
|
||||
function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) {
|
||||
const mesh = useRef<THREE.InstancedMesh>(null);
|
||||
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mesh.current) return;
|
||||
BLOCKBREAKER_BIOME_FIXTURES.forEach(([x, z, scale], index) => {
|
||||
transform.position.set(x, biome.fixture === "reactor" ? 1.45 : 1.75, z);
|
||||
transform.rotation.set(
|
||||
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? -0.08 : 0.08,
|
||||
index * 0.73,
|
||||
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? 0.06 : -0.06,
|
||||
);
|
||||
transform.scale.setScalar(scale);
|
||||
transform.updateMatrix();
|
||||
mesh.current!.setMatrixAt(index, transform.matrix);
|
||||
});
|
||||
mesh.current.instanceMatrix.needsUpdate = true;
|
||||
mesh.current.computeBoundingSphere();
|
||||
}, [biome.fixture, transform]);
|
||||
|
||||
return (
|
||||
<instancedMesh ref={mesh} args={[undefined, undefined, BLOCKBREAKER_BIOME_FIXTURES.length]} castShadow receiveShadow>
|
||||
<BlockbreakerFixtureGeometry fixture={biome.fixture} />
|
||||
<meshStandardMaterial
|
||||
color={biome.fixtureColor}
|
||||
emissive={biome.fixtureEmissive}
|
||||
emissiveIntensity={0.72}
|
||||
roughness={biome.fixture === "monolith" ? 0.26 : 0.48}
|
||||
metalness={biome.fixture === "forge" || biome.fixture === "reactor" ? 0.42 : 0.12}
|
||||
/>
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
|
||||
function BrightArcadeRoom({
|
||||
variant,
|
||||
biome = BLOCKBREAKER_BIOMES[0],
|
||||
}: {
|
||||
variant: "blockbreaker" | "aether-assault";
|
||||
biome?: BlockbreakerArenaBiome;
|
||||
}) {
|
||||
const roomWidth = HOCKEY_ARENA_WIDTH + 10;
|
||||
const roomLength = HOCKEY_ARENA_LENGTH + 12;
|
||||
const roomMinX = HOCKEY_ARENA_MIN_X - 5;
|
||||
const roomMaxX = HOCKEY_ARENA_MAX_X + 5;
|
||||
const roomMinZ = HOCKEY_ARENA_MIN_Z - 6;
|
||||
const roomMaxZ = HOCKEY_ARENA_MAX_Z + 6;
|
||||
const wallHeight = 4.8;
|
||||
const wallMaterial = (
|
||||
<meshStandardMaterial
|
||||
color={biome.wall}
|
||||
emissive={biome.wallEmissive}
|
||||
emissiveIntensity={biome.wallEmissiveIntensity}
|
||||
roughness={0.62}
|
||||
metalness={0.12}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<group key={variant === "blockbreaker" ? biome.id : variant}>
|
||||
<color attach="background" args={[biome.background]} />
|
||||
<fog attach="fog" args={[biome.fog, 34, 72]} />
|
||||
<ambientLight color={biome.ambient} intensity={biome.ambientIntensity} />
|
||||
<hemisphereLight args={[biome.sky, biome.ground, biome.hemisphereIntensity]} />
|
||||
<directionalLight
|
||||
castShadow
|
||||
position={[8, 15, 10]}
|
||||
intensity={biome.keyLightIntensity}
|
||||
color={biome.keyLight}
|
||||
shadow-mapSize={[512, 512]}
|
||||
/>
|
||||
<pointLight color={biome.fillLightA} intensity={biome.fillLightIntensityA} distance={30} position={[-8, 7, 7]} />
|
||||
<pointLight color={biome.fillLightB} intensity={biome.fillLightIntensityB} distance={30} position={[8, 7, -9]} />
|
||||
|
||||
<mesh position={[0, -0.48, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
|
||||
<boxGeometry args={[roomWidth + 1.2, 0.9, roomLength + 1.2]} />
|
||||
<meshStandardMaterial color={biome.foundation} roughness={0.92} metalness={0.04} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.015, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<planeGeometry args={[roomWidth, roomLength]} />
|
||||
<meshStandardMaterial color={biome.floor} roughness={biome.floorRoughness} metalness={biome.floorMetalness} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0.006, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
|
||||
<meshStandardMaterial color={biome.playfield} transparent opacity={0.72} roughness={0.52} metalness={0.05} />
|
||||
</mesh>
|
||||
{[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => (
|
||||
<mesh key={`lane-x-${x}`} position={[x, 0.025, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[0.1, HOCKEY_ARENA_LENGTH]} />
|
||||
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => (
|
||||
<mesh key={`lane-z-${z}`} position={[0, 0.025, z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.1]} />
|
||||
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{variant === "blockbreaker" ? (
|
||||
<mesh position={[0, 0.028, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.12]} />
|
||||
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
|
||||
</mesh>
|
||||
) : (
|
||||
<group>
|
||||
{[-6.7, -3.35, 0, 3.35, 6.7].map((x) => (
|
||||
<mesh key={`aether-lane-${x}`} position={[x, 0.029, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[0.055, HOCKEY_ARENA_LENGTH - 0.8]} />
|
||||
<meshBasicMaterial color="#c6fbff" transparent opacity={x === 0 ? 0.58 : 0.3} toneMapped={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{[-10.7, -8.45, -6.2, -3.95].map((z) => (
|
||||
<mesh key={`aether-rank-${z}`} position={[0, 0.03, z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 1, 0.045]} />
|
||||
<meshBasicMaterial color="#fff2bf" transparent opacity={0.36} toneMapped={false} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
)}
|
||||
|
||||
{[roomMinX - 0.18, roomMaxX + 0.18].map((x) => (
|
||||
<mesh key={`room-x-${x}`} position={[x, wallHeight * 0.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
|
||||
<boxGeometry args={[0.36, wallHeight, roomLength + 0.7]} />
|
||||
{wallMaterial}
|
||||
</mesh>
|
||||
))}
|
||||
{[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => (
|
||||
<mesh key={`room-z-${z}`} position={[0, wallHeight * 0.5, z]} castShadow receiveShadow>
|
||||
<boxGeometry args={[roomWidth + 0.7, wallHeight, 0.36]} />
|
||||
{wallMaterial}
|
||||
</mesh>
|
||||
))}
|
||||
{[roomMinX - 0.36, roomMaxX + 0.36].map((x) => (
|
||||
<mesh key={`light-x-${x}`} position={[x, 3.7, HOCKEY_ARENA_CENTER_Z]}>
|
||||
<boxGeometry args={[0.12, 0.18, roomLength - 1]} />
|
||||
<meshBasicMaterial color={biome.railA} toneMapped={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => (
|
||||
<mesh key={`light-z-${z}`} position={[0, 3.7, z]}>
|
||||
<boxGeometry args={[roomWidth - 1, 0.18, 0.12]} />
|
||||
<meshBasicMaterial color={biome.railB} toneMapped={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{variant === "blockbreaker" && <BlockbreakerBiomeFixtures biome={biome} />}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function HealingHockeyPvpRoom() {
|
||||
const width = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_ARENA_MIN_X;
|
||||
const length = HOCKEY_PVP_ARENA_MAX_Z - HOCKEY_PVP_ARENA_MIN_Z;
|
||||
const goalSideWidth = (width - HOCKEY_PVP_GOAL_HALF_WIDTH * 2) * 0.5;
|
||||
const sideCenters = [
|
||||
HOCKEY_PVP_ARENA_MIN_X + goalSideWidth * 0.5,
|
||||
HOCKEY_PVP_ARENA_MAX_X - goalSideWidth * 0.5,
|
||||
];
|
||||
return (
|
||||
<group>
|
||||
<color attach="background" args={["#040912"]} />
|
||||
<fog attach="fog" args={["#071522", 28, 68]} />
|
||||
<hemisphereLight args={["#8deaff", "#130812", 1.1]} />
|
||||
<directionalLight castShadow position={[7, 14, 10]} intensity={1.9} color="#d8fbff" shadow-mapSize={[512, 512]} />
|
||||
<pointLight color="#62e7ff" intensity={2.5} distance={18} position={[0, 4, 13]} />
|
||||
<pointLight color="#ff6f83" intensity={2.5} distance={18} position={[0, 4, -13]} />
|
||||
<mesh position={[0, -0.42, 0]} receiveShadow>
|
||||
<boxGeometry args={[width + 1.2, 0.8, length + 1.2]} />
|
||||
<meshStandardMaterial color="#070d17" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<planeGeometry args={[width, length]} />
|
||||
<meshStandardMaterial color="#112632" roughness={0.7} metalness={0.32} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[width - 0.5, 0.11]} />
|
||||
<meshBasicMaterial color="#f4d978" transparent opacity={0.7} />
|
||||
</mesh>
|
||||
{[-11, 11].map((z) => <mesh key={z} position={[0, 0.014, z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[width - 0.8, 0.07]} />
|
||||
<meshBasicMaterial color={z > 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} />
|
||||
</mesh>)}
|
||||
{[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => (
|
||||
<mesh key={x} position={[x, 1.5, 0]} castShadow receiveShadow>
|
||||
<boxGeometry args={[0.3, 3, length + 0.6]} />
|
||||
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
|
||||
</mesh>
|
||||
))}
|
||||
{[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => (
|
||||
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
|
||||
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
|
||||
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
|
||||
</mesh>
|
||||
)))}
|
||||
<HockeyGoal z={-HOCKEY_PVP_GOAL_Z} color="#ff647b" />
|
||||
<HockeyGoal z={HOCKEY_PVP_GOAL_Z} color="#62e7ff" />
|
||||
<HockeyPvpScoreboards />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function HockeyPvpScoreboards() {
|
||||
const localGoals = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
|
||||
const opponentGoals = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
|
||||
const texture = useMemo(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 512;
|
||||
canvas.height = 192;
|
||||
const next = new THREE.CanvasTexture(canvas);
|
||||
next.colorSpace = THREE.SRGBColorSpace;
|
||||
next.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
next.magFilter = THREE.LinearFilter;
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = texture.image as HTMLCanvasElement;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height);
|
||||
gradient.addColorStop(0, "#061d28");
|
||||
gradient.addColorStop(0.5, "#05080d");
|
||||
gradient.addColorStop(1, "#2b0a17");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.strokeStyle = "#89efff";
|
||||
context.lineWidth = 5;
|
||||
context.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
|
||||
context.fillStyle = "#a8c4c9";
|
||||
context.font = "700 20px Inter, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.fillText("HEALING HOCKEY", canvas.width / 2, 31);
|
||||
context.fillStyle = "#74ecff";
|
||||
context.font = "700 18px Inter, sans-serif";
|
||||
context.fillText("YOU", 132, 58);
|
||||
context.fillStyle = "#ff819f";
|
||||
context.fillText("RIVAL", 380, 58);
|
||||
context.font = "700 94px Impact, Inter, sans-serif";
|
||||
context.fillStyle = "#eaffff";
|
||||
context.fillText(String(Math.min(99, localGoals)).padStart(2, "0"), 132, 151);
|
||||
context.fillStyle = "#ffedf4";
|
||||
context.fillText(String(Math.min(99, opponentGoals)).padStart(2, "0"), 380, 151);
|
||||
context.fillStyle = "#f3d87c";
|
||||
context.font = "700 56px Inter, sans-serif";
|
||||
context.fillText("–", 256, 137);
|
||||
texture.needsUpdate = true;
|
||||
}, [localGoals, opponentGoals, texture]);
|
||||
|
||||
useEffect(() => () => texture.dispose(), [texture]);
|
||||
|
||||
return <>{([-1, 1] as const).map((side) => (
|
||||
<group
|
||||
key={side}
|
||||
position={[side * 9.78, 4.05, 5.5]}
|
||||
rotation={[0, side < 0 ? Math.PI / 2 : -Math.PI / 2, 0]}
|
||||
>
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[7.6, 3.25, 0.24]} />
|
||||
<meshStandardMaterial color="#101923" roughness={0.32} metalness={0.72} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0.126]}>
|
||||
<planeGeometry args={[7.28, 2.93]} />
|
||||
<meshBasicMaterial map={texture} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}</>;
|
||||
}
|
||||
|
||||
/** Main-display room projection. Gameplay stays in the shared arena domain. */
|
||||
export function BossRoom() {
|
||||
const bossId = useGameStore((state) => state.boss.id);
|
||||
const blockbreakerSeed = useGameStore((state) => state.blockbreaker.seed);
|
||||
const hockeyMode = useGameStore((state) => state.activityMode === "hockey-healing");
|
||||
const blockbreakerMode = useGameStore((state) => state.activityMode === "blockbreaker");
|
||||
const aetherAssaultMode = useGameStore((state) => state.activityMode === "aether-assault");
|
||||
const hockeyPvpMode = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
|
||||
const rpgPhase = useGameStore((state) => state.rpgRun?.phase ?? null);
|
||||
const rpgBossRoom = useGameStore((state) => state.runMode === "rpg-roguelike" && state.activityMode === "boss");
|
||||
if (hockeyPvpMode) return <HealingHockeyPvpRoom />;
|
||||
if (aetherAssaultMode) return <BrightArcadeRoom variant="aether-assault" />;
|
||||
if (blockbreakerMode) return <BrightArcadeRoom variant="blockbreaker" biome={blockbreakerBiomeForSeed(blockbreakerSeed)} />;
|
||||
if (hockeyMode) return <HockeyHealingRoom />;
|
||||
const room = bossRoomFor(bossId);
|
||||
return (
|
||||
<group key={room.id}>
|
||||
@@ -418,7 +820,15 @@ export function BossRoom() {
|
||||
<directionalLight castShadow position={[5, 10, 8]} intensity={2.1} color={room.accentSecondary} shadow-mapSize={[512, 512]} />
|
||||
<pointLight color={room.accent} intensity={2.35} distance={8} position={[-6, 2.8, ROOM_CENTER_Z]} />
|
||||
<pointLight color={room.accentSecondary} intensity={1.75} distance={7} position={[6, 2.4, ROOM_CENTER_Z - 1]} />
|
||||
<RoomFloor room={room} />
|
||||
<RoomFloor room={room} portalOpenings={rpgBossRoom} />
|
||||
{rpgBossRoom && rpgPhase && (
|
||||
<RpgRoomPortals
|
||||
entryOpen
|
||||
exitOpen={rpgPhase === "boss-cleared" || rpgPhase === "reward"}
|
||||
accent={room.accent}
|
||||
wallColor={room.wallColor}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
+379
-37
@@ -1,12 +1,38 @@
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { HEALER_CLASSES } from "../game/healers";
|
||||
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||
import type { BottomTab, PartyMember } from "../game/types";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||
import {
|
||||
HOCKEY_ARENA_CENTER_Z,
|
||||
HOCKEY_GOAL_HALF_WIDTH,
|
||||
HOCKEY_HEALER_GOAL_Z,
|
||||
HOCKEY_NPC_PADDLE_HALF_WIDTH,
|
||||
HOCKEY_NPC_PADDLE_Z,
|
||||
HOCKEY_NPC_GOAL_Z,
|
||||
} from "../game/hockeyHealing";
|
||||
import {
|
||||
HOCKEY_PVP_GOAL_DAMAGE,
|
||||
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
||||
HOCKEY_PVP_GOAL_Z,
|
||||
HOCKEY_PVP_SIDE_OFFSET_Z,
|
||||
} from "../game/hockeyHealingPvp";
|
||||
import { bottomTabsFor } from "../game/bottomTabs";
|
||||
import {
|
||||
BLOCKBREAKER_BREACH_DAMAGE,
|
||||
BLOCKBREAKER_BRICK_COLORS,
|
||||
BLOCKBREAKER_DANGER_Z,
|
||||
blockbreakerColumnX,
|
||||
blockbreakerRowZ,
|
||||
blockbreakerTimeMultiplier,
|
||||
} from "../game/blockbreaker";
|
||||
import { aetherShipColor } from "./aetherAssaultVisuals";
|
||||
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
|
||||
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
|
||||
|
||||
function RewardSummary() {
|
||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||
@@ -30,14 +56,15 @@ function PartyFrame({ member }: { member: PartyMember }) {
|
||||
const selected = useGameStore((state) => state.selectedMemberId === member.id);
|
||||
const selectMember = useGameStore((state) => state.selectMember);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const renewRemaining = Math.max(0, member.renewExpiresAt - time);
|
||||
const activeHealingEffects = member.healingEffects.filter((effect) => effect.expiresAt > time).slice(0, 3);
|
||||
const knockedRemaining = Math.max(0, member.knockedUntil - time);
|
||||
const barrier = useGameStore((state) => state.barrier);
|
||||
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
|
||||
const tankPosition = useGameStore((state) => state.partyPositions.brann);
|
||||
const tankPosition = useGameStore((state) => state.partyPositions[state.partyCombat.tankAura.sourceId]);
|
||||
const combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]);
|
||||
const position = useGameStore((state) => state.partyPositions[member.id]);
|
||||
const protectedByBarrier = barrierProtects(position, barrier, time);
|
||||
const linkedBySpirit = barrier.kind === "spirit-link" && healerFieldContains(position, barrier, time);
|
||||
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
|
||||
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
|
||||
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
|
||||
@@ -56,9 +83,14 @@ function PartyFrame({ member }: { member: PartyMember }) {
|
||||
</span>
|
||||
<span className="effect-stack">
|
||||
{member.absorb > 0 && <i className="effect shield-effect" title={`${Math.ceil(member.absorb)} absorption`}>◇</i>}
|
||||
{renewRemaining > 0 && <i className="effect renew-effect" title={`Renew: ${renewRemaining.toFixed(1)} seconds`}>{Math.ceil(renewRemaining)}</i>}
|
||||
{activeHealingEffects.map((effect) => {
|
||||
const label = effect.id === "renew" ? "R" : effect.id === "regrowth" ? "G" : effect.id === "rejuvenation" ? "J" : effect.id === "lifebloom" ? `L${effect.stacks}` : effect.id === "wild-growth" ? "W" : "T";
|
||||
return <i key={effect.id} className="effect renew-effect" title={`${effect.id}: ${Math.max(0, effect.expiresAt - time).toFixed(1)} seconds`}>{label}</i>;
|
||||
})}
|
||||
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <i className="effect earth-shield-effect" title={`Earth Shield: ${member.reactiveHeal.charges} charges`}>{member.reactiveHeal.charges}</i>}
|
||||
{member.debuffs.length > 0 && <i className="effect debuff-effect" title={`${member.debuffs[0].name} — Purify`}>!</i>}
|
||||
{protectedByBarrier && <i className="effect barrier-effect" title="Barrier: 30% reduced damage">B</i>}
|
||||
{linkedBySpirit && <i className="effect spirit-link-effect" title="Spirit Link: health equalized each second">S</i>}
|
||||
{protectedByTank && <i className="effect tank-aura-effect" title="Bulwark March: 30% reduced damage">T</i>}
|
||||
{knockedRemaining > 0 && <i className="effect knock-effect" title={`Knocked down: ${knockedRemaining.toFixed(1)} seconds`}>KD</i>}
|
||||
</span>
|
||||
@@ -78,7 +110,7 @@ function PartyList() {
|
||||
|
||||
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const ability = HEALER_CLASSES[healerClassId].abilities[abilityId];
|
||||
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
|
||||
const time = useGameStore((state) => state.time);
|
||||
const cooldowns = useGameStore((state) => state.cooldowns);
|
||||
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
||||
@@ -89,15 +121,21 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
|
||||
const activeCast = useGameStore((state) => state.activeCast);
|
||||
const castAbility = useGameStore((state) => state.castAbility);
|
||||
const runModifiers = useGameStore((state) => state.runModifiers);
|
||||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||
if (!ability) {
|
||||
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon">—</span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
|
||||
}
|
||||
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
||||
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
|
||||
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
|
||||
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
|
||||
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
|
||||
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
|
||||
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
||||
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
|
||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
||||
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
|
||||
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
|
||||
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -108,10 +146,10 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
|
||||
title={ability.description}
|
||||
aria-label={`${ability.name}. ${ability.description}`}
|
||||
>
|
||||
<span className="ability-key">{ability.key}</span>
|
||||
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
|
||||
<span className="ability-icon">{ability.icon}</span>
|
||||
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
|
||||
<span className="ability-pad">{ability.gamepad}</span>
|
||||
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
|
||||
{remaining > 0 && (
|
||||
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
|
||||
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
||||
@@ -131,17 +169,37 @@ function AbilityTray() {
|
||||
const healer = HEALER_CLASSES[healerClassId];
|
||||
const mana = useGameStore((state) => state.mana);
|
||||
const maxMana = useGameStore((state) => state.maxMana);
|
||||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const boss = useGameStore((state) => state.boss);
|
||||
const bossMotion = useGameStore((state) => state.bossMotion);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const hockeyReturns = useGameStore((state) => state.hockey.returns);
|
||||
const bossKills = useGameStore((state) => state.endlessBossKills);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const hockeyMode = activityMode === "hockey-healing";
|
||||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||||
const blockbreakerMode = activityMode === "blockbreaker";
|
||||
const aetherMode = activityMode === "aether-assault";
|
||||
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
||||
const nextRowSeconds = Math.max(0, blockbreaker.nextRowAt - time);
|
||||
return (
|
||||
<div className="ability-column">
|
||||
<div className="ability-meta">
|
||||
<div className={`ability-meta ${hockeyMode || pvpMode || blockbreakerMode || aetherMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""}`}>
|
||||
<div className="target-chip"><span>Target</span><strong>{selected.name}</strong></div>
|
||||
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
|
||||
<div className="resource-stack">
|
||||
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
|
||||
{healer.secondaryResourceName && <div className="class-resource"><span>{healer.secondaryResourceName}</span><b>{healerMechanic.resource} / {healerMechanic.maxResource}</b></div>}
|
||||
</div>
|
||||
{hockeyMode && <div className="hockey-run-meta"><span>Returns</span><strong>{hockeyReturns}</strong><small>{duration} · {bossKills} KOs</small></div>}
|
||||
{pvpMode && <div className="hockey-run-meta pvp-run-meta"><span>Goals · Bosses</span><strong>{hockeyPvp.opponentGoalsConceded}–{hockeyPvp.localGoalsConceded}</strong><small>{bossKills}–{hockeyPvp.opponentBossKills} · {hockeyPvp.opponentName}</small></div>}
|
||||
{blockbreakerMode && <div className="hockey-run-meta blockbreaker-run-meta"><span>Score · Bricks</span><strong>{blockbreaker.score} · {blockbreaker.bricksBroken}</strong><small>{blockbreakerTimeMultiplier(time).toFixed(1)}× · next row {nextRowSeconds.toFixed(1)}s</small></div>}
|
||||
{aetherMode && <div className="hockey-run-meta aether-run-meta"><span>Score · Wave</span><strong>{aetherAssault.score} · {aetherAssault.wave}</strong><small>{aetherAssault.multiplier.toFixed(2)}× · {aetherAssault.ships.length} ships</small></div>}
|
||||
</div>
|
||||
<div className="ability-grid">
|
||||
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
|
||||
@@ -165,14 +223,20 @@ function BriefingPanel() {
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const hockeyMode = activityMode === "hockey-healing";
|
||||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||||
const blockbreakerMode = activityMode === "blockbreaker";
|
||||
const aetherMode = activityMode === "aether-assault";
|
||||
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||
return (
|
||||
<div className="briefing-panel">
|
||||
<div className="briefing-class">
|
||||
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
|
||||
<span>Chosen discipline</span>
|
||||
<h2>{healer.specialization}</h2>
|
||||
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
|
||||
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
|
||||
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
|
||||
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
|
||||
</div>
|
||||
<div className="briefing-kit">
|
||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||
@@ -195,6 +259,7 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const hunter = useActiveHunter();
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const round = useGameStore((state) => state.round);
|
||||
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||
@@ -207,38 +272,44 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||
const startEncounter = useGameStore((state) => state.startEncounter);
|
||||
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
|
||||
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||
const hockey = useGameStore((state) => state.hockey);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||
const hockeyDefeat = phase === "defeat" && activityMode === "hockey-healing";
|
||||
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
|
||||
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
|
||||
const pvpMatch = activityMode === "hockey-healing-pvp";
|
||||
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat;
|
||||
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||
return (
|
||||
<div className={`end-panel end-${phase}`}>
|
||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
||||
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
||||
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
|
||||
<h2>{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
|
||||
<div className="result-stats">
|
||||
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
|
||||
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
|
||||
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
||||
<span><small>{hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : "Party vitality"}</small><strong>{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded}` : `${Math.round((totalHp / totalMax) * 100)}%`}</strong></span>
|
||||
<span><small>{hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}–${hockeyPvp.opponentBossKills}` : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
|
||||
</div>
|
||||
{phase === "victory" && <RewardSummary />}
|
||||
{(phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />}
|
||||
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
|
||||
<button
|
||||
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
|
||||
onFocus={() => setEndlessChoiceSelection("continue")}
|
||||
className={endlessChoiceSelection === "continue" ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => setEndlessChoiceSelection("continue")}
|
||||
onClick={startRogueTrialsEndless}
|
||||
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
|
||||
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
|
||||
<button
|
||||
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
|
||||
onFocus={() => setEndlessChoiceSelection("quit")}
|
||||
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-selected" : ""}`}
|
||||
onPointerEnter={() => setEndlessChoiceSelection("quit")}
|
||||
onClick={onExit}
|
||||
>Quit to Main Menu</button>
|
||||
</div> : <div className="end-actions">
|
||||
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
|
||||
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
|
||||
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
|
||||
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -275,10 +346,182 @@ function MapPanel() {
|
||||
const time = useGameStore((state) => state.time);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const bossId = useGameStore((state) => state.bossId);
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const hockey = useGameStore((state) => state.hockey);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const hockeyPvpOpponent = useGameStore((state) => state.hockeyPvpOpponent);
|
||||
const bossDefinition = BOSS_DEFINITIONS[bossId];
|
||||
const playerX = 120 + playerPosition[0] * 7;
|
||||
const playerY = 143 + playerPosition[1] * 5.3;
|
||||
const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)];
|
||||
if (activityMode === "aether-assault") {
|
||||
const mapX = (x: number) => 120 + x * 8.5;
|
||||
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
|
||||
return (
|
||||
<div className="map-panel hockey-map-panel aether-map-panel">
|
||||
<div className="map-copy">
|
||||
<span>Aether Assault</span>
|
||||
<h2>Arcane Formation Runway</h2>
|
||||
<p>Focus fire stays automatic. Move anywhere in the rink, heal freely, and evade red volleys plus amber dive warnings.</p>
|
||||
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-ship" /> Ships <i className="legend-shot" /> Shots</div>
|
||||
</div>
|
||||
<div className="map-canvas">
|
||||
<svg viewBox="0 0 240 280" role="img" aria-label="Aether Assault tactical map">
|
||||
<defs><linearGradient id="aether-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#18394a" /><stop offset="0.55" stopColor="#d9f3ef" /><stop offset="1" stopColor="#dff8f4" /></linearGradient></defs>
|
||||
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#aether-room)" />
|
||||
<path className="map-ring aether-map-lanes" d="M63 20 V260 M91 20 V260 M120 20 V260 M149 20 V260 M177 20 V260" />
|
||||
{aetherAssault.ships.map((ship, index) => {
|
||||
const color = aetherShipColor(aetherAssault.seed, aetherAssault.wave, index, ship.kind);
|
||||
return <g key={ship.id}>
|
||||
{ship.phase === "diving" && <circle className="aether-map-warning" cx={mapX(ship.position[0])} cy={mapY(ship.position[1])} r="8" />}
|
||||
<path className={`aether-map-ship is-${ship.kind}`} style={{ fill: color, filter: `drop-shadow(0 0 4px ${color})` }} d={`M${mapX(ship.position[0])} ${mapY(ship.position[1]) - 5} l6 9 h-12 z`} />
|
||||
</g>;
|
||||
})}
|
||||
{aetherAssault.playerShots.map((shot) => <circle key={`player-shot-${shot.id}`} className="aether-map-player-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2" />)}
|
||||
{aetherAssault.enemyShots.map((shot) => <circle key={`enemy-shot-${shot.id}`} className="aether-map-enemy-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2.5" />)}
|
||||
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
|
||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
|
||||
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="11" />
|
||||
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
|
||||
{barrier.expiresAt > time && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
|
||||
</svg>
|
||||
<span className="map-state">{phase === "combat" ? `WAVE ${aetherAssault.wave} · ${aetherAssault.score} SCORE` : "FORMATION PREVIEW"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activityMode === "blockbreaker") {
|
||||
const mapX = (x: number) => 120 + x * 8.5;
|
||||
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
|
||||
const colors: Record<(typeof BLOCKBREAKER_BRICK_COLORS)[number], string> = {
|
||||
cyan: "#36d9ef",
|
||||
amber: "#f1b74f",
|
||||
magenta: "#e85aa9",
|
||||
lime: "#93db54",
|
||||
};
|
||||
return (
|
||||
<div className="map-panel hockey-map-panel blockbreaker-map-panel">
|
||||
<div className="map-copy">
|
||||
<span>Blockbreaker</span>
|
||||
<h2>Advancing Color Wall</h2>
|
||||
<p>Match orthogonal colors. Crossing bricks disappear and deal {BLOCKBREAKER_BREACH_DAMAGE} partywide damage.</p>
|
||||
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-brick" /> Bricks <i className="legend-exit" /> Puck</div>
|
||||
</div>
|
||||
<div className="map-canvas">
|
||||
<svg viewBox="0 0 240 280" role="img" aria-label="Blockbreaker tactical map">
|
||||
<defs><linearGradient id="blockbreaker-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient></defs>
|
||||
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#blockbreaker-room)" />
|
||||
<path className="map-ring" d="M35 140 H205" />
|
||||
<path className="blockbreaker-map-danger" d={`M35 ${mapY(BLOCKBREAKER_DANGER_Z)} H205`} />
|
||||
{blockbreaker.bricks.map((brick) => (
|
||||
<rect
|
||||
className="blockbreaker-map-brick"
|
||||
key={brick.id}
|
||||
x={mapX(blockbreakerColumnX(brick.column)) - 12.5}
|
||||
y={mapY(blockbreakerRowZ(brick.row)) - 5}
|
||||
width="25"
|
||||
height="10"
|
||||
rx="2"
|
||||
fill={colors[brick.color]}
|
||||
/>
|
||||
))}
|
||||
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
|
||||
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
|
||||
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
|
||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
|
||||
<circle className="hockey-map-puck" cx={mapX(blockbreaker.puckPosition[0])} cy={mapY(blockbreaker.puckPosition[1])} r="5" />
|
||||
{barrier.expiresAt > time && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
|
||||
</svg>
|
||||
<span className="map-state">{phase === "combat" ? `${blockbreaker.bricks.length} BRICKS · ${blockbreaker.score} SCORE` : "WALL PREVIEW"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activityMode === "hockey-healing-pvp") {
|
||||
const mapX = (x: number) => 120 + x * 8.2;
|
||||
const mapY = (z: number) => 140 + z * 4.85;
|
||||
const localWorldZ = (z: number) => z + HOCKEY_PVP_SIDE_OFFSET_Z;
|
||||
const opponentWorldX = (x: number) => -x;
|
||||
const opponentWorldZ = (z: number) => -z - HOCKEY_PVP_SIDE_OFFSET_Z;
|
||||
return (
|
||||
<div className="map-panel hockey-map-panel pvp-map-panel">
|
||||
<div className="map-copy">
|
||||
<span>Healing Hockey PVP</span>
|
||||
<h2>You vs {hockeyPvp.opponentName}</h2>
|
||||
<p>Matching boss order. Each goal hits all five allies for {HOCKEY_PVP_GOAL_DAMAGE} damage.</p>
|
||||
<div className="map-legend"><i className="legend-party" /> Your party <i className="legend-boss" /> Bosses <i className="legend-opponent" /> Rival <i className="legend-exit" /> Puck</div>
|
||||
</div>
|
||||
<div className="map-canvas">
|
||||
<svg viewBox="0 0 240 280" role="img" aria-label="Healing Hockey PVP tactical map">
|
||||
<defs><linearGradient id="pvp-hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#361819" /><stop offset="0.5" stopColor="#15262b" /><stop offset="1" stopColor="#0a3040" /></linearGradient></defs>
|
||||
<rect className="map-room hockey-map-room" x="35" y="14" width="170" height="252" rx="3" fill="url(#pvp-hockey-room)" />
|
||||
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
|
||||
<path className="hockey-map-goal is-npc" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(-HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
|
||||
<path className="hockey-map-goal is-healer" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
|
||||
<circle className="map-boss" cx={mapX(bossMotion.position[0])} cy={mapY(localWorldZ(bossMotion.position[1]))} r="7" />
|
||||
<circle className="map-boss is-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.bossMotion.position[0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.bossMotion.position[1]))} r="7" />
|
||||
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="10" />
|
||||
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="5" />
|
||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(localWorldZ(partyPositions[memberId][1]))} r="3.5" />)}
|
||||
{(["aelia", "brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={`opponent-${memberId}`} className="map-ally map-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.partyPositions[memberId][0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.partyPositions[memberId][1]))} r="3.5" />)}
|
||||
<circle className="hockey-map-puck" cx={mapX(hockeyPvp.puckPosition[0])} cy={mapY(hockeyPvp.puckPosition[1])} r="5" />
|
||||
</svg>
|
||||
<span className="map-state">{phase === "combat" ? `GOALS ${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded} · LIVE` : "VERSUS RINK"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activityMode === "hockey-healing") {
|
||||
const mapX = (x: number) => 120 + x * 8.5;
|
||||
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
|
||||
const npcGoalLeft = mapX(-HOCKEY_GOAL_HALF_WIDTH);
|
||||
const npcGoalRight = mapX(HOCKEY_GOAL_HALF_WIDTH);
|
||||
return (
|
||||
<div className="map-panel hockey-map-panel">
|
||||
<div className="map-copy">
|
||||
<span>Hockey Healing</span>
|
||||
<h2>Rectangular Boss Rink</h2>
|
||||
<p>Party fights enemy half. Moving Pong paddle tracks each return and strikes it back toward healer.</p>
|
||||
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Boss <i className="legend-paddle" /> Paddle <i className="legend-exit" /> Puck</div>
|
||||
</div>
|
||||
<div className="map-canvas">
|
||||
<svg viewBox="0 0 240 280" role="img" aria-label="Hockey Healing tactical map">
|
||||
<defs>
|
||||
<linearGradient id="hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient>
|
||||
</defs>
|
||||
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#hockey-room)" />
|
||||
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
|
||||
<path className="hockey-map-goal is-npc" d={`M${npcGoalLeft} ${mapY(HOCKEY_NPC_GOAL_Z)} H${npcGoalRight}`} />
|
||||
<path className="hockey-map-goal is-healer" d={`M${npcGoalLeft} ${mapY(HOCKEY_HEALER_GOAL_Z)} H${npcGoalRight}`} />
|
||||
<rect
|
||||
className="hockey-map-paddle"
|
||||
x={mapX(hockey.paddleX - HOCKEY_NPC_PADDLE_HALF_WIDTH)}
|
||||
y={mapY(HOCKEY_NPC_PADDLE_Z) - 4}
|
||||
width={HOCKEY_NPC_PADDLE_HALF_WIDTH * 2 * 8.5}
|
||||
height="8"
|
||||
rx="3"
|
||||
/>
|
||||
{bossMotions.map((motion, index) => (
|
||||
<g key={`${motion.bossId}-${index}`}>
|
||||
<circle className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="8" />
|
||||
<path className="map-boss-arrow" d={`M${mapX(motion.position[0])} ${mapY(motion.position[1]) - 14} l6 9 h-12 z`} />
|
||||
</g>
|
||||
))}
|
||||
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
|
||||
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
|
||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
|
||||
<circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />
|
||||
))}
|
||||
<circle className="hockey-map-puck" cx={mapX(hockey.puckPosition[0])} cy={mapY(hockey.puckPosition[1])} r="5" />
|
||||
{barrier.expiresAt > time && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
|
||||
</svg>
|
||||
<span className="map-state">{phase === "combat" ? `${hockey.returns} RETURNS · LIVE` : "RINK PREVIEW"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="map-panel">
|
||||
<div className="map-copy">
|
||||
@@ -362,33 +605,132 @@ function PackPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
|
||||
{ id: "combat", label: "Heal", icon: "✦", key: "" },
|
||||
{ id: "map", label: "Map", icon: "⌁", key: "M" },
|
||||
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
|
||||
];
|
||||
function PvpPanel() {
|
||||
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||
const opponentParty = useGameStore((state) => state.hockeyPvpOpponent.party);
|
||||
const opponentGoalsConceded = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
|
||||
const localGoalsConceded = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
|
||||
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
|
||||
const living = opponentParty.filter((member) => member.hp > 0).length;
|
||||
const currentHealth = opponentParty.reduce((total, member) => total + Math.max(0, member.hp), 0);
|
||||
const maximumHealth = opponentParty.reduce((total, member) => total + member.maxHp, 0);
|
||||
|
||||
return (
|
||||
<div className="pvp-panel">
|
||||
<header className="pvp-roster-header">
|
||||
<span><small>Opponent party</small><strong>{opponentName}</strong></span>
|
||||
<div><small>Goals</small><strong>{opponentGoalsConceded}–{localGoalsConceded}</strong></div>
|
||||
<div><small>Boss KOs</small><strong>{opponentBossKills}</strong></div>
|
||||
<div><small>Standing</small><strong>{living} / {opponentParty.length}</strong></div>
|
||||
</header>
|
||||
<div className="pvp-party-list" aria-label={`${opponentName} party health`}>
|
||||
<div className="section-label"><span>Rival health feed</span><small>{Math.ceil(currentHealth)} / {maximumHealth} total</small></div>
|
||||
{opponentParty.map((member) => (
|
||||
<article className={`pvp-party-frame ${member.hp <= 0 ? "is-down" : ""}`} key={member.id}>
|
||||
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
|
||||
<span className="party-data">
|
||||
<span className="party-name"><strong>{member.name}</strong><em>{Math.ceil(member.hp)} / {member.maxHp}</em></span>
|
||||
<HealthBar member={member} />
|
||||
<small>{member.className}</small>
|
||||
</span>
|
||||
<b>{member.hp <= 0 ? "DOWN" : `${Math.ceil((member.hp / member.maxHp) * 100)}%`}</b>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabPresentation: Record<BottomTab, { label: string; icon: string; key: string }> = {
|
||||
combat: { label: "Heal", icon: "✦", key: "" },
|
||||
map: { label: "Map", icon: "⌁", key: "M" },
|
||||
pack: { label: "Pack", icon: "▧", key: "I" },
|
||||
pvp: { label: "PVP", icon: "⚔", key: "P" },
|
||||
};
|
||||
|
||||
function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
|
||||
readonly run: NonNullable<ReturnType<typeof useGameStore.getState>["rpgRun"]>;
|
||||
readonly focusedId: string | null;
|
||||
readonly paused: boolean;
|
||||
readonly onExit?: () => void;
|
||||
}) {
|
||||
const party = useGameStore((state) => state.party);
|
||||
const selectedMemberId = useGameStore((state) => state.selectedMemberId);
|
||||
const mana = useGameStore((state) => state.mana);
|
||||
const maxMana = useGameStore((state) => state.maxMana);
|
||||
const cooldowns = useGameStore((state) => state.cooldowns);
|
||||
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const activeCast = useGameStore((state) => state.activeCast);
|
||||
const spellResources = useGameStore((state) => state.rpgSpellResources);
|
||||
const selectMember = useGameStore((state) => state.selectMember);
|
||||
const castAbility = useGameStore((state) => state.castAbility);
|
||||
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
|
||||
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
|
||||
const restart = useGameStore((state) => state.restart);
|
||||
return (
|
||||
<section className="display bottom-display rpg-bottom-display" aria-label="RPG Roguelike tactical display">
|
||||
<RpgRunTacticalPanel
|
||||
run={run}
|
||||
focusedId={focusedId}
|
||||
onFocusChange={setRpgFocusId}
|
||||
onAction={dispatchRpgAction}
|
||||
onRestartRun={restart}
|
||||
onExitRun={onExit}
|
||||
liveCombat={{
|
||||
party,
|
||||
selectedMemberId,
|
||||
mana,
|
||||
maxMana,
|
||||
cooldowns,
|
||||
globalCooldownUntil,
|
||||
time,
|
||||
activeCast,
|
||||
spellResources,
|
||||
onSelectMember: selectMember,
|
||||
onCastAbility: castAbility,
|
||||
}}
|
||||
/>
|
||||
{paused && (
|
||||
<div className="lower-pause-overlay" aria-hidden="true">
|
||||
<span>PAUSED</span><strong>Expedition suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · ↑↓ selects menu action</small>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
|
||||
const activeTab = useGameStore((state) => state.activeTab);
|
||||
const setActiveTab = useGameStore((state) => state.setActiveTab);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const rpgRun = useGameStore((state) => state.rpgRun);
|
||||
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
|
||||
const tabs = bottomTabsFor(runMode);
|
||||
if (runMode === "rpg-roguelike" && rpgRun) {
|
||||
return <RpgBottomDisplay run={rpgRun} focusedId={rpgFocusId} paused={paused} onExit={onExit} />;
|
||||
}
|
||||
return (
|
||||
<section className="display bottom-display" aria-label="Tactical touch display">
|
||||
<header className="lower-header">
|
||||
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
|
||||
<nav aria-label="Lower display sections">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} className={activeTab === tab.id ? "is-active" : ""} onClick={() => setActiveTab(tab.id)}>
|
||||
<nav aria-label="Lower display sections" role="tablist">
|
||||
{tabs.map((tabId) => {
|
||||
const tab = tabPresentation[tabId];
|
||||
return <button key={tabId} role="tab" aria-selected={activeTab === tabId} className={activeTab === tabId ? "is-active" : ""} onClick={() => setActiveTab(tabId)}>
|
||||
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
|
||||
</button>
|
||||
))}
|
||||
})}
|
||||
</nav>
|
||||
</header>
|
||||
<main className="lower-content">
|
||||
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
|
||||
{activeTab === "map" && <MapPanel />}
|
||||
{activeTab === "pack" && <PackPanel />}
|
||||
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
|
||||
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
|
||||
</main>
|
||||
{paused && (
|
||||
<div className="lower-pause-overlay" aria-hidden="true">
|
||||
|
||||
@@ -41,13 +41,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
||||
const buff = RUN_BUFFS[buffId];
|
||||
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
|
||||
const nextRank = Math.min(buff.maxRank, rank + 1);
|
||||
const ability = abilities[buff.abilityId];
|
||||
const ability = abilities[buff.abilitySlotId];
|
||||
return (
|
||||
<button
|
||||
key={buffId}
|
||||
className={selected === buffId ? "is-controller-focused" : ""}
|
||||
className={selected === buffId ? "is-controller-selected" : ""}
|
||||
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
|
||||
onFocus={() => setSelected(buffId)}
|
||||
onPointerEnter={() => setSelected(buffId)}
|
||||
onClick={() => choose(buffId)}
|
||||
disabled={inputLocked}
|
||||
@@ -55,12 +54,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
|
||||
>
|
||||
<i>{buff.icon}</i>
|
||||
<span><small>{rank ? `Rank ${rank} → ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
|
||||
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
|
||||
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
|
||||
<p>{buff.detail}</p>
|
||||
</button>
|
||||
);
|
||||
}) : (
|
||||
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun} disabled={inputLocked}>
|
||||
<button className="buff-mastery-continue is-controller-selected" onClick={continueRun} disabled={inputLocked}>
|
||||
<i>✦</i>
|
||||
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
|
||||
<b>All 18 blessings reached maximum rank.</b>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLAUDECRAFT_WEAPON_CATALOG } from "../game/weaponCatalog";
|
||||
import { characterEquipmentAssetUrl, characterEquipmentAssetUrls } from "./CharacterEquipmentAssets";
|
||||
|
||||
describe("Claudecraft runtime weapon URLs", () => {
|
||||
it("resolves every catalog entry to one emitted GLB URL", () => {
|
||||
const urls = characterEquipmentAssetUrls();
|
||||
expect(Object.keys(urls)).toHaveLength(55);
|
||||
for (const definition of CLAUDECRAFT_WEAPON_CATALOG) {
|
||||
expect(characterEquipmentAssetUrl(definition.id)).toMatch(/\.glb(?:\?|$)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
CLAUDECRAFT_WEAPON_CATALOG,
|
||||
weaponDefinition,
|
||||
type WeaponCatalogId,
|
||||
} from "../game/weaponCatalog";
|
||||
import { selectedGameAssetUrl } from "./GameAssetProvider";
|
||||
|
||||
const WEAPON_ASSET_PREFIX = "../assets/game/models/claudecraft/weapons/";
|
||||
const WEAPON_ASSET_URLS = import.meta.glob<string>(
|
||||
"../assets/game/models/claudecraft/weapons/*.glb",
|
||||
{ eager: true, import: "default", query: "?url" },
|
||||
);
|
||||
|
||||
function importedWeaponUrl(fileName: string) {
|
||||
const url = WEAPON_ASSET_URLS[`${WEAPON_ASSET_PREFIX}${fileName}`];
|
||||
if (!url) throw new Error(`Missing imported Claudecraft weapon asset: ${fileName}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Resolves one selected asset. Creating the URL table does not fetch or decode every GLB. */
|
||||
export function characterEquipmentAssetUrl(modelId: WeaponCatalogId) {
|
||||
const definition = weaponDefinition(modelId);
|
||||
const sourceUrl = importedWeaponUrl(definition.sourceFile);
|
||||
const optimizedUrl = definition.optimizedFile
|
||||
? importedWeaponUrl(definition.optimizedFile)
|
||||
: sourceUrl;
|
||||
return selectedGameAssetUrl(sourceUrl, optimizedUrl);
|
||||
}
|
||||
|
||||
export function characterEquipmentAssetUrls() {
|
||||
return Object.fromEntries(CLAUDECRAFT_WEAPON_CATALOG.map((definition) => [
|
||||
definition.id,
|
||||
characterEquipmentAssetUrl(definition.id),
|
||||
])) as Record<WeaponCatalogId, string>;
|
||||
}
|
||||
+796
-202
File diff suppressed because it is too large
Load Diff
+1605
-163
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef } from "react";
|
||||
import type * as THREE from "three";
|
||||
import type { HealerVisualProfile } from "../game/healerVisuals";
|
||||
|
||||
function RelicMaterial({ color }: { color: string }) {
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.45}
|
||||
metalness={0.35}
|
||||
roughness={0.42}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealerClassAccessory({ profile }: { profile: HealerVisualProfile }) {
|
||||
const animated = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!animated.current || profile.accessory !== "clockwork-rings") return;
|
||||
animated.current.rotation.z += delta * 0.42;
|
||||
animated.current.rotation.y -= delta * 0.18;
|
||||
});
|
||||
|
||||
if (profile.accessory === "sun-halo") {
|
||||
return (
|
||||
<group position={[0, 0.72, 0]}>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.42, 0.045, 6, 18]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0]} rotation={[0, 0, Math.PI / 4]}>
|
||||
<octahedronGeometry args={[0.11, 0]} />
|
||||
<RelicMaterial color={profile.secondaryColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile.accessory === "grove-antlers") {
|
||||
return (
|
||||
<group position={[0, 0.34, 0]}>
|
||||
<mesh position={[-0.2, 0.18, 0]} rotation={[0, 0, -0.36]}>
|
||||
<coneGeometry args={[0.055, 0.5, 5]} />
|
||||
<RelicMaterial color={profile.secondaryColor} />
|
||||
</mesh>
|
||||
<mesh position={[0.2, 0.18, 0]} rotation={[0, 0, 0.36]}>
|
||||
<coneGeometry args={[0.055, 0.5, 5]} />
|
||||
<RelicMaterial color={profile.secondaryColor} />
|
||||
</mesh>
|
||||
<mesh position={[-0.31, 0.28, 0]} rotation={[0, 0, -0.62]}>
|
||||
<tetrahedronGeometry args={[0.1, 0]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
<mesh position={[0.31, 0.28, 0]} rotation={[0, 0, 0.62]}>
|
||||
<tetrahedronGeometry args={[0.1, 0]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile.accessory === "storm-totem") {
|
||||
return (
|
||||
<group position={[0, 0.78, 0]}>
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.09, 0.12, 0.42, 6]} />
|
||||
<RelicMaterial color={profile.secondaryColor} />
|
||||
</mesh>
|
||||
<mesh position={[-0.22, 0.1, 0]}>
|
||||
<octahedronGeometry args={[0.11, 0]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
<mesh position={[0.22, -0.08, 0]} scale={0.75}>
|
||||
<octahedronGeometry args={[0.11, 0]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile.accessory === "sun-crest") {
|
||||
return (
|
||||
<group position={[0, 0.68, 0]}>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.46, 0.055, 6, 14]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
<mesh rotation={[0, 0, Math.PI / 4]}>
|
||||
<octahedronGeometry args={[0.23, 0]} />
|
||||
<RelicMaterial color={profile.secondaryColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<group ref={animated} position={[0, 0.68, 0]}>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.46, 0.04, 6, 18]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 3, 0, 0]}>
|
||||
<torusGeometry args={[0.25, 0.035, 6, 14]} />
|
||||
<RelicMaterial color={profile.secondaryColor} />
|
||||
</mesh>
|
||||
<mesh rotation={[0, 0, Math.PI / 4]}>
|
||||
<octahedronGeometry args={[0.11, 0]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createPortal } from "@react-three/fiber";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
CHARACTER_PART_CATALOG,
|
||||
characterAppearancePartIds,
|
||||
type CharacterAppearanceV1,
|
||||
type CharacterPartId,
|
||||
} from "../game/characterAppearance";
|
||||
import type { MemberId } from "../game/types";
|
||||
import { useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
interface BoundCharacterPart {
|
||||
group: THREE.Group;
|
||||
skeletons: THREE.Skeleton[];
|
||||
}
|
||||
|
||||
function rigBonesByName(rigScene: THREE.Object3D) {
|
||||
const bones = new Map<string, THREE.Bone>();
|
||||
rigScene.traverse((object) => {
|
||||
if (object instanceof THREE.Bone) bones.set(object.name, object);
|
||||
});
|
||||
return bones;
|
||||
}
|
||||
|
||||
function createBoundCharacterPart(
|
||||
sourceScene: THREE.Object3D,
|
||||
rigScene: THREE.Object3D,
|
||||
partId: CharacterPartId,
|
||||
): BoundCharacterPart {
|
||||
const definition = CHARACTER_PART_CATALOG[partId];
|
||||
const rigBones = rigBonesByName(rigScene);
|
||||
const group = new THREE.Group();
|
||||
group.name = `character-part:${partId}`;
|
||||
const skeletons: THREE.Skeleton[] = [];
|
||||
|
||||
sourceScene.updateMatrixWorld(true);
|
||||
for (const nodeName of definition.nodeNames) {
|
||||
const sourceNode = sourceScene.getObjectByName(nodeName);
|
||||
if (!sourceNode) throw new Error(`Character part ${partId} is missing node ${nodeName}.`);
|
||||
|
||||
const partNode = sourceNode.clone(true);
|
||||
partNode.matrix.copy(sourceNode.matrixWorld);
|
||||
partNode.matrix.decompose(partNode.position, partNode.quaternion, partNode.scale);
|
||||
partNode.traverse((object) => {
|
||||
if (!(object instanceof THREE.SkinnedMesh)) return;
|
||||
const mappedBones = object.skeleton.bones.map((sourceBone) => {
|
||||
const rigBone = rigBones.get(sourceBone.name);
|
||||
if (!rigBone) throw new Error(`Character part ${partId} cannot resolve rig bone ${sourceBone.name}.`);
|
||||
return rigBone;
|
||||
});
|
||||
const skeleton = new THREE.Skeleton(
|
||||
mappedBones,
|
||||
object.skeleton.boneInverses.map((inverse) => inverse.clone()),
|
||||
);
|
||||
const bindMatrix = object.bindMatrix.clone();
|
||||
object.bind(skeleton, bindMatrix);
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
object.frustumCulled = false;
|
||||
skeletons.push(skeleton);
|
||||
});
|
||||
group.add(partNode);
|
||||
}
|
||||
|
||||
return { group, skeletons };
|
||||
}
|
||||
|
||||
function ModularCharacterPart({
|
||||
actorScene,
|
||||
modelUrls,
|
||||
partId,
|
||||
}: {
|
||||
actorScene: THREE.Object3D;
|
||||
modelUrls: Record<MemberId, string>;
|
||||
partId: CharacterPartId;
|
||||
}) {
|
||||
const definition = CHARACTER_PART_CATALOG[partId];
|
||||
const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]);
|
||||
const boundPart = useMemo(
|
||||
() => createBoundCharacterPart(gltf.scene, actorScene, partId),
|
||||
[actorScene, gltf.scene, partId],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const skeleton of boundPart.skeletons) skeleton.dispose();
|
||||
}, [boundPart]);
|
||||
|
||||
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
|
||||
return createPortal(<primitive object={boundPart.group} />, rigRoot);
|
||||
}
|
||||
|
||||
export function ModularCharacterBody({
|
||||
actorScene,
|
||||
appearance,
|
||||
modelUrls,
|
||||
}: {
|
||||
actorScene: THREE.Object3D;
|
||||
appearance: CharacterAppearanceV1;
|
||||
modelUrls: Record<MemberId, string>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{characterAppearancePartIds(appearance).map((partId) => (
|
||||
<ModularCharacterPart
|
||||
key={`${partId}:${appearance.version}`}
|
||||
actorScene={actorScene}
|
||||
modelUrls={modelUrls}
|
||||
partId={partId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+201
-31
@@ -1,11 +1,15 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { HEALER_CLASSES } from "../game/healers";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { HEALER_ABILITIES } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { bossRoomFor } from "../game/bossRooms";
|
||||
import { tankAuraProtects } from "../game/partyCombat";
|
||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||
|
||||
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
|
||||
|
||||
@@ -43,10 +47,12 @@ function CompactParty() {
|
||||
</span>
|
||||
)}
|
||||
<span className="top-effects">
|
||||
{member.renewExpiresAt > 0 && <em className="renew-pip">R</em>}
|
||||
{member.healingEffects.some((effect) => effect.expiresAt > time) && <em className="renew-pip">H</em>}
|
||||
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <em className="renew-pip">E{member.reactiveHeal.charges}</em>}
|
||||
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
|
||||
{barrierProtects(partyPositions[member.id], barrier, time) && <em className="barrier-pip">B</em>}
|
||||
{tankAuraProtects(partyPositions[member.id], partyPositions.brann, tankAura, time) && <em className="tank-aura-pip">T</em>}
|
||||
{barrier.kind === "spirit-link" && healerFieldContains(partyPositions[member.id], barrier, time) && <em className="barrier-pip">S</em>}
|
||||
{tankAuraProtects(partyPositions[member.id], partyPositions[tankAura.sourceId], tankAura, time) && <em className="tank-aura-pip">T</em>}
|
||||
{member.knockedUntil > time && <em className="knockdown-pip">KD</em>}
|
||||
</span>
|
||||
</button>
|
||||
@@ -57,12 +63,11 @@ function CompactParty() {
|
||||
}
|
||||
|
||||
function CastingBar() {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const abilityName = HEALER_CLASSES[healerClassId].abilities.mend.name;
|
||||
const activeCast = useGameStore((state) => state.activeCast);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const party = useGameStore((state) => state.party);
|
||||
if (!activeCast) return null;
|
||||
const abilityName = HEALER_ABILITIES[activeCast.abilityId].name;
|
||||
const duration = activeCast.completesAt - activeCast.startedAt;
|
||||
const progress = Math.min(1, Math.max(0, (time - activeCast.startedAt) / duration));
|
||||
const target = party.find((member) => member.id === activeCast.targetId);
|
||||
@@ -78,13 +83,19 @@ function CastingBar() {
|
||||
function BossBar() {
|
||||
const boss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const opponentBoss = useGameStore((state) => state.hockeyPvpOpponent.boss);
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
if (phase === "briefing") return null;
|
||||
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const hockeyMode = activityMode === "hockey-healing";
|
||||
const blockbreakerMode = activityMode === "blockbreaker";
|
||||
const aetherAssaultMode = activityMode === "aether-assault";
|
||||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||||
const bosses = pvpMode ? [boss, opponentBoss] : [boss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
return (
|
||||
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}>
|
||||
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
|
||||
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
|
||||
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""} ${pvpMode ? "is-pvp" : ""}`}>
|
||||
{bosses.map((entry, index) => <div className={`boss-bar-entry ${pvpMode && index === 1 ? "is-opponent" : ""}`} key={`${entry.id}-${index}`}>
|
||||
<div className="boss-name"><span>{pvpMode ? index === 0 ? "Your target" : "Rival target" : hockeyMode ? index === 0 ? "Striker · paddle + damage" : "Frontline · party damage" : blockbreakerMode ? index === 0 ? "Breaker flank" : "Wall pressure" : aetherAssaultMode ? index === 0 ? "Arcade flank" : "Party pressure" : "Vault Beast"}</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
|
||||
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
|
||||
</div>)}
|
||||
</div>
|
||||
@@ -110,48 +121,123 @@ function EncounterCallout() {
|
||||
function PhaseOverlay() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const round = useGameStore((state) => state.round);
|
||||
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||
const primaryBoss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const hockey = useGameStore((state) => state.hockey);
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
if (runMode === "rpg-roguelike") return null;
|
||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||
const room = bossRoomFor(primaryBoss.id);
|
||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||
const hockeyMode = activityMode === "hockey-healing";
|
||||
const blockbreakerMode = activityMode === "blockbreaker";
|
||||
const aetherAssaultMode = activityMode === "aether-assault";
|
||||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||
const briefingMode = runMode === "rogue-trials"
|
||||
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode;
|
||||
const briefingMode = hockeyMode
|
||||
? "Endless Goal Defense"
|
||||
: blockbreakerMode
|
||||
? "Endless Color Break"
|
||||
: aetherAssaultMode
|
||||
? "Endless Arcade Assault"
|
||||
: pvpMode
|
||||
? `Versus ${hockeyPvp.opponentName}`
|
||||
: runMode === "rogue-trials"
|
||||
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
|
||||
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
|
||||
if (phase === "combat") return null;
|
||||
const title = phase === "briefing"
|
||||
? room.name
|
||||
? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : room.name
|
||||
: phase === "victory"
|
||||
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
|
||||
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
|
||||
? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode ? "Match Won" : `${bossNames} Broken`
|
||||
: hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken";
|
||||
const eyebrow = phase === "briefing"
|
||||
? `${briefingMode} · ${room.biome}`
|
||||
? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : `${briefingMode} · ${room.biome}`
|
||||
: phase === "victory"
|
||||
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
|
||||
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
|
||||
? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : "Encounter Complete"
|
||||
: hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
|
||||
const copy = phase === "briefing"
|
||||
? definitions.map((boss) => boss.briefing).join(" ")
|
||||
? hockeyMode
|
||||
? "Defend the wide blue goal. Aim each return; the moving Pong paddle strikes it back. Party fights both bosses on enemy half. Fallen bosses are replaced."
|
||||
: blockbreakerMode
|
||||
? "Aim the puck into advancing five-brick rows. Matching orthogonal colors break as one combo while two bosses pressure the party. Missed pucks safely re-serve."
|
||||
: aetherAssaultMode
|
||||
? "Move across the full bright rink while spellfire launches automatically. Line up ship formations, dodge red bolts and dives, and keep healing through two endless bosses."
|
||||
: pvpMode
|
||||
? "Two parties fight matching boss sequences. Defend your goal and aim each return. Every goal deals 45 damage to all five players. Fallen bosses respawn instantly."
|
||||
: definitions.map((boss) => boss.briefing).join(" ")
|
||||
: phase === "victory"
|
||||
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
|
||||
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
||||
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : "Five entered. Five endured."
|
||||
: hockeyMode
|
||||
? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.`
|
||||
: blockbreakerMode
|
||||
? `Run record: ${blockbreaker.bricksBroken} bricks, ${blockbreaker.score.toLocaleString()} points, and ${endlessBossKills} boss kills.`
|
||||
: aetherAssaultMode
|
||||
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
||||
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
||||
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
||||
return (
|
||||
<div className={`phase-overlay phase-${phase}`}>
|
||||
<div className="phase-sigil">✦</div>
|
||||
<span>{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{copy}</p>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : "Restart from lower display"}</small>
|
||||
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockbreakerScorePopup() {
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const lastBreakAt = useGameStore((state) => state.blockbreaker.lastBreakAt);
|
||||
const count = useGameStore((state) => state.blockbreaker.lastBreakCount);
|
||||
const award = useGameStore((state) => state.blockbreaker.lastScoreAward);
|
||||
const [visibleAt, setVisibleAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activityMode !== "blockbreaker" || !Number.isFinite(lastBreakAt)) {
|
||||
setVisibleAt(null);
|
||||
return;
|
||||
}
|
||||
setVisibleAt(lastBreakAt);
|
||||
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastBreakAt ? null : current), 850);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [activityMode, lastBreakAt]);
|
||||
|
||||
if (visibleAt === null) return null;
|
||||
return <div className="blockbreaker-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{count} {count === 1 ? "brick" : "brick combo"}</small></div>;
|
||||
}
|
||||
|
||||
function AetherScorePopup() {
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const lastKillAt = useGameStore((state) => state.aetherAssault.lastKillAt);
|
||||
const award = useGameStore((state) => state.aetherAssault.lastKillScore);
|
||||
const multiplier = useGameStore((state) => state.aetherAssault.multiplier);
|
||||
const [visibleAt, setVisibleAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activityMode !== "aether-assault" || !Number.isFinite(lastKillAt)) {
|
||||
setVisibleAt(null);
|
||||
return;
|
||||
}
|
||||
setVisibleAt(lastKillAt);
|
||||
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastKillAt ? null : current), 700);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [activityMode, lastKillAt]);
|
||||
|
||||
if (visibleAt === null) return null;
|
||||
return <div className="blockbreaker-score-popup aether-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{multiplier.toFixed(2)}× streak</small></div>;
|
||||
}
|
||||
|
||||
function PauseOverlay({ onExit }: { onExit?: () => void }) {
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
const selection = useGameStore((state) => state.pauseSelection);
|
||||
@@ -170,14 +256,12 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
|
||||
<p>Simulation, damage, and movement are stopped.</p>
|
||||
<div className="pause-actions">
|
||||
<button
|
||||
className={selection === "resume" ? "is-controller-focused" : ""}
|
||||
onFocus={() => setPauseSelection("resume")}
|
||||
className={selection === "resume" ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => setPauseSelection("resume")}
|
||||
onClick={() => setPaused(false)}
|
||||
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
|
||||
<button
|
||||
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
|
||||
onFocus={() => setPauseSelection("exit")}
|
||||
className={`secondary ${selection === "exit" ? "is-controller-selected" : ""}`}
|
||||
onPointerEnter={() => setPauseSelection("exit")}
|
||||
onClick={exit}
|
||||
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
|
||||
@@ -188,30 +272,116 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TopScreen({ onExit }: { onExit?: () => void }) {
|
||||
function GoalPopup() {
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const goalSequence = useGameStore((state) => state.hockeyPvp.goalSequence);
|
||||
const lastGoalSide = useGameStore((state) => state.hockeyPvp.lastGoalSide);
|
||||
const lastSeenSequence = useRef(goalSequence);
|
||||
const [visibleSequence, setVisibleSequence] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activityMode !== "hockey-healing-pvp" || goalSequence < lastSeenSequence.current) {
|
||||
lastSeenSequence.current = goalSequence;
|
||||
setVisibleSequence(null);
|
||||
return;
|
||||
}
|
||||
if (goalSequence === lastSeenSequence.current) return;
|
||||
lastSeenSequence.current = goalSequence;
|
||||
setVisibleSequence(goalSequence);
|
||||
const timeout = window.setTimeout(() => {
|
||||
setVisibleSequence((current) => current === goalSequence ? null : current);
|
||||
}, 1_250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [activityMode, goalSequence]);
|
||||
|
||||
if (visibleSequence === null) return null;
|
||||
return (
|
||||
<div
|
||||
key={visibleSequence}
|
||||
className={`goal-popup ${lastGoalSide === "local" ? "is-conceded" : "is-scored"}`}
|
||||
role="status"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<strong>GOAL</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DampeningIndicator() {
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const localBossKills = useGameStore((state) => state.endlessBossKills);
|
||||
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
|
||||
if (activityMode !== "hockey-healing-pvp") return null;
|
||||
const percent = hockeyPvpDampeningPercent(localBossKills, opponentBossKills);
|
||||
return (
|
||||
<div className="dampening-indicator" role="status" aria-label={`${percent}% healing dampening`}>
|
||||
<span>Dampening</span><strong>{percent}%</strong>
|
||||
<i><em style={{ width: `${percent}%` }} /></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopScreen({
|
||||
onExit,
|
||||
playerAppearance,
|
||||
}: {
|
||||
onExit?: () => void;
|
||||
playerAppearance?: CharacterAppearanceV1;
|
||||
}) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
||||
const round = useGameStore((state) => state.round);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const activityMode = useGameStore((state) => state.activityMode);
|
||||
const endlessMode = useGameStore((state) => state.endlessMode);
|
||||
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
|
||||
const hockeyReturns = useGameStore((state) => state.hockey.returns);
|
||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const setPaused = useGameStore((state) => state.setPaused);
|
||||
const rpgRun = useGameStore((state) => state.rpgRun);
|
||||
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
|
||||
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
|
||||
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
|
||||
const restart = useGameStore((state) => state.restart);
|
||||
const hockeyMode = activityMode === "hockey-healing";
|
||||
const blockbreakerMode = activityMode === "blockbreaker";
|
||||
const aetherAssaultMode = activityMode === "aether-assault";
|
||||
const pvpMode = activityMode === "hockey-healing-pvp";
|
||||
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
||||
return (
|
||||
<section className="display top-display" aria-label="Main game viewport">
|
||||
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
|
||||
<GameScene />
|
||||
<GameScene playerAppearance={playerAppearance} />
|
||||
</Suspense>
|
||||
<div className="top-vignette" />
|
||||
<div className="top-hud">
|
||||
<CompactParty />
|
||||
<BossBar />
|
||||
<div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
||||
<div className={`objective-chip ${hockeyMode || pvpMode || blockbreakerMode || aetherAssaultMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""} ${blockbreakerMode ? "is-blockbreaker" : ""} ${aetherAssaultMode ? "is-aether" : ""}`}><span>{hockeyMode ? `Hockey Healing · ${hockeyReturns} returns · ${duration}` : blockbreakerMode ? `Blockbreaker · ${blockbreaker.score.toLocaleString()} pts · ${duration}` : aetherAssaultMode ? `Aether Assault · ${aetherAssault.score.toLocaleString()} pts · ${duration}` : pvpMode ? `VS ${hockeyPvp.opponentName} · ${duration}` : endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{hockeyMode ? `Defend wide goal · ${endlessBossKills} boss kills` : blockbreakerMode ? `${blockbreaker.bricksBroken} bricks · ${blockbreakerTimeMultiplier(time).toFixed(1)}× · row in ${Math.max(0, blockbreaker.nextRowAt - time).toFixed(1)}s` : aetherAssaultMode ? `Wave ${aetherAssault.wave} · ${aetherAssault.ships.length} ships · ${aetherAssault.multiplier.toFixed(2)}×` : pvpMode ? `Goals ${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded} · Bosses ${endlessBossKills}–${hockeyPvp.opponentBossKills}` : endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
|
||||
<EncounterCallout />
|
||||
<DampeningIndicator />
|
||||
<CastingBar />
|
||||
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
||||
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
||||
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b>☰</b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
|
||||
</div>
|
||||
<GoalPopup />
|
||||
<BlockbreakerScorePopup />
|
||||
<AetherScorePopup />
|
||||
<PhaseOverlay />
|
||||
{runMode === "rpg-roguelike" && rpgRun && (
|
||||
<RpgRunOverlay
|
||||
run={rpgRun}
|
||||
focusedId={rpgFocusId}
|
||||
onFocusChange={setRpgFocusId}
|
||||
onAction={dispatchRpgAction}
|
||||
onRestartRun={restart}
|
||||
onExitRun={onExit}
|
||||
/>
|
||||
)}
|
||||
<PauseOverlay onExit={onExit} />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AETHER_ARMORED_SHIP_COLORS,
|
||||
AETHER_STANDARD_SHIP_COLORS,
|
||||
aetherShipColor,
|
||||
aetherShipColorIndex,
|
||||
} from "./aetherAssaultVisuals";
|
||||
|
||||
describe("Aether Assault ship colors", () => {
|
||||
it("assigns a deterministic mixed palette across a formation", () => {
|
||||
const first = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
|
||||
const replay = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
|
||||
|
||||
expect(replay).toEqual(first);
|
||||
expect(new Set(first).size).toBe(AETHER_STANDARD_SHIP_COLORS.length);
|
||||
});
|
||||
|
||||
it("changes palette ordering between waves and keeps armored variants distinct", () => {
|
||||
const waveOneIndex = aetherShipColorIndex(42, 1, 0);
|
||||
const waveTwoIndex = aetherShipColorIndex(42, 2, 0);
|
||||
|
||||
expect(waveTwoIndex).not.toBe(waveOneIndex);
|
||||
expect(aetherShipColor(42, 1, 0, "standard")).toBe(AETHER_STANDARD_SHIP_COLORS[waveOneIndex]);
|
||||
expect(aetherShipColor(42, 1, 0, "armored")).toBe(AETHER_ARMORED_SHIP_COLORS[waveOneIndex]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AetherShipKind } from "../game/aetherAssault";
|
||||
|
||||
export const AETHER_STANDARD_SHIP_COLORS = [
|
||||
"#20c8e8",
|
||||
"#8a5cff",
|
||||
"#f044b5",
|
||||
"#31cf74",
|
||||
"#f06b3c",
|
||||
"#d6b91c",
|
||||
] as const;
|
||||
|
||||
export const AETHER_ARMORED_SHIP_COLORS = [
|
||||
"#9ef5ff",
|
||||
"#d0b8ff",
|
||||
"#ff9bdc",
|
||||
"#9af0b8",
|
||||
"#ffb088",
|
||||
"#ffe976",
|
||||
] as const;
|
||||
|
||||
export function aetherShipColorIndex(seed: number, wave: number, shipIndex: number) {
|
||||
const normalizedSeed = Math.floor(Number.isFinite(seed) ? seed : 0) >>> 0;
|
||||
const mixedSeed = (normalizedSeed ^ (normalizedSeed >>> 16)) >>> 0;
|
||||
const normalizedWave = Math.max(1, Math.floor(Number.isFinite(wave) ? wave : 1));
|
||||
const normalizedIndex = Math.max(0, Math.floor(Number.isFinite(shipIndex) ? shipIndex : 0));
|
||||
return (mixedSeed + (normalizedWave - 1) * 3 + normalizedIndex * 5) % AETHER_STANDARD_SHIP_COLORS.length;
|
||||
}
|
||||
|
||||
export function aetherShipColor(seed: number, wave: number, shipIndex: number, kind: AetherShipKind) {
|
||||
const palette = kind === "armored" ? AETHER_ARMORED_SHIP_COLORS : AETHER_STANDARD_SHIP_COLORS;
|
||||
return palette[aetherShipColorIndex(seed, wave, shipIndex)];
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
BOSS_DEATH_HOLD_SECONDS,
|
||||
advanceBossIndicatorOpacity,
|
||||
bossCanTrackTarget,
|
||||
bossDeathDespawnSeconds,
|
||||
bossDeathHoldSeconds,
|
||||
bossDeathOpacity,
|
||||
} from "./bossDeathVisuals";
|
||||
|
||||
@@ -28,4 +30,11 @@ describe("boss death visuals", () => {
|
||||
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
|
||||
expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Gravehorn visible through its full authored fall", () => {
|
||||
const hold = bossDeathHoldSeconds("gravehorn-triceratops");
|
||||
expect(hold).toBeGreaterThan(5.73);
|
||||
expect(bossDeathOpacity(5.73, "gravehorn-triceratops")).toBe(1);
|
||||
expect(bossDeathOpacity(bossDeathDespawnSeconds("gravehorn-triceratops"), "gravehorn-triceratops")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ export {
|
||||
BOSS_DEATH_DESPAWN_SECONDS,
|
||||
BOSS_DEATH_FADE_SECONDS,
|
||||
BOSS_DEATH_HOLD_SECONDS,
|
||||
bossDeathDespawnSeconds,
|
||||
bossDeathHoldSeconds,
|
||||
bossDeathOpacity,
|
||||
} from "../../game/bossDeath";
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "../../game/arena";
|
||||
import { BOSS_ARENA_PORTAL_HALF_WIDTH } from "../../game/rpgRoguelike/playSpace";
|
||||
|
||||
export interface RpgRoomPortalsProps {
|
||||
entryOpen: boolean;
|
||||
exitOpen: boolean;
|
||||
accent: string;
|
||||
wallColor: string;
|
||||
}
|
||||
|
||||
interface PortalDoorwayProps {
|
||||
open: boolean;
|
||||
accent: string;
|
||||
wallColor: string;
|
||||
position: readonly [number, number, number];
|
||||
rotationY: number;
|
||||
hideNearCamera?: boolean;
|
||||
}
|
||||
|
||||
const DOOR_HEIGHT = 3.25;
|
||||
const DOOR_DEPTH = 0.24;
|
||||
const FRAME_DEPTH = 0.72;
|
||||
const FRAME_POST_WIDTH = 0.48;
|
||||
const OPEN_ANGLE = Math.PI * 0.56;
|
||||
const OPEN_DAMPING = 8;
|
||||
const CAMERA_HIDE_RADIUS = 3.6;
|
||||
|
||||
function DoorLeaf({ side, accent, wallColor }: {
|
||||
side: "left" | "right";
|
||||
accent: string;
|
||||
wallColor: string;
|
||||
}) {
|
||||
const leafWidth = BOSS_ARENA_PORTAL_HALF_WIDTH;
|
||||
const centerX = side === "left" ? leafWidth * 0.5 : -leafWidth * 0.5;
|
||||
return (
|
||||
<group position={[centerX, DOOR_HEIGHT * 0.5, 0]}>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[leafWidth - 0.035, DOOR_HEIGHT, DOOR_DEPTH]} />
|
||||
<meshStandardMaterial
|
||||
color={wallColor}
|
||||
emissive={accent}
|
||||
emissiveIntensity={0.08}
|
||||
metalness={0.36}
|
||||
roughness={0.62}
|
||||
/>
|
||||
</mesh>
|
||||
{[-0.9, 0, 0.9].map((y) => (
|
||||
<mesh key={y} position={[0, y, DOOR_DEPTH * 0.56]}>
|
||||
<boxGeometry args={[leafWidth - 0.12, 0.09, 0.055]} />
|
||||
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.4} metalness={0.7} roughness={0.3} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function PortalDoorway({
|
||||
open,
|
||||
accent,
|
||||
wallColor,
|
||||
position,
|
||||
rotationY,
|
||||
hideNearCamera = false,
|
||||
}: PortalDoorwayProps) {
|
||||
const root = useRef<THREE.Group>(null);
|
||||
const leftHinge = useRef<THREE.Group>(null);
|
||||
const rightHinge = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ camera }, delta) => {
|
||||
if (root.current) {
|
||||
const distanceToCamera = Math.hypot(
|
||||
camera.position.x - position[0],
|
||||
camera.position.z - position[2],
|
||||
);
|
||||
root.current.visible = !hideNearCamera || distanceToCamera >= CAMERA_HIDE_RADIUS;
|
||||
}
|
||||
if (!leftHinge.current || !rightHinge.current) return;
|
||||
const reducedMotion = typeof document !== "undefined"
|
||||
&& document.documentElement.classList.contains("force-reduced-motion");
|
||||
const leftTarget = open ? OPEN_ANGLE : 0;
|
||||
const rightTarget = -leftTarget;
|
||||
if (reducedMotion) {
|
||||
leftHinge.current.rotation.y = leftTarget;
|
||||
rightHinge.current.rotation.y = rightTarget;
|
||||
return;
|
||||
}
|
||||
leftHinge.current.rotation.y = THREE.MathUtils.damp(
|
||||
leftHinge.current.rotation.y,
|
||||
leftTarget,
|
||||
OPEN_DAMPING,
|
||||
delta,
|
||||
);
|
||||
rightHinge.current.rotation.y = THREE.MathUtils.damp(
|
||||
rightHinge.current.rotation.y,
|
||||
rightTarget,
|
||||
OPEN_DAMPING,
|
||||
delta,
|
||||
);
|
||||
});
|
||||
|
||||
const postX = BOSS_ARENA_PORTAL_HALF_WIDTH + FRAME_POST_WIDTH * 0.5;
|
||||
return (
|
||||
<group ref={root} position={position} rotation={[0, rotationY, 0]}>
|
||||
{([-1, 1] as const).map((side) => (
|
||||
<mesh key={side} position={[side * postX, DOOR_HEIGHT * 0.55, 0]} castShadow receiveShadow>
|
||||
<boxGeometry args={[FRAME_POST_WIDTH, DOOR_HEIGHT * 1.1, FRAME_DEPTH]} />
|
||||
<meshStandardMaterial color={wallColor} roughness={0.82} metalness={0.08} />
|
||||
</mesh>
|
||||
))}
|
||||
<mesh position={[0, DOOR_HEIGHT, 0]} castShadow receiveShadow>
|
||||
<torusGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH + 0.04, 0.24, 6, 20, Math.PI]} />
|
||||
<meshStandardMaterial color={wallColor} roughness={0.78} metalness={0.1} />
|
||||
</mesh>
|
||||
<mesh position={[0, DOOR_HEIGHT + BOSS_ARENA_PORTAL_HALF_WIDTH + 0.06, 0.03]} rotation={[0, 0, Math.PI / 4]} castShadow>
|
||||
<octahedronGeometry args={[0.34, 0]} />
|
||||
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.65} metalness={0.42} roughness={0.28} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.055, 0]} receiveShadow>
|
||||
<boxGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH * 2 + FRAME_POST_WIDTH, 0.11, FRAME_DEPTH]} />
|
||||
<meshStandardMaterial color={wallColor} roughness={0.88} />
|
||||
</mesh>
|
||||
|
||||
<group ref={leftHinge} position={[-BOSS_ARENA_PORTAL_HALF_WIDTH, 0, 0]}>
|
||||
<DoorLeaf side="left" accent={accent} wallColor={wallColor} />
|
||||
</group>
|
||||
<group ref={rightHinge} position={[BOSS_ARENA_PORTAL_HALF_WIDTH, 0, 0]}>
|
||||
<DoorLeaf side="right" accent={accent} wallColor={wallColor} />
|
||||
</group>
|
||||
|
||||
<group visible={!open} position={[0, DOOR_HEIGHT * 0.52, DOOR_DEPTH * 0.76]}>
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH * 1.3, 0.16, 0.12]} />
|
||||
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.48} metalness={0.72} roughness={0.24} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0.1]} rotation={[0, 0, Math.PI / 4]}>
|
||||
<octahedronGeometry args={[0.28, 0]} />
|
||||
<meshBasicMaterial color={accent} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the linear RPG route through the boss arena: entry is the south
|
||||
* portal (+Z), while the next-room exit is the north portal (-Z).
|
||||
*/
|
||||
export function RpgRoomPortals({ entryOpen, exitOpen, accent, wallColor }: RpgRoomPortalsProps) {
|
||||
return (
|
||||
<group>
|
||||
<PortalDoorway
|
||||
open={exitOpen}
|
||||
accent={accent}
|
||||
wallColor={wallColor}
|
||||
position={[ARENA_CENTER[0], 0, ARENA_CENTER[1] - ARENA_WALL_RADIUS]}
|
||||
rotationY={0}
|
||||
/>
|
||||
<PortalDoorway
|
||||
open={entryOpen}
|
||||
accent={accent}
|
||||
wallColor={wallColor}
|
||||
position={[ARENA_CENTER[0], 0, ARENA_CENTER[1] + ARENA_WALL_RADIUS]}
|
||||
rotationY={Math.PI}
|
||||
hideNearCamera
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import { HEALER_ABILITIES } from "../../game/healers";
|
||||
import {
|
||||
ABILITY_LOADOUT_SLOTS,
|
||||
MAX_ACTIVE_ROSTER,
|
||||
MAX_EQUIPPED_SPELLS,
|
||||
PARTY_DRAFT_WAVE_COUNT,
|
||||
PARTY_RECRUITS_PER_WAVE,
|
||||
SPELL_DRAFT_WAVE_COUNT,
|
||||
SPELL_PICKS_PER_WAVE,
|
||||
rpgFocusId,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
|
||||
import {
|
||||
ChallengeObjective,
|
||||
createUiContext,
|
||||
currentBoss,
|
||||
FocusButton,
|
||||
GearCard,
|
||||
PartyCard,
|
||||
rewardSummary,
|
||||
RoutePips,
|
||||
RunHeader,
|
||||
runAccentStyle,
|
||||
SpellCard,
|
||||
type RpgRunUiContext,
|
||||
type RpgRunUiProps,
|
||||
} from "./RpgRunUiShared";
|
||||
import "./rpgRoguelike.css";
|
||||
|
||||
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
|
||||
readonly context: RpgRunUiContext;
|
||||
readonly focusId: string;
|
||||
readonly action: Parameters<RpgRunUiProps["onAction"]>[0];
|
||||
readonly disabled: boolean;
|
||||
readonly label: string;
|
||||
readonly hint: string;
|
||||
}) {
|
||||
return (
|
||||
<footer className="rpg-draft-footer">
|
||||
<span>{hint}</span>
|
||||
<FocusButton
|
||||
context={context}
|
||||
focusId={focusId}
|
||||
command={{ type: "run-action", action }}
|
||||
className="rpg-primary-action"
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}<b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
|
||||
</FocusButton>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function PartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const draft = run.partyDraft!;
|
||||
const canRemove = draft.waveIndex > 0;
|
||||
const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER
|
||||
&& draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE;
|
||||
const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1;
|
||||
const canContinue = finalWave
|
||||
? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank")
|
||||
: run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER;
|
||||
return (
|
||||
<section className="rpg-phase-panel rpg-party-draft" aria-labelledby="rpg-party-draft-title">
|
||||
<div className="rpg-phase-title">
|
||||
<div><small>Draft {draft.waveIndex + 1} / {PARTY_DRAFT_WAVE_COUNT}</small><h2 id="rpg-party-draft-title">Assemble your party</h2></div>
|
||||
<p>Recruit up to {PARTY_RECRUITS_PER_WAVE} this wave. Four companions enter each room.</p>
|
||||
<b>{draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked</b>
|
||||
</div>
|
||||
<div className="rpg-party-offer-grid">
|
||||
{draft.offers.map((candidate) => {
|
||||
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
|
||||
const disabled = !recruited && !canRecruit;
|
||||
const action = recruited
|
||||
? { type: "party-remove", memberId: candidate.candidateId } as const
|
||||
: { type: "party-recruit", candidateId: candidate.candidateId } as const;
|
||||
return (
|
||||
<PartyCard
|
||||
key={candidate.candidateId}
|
||||
candidate={candidate}
|
||||
selected={recruited}
|
||||
action={disabled ? <span className="rpg-card-status">Party full</span> : (
|
||||
<FocusButton
|
||||
context={context}
|
||||
focusId={rpgFocusId.partyOffer(candidate.candidateId)}
|
||||
command={{ type: "run-action", action }}
|
||||
className="rpg-card-action"
|
||||
disabled={recruited && !canRemove}
|
||||
pressed={recruited}
|
||||
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
|
||||
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
|
||||
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
|
||||
{run.roster.map((member) => (
|
||||
<FocusButton
|
||||
key={member.instanceId}
|
||||
context={context}
|
||||
focusId={rpgFocusId.partyMember(member.instanceId)}
|
||||
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
|
||||
className={`rpg-picked-chip rarity-${member.rarity}`}
|
||||
disabled={!canRemove}
|
||||
label={`Remove ${member.name}`}
|
||||
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
|
||||
</div>
|
||||
<DraftFooter
|
||||
context={context}
|
||||
focusId={rpgFocusId.partyContinue}
|
||||
action={{ type: "party-next-wave" }}
|
||||
disabled={!canContinue}
|
||||
label={finalWave ? "Lock Party" : "Reroll Offers"}
|
||||
hint={finalWave && !canContinue ? `Recruit exactly ${MAX_ACTIVE_ROSTER} companions, including a tank.` : "Previous recruits stay; remove any companion before locking the run."}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SpellDraft({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const draft = run.spellDraft!;
|
||||
const canRemove = draft.waveIndex > 0;
|
||||
const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS
|
||||
&& draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE;
|
||||
const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1;
|
||||
const canContinue = !finalWave || run.selectedSpellIds.length > 0;
|
||||
return (
|
||||
<section className="rpg-phase-panel rpg-spell-draft" aria-labelledby="rpg-spell-draft-title">
|
||||
<div className="rpg-phase-title">
|
||||
<div><small>Draft {draft.waveIndex + 1} / {SPELL_DRAFT_WAVE_COUNT}</small><h2 id="rpg-spell-draft-title">Build your spellbook</h2></div>
|
||||
<p>Learn up to {SPELL_PICKS_PER_WAVE} spells. Abilities from every enabled healer class can mix.</p>
|
||||
<b>{draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked</b>
|
||||
</div>
|
||||
<div className="rpg-spell-offer-grid">
|
||||
{draft.offers.map((spellId) => {
|
||||
const selected = run.selectedSpellIds.includes(spellId);
|
||||
const disabled = !selected && !canPick;
|
||||
const action = selected
|
||||
? { type: "spell-remove", spellId } as const
|
||||
: { type: "spell-pick", spellId } as const;
|
||||
return (
|
||||
<SpellCard
|
||||
key={spellId}
|
||||
spellId={spellId}
|
||||
selected={selected}
|
||||
action={disabled ? <span className="rpg-card-status">Wave cap reached</span> : (
|
||||
<FocusButton
|
||||
context={context}
|
||||
focusId={rpgFocusId.spellOffer(spellId)}
|
||||
command={{ type: "run-action", action }}
|
||||
className="rpg-card-action"
|
||||
disabled={selected && !canRemove}
|
||||
pressed={selected}
|
||||
label={`${selected ? "Remove" : "Learn"} ${HEALER_ABILITIES[spellId].name}`}
|
||||
><span>{selected ? canRemove ? "Remove" : "Locked" : "Learn"}</span></FocusButton>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rpg-spellbook-strip" aria-label={`Spellbook, ${run.selectedSpellIds.length} of ${MAX_EQUIPPED_SPELLS}`}>
|
||||
<strong>Spellbook {run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS}</strong>
|
||||
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] }))
|
||||
.filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable<typeof entry.spellId> } => Boolean(entry.spellId))
|
||||
.map(({ spellId, index }) => {
|
||||
const spell = HEALER_ABILITIES[spellId];
|
||||
return (
|
||||
<FocusButton
|
||||
key={spellId}
|
||||
context={context}
|
||||
focusId={rpgFocusId.spellSelected(spellId)}
|
||||
command={{ type: "run-action", action: { type: "spell-remove", spellId } }}
|
||||
className="rpg-spellbook-chip"
|
||||
disabled={!canRemove}
|
||||
style={runAccentStyle(spell.color)}
|
||||
label={`Remove ${spell.name}`}
|
||||
><b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}</span><small>×</small></FocusButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DraftFooter
|
||||
context={context}
|
||||
focusId={rpgFocusId.spellContinue}
|
||||
action={{ type: "spell-next-wave" }}
|
||||
disabled={!canContinue}
|
||||
label={finalWave ? "Lock Spellbook" : "Reroll Spells"}
|
||||
hint={draft.waveIndex > 0 ? "Remove drafted spells to make room for new magic." : "Each learned spell fills the next ability slot."}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Briefing({ context, kind }: { context: RpgRunUiContext; kind: "challenge" | "boss" }) {
|
||||
const { run } = context;
|
||||
const boss = currentBoss(run);
|
||||
if (kind === "challenge") {
|
||||
return (
|
||||
<section className="rpg-phase-panel rpg-centered-panel" aria-labelledby="rpg-challenge-title">
|
||||
<span className="rpg-hero-sigil">◇</span>
|
||||
<small>Hallway challenge</small>
|
||||
<h2 id="rpg-challenge-title">Prove the party before the next door</h2>
|
||||
<ChallengeObjective run={run} />
|
||||
<p className="rpg-soft-fail-copy">Failure does not end the run. Success adds gold and improves the next chest.</p>
|
||||
<FocusButton context={context} focusId={rpgFocusId.challengeStart} command={{ type: "run-action", action: { type: "challenge-start" } }} className="rpg-primary-action rpg-large-action">
|
||||
Start Challenge <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
|
||||
</FocusButton>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="rpg-phase-panel rpg-centered-panel rpg-boss-briefing" style={boss ? runAccentStyle(boss.accent) : undefined} aria-labelledby="rpg-boss-title">
|
||||
<span className="rpg-hero-sigil">{boss?.icon ?? "♛"}</span>
|
||||
<small>{run.bossIndex === run.bossRoute.length - 1 ? "Final encounter" : "Boss chamber"}</small>
|
||||
<h2 id="rpg-boss-title">{boss?.name ?? "Unknown Guardian"}</h2>
|
||||
<h3>{boss?.title}</h3>
|
||||
<p>{boss?.briefing}</p>
|
||||
{run.lastChallengeResult && (
|
||||
<div className={`rpg-challenge-result ${run.lastChallengeResult.succeeded ? "is-success" : "is-failure"}`}>
|
||||
{run.lastChallengeResult.succeeded ? `Challenge cleared · +${run.lastChallengeResult.objective.rewardCurrency} gold · upgraded chest` : "Challenge missed · standard chest remains"}
|
||||
</div>
|
||||
)}
|
||||
<FocusButton context={context} focusId={rpgFocusId.bossStart} command={{ type: "run-action", action: { type: "boss-start" } }} className="rpg-primary-action rpg-large-action">
|
||||
Enter Chamber <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
|
||||
</FocusButton>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BossCleared({ context }: { context: RpgRunUiContext }) {
|
||||
const boss = currentBoss(context.run);
|
||||
return (
|
||||
<section className="rpg-phase-panel rpg-cleared-panel" style={boss ? runAccentStyle(boss.accent) : undefined} aria-labelledby="rpg-cleared-title">
|
||||
<div className="rpg-exit-arrow"><i />↑<span>North gate open</span></div>
|
||||
<small>Boss defeated</small>
|
||||
<h2 id="rpg-cleared-title">{boss?.name} has fallen</h2>
|
||||
<p>Walk through the far door to claim the chest. Party health carries forward.</p>
|
||||
<FocusButton context={context} focusId={rpgFocusId.rewardOpen} command={{ type: "run-action", action: { type: "reward-open" } }} className="rpg-primary-action rpg-large-action">
|
||||
Open Chest <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
|
||||
</FocusButton>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Rewards({ context }: { context: RpgRunUiContext }) {
|
||||
const chest = context.run.pendingReward;
|
||||
if (!chest) return null;
|
||||
return (
|
||||
<section className="rpg-phase-panel rpg-reward-panel" aria-labelledby="rpg-reward-title">
|
||||
<div className="rpg-phase-title">
|
||||
<div><small>Chest quality +{chest.quality}</small><h2 id="rpg-reward-title">Choose one reward</h2></div>
|
||||
<p>Rewards improve this run only. Gear auto-equips when stronger.</p>
|
||||
</div>
|
||||
<div className="rpg-reward-grid">
|
||||
{chest.choices.map((choice) => {
|
||||
const summary = rewardSummary(choice);
|
||||
return (
|
||||
<FocusButton
|
||||
key={choice.id}
|
||||
context={context}
|
||||
focusId={rpgFocusId.rewardChoice(choice.id)}
|
||||
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
|
||||
className="rpg-reward-card"
|
||||
style={runAccentStyle(summary.accent)}
|
||||
>
|
||||
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<footer className="rpg-controller-hint"><b>← / →</b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Claim</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Shop({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const shop = run.shop;
|
||||
if (!shop) return null;
|
||||
const needsRest = run.playerHp > 0 && run.playerHp < 100
|
||||
|| run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp);
|
||||
const dead = run.roster.filter((member) => member.hp <= 0);
|
||||
return (
|
||||
<section className="rpg-phase-panel rpg-shop-panel" aria-labelledby="rpg-shop-title">
|
||||
<div className="rpg-phase-title">
|
||||
<div><small>Safe intermission</small><h2 id="rpg-shop-title">Wayfarer's Exchange</h2></div>
|
||||
<p>Run gear auto-equips when stronger. Sell displaced gear from the bag.</p>
|
||||
</div>
|
||||
<div className="rpg-shop-layout">
|
||||
<section><h3>Buy gear</h3><div className="rpg-shop-grid">
|
||||
{shop.offers.map((offer) => {
|
||||
const disabled = offer.sold || run.currency < offer.price;
|
||||
return (
|
||||
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
|
||||
<FocusButton
|
||||
context={context}
|
||||
focusId={rpgFocusId.shopOffer(offer.id)}
|
||||
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
|
||||
className="rpg-card-action"
|
||||
disabled={disabled}
|
||||
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`}
|
||||
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
|
||||
} />
|
||||
);
|
||||
})}
|
||||
</div></section>
|
||||
<section><h3>Bag & services</h3><div className="rpg-shop-side-list">
|
||||
{run.bag.length ? run.bag.map((item) => (
|
||||
<FocusButton key={item.id} context={context} focusId={rpgFocusId.shopSell(item.id)} command={{ type: "run-action", action: { type: "shop-sell", itemId: item.id } }} className="rpg-shop-row">
|
||||
<span><small>{item.slotId} · +{item.enhancement}</small>{item.name}</span><b>Sell ◆ {item.sellPrice}</b>
|
||||
</FocusButton>
|
||||
)) : <p className="rpg-empty-copy">Bag empty. Replaced gear appears here.</p>}
|
||||
<FocusButton context={context} focusId={rpgFocusId.shopRest} command={{ type: "run-action", action: { type: "shop-rest" } }} className="rpg-shop-row" disabled={!needsRest || run.currency < shop.restCost}>
|
||||
<span><small>Living members</small>Rest party</span><b>◆ {shop.restCost}</b>
|
||||
</FocusButton>
|
||||
{dead.map((member) => (
|
||||
<FocusButton key={member.instanceId} context={context} focusId={rpgFocusId.shopRevive(member.instanceId)} command={{ type: "run-action", action: { type: "shop-revive", memberId: member.instanceId } }} className="rpg-shop-row" disabled={run.currency < shop.reviveCost}>
|
||||
<span><small>Return at 50% HP</small>Revive {member.name}</span><b>◆ {shop.reviveCost}</b>
|
||||
</FocusButton>
|
||||
))}
|
||||
</div></section>
|
||||
</div>
|
||||
<DraftFooter context={context} focusId={rpgFocusId.shopLeave} action={{ type: "shop-leave" }} disabled={false} label="Leave Shop" hint="Next act begins after leaving." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Terminal({ context }: { context: RpgRunUiContext }) {
|
||||
const victory = context.run.phase === "victory";
|
||||
return (
|
||||
<section className={`rpg-phase-panel rpg-centered-panel rpg-terminal ${victory ? "is-victory" : "is-defeat"}`} aria-labelledby="rpg-terminal-title">
|
||||
<span className="rpg-hero-sigil">{victory ? "♛" : "◇"}</span>
|
||||
<small>{victory ? "Expedition complete" : "Run ended"}</small>
|
||||
<h2 id="rpg-terminal-title">{victory ? "The gauntlet is conquered" : "The party has fallen"}</h2>
|
||||
<p>{context.run.bossesDefeated} bosses defeated · {context.run.selectedSpellIds.length} spells drafted · ◆ {context.run.currency} remaining</p>
|
||||
<div className="rpg-terminal-actions">
|
||||
<FocusButton context={context} focusId={rpgFocusId.restartRun} command={{ type: "restart-run" }} className="rpg-primary-action rpg-large-action" disabled={!context.onRestartRun}>New Run</FocusButton>
|
||||
<FocusButton context={context} focusId={rpgFocusId.exitRun} command={{ type: "exit-run" }} className="rpg-secondary-action rpg-large-action" disabled={!context.onExitRun}>Mode Select</FocusButton>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCombatHud({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const boss = currentBoss(run);
|
||||
const cleared = run.phase === "boss-cleared";
|
||||
return (
|
||||
<section className="rpg-compact-combat-hud" aria-live="polite">
|
||||
<div>
|
||||
<small>{run.phase === "challenge-active" ? "Hallway challenge" : cleared ? "Room cleared" : "RPG Roguelike"}</small>
|
||||
<strong>{run.phase === "challenge-active" ? run.currentChallenge?.objective.name : cleared ? "North gate open · cross it or confirm" : boss?.name}</strong>
|
||||
</div>
|
||||
{run.phase === "challenge-active" ? <ChallengeObjective run={run} compact /> : <RoutePips run={run} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Main-display overlay. Draft/reward/shop phases gate play; live phases render compact HUD only. */
|
||||
export function RpgRunOverlay(props: RpgRunUiProps) {
|
||||
const context = createUiContext(props);
|
||||
const live = props.run.phase === "challenge-active" || props.run.phase === "boss-combat" || props.run.phase === "boss-cleared";
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (live) return;
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
return () => {
|
||||
const previous = previousFocusRef.current;
|
||||
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [live]);
|
||||
|
||||
useEffect(() => {
|
||||
if (live) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const selected = overlayRef.current?.querySelector<HTMLButtonElement>("[data-rpg-focus].is-controller-selected:not(:disabled)");
|
||||
selected?.focus({ preventScroll: true });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [context.activeFocusId, live, props.run.phase]);
|
||||
|
||||
const trapModalTab = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (live || event.key !== "Tab") return;
|
||||
const buttons = [...(overlayRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? [])];
|
||||
if (!buttons.length) {
|
||||
event.preventDefault();
|
||||
overlayRef.current?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const nextIndex = event.shiftKey
|
||||
? currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1
|
||||
: currentIndex < 0 || currentIndex === buttons.length - 1 ? 0 : currentIndex + 1;
|
||||
event.preventDefault();
|
||||
buttons[nextIndex].focus({ preventScroll: true });
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={`rpg-run-overlay ${live ? "is-live" : "is-gated"} ${props.className ?? ""}`.trim()}
|
||||
role={live ? "status" : "dialog"}
|
||||
aria-modal={live ? undefined : true}
|
||||
aria-label="RPG roguelike run"
|
||||
tabIndex={live ? undefined : -1}
|
||||
onKeyDown={trapModalTab}
|
||||
>
|
||||
{live ? <CompactCombatHud context={context} /> : (
|
||||
<>
|
||||
<RunHeader run={props.run} />
|
||||
<RoutePips run={props.run} />
|
||||
{props.run.phase === "party-draft" && <PartyDraft context={context} />}
|
||||
{props.run.phase === "spell-draft" && <SpellDraft context={context} />}
|
||||
{props.run.phase === "challenge-briefing" && <Briefing context={context} kind="challenge" />}
|
||||
{props.run.phase === "boss-briefing" && <Briefing context={context} kind="boss" />}
|
||||
{props.run.phase === "reward" && <Rewards context={context} />}
|
||||
{props.run.phase === "shop" && <Shop context={context} />}
|
||||
{(props.run.phase === "victory" || props.run.phase === "defeat") && <Terminal context={context} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { HEALER_ABILITIES } from "../../game/healers";
|
||||
import { ABILITY_CONTROLLER_BINDINGS } from "../../game/controllerBindings";
|
||||
import {
|
||||
ABILITY_LOADOUT_SLOTS,
|
||||
MAX_ACTIVE_ROSTER,
|
||||
MAX_EQUIPPED_SPELLS,
|
||||
PARTY_DRAFT_WAVE_COUNT,
|
||||
PARTY_RECRUITS_PER_WAVE,
|
||||
SPELL_DRAFT_WAVE_COUNT,
|
||||
SPELL_PICKS_PER_WAVE,
|
||||
rpgFocusId,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
|
||||
import {
|
||||
ChallengeObjective,
|
||||
createUiContext,
|
||||
currentBoss,
|
||||
FocusButton,
|
||||
GearCard,
|
||||
PartyCard,
|
||||
rewardSummary,
|
||||
RoutePips,
|
||||
RunHeader,
|
||||
runAccentStyle,
|
||||
SpellCard,
|
||||
type RpgRunUiContext,
|
||||
type RpgLiveCombatUi,
|
||||
type RpgRunUiProps,
|
||||
} from "./RpgRunUiShared";
|
||||
import "./rpgRoguelike.css";
|
||||
|
||||
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
|
||||
const { run } = context;
|
||||
return (
|
||||
<section className="rpg-tactical-section">
|
||||
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
|
||||
<div className="rpg-tactical-party-grid">
|
||||
{run.roster.map((member) => (
|
||||
<PartyCard key={member.instanceId} member={member} compact action={interactive ? (
|
||||
<FocusButton
|
||||
context={context}
|
||||
focusId={rpgFocusId.partyMember(member.instanceId)}
|
||||
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
|
||||
className="rpg-card-action"
|
||||
label={`Remove ${member.name}`}
|
||||
><span>Remove</span></FocusButton>
|
||||
) : undefined} />
|
||||
))}
|
||||
{!run.roster.length && <p className="rpg-empty-copy">Drafted companions appear here.</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalSpellbook({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
|
||||
const { run } = context;
|
||||
return (
|
||||
<section className="rpg-tactical-section">
|
||||
<header><h3>Spellbook</h3><span>{run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS} slots</span></header>
|
||||
<div className="rpg-tactical-spell-grid">
|
||||
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] }))
|
||||
.filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable<typeof entry.spellId> } => Boolean(entry.spellId))
|
||||
.map(({ spellId, index }) => {
|
||||
const spell = HEALER_ABILITIES[spellId];
|
||||
return interactive ? (
|
||||
<FocusButton
|
||||
key={spellId}
|
||||
context={context}
|
||||
focusId={rpgFocusId.spellSelected(spellId)}
|
||||
command={{ type: "run-action", action: { type: "spell-remove", spellId } }}
|
||||
className="rpg-tactical-spell"
|
||||
style={runAccentStyle(spell.color)}
|
||||
label={`Remove ${spell.name}`}
|
||||
><b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}<small>Rank {run.spellRanks[spellId] ?? 0}</small></span><em>×</em></FocusButton>
|
||||
) : (
|
||||
<div key={spellId} className="rpg-tactical-spell" style={runAccentStyle(spell.color)}>
|
||||
<b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}<small>Rank {run.spellRanks[spellId] ?? 0}</small></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!run.selectedSpellIds.length && <p className="rpg-empty-copy">Drafted spells appear here.</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalContinue({ context, focusId, action, label, disabled = false }: {
|
||||
readonly context: RpgRunUiContext;
|
||||
readonly focusId: string;
|
||||
readonly action: Parameters<RpgRunUiProps["onAction"]>[0];
|
||||
readonly label: string;
|
||||
readonly disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<FocusButton context={context} focusId={focusId} command={{ type: "run-action", action }} className="rpg-tactical-continue" disabled={disabled}>
|
||||
<span>{label}</span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
|
||||
</FocusButton>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const draft = run.partyDraft!;
|
||||
const canRemove = draft.waveIndex > 0;
|
||||
const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER && draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE;
|
||||
const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1;
|
||||
const canContinue = finalWave
|
||||
? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank")
|
||||
: run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER;
|
||||
return (
|
||||
<>
|
||||
<section className="rpg-tactical-section rpg-tactical-offers">
|
||||
<header><h3>Offers · wave {draft.waveIndex + 1}/{PARTY_DRAFT_WAVE_COUNT}</h3><span>{draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked</span></header>
|
||||
<div className="rpg-tactical-list">
|
||||
{draft.offers.map((candidate) => {
|
||||
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
|
||||
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
|
||||
return (
|
||||
<FocusButton
|
||||
key={candidate.candidateId}
|
||||
context={context}
|
||||
focusId={rpgFocusId.partyOffer(candidate.candidateId)}
|
||||
command={{ type: "run-action", action: recruited ? { type: "party-remove", memberId: candidate.candidateId } : { type: "party-recruit", candidateId: candidate.candidateId } }}
|
||||
className={`rpg-tactical-offer rarity-${candidate.rarity}`}
|
||||
style={runAccentStyle(candidate.color)}
|
||||
disabled={disabled}
|
||||
pressed={recruited}
|
||||
>
|
||||
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
|
||||
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
|
||||
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
|
||||
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<TacticalParty context={context} interactive={canRemove} />
|
||||
<TacticalContinue context={context} focusId={rpgFocusId.partyContinue} action={{ type: "party-next-wave" }} label={finalWave ? "Lock party" : "Reroll offers"} disabled={!canContinue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalSpellDraft({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const draft = run.spellDraft!;
|
||||
const canRemove = draft.waveIndex > 0;
|
||||
const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS && draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE;
|
||||
const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1;
|
||||
return (
|
||||
<>
|
||||
<section className="rpg-tactical-section rpg-tactical-offers">
|
||||
<header><h3>Spells · wave {draft.waveIndex + 1}/{SPELL_DRAFT_WAVE_COUNT}</h3><span>{draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked</span></header>
|
||||
<div className="rpg-tactical-list">
|
||||
{draft.offers.map((spellId) => {
|
||||
const spell = HEALER_ABILITIES[spellId];
|
||||
const selected = run.selectedSpellIds.includes(spellId);
|
||||
const disabled = (selected && !canRemove) || (!selected && !canPick);
|
||||
return (
|
||||
<FocusButton
|
||||
key={spellId}
|
||||
context={context}
|
||||
focusId={rpgFocusId.spellOffer(spellId)}
|
||||
command={{ type: "run-action", action: selected ? { type: "spell-remove", spellId } : { type: "spell-pick", spellId } }}
|
||||
className="rpg-tactical-offer is-spell"
|
||||
style={runAccentStyle(spell.color)}
|
||||
disabled={disabled}
|
||||
pressed={selected}
|
||||
>
|
||||
<i>{spell.icon}</i><span><small>{spell.targeting} · {spell.mana} mana</small><strong>{spell.name}</strong><em>{spell.description}</em></span><u>{selected ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Learn"}</u>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<TacticalSpellbook context={context} interactive={canRemove} />
|
||||
<TacticalContinue context={context} focusId={rpgFocusId.spellContinue} action={{ type: "spell-next-wave" }} label={finalWave ? "Lock spellbook" : "Reroll spells"} disabled={finalWave && !run.selectedSpellIds.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalBriefing({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const challenge = run.phase === "challenge-briefing";
|
||||
const boss = currentBoss(run);
|
||||
return (
|
||||
<>
|
||||
<section className="rpg-tactical-hero" style={!challenge && boss ? runAccentStyle(boss.accent) : undefined}>
|
||||
<i>{challenge ? "◇" : boss?.icon ?? "♛"}</i>
|
||||
<small>{challenge ? "Hallway challenge" : "Boss chamber"}</small>
|
||||
<h2>{challenge ? run.currentChallenge?.objective.name : boss?.name}</h2>
|
||||
<p>{challenge ? "Success improves the next chest. Failure still opens the boss door." : boss?.briefing}</p>
|
||||
</section>
|
||||
{challenge && <ChallengeObjective run={run} />}
|
||||
<TacticalParty context={context} />
|
||||
<TacticalContinue
|
||||
context={context}
|
||||
focusId={challenge ? rpgFocusId.challengeStart : rpgFocusId.bossStart}
|
||||
action={challenge ? { type: "challenge-start" } : { type: "boss-start" }}
|
||||
label={challenge ? "Start challenge" : "Enter chamber"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalLiveParty({ live }: { live: RpgLiveCombatUi }) {
|
||||
return (
|
||||
<section className="rpg-tactical-section rpg-live-party">
|
||||
<header><h3>Live party</h3><span>{live.party.filter((member) => member.hp > 0).length}/{live.party.length} standing</span></header>
|
||||
<div className="rpg-live-party-grid">
|
||||
{live.party.map((member) => {
|
||||
const health = Math.max(0, Math.min(100, member.hp / Math.max(1, member.maxHp) * 100));
|
||||
const selected = member.id === live.selectedMemberId;
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
className={`${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`.trim()}
|
||||
onClick={() => live.onSelectMember?.(member.id)}
|
||||
disabled={member.hp <= 0}
|
||||
aria-pressed={selected}
|
||||
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} of ${member.maxHp} health`}
|
||||
>
|
||||
<i style={{ "--rpg-member-color": member.color } as CSSProperties}>{member.name[0]}</i>
|
||||
<span><strong>{member.name}</strong><small>{member.className}</small><em><b style={{ width: `${health}%` }} /></em></span>
|
||||
<u>{member.hp <= 0 ? "DOWN" : `${Math.ceil(member.hp)} / ${member.maxHp}`}</u>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalLiveSpellbook({ context, live }: { context: RpgRunUiContext; live: RpgLiveCombatUi }) {
|
||||
const castProgress = live.activeCast
|
||||
? Math.max(0, Math.min(1, (live.time - live.activeCast.startedAt) / Math.max(0.001, live.activeCast.completesAt - live.activeCast.startedAt)))
|
||||
: 0;
|
||||
return (
|
||||
<section className="rpg-tactical-section rpg-live-spells">
|
||||
<header><h3>Spell arsenal</h3><span>{Math.ceil(live.mana)} / {live.maxMana} mana</span></header>
|
||||
<div className="rpg-live-resource-bar"><i style={{ width: `${Math.max(0, Math.min(100, live.mana / Math.max(1, live.maxMana) * 100))}%` }} /></div>
|
||||
<div className="rpg-live-resources">
|
||||
<span><b>Verdancy</b>{live.spellResources.verdancy}/5</span>
|
||||
<span><b>Tidal Surge</b>{live.spellResources.tidalSurge}/2</span>
|
||||
<span><b>Conviction</b>{live.spellResources.conviction}/3</span>
|
||||
<span><b>Chronoshards</b>{live.spellResources.chronoshards}/3</span>
|
||||
</div>
|
||||
{live.activeCast && (
|
||||
<div className="rpg-live-cast" style={{ "--rpg-cast-progress": `${castProgress * 100}%` } as CSSProperties}>
|
||||
<span>Casting <b>{HEALER_ABILITIES[live.activeCast.abilityId].name}</b></span><i><em /></i>
|
||||
</div>
|
||||
)}
|
||||
<div className="rpg-live-spell-grid">
|
||||
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => {
|
||||
const spellId = context.run.abilityLoadout[slotId];
|
||||
if (!spellId) return <div key={slotId} className="rpg-live-spell is-empty"><b>{index + 1}</b><span>Empty</span></div>;
|
||||
const spell = HEALER_ABILITIES[spellId];
|
||||
const cooldown = Math.max(0, live.cooldowns[slotId] - live.time, live.globalCooldownUntil - live.time);
|
||||
const selected = live.party.find((member) => member.id === live.selectedMemberId);
|
||||
const disabled = !live.onCastAbility || live.activeCast !== null || cooldown > 0 || live.mana < spell.mana
|
||||
|| (spell.targeting === "ally" && (!selected || selected.hp <= 0))
|
||||
|| (spell.pulseKind === "cleanse" && !selected?.debuffs.length);
|
||||
return (
|
||||
<button
|
||||
key={slotId}
|
||||
type="button"
|
||||
className={`rpg-live-spell ${cooldown > 0 ? "is-cooling" : ""}`}
|
||||
style={{ "--rpg-accent": spell.color } as CSSProperties}
|
||||
onClick={() => live.onCastAbility?.(slotId)}
|
||||
disabled={disabled}
|
||||
aria-label={`${spell.name}, ${cooldown > 0 ? `${cooldown.toFixed(1)} seconds remaining` : "ready"}`}
|
||||
>
|
||||
<b>{ABILITY_CONTROLLER_BINDINGS[slotId].glyph}</b><i>{spell.icon}</i><span><strong>{spell.shortName}</strong><small>{spell.mana} mana · Rank {context.run.spellRanks[spellId] ?? 0}</small></span>
|
||||
<u>{cooldown > 0 ? cooldown.toFixed(1) : "Ready"}</u>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalLive({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const boss = currentBoss(run);
|
||||
const live = context.liveCombat;
|
||||
return (
|
||||
<>
|
||||
{run.phase === "challenge-active" ? <ChallengeObjective run={run} /> : (
|
||||
<section className="rpg-tactical-hero is-live" style={boss ? runAccentStyle(boss.accent) : undefined}>
|
||||
<i>{boss?.icon ?? "♛"}</i><small>Boss battle</small><h2>{boss?.name}</h2><p>{boss?.summary}</p>
|
||||
</section>
|
||||
)}
|
||||
{live ? <TacticalLiveParty live={live} /> : <TacticalParty context={context} />}
|
||||
{live ? <TacticalLiveSpellbook context={context} live={live} /> : <TacticalSpellbook context={context} />}
|
||||
<p className="rpg-controller-hint"><b>D-pad</b> Target <i /> <b>Face buttons</b> Cast</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalCleared({ context }: { context: RpgRunUiContext }) {
|
||||
return (
|
||||
<>
|
||||
<section className="rpg-tactical-hero rpg-tactical-cleared">
|
||||
<i>↑</i><small>North gate open</small><h2>Room cleared</h2><p>Cross the far doorway. Health and fallen companions carry forward.</p>
|
||||
</section>
|
||||
<TacticalParty context={context} />
|
||||
<TacticalContinue context={context} focusId={rpgFocusId.rewardOpen} action={{ type: "reward-open" }} label="Open chest" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalRewards({ context }: { context: RpgRunUiContext }) {
|
||||
const chest = context.run.pendingReward;
|
||||
if (!chest) return null;
|
||||
return (
|
||||
<section className="rpg-tactical-section">
|
||||
<header><h3>Choose one reward</h3><span>Chest +{chest.quality}</span></header>
|
||||
<div className="rpg-tactical-rewards">
|
||||
{chest.choices.map((choice) => {
|
||||
const summary = rewardSummary(choice);
|
||||
return (
|
||||
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}>
|
||||
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalShop({ context }: { context: RpgRunUiContext }) {
|
||||
const { run } = context;
|
||||
const shop = run.shop;
|
||||
if (!shop) return null;
|
||||
const needsRest = run.playerHp > 0 && run.playerHp < 100
|
||||
|| run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp);
|
||||
return (
|
||||
<>
|
||||
<section className="rpg-tactical-section">
|
||||
<header><h3>Buy gear</h3><span>◆ {run.currency}</span></header>
|
||||
<div className="rpg-tactical-shop-grid">
|
||||
{shop.offers.map((offer) => (
|
||||
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
|
||||
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}>
|
||||
<span>{offer.sold ? "Sold" : "Buy"}</span>
|
||||
</FocusButton>
|
||||
} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rpg-tactical-section">
|
||||
<header><h3>Bag & services</h3><span>{run.bag.length} stored</span></header>
|
||||
<div className="rpg-tactical-list">
|
||||
{run.bag.map((item) => (
|
||||
<FocusButton key={item.id} context={context} focusId={rpgFocusId.shopSell(item.id)} command={{ type: "run-action", action: { type: "shop-sell", itemId: item.id } }} className="rpg-service-row">
|
||||
<span><small>Sell gear</small>{item.name}</span><b>◆ {item.sellPrice}</b>
|
||||
</FocusButton>
|
||||
))}
|
||||
<FocusButton context={context} focusId={rpgFocusId.shopRest} command={{ type: "run-action", action: { type: "shop-rest" } }} className="rpg-service-row" disabled={!needsRest || run.currency < shop.restCost}>
|
||||
<span><small>Restore living party</small>Rest</span><b>◆ {shop.restCost}</b>
|
||||
</FocusButton>
|
||||
{run.roster.filter((member) => member.hp <= 0).map((member) => (
|
||||
<FocusButton key={member.instanceId} context={context} focusId={rpgFocusId.shopRevive(member.instanceId)} command={{ type: "run-action", action: { type: "shop-revive", memberId: member.instanceId } }} className="rpg-service-row" disabled={run.currency < shop.reviveCost}>
|
||||
<span><small>Return at 50% HP</small>Revive {member.name}</span><b>◆ {shop.reviveCost}</b>
|
||||
</FocusButton>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<TacticalContinue context={context} focusId={rpgFocusId.shopLeave} action={{ type: "shop-leave" }} label="Leave shop" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TacticalTerminal({ context }: { context: RpgRunUiContext }) {
|
||||
const victory = context.run.phase === "victory";
|
||||
return (
|
||||
<section className={`rpg-tactical-terminal ${victory ? "is-victory" : "is-defeat"}`}>
|
||||
<i>{victory ? "♛" : "◇"}</i><small>{victory ? "Run complete" : "Run ended"}</small><h2>{victory ? "Victory" : "Party fallen"}</h2>
|
||||
<p>{context.run.bossesDefeated} bosses · {context.run.selectedSpellIds.length} spells · ◆ {context.run.currency}</p>
|
||||
<FocusButton context={context} focusId={rpgFocusId.restartRun} command={{ type: "restart-run" }} className="rpg-tactical-continue" disabled={!context.onRestartRun}><span>New run</span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b></FocusButton>
|
||||
<FocusButton context={context} focusId={rpgFocusId.exitRun} command={{ type: "exit-run" }} className="rpg-secondary-action" disabled={!context.onExitRun}>Mode select</FocusButton>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Secondary-display tactical projection. Same semantic focus IDs as top overlay. */
|
||||
export function RpgRunTacticalPanel(props: RpgRunUiProps) {
|
||||
const context = createUiContext(props);
|
||||
const run = props.run;
|
||||
return (
|
||||
<aside className={`rpg-run-tactical ${props.className ?? ""}`.trim()} aria-label="RPG roguelike tactics">
|
||||
<RunHeader run={run} compact />
|
||||
<RoutePips run={run} />
|
||||
<div className="rpg-tactical-body">
|
||||
{run.phase === "party-draft" && <TacticalPartyDraft context={context} />}
|
||||
{run.phase === "spell-draft" && <TacticalSpellDraft context={context} />}
|
||||
{(run.phase === "challenge-briefing" || run.phase === "boss-briefing") && <TacticalBriefing context={context} />}
|
||||
{(run.phase === "challenge-active" || run.phase === "boss-combat") && <TacticalLive context={context} />}
|
||||
{run.phase === "boss-cleared" && <TacticalCleared context={context} />}
|
||||
{run.phase === "reward" && <TacticalRewards context={context} />}
|
||||
{run.phase === "shop" && <TacticalShop context={context} />}
|
||||
{(run.phase === "victory" || run.phase === "defeat") && <TacticalTerminal context={context} />}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
|
||||
import { BOSS_DEFINITIONS } from "../../game/bossCatalog";
|
||||
import { HEALER_ABILITIES } from "../../game/healers";
|
||||
import type { AbilitySlotId, ActiveCast, HealerAbilityId, MemberId, PartyMember } from "../../game/types";
|
||||
import type {
|
||||
PartyDraftCandidate,
|
||||
PartyRosterMember,
|
||||
RewardChoice,
|
||||
RpgRoguelikeAction,
|
||||
RpgRoguelikeRunState,
|
||||
RunGearItem,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
|
||||
import type { RpgUiCommand } from "../../game/rpgRoguelike";
|
||||
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
|
||||
|
||||
export interface RpgRunUiProps {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
readonly focusedId?: string | null;
|
||||
readonly onFocusChange?: (focusId: string) => void;
|
||||
readonly onAction: (action: RpgRoguelikeAction) => void;
|
||||
readonly onRestartRun?: () => void;
|
||||
readonly onExitRun?: () => void;
|
||||
readonly className?: string;
|
||||
readonly liveCombat?: RpgLiveCombatUi;
|
||||
}
|
||||
|
||||
export interface RpgLiveCombatUi {
|
||||
readonly party: readonly PartyMember[];
|
||||
readonly selectedMemberId: MemberId;
|
||||
readonly mana: number;
|
||||
readonly maxMana: number;
|
||||
readonly cooldowns: Readonly<Record<AbilitySlotId, number>>;
|
||||
readonly globalCooldownUntil: number;
|
||||
readonly time: number;
|
||||
readonly activeCast: ActiveCast | null;
|
||||
readonly spellResources: {
|
||||
readonly verdancy: number;
|
||||
readonly tidalSurge: number;
|
||||
readonly conviction: number;
|
||||
readonly chronoshards: number;
|
||||
};
|
||||
readonly onSelectMember?: (memberId: MemberId) => void;
|
||||
readonly onCastAbility?: (abilitySlotId: AbilitySlotId) => void;
|
||||
}
|
||||
|
||||
export interface RpgRunUiContext extends RpgRunUiProps {
|
||||
readonly activeFocusId: string | null;
|
||||
}
|
||||
|
||||
export function createUiContext(props: RpgRunUiProps): RpgRunUiContext {
|
||||
return { ...props, activeFocusId: normalizeRpgFocusId(props.run, props.focusedId) };
|
||||
}
|
||||
|
||||
export function runAccentStyle(accent: string): CSSProperties {
|
||||
return { "--rpg-accent": accent } as CSSProperties;
|
||||
}
|
||||
|
||||
export function executeUiCommand(context: RpgRunUiContext, command: RpgUiCommand): void {
|
||||
if (command.type === "run-action") context.onAction(command.action);
|
||||
else if (command.type === "restart-run") context.onRestartRun?.();
|
||||
else context.onExitRun?.();
|
||||
}
|
||||
|
||||
interface FocusButtonProps {
|
||||
readonly context: RpgRunUiContext;
|
||||
readonly focusId: string;
|
||||
readonly command: RpgUiCommand;
|
||||
readonly className?: string;
|
||||
readonly disabled?: boolean;
|
||||
readonly pressed?: boolean;
|
||||
readonly style?: CSSProperties;
|
||||
readonly children: ReactNode;
|
||||
readonly label?: string;
|
||||
}
|
||||
|
||||
export function FocusButton({
|
||||
context,
|
||||
focusId,
|
||||
command,
|
||||
className = "",
|
||||
disabled = false,
|
||||
pressed,
|
||||
style,
|
||||
children,
|
||||
label,
|
||||
}: FocusButtonProps) {
|
||||
const selected = context.activeFocusId === focusId;
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!selected || !button) return;
|
||||
let parent = button.parentElement;
|
||||
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
|
||||
const childRect = button.getBoundingClientRect();
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
if (parent.scrollHeight > parent.clientHeight) {
|
||||
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
|
||||
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
|
||||
}
|
||||
if (parent.scrollWidth > parent.clientWidth) {
|
||||
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
|
||||
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}, [selected]);
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={`${className} ${selected ? "is-controller-selected" : ""}`.trim()}
|
||||
data-rpg-focus={focusId}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
aria-pressed={pressed}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
onFocus={() => context.onFocusChange?.(focusId)}
|
||||
onPointerEnter={() => !disabled && context.onFocusChange?.(focusId)}
|
||||
onClick={() => executeUiCommand(context, command)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function phaseLabel(run: RpgRoguelikeRunState): string {
|
||||
switch (run.phase) {
|
||||
case "party-draft": return "Party Draft";
|
||||
case "spell-draft": return "Spell Draft";
|
||||
case "challenge-briefing": return "Hallway Challenge";
|
||||
case "challenge-active": return "Challenge Active";
|
||||
case "boss-briefing": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Boss" : "Boss Door";
|
||||
case "boss-combat": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Battle" : "Boss Battle";
|
||||
case "boss-cleared": return "Room Cleared";
|
||||
case "reward": return "Reward Chest";
|
||||
case "shop": return `Act ${run.shop?.act ?? 1} Intermission`;
|
||||
case "victory": return "Run Complete";
|
||||
case "defeat": return "Party Fallen";
|
||||
}
|
||||
}
|
||||
|
||||
export function routeLabel(run: RpgRoguelikeRunState): string {
|
||||
if (run.bossIndex >= TOTAL_BOSS_COUNT - 1) return "Finale";
|
||||
return `Act ${Math.floor(run.bossIndex / BOSSES_PER_ACT) + 1} · Room ${(run.bossIndex % BOSSES_PER_ACT) + 1}`;
|
||||
}
|
||||
|
||||
export function currentBoss(run: RpgRoguelikeRunState) {
|
||||
const bossId = run.bossRoute[run.bossIndex];
|
||||
return bossId ? BOSS_DEFINITIONS[bossId] : null;
|
||||
}
|
||||
|
||||
export function RunHeader({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
|
||||
return (
|
||||
<header className={`rpg-run-header ${compact ? "is-compact" : ""}`.trim()}>
|
||||
<div>
|
||||
<small>{routeLabel(run)}</small>
|
||||
<strong>{phaseLabel(run)}</strong>
|
||||
</div>
|
||||
<div className="rpg-run-resources" aria-label={`${run.currency} gold, ${Math.ceil(run.playerHp)} healer health, ${run.bossesDefeated} bosses defeated`}>
|
||||
<span><i>◆</i>{run.currency}</span>
|
||||
<span><i>♥</i>{Math.ceil(run.playerHp)}</span>
|
||||
<span><i>♛</i>{run.bossesDefeated}/{TOTAL_BOSS_COUNT}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutePips({ run }: { run: RpgRoguelikeRunState }) {
|
||||
return (
|
||||
<div className="rpg-route-pips" aria-label={`${run.bossesDefeated} of ${TOTAL_BOSS_COUNT} bosses defeated`}>
|
||||
{run.bossRoute.map((bossId, index) => (
|
||||
<i
|
||||
key={`${bossId}-${index}`}
|
||||
className={`${index < run.bossesDefeated ? "is-cleared" : ""} ${index === run.bossIndex ? "is-current" : ""} ${index === TOTAL_BOSS_COUNT - 1 ? "is-finale" : ""}`.trim()}
|
||||
title={BOSS_DEFINITIONS[bossId].name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
export function PartyCard({
|
||||
member,
|
||||
candidate,
|
||||
selected = false,
|
||||
compact = false,
|
||||
action,
|
||||
}: {
|
||||
readonly member?: PartyRosterMember;
|
||||
readonly candidate?: PartyDraftCandidate;
|
||||
readonly selected?: boolean;
|
||||
readonly compact?: boolean;
|
||||
readonly action?: ReactNode;
|
||||
}) {
|
||||
const entry = member ?? candidate;
|
||||
if (!entry) return null;
|
||||
const hp = member?.hp ?? entry.stats.maxHp;
|
||||
const hpPercent = Math.max(0, Math.min(100, (hp / entry.stats.maxHp) * 100));
|
||||
return (
|
||||
<article
|
||||
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
|
||||
style={runAccentStyle(entry.color)}
|
||||
>
|
||||
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div>
|
||||
<h3>{entry.name}</h3>
|
||||
<p>{entry.className}</p>
|
||||
{!compact && (
|
||||
<div className="rpg-stat-row" aria-label="Combat stats">
|
||||
<span><small>HP</small>{entry.stats.maxHp}</span>
|
||||
<span><small>ST</small>{entry.stats.singleTarget.toFixed(2)}</span>
|
||||
<span><small>AOE</small>{entry.stats.areaDamage.toFixed(2)}</span>
|
||||
<span><small>DEF</small>{entry.stats.defense.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{member && (
|
||||
<div className="rpg-vital-bar" aria-label={`${Math.round(hp)} of ${entry.stats.maxHp} health`}>
|
||||
<i style={{ width: `${hpPercent}%` }} />
|
||||
<small>{hp <= 0 ? "Fallen" : `${Math.ceil(hp)} / ${entry.stats.maxHp}`}</small>
|
||||
</div>
|
||||
)}
|
||||
{!compact && entry.traitIds.length > 0 && <footer>{entry.traitIds.map(titleCase).join(" · ")}</footer>}
|
||||
{action}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpellCard({
|
||||
spellId,
|
||||
rank = 0,
|
||||
selected = false,
|
||||
compact = false,
|
||||
action,
|
||||
}: {
|
||||
readonly spellId: HealerAbilityId;
|
||||
readonly rank?: number;
|
||||
readonly selected?: boolean;
|
||||
readonly compact?: boolean;
|
||||
readonly action?: ReactNode;
|
||||
}) {
|
||||
const spell = HEALER_ABILITIES[spellId];
|
||||
return (
|
||||
<article
|
||||
className={`rpg-spell-card ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
|
||||
style={runAccentStyle(spell.color)}
|
||||
>
|
||||
<i className="rpg-spell-icon">{spell.icon}</i>
|
||||
<div>
|
||||
<small>{spell.targeting} · {spell.mana} mana{rank > 0 ? ` · Rank ${rank}` : ""}</small>
|
||||
<h3>{spell.name}</h3>
|
||||
{!compact && <p>{spell.description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): string {
|
||||
if (item.ownerId === "player") return "Healer";
|
||||
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
|
||||
}
|
||||
|
||||
export function GearCard({ run, item, price, action }: {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
readonly item: RunGearItem;
|
||||
readonly price?: number;
|
||||
readonly action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className="rpg-gear-card">
|
||||
<i>{item.slotId === "weapon" ? "⚔" : item.slotId === "armor" ? "⬡" : "◇"}</i>
|
||||
<div>
|
||||
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
|
||||
<h3>{item.name}</h3>
|
||||
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
|
||||
</div>
|
||||
{action}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function rewardSummary(choice: RewardChoice): { icon: string; eyebrow: string; detail: string; accent: string } {
|
||||
if (choice.kind === "spell-rank") {
|
||||
const spell = HEALER_ABILITIES[choice.spellId];
|
||||
return { icon: spell.icon, eyebrow: "Spell upgrade", detail: `${spell.name} reaches rank ${choice.nextRank}.`, accent: spell.color };
|
||||
}
|
||||
if (choice.kind === "member-rarity") {
|
||||
return { icon: "♟", eyebrow: "Party upgrade", detail: `Promote companion to ${choice.nextRarity}.`, accent: "#b47aec" };
|
||||
}
|
||||
if (choice.kind === "run-gear") {
|
||||
return { icon: "◇", eyebrow: `Gear +${choice.item.enhancement}`, detail: `Equip ${choice.item.name}; displaced gear moves to bag.`, accent: "#65aef2" };
|
||||
}
|
||||
return { icon: "◆", eyebrow: "Run currency", detail: `Gain ${choice.amount} gold for this run.`, accent: "#efc858" };
|
||||
}
|
||||
|
||||
export function ChallengeObjective({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
|
||||
const challenge = run.currentChallenge;
|
||||
const previous = run.lastChallengeResult;
|
||||
if (!challenge && !previous) return null;
|
||||
const objective = challenge?.objective ?? previous!.objective;
|
||||
const metrics = challenge?.metrics ?? previous!.metrics;
|
||||
const current = metrics[objective.metric];
|
||||
const progress = Math.max(0, Math.min(100, (current / objective.target) * 100));
|
||||
const copy = objective.challengeId === "blockbreaker"
|
||||
? `Break ${objective.target} bricks before the board falls.`
|
||||
: objective.challengeId === "hockey"
|
||||
? `Defeat ${objective.target} ${objective.target === 1 ? "boss" : "bosses"} without conceding.`
|
||||
: `Defeat ${objective.target} enemies before the assault ends.`;
|
||||
return (
|
||||
<section className={`rpg-challenge-objective ${compact ? "is-compact" : ""}`.trim()}>
|
||||
<div><small>Repeat tier {objective.repeatIndex + 1}</small><strong>{objective.name}</strong></div>
|
||||
{!compact && <p>{copy}</p>}
|
||||
<div className="rpg-objective-progress">
|
||||
<i style={{ width: `${progress}%` }} />
|
||||
<span>{current} / {objective.target}</span>
|
||||
</div>
|
||||
{!compact && <footer>Success: ◆ {objective.rewardCurrency} · chest quality +{objective.chestQualityBonus}</footer>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { RpgRoomPortals, type RpgRoomPortalsProps } from "./RpgRoomPortals";
|
||||
export { RpgRunOverlay } from "./RpgRunOverlay";
|
||||
export { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
|
||||
export type { RpgRunUiProps } from "./RpgRunUiShared";
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user