new rpg mode

This commit is contained in:
Warren H
2026-07-17 12:47:42 -04:00
parent de7b59ce77
commit 10ff124466
180 changed files with 20863 additions and 1014 deletions
+431 -21
View File
@@ -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
View File
@@ -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">
+4 -5
View File
@@ -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>;
}
File diff suppressed because it is too large Load Diff
+1605 -163
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -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>
);
}
+114
View File
@@ -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
View File
@@ -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>16</b> Cast</div>
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>16</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]);
});
});
+32
View File
@@ -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
View File
@@ -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>
);
}
+4
View File
@@ -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