Release v0.1.4 2026-07-12

This commit is contained in:
Warren H
2026-07-12 13:49:46 -04:00
parent b48b3a4f8f
commit bef71d391a
803 changed files with 3800 additions and 358322 deletions
+350
View File
@@ -0,0 +1,350 @@
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
import { useGameStore } from "../game/store";
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;
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
type ArenaFixture = {
position: readonly [number, number, number];
rotationY: number;
scale: number;
scaleX?: number;
scaleZ?: number;
scaleY?: number;
};
const ARENA_WALL_SEGMENTS: readonly ArenaFixture[] = Array.from({ length: 16 }, (_, index) => {
const angle = (index / 16) * Math.PI * 2;
return {
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 0, ROOM_CENTER_Z + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
rotationY: angle,
scale: 1,
scaleX: ARENA_SIZE_MULTIPLIER,
};
});
const ARENA_COLUMNS: readonly ArenaFixture[] = Array.from({ length: 8 }, (_, index) => {
const angle = (index / 8) * Math.PI * 2 + Math.PI / 8;
return {
position: [Math.sin(angle) * 8.2 * ARENA_SIZE_MULTIPLIER, 0, ROOM_CENTER_Z + Math.cos(angle) * 8.2 * ARENA_SIZE_MULTIPLIER] as const,
rotationY: angle + Math.PI / 2,
scale: 0.9,
};
});
const ARENA_TORCH_COLORS = [
new THREE.Color("#ffb24d"),
new THREE.Color("#ff7048"),
new THREE.Color("#f7dd88"),
new THREE.Color("#72d6ce"),
] as const;
const ARENA_TORCHES: readonly ArenaFixture[] = ARENA_COLUMNS.map((column) => ({
position: [column.position[0], 3.45, column.position[2]] as const,
rotationY: column.rotationY,
scale: 0.9,
}));
const SCENERY_SLOTS = Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2 + Math.PI / 10;
return { angle, x: Math.sin(angle) * 8.45 * ARENA_SIZE_MULTIPLIER, z: ROOM_CENTER_Z + Math.cos(angle) * 8.45 * ARENA_SIZE_MULTIPLIER };
});
type MarkPattern = "rays" | "rings" | "cross" | "pools";
const ROOM_PATTERNS: Record<BossRoomFloor, MarkPattern> = {
cinder: "rays",
desert: "rings",
tide: "pools",
reliquary: "rings",
royal: "cross",
prism: "rays",
storm: "rays",
wilds: "rings",
void: "rays",
mire: "pools",
quarry: "rays",
junkyard: "cross",
web: "rings",
moon: "rings",
frost: "rays",
warcamp: "cross",
sky: "pools",
};
type SceneryKind = "crystal" | "obelisk" | "coral" | "grave" | "crown" | "shard" | "lightning" | "tree" | "rift" | "reed" | "boulder" | "scrap" | "web" | "moonstone" | "ice" | "banner" | "cloud";
const ROOM_SCENERY: Record<BossRoomFloor, SceneryKind> = {
cinder: "crystal",
desert: "obelisk",
tide: "coral",
reliquary: "grave",
royal: "crown",
prism: "shard",
storm: "lightning",
wilds: "tree",
void: "rift",
mire: "reed",
quarry: "boulder",
junkyard: "scrap",
web: "web",
moon: "moonstone",
frost: "ice",
warcamp: "banner",
sky: "cloud",
};
function firstMesh(scene: THREE.Object3D) {
let mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]> | undefined;
scene.traverse((child) => {
if (!mesh && child instanceof THREE.Mesh) mesh = child;
});
if (!mesh) throw new Error("Dungeon arena asset has no mesh.");
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
function DungeonAssetInstances({
url,
fixtures,
tint,
opacity = 1,
colors,
}: {
url: string;
fixtures: readonly ArenaFixture[];
tint: string;
opacity?: number;
colors?: readonly THREE.Color[];
}) {
const gltf = useGLTF(url, false, true);
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
const material = useMemo(() => {
const next = sourceMaterial.clone();
if ("color" in next) (next as THREE.MeshStandardMaterial).color.set(tint);
next.transparent = opacity < 1;
next.opacity = opacity;
next.depthWrite = opacity >= 1;
return next;
}, [opacity, sourceMaterial, tint]);
const instances = useRef<THREE.InstancedMesh>(null);
useEffect(() => () => material.dispose(), [material]);
useLayoutEffect(() => {
if (!instances.current) return;
const dummy = new THREE.Object3D();
fixtures.forEach((fixture, index) => {
dummy.position.fromArray(fixture.position);
dummy.rotation.set(0, fixture.rotationY, 0);
dummy.scale.set(
fixture.scale * (fixture.scaleX ?? 1),
fixture.scale * (fixture.scaleY ?? 1),
fixture.scale * (fixture.scaleZ ?? 1),
);
dummy.updateMatrix();
instances.current!.setMatrixAt(index, dummy.matrix);
if (colors) instances.current!.setColorAt(index, colors[index % colors.length]);
});
instances.current.instanceMatrix.needsUpdate = true;
if (instances.current.instanceColor) instances.current.instanceColor.needsUpdate = true;
instances.current.computeBoundingSphere();
}, [colors, fixtures]);
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
}
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
...fixture,
scaleY: room.wallHeight / 4,
})), [room.wallHeight]);
return (
<group>
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
const walls = useRef<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
useFrame(({ camera }) => {
if (!walls.current) return;
const previous = previousCameraPosition.current;
if (previous && previous.distanceToSquared(camera.position) < 0.0001) return;
const cameraPosition = previous ?? new THREE.Vector3();
cameraPosition.copy(camera.position);
previousCameraPosition.current = cameraPosition;
for (const child of walls.current.children) {
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
const distance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
material.opacity = THREE.MathUtils.smoothstep(distance, 2.5, 7.5) * 0.48 + 0.05;
}
});
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
<mesh
key={index}
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
rotation={[0, fixture.rotationY, 0]}
receiveShadow
>
<boxGeometry args={[3.86 * ARENA_SIZE_MULTIPLIER, room.wallHeight, 0.22]} />
<meshStandardMaterial color={room.wallColor} roughness={0.9} transparent opacity={0.53} depthWrite={false} />
</mesh>
))}
</group>
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<ArenaArchitecture room={room} />
</Suspense>
);
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
const rays = useRef<THREE.InstancedMesh>(null);
const pattern = ROOM_PATTERNS[room.floor];
useLayoutEffect(() => {
if (!rays.current || pattern === "rings" || pattern === "pools") return;
const dummy = new THREE.Object3D();
const count = pattern === "cross" ? 4 : 8;
for (let index = 0; index < count; index += 1) {
const angle = (index / count) * Math.PI * 2 + (pattern === "cross" ? Math.PI / 4 : 0);
const radius = (pattern === "cross" ? 3 : 4.3) * ARENA_SIZE_MULTIPLIER;
dummy.position.set(Math.sin(angle) * radius, 0.025, ROOM_CENTER_Z + Math.cos(angle) * radius);
dummy.rotation.set(0, -angle, 0);
dummy.scale.set(
(pattern === "cross" ? 1.6 : 0.75) * ARENA_SIZE_MULTIPLIER,
1,
(pattern === "cross" ? 0.48 : 2.6) * ARENA_SIZE_MULTIPLIER,
);
dummy.updateMatrix();
rays.current.setMatrixAt(index, dummy.matrix);
}
rays.current.instanceMatrix.needsUpdate = true;
}, [pattern]);
if (pattern === "rings") {
return (
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.022, ROOM_CENTER_Z]}>
<mesh><ringGeometry args={[2.4 * ARENA_SIZE_MULTIPLIER, 2.5 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accent} transparent opacity={0.32} /></mesh>
<mesh><ringGeometry args={[5.2 * ARENA_SIZE_MULTIPLIER, 5.3 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accentSecondary} transparent opacity={0.22} /></mesh>
<mesh><ringGeometry args={[7.5 * ARENA_SIZE_MULTIPLIER, 7.58 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accent} transparent opacity={0.16} /></mesh>
</group>
);
}
if (pattern === "pools") {
return (
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.024, ROOM_CENTER_Z]}>
{[[0, 0, 2.5], [4, -2.4, 1.5], [-4.4, 2.8, 1.25]].map(([x, z, radius], index) => (
<mesh key={index} position={[x * ARENA_SIZE_MULTIPLIER, z * ARENA_SIZE_MULTIPLIER, 0]}>
<circleGeometry args={[radius * ARENA_SIZE_MULTIPLIER, 32]} />
<meshBasicMaterial color={index === 0 ? room.accent : room.accentSecondary} transparent opacity={0.2} depthWrite={false} />
</mesh>
))}
</group>
);
}
return (
<instancedMesh ref={rays} args={[undefined, undefined, 8]}>
<boxGeometry args={[0.15, 0.025, 1]} />
<meshBasicMaterial color={room.accent} transparent opacity={0.3} depthWrite={false} />
</instancedMesh>
);
}
function RoomScenery({ room }: { room: BossRoomDefinition }) {
const instances = useRef<THREE.InstancedMesh>(null);
const kind = ROOM_SCENERY[room.floor];
const height = kind === "tree" ? 2.1 : kind === "obelisk" || kind === "banner" ? 1.5 : kind === "ice" || kind === "lightning" ? 1.2 : 0.7;
useLayoutEffect(() => {
if (!instances.current) return;
const dummy = new THREE.Object3D();
SCENERY_SLOTS.forEach(({ angle, x, z }, index) => {
const scale = 0.78 + (index % 3) * 0.12;
dummy.position.set(x, height * scale, z);
dummy.rotation.set(0, -angle + (index % 2 ? 0.35 : 0), 0);
dummy.scale.setScalar(scale);
dummy.updateMatrix();
instances.current!.setMatrixAt(index, dummy.matrix);
instances.current!.setColorAt(index, new THREE.Color(index % 2 ? room.accent : room.accentSecondary));
});
instances.current.instanceMatrix.needsUpdate = true;
if (instances.current.instanceColor) instances.current.instanceColor.needsUpdate = true;
}, [height, kind, room.accent, room.accentSecondary]);
return (
<instancedMesh ref={instances} args={[undefined, undefined, SCENERY_SLOTS.length]} castShadow receiveShadow>
{kind === "crystal" || kind === "shard" || kind === "ice" || kind === "lightning" ? <coneGeometry args={[0.46, height * 2, kind === "ice" ? 4 : 5]} />
: kind === "obelisk" || kind === "grave" || kind === "moonstone" ? <boxGeometry args={[0.62, height * 2, 0.38]} />
: kind === "coral" || kind === "reed" ? <cylinderGeometry args={[0.12, 0.34, height * 2, 5]} />
: kind === "crown" ? <cylinderGeometry args={[0.52, 0.68, height * 2, 6]} />
: kind === "tree" ? <coneGeometry args={[0.8, height * 2, 7]} />
: kind === "rift" ? <tetrahedronGeometry args={[height]} />
: kind === "boulder" || kind === "cloud" ? <dodecahedronGeometry args={[height, 0]} />
: kind === "scrap" ? <boxGeometry args={[0.94, height, 0.48]} />
: kind === "web" ? <icosahedronGeometry args={[height, 1]} />
: <boxGeometry args={[0.16, height * 2, 0.95]} />}
<meshStandardMaterial vertexColors color={room.accent} emissive={room.accent} emissiveIntensity={kind === "web" || kind === "rift" ? 0.24 : 0.08} roughness={0.68} />
</instancedMesh>
);
}
function RoomFloor({ room }: { room: BossRoomDefinition }) {
return (
<group>
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
<cylinderGeometry args={[10.5 * ARENA_SIZE_MULTIPLIER, 11.2 * ARENA_SIZE_MULTIPLIER, 0.8, 48]} />
<meshStandardMaterial color={room.ground} roughness={0.94} />
</mesh>
<mesh position={[0, -0.02, ROOM_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[9.35 * ARENA_SIZE_MULTIPLIER, 48]} />
<meshStandardMaterial color={room.floorColor} roughness={0.86} metalness={room.floor === "royal" || room.floor === "junkyard" ? 0.34 : 0.04} />
</mesh>
<mesh position={[0, -0.008, ROOM_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[8.62 * ARENA_SIZE_MULTIPLIER, 8.82 * ARENA_SIZE_MULTIPLIER, 48]} />
<meshBasicMaterial color={room.accentSecondary} transparent opacity={0.32} />
</mesh>
<RoomMarks room={room} />
<RoomScenery room={room} />
<RoomWalls room={room} />
</group>
);
}
/** Main-display room projection. Gameplay stays in the shared arena domain. */
export function BossRoom() {
const bossId = useGameStore((state) => state.boss.id);
const room = bossRoomFor(bossId);
return (
<group key={room.id}>
<color attach="background" args={[room.background]} />
<fog attach="fog" args={[room.fog, 15 * ARENA_SIZE_MULTIPLIER, 32 * ARENA_SIZE_MULTIPLIER]} />
<hemisphereLight args={[room.sky, room.ground, 1.18]} />
<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} />
</group>
);
}
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
+1 -1
View File
@@ -9,7 +9,7 @@ import { useFrontendStore } from "../frontend/store";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
if (!rewards.length) return null;
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.coin.id}-${index}`}><b>{reward.coin.glyph}</b>{reward.coin.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.drop.id}-${index}`}><b>{reward.drop.glyph}</b>{reward.drop.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
}
function HealthBar({ member }: { member: PartyMember }) {
+53 -34
View File
@@ -5,7 +5,7 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import {
@@ -20,7 +20,7 @@ import {
gearBonusText,
gearUpgradeCosts,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } from "../game/progression/loot";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
import {
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
PASSIVE_INFUSIONS,
@@ -388,7 +388,7 @@ function HomeScreen() {
</div>
<div className="home-secondary-actions">
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i></i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend boss coins</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b></b></FocusButton>
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i></i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b></b></FocusButton>
</div>
<ControllerLegend back />
@@ -424,10 +424,10 @@ function ProfileScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const actions = useMemo<MenuAction[]>(() => [
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
{ id: "back", run: () => navigate("home") },
], [collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
@@ -441,22 +441,22 @@ function ProfileScreen() {
top={
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="collection-heading"><span><small>Boss spoils</small><h2>{collection.bossName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}</p>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Every drop stays counted.</strong><small>Duplicates increase quantity instead of disappearing.</small></span></div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}</small></span></div>
</FrontSurface>
}
bottom={
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and boss list">
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and mechanic group list">
<header className="context-header"><span>{hunter.hunterName} · {activeHealer.name} stats</span><b>LEVEL {activeProgress.level}</b></header>
<div className="profile-stats">
<span><small>Total boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
@@ -464,9 +464,9 @@ function ProfileScreen() {
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
</div>
<div className="boss-log"><span>Boss records</span>{collections.map((boss) => (
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
<i>{group.defeated ? group.groupLetter : "?"}</i><span><strong>Group {group.groupLetter} · {group.groupName}</strong><small>{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}</small></span><b>{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length}</b>
</FocusButton>
))}</div>
</FrontSurface>
@@ -558,7 +558,7 @@ function GearScreen() {
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
@@ -576,7 +576,7 @@ function GearScreen() {
{workshopMode === "upgrade" ? <article className="gear-preview">
<span>Selected upgrade</span>
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
<p>{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</p>
<p>{GEAR_STAT_LABELS[recipe.statId]} from Group {BOSS_GROUP_BY_ID[recipe.primaryGroupId].letter} and Group {BOSS_GROUP_BY_ID[recipe.secondaryGroupId].letter} drops.</p>
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i></i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
</article> : <article className="gear-preview gear-infusion-preview">
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
@@ -593,7 +593,7 @@ function GearScreen() {
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
@@ -601,7 +601,7 @@ function GearScreen() {
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -664,6 +664,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const bossPageSize = 12;
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
@@ -671,36 +673,46 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
const changeBossPage = (nextPage: number) => {
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
setBossPage(page);
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
};
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking connects here when game server is configured.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
};
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
? `boss-${BOSS_ORDER[index + 1]}`
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
? `boss-${visibleBossIds[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1),
right: neighborInColumn(column + 1),
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
},
};
}) : []),
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
@@ -712,8 +724,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
@@ -741,10 +753,17 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<span>Choose encounter</span>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
{BOSS_ORDER.map((bossId) => {
<div className="boss-picker-heading">
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
<div>
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}> Previous</FocusButton>
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next </FocusButton>
</div>
</div>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
{visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
const group = BOSS_GROUP_BY_ID[boss.groupId];
return (
<FocusButton
key={bossId}
@@ -756,7 +775,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
@@ -778,7 +797,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
</FrontSurface>
}
/>
+300 -253
View File
@@ -1,53 +1,79 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
import { useGameStore } from "../game/store";
import type { MemberId, PulseKind } from "../game/types";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "bulldrome"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
>, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
"warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href,
"tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href,
"broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href,
"sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href,
"razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href,
"bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href,
"moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href,
"frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href,
"rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href,
};
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
nia: new URL("../../game_assets/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../../game_assets/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../../game_assets/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
};
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
aelia: { right: new URL("../../game_assets/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
brann: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
},
nia: { right: new URL("../../game_assets/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
orin: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
},
vale: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
},
};
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
@@ -58,19 +84,8 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
orin: "Spellcast_Shoot",
vale: "Dualwield_Melee_Attack_Chop",
};
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
});
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
const angle = (index / 16) * Math.PI * 2;
return {
angle,
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
};
});
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
@@ -244,77 +259,89 @@ function PartyCharacterModel({
);
}
function Arena() {
const pillarInstances = useRef<THREE.InstancedMesh>(null);
const torchInstances = useRef<THREE.InstancedMesh>(null);
const GAMEPLAY_FRAME_INTERVAL_MS = 1000 / 60;
const BACKGROUND_FRAME_INTERVAL_MS = 1000 / 30;
const FRAME_INTERVAL_JITTER_MS = 1.5;
const SIMULATION_STEP_SECONDS = 0.1;
const MAX_SIMULATION_STEPS_PER_FRAME = 3;
const MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25;
/**
* Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains
* in the store and is advanced at its existing fixed 10 Hz cadence.
*/
function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) {
const { advance } = useThree();
const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused);
const frameId = useRef<number | null>(null);
const lastRenderedAt = useRef<number | null>(null);
const simulationAccumulator = useRef(0);
const slowFrameMs = useRef(0);
const stableFrameMs = useRef(0);
const dprRef = useRef(dpr);
dprRef.current = dpr;
useEffect(() => {
const pillars = pillarInstances.current;
const torches = torchInstances.current;
if (!pillars || !torches) return;
const matrix = new THREE.Matrix4();
ARENA_COLUMNS.forEach(([x, z], index) => {
matrix.makeTranslation(x, 1.1, z - 1);
pillars.setMatrixAt(index, matrix);
matrix.makeTranslation(x, 2.6, z - 1);
torches.setMatrixAt(index, matrix);
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
});
pillars.instanceMatrix.needsUpdate = true;
torches.instanceMatrix.needsUpdate = true;
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
}, []);
const schedule = (now: number) => {
const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS;
const previous = lastRenderedAt.current;
if (previous === null) {
lastRenderedAt.current = now;
advance(now / 1000, true);
} else {
const elapsedMs = now - previous;
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) {
lastRenderedAt.current = now;
const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25);
return (
<group>
<mesh position={[0, -0.45, -1]} receiveShadow>
<cylinderGeometry args={[10.5, 11.2, 0.8, 48]} />
<meshStandardMaterial color="#182420" roughness={0.92} />
</mesh>
<mesh position={[0, -0.015, -1]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<ringGeometry args={[4.8, 5.05, 64]} />
<meshBasicMaterial color="#765c32" transparent opacity={0.55} />
</mesh>
<mesh position={[0, -0.01, -1]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[2.1, 48]} />
<meshStandardMaterial color="#27342d" roughness={1} />
</mesh>
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
<meshStandardMaterial color="#26342f" roughness={0.8} />
</instancedMesh>
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
<octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial />
</instancedMesh>
<ArenaWalls />
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
</group>
);
}
if (gameplayActive) {
simulationAccumulator.current += elapsedSeconds;
let steps = 0;
while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) {
const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
simulationAccumulator.current -= SIMULATION_STEP_SECONDS;
steps += 1;
}
function ArenaWalls() {
const walls = useRef<THREE.Group>(null);
useFrame(({ camera }) => {
if (!walls.current) return;
for (const child of walls.current.children) {
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
}
});
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
<boxGeometry args={[3.86, 2.3, 0.18]} />
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
</mesh>
))}
</group>
);
if (elapsedMs > 20) {
slowFrameMs.current += elapsedMs;
stableFrameMs.current = 0;
if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
slowFrameMs.current = 0;
}
} else {
slowFrameMs.current = 0;
stableFrameMs.current += elapsedMs;
if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
stableFrameMs.current = 0;
}
}
} else {
simulationAccumulator.current = 0;
slowFrameMs.current = 0;
stableFrameMs.current = 0;
}
advance(now / 1000, true);
}
}
frameId.current = requestAnimationFrame(schedule);
};
frameId.current = requestAnimationFrame(schedule);
return () => {
if (frameId.current !== null) cancelAnimationFrame(frameId.current);
frameId.current = null;
lastRenderedAt.current = null;
simulationAccumulator.current = 0;
};
}, [advance, gameplayActive, onDprChange]);
return null;
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
@@ -517,25 +544,35 @@ function PlayerCharacter() {
function Party() {
const selected = useGameStore((state) => state.selectedMemberId);
const [loadSupportModels, setLoadSupportModels] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => setLoadSupportModels(true), 750);
return () => window.clearTimeout(timer);
}, []);
return (
<>
<PlayerCharacter />
{PARTY_MEMBER_IDS.map((memberId) => (
<Character
key={memberId}
memberId={memberId}
selected={selected === memberId}
/>
))}
<Suspense fallback={<PartyFallback memberIds={CRITICAL_PARTY_MEMBER_IDS} />}>
<PlayerCharacter />
<Character memberId="brann" selected={selected === "brann"} />
</Suspense>
{loadSupportModels ? (
<Suspense fallback={<PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}>
{SUPPORT_PARTY_MEMBER_IDS.map((memberId) => (
<Character key={memberId} memberId={memberId} selected={selected === memberId} />
))}
</Suspense>
) : <PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}
</>
);
}
function PartyFallback() {
function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
const positions = useGameStore((state) => state.partyPositions);
return (
<>
{(Object.keys(positions) as MemberId[]).map((memberId) => (
{memberIds.map((memberId) => (
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
<meshStandardMaterial color="#79998c" roughness={0.8} />
@@ -554,7 +591,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
);
}
@@ -625,8 +662,8 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
} else if (motion.mode === "returning") {
facingX = state.partyPositions.brann[0] + motion.formationOffsetX - motion.position[0];
facingZ = state.partyPositions.brann[1] - 4.25 - motion.position[1];
facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0];
facingZ = ARENA_CENTER[1] - motion.position[1];
}
if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ);
@@ -645,67 +682,21 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
const ALTERNATE_BOSS_CONFIG = {
vexa: {
url: INSECT_QUEEN_URL,
scale: 9,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#bb67ff",
rotationOffset: 0,
prototype: true,
},
cindermaw: {
url: BLUE_EYES_WHITE_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8742",
rotationOffset: 0,
prototype: true,
},
"ember-mantis-duelist": {
url: GATE_GUARDIAN_URL,
scale: 4.3,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff5a24",
rotationOffset: 0,
prototype: true,
},
"obsidian-ram-golem": {
url: GANDORA_URL,
scale: 6.2,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff7438",
rotationOffset: 0,
prototype: true,
},
"cinderback-ricochet": {
url: RED_EYES_BLACK_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8b3d",
rotationOffset: 0,
prototype: true,
},
interface AlternateBossConfig {
url: string;
scale: number;
idle: string;
move: string;
attack: string;
special: string;
death: string;
light: string;
rotationOffset: number;
prototype?: boolean;
floating?: boolean;
}
const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
"sandglass-scorpion": {
url: SANDGLASS_URL,
scale: 0.7,
@@ -728,30 +719,6 @@ const ALTERNATE_BOSS_CONFIG = {
light: "#49d5df",
rotationOffset: 0,
},
"pumpking-king-of-ghosts": {
url: PUMPKING_URL,
scale: 1.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#d87842",
rotationOffset: 0,
prototype: true,
},
"blue-eyes-ultimate-dragon": {
url: BLUE_EYES_ULTIMATE_URL,
scale: 4.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#8fc8ff",
rotationOffset: 0,
prototype: true,
},
"mournveil-ghost": {
url: MOURNVEIL_URL,
scale: 1.1,
@@ -762,6 +729,7 @@ const ALTERNATE_BOSS_CONFIG = {
death: "Death",
light: "#9d72ff",
rotationOffset: 0,
floating: true,
},
"crownshard-golem": {
url: CROWNSHARD_URL,
@@ -773,14 +741,94 @@ const ALTERNATE_BOSS_CONFIG = {
death: "Death",
light: "#e0bd45",
rotationOffset: 0,
floating: true,
},
} as const;
"crystal-bat-matriarch": {
url: CRYSTAL_BAT_MATRIARCH_URL,
scale: 1.04,
idle: "Idle",
move: "Swoop",
attack: "SonicPulse",
special: "MirrorShatter",
death: "Death",
light: "#8eeaff",
rotationOffset: 0,
floating: true,
},
"stormwool-alpaca": {
url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0, prototype: true,
},
"cluckhorn-colossus": {
url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0, prototype: true,
},
"ashwing-demon": {
url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, prototype: true, floating: true,
},
"riftclaw-demon": {
url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0, prototype: true,
},
"tempestscale-dragon": {
url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, prototype: true, floating: true,
},
emberfox: {
url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0, prototype: true,
},
"mirelord-frog": {
url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0, prototype: true,
},
"stonebreaker-giant": {
url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0, prototype: true,
},
"glub-sovereign": {
url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, prototype: true, floating: true,
},
"scrapking-goblin": {
url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0, prototype: true,
},
"warcaller-orc": {
url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0, prototype: true,
},
"tuskmaw-orc": {
url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0, prototype: true,
},
"broodfang-spider": {
url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0, prototype: true,
},
"silkfang-spider": {
url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0, prototype: true,
},
"thorncrown-stag": {
url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0, prototype: true,
},
"sky-totem": {
url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, prototype: true, floating: true,
},
"razorcrest-raptor": {
url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0, prototype: true,
},
"bristlequake-boar": {
url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0, prototype: true,
},
"moonfang-wolf": {
url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0, prototype: true,
},
"frostmaw-yeti": {
url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0, prototype: true,
},
"rimeclaw-yeti": {
url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0, prototype: true,
},
};
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
"charging",
"returning",
"sandglass_burrowing",
"crab_scuttling",
] as const;
const PROTOTYPE_ATTACK_MODES = [
@@ -800,33 +848,23 @@ const PROTOTYPE_ATTACK_MODES = [
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
"telegraph",
"stacking",
"pouncing",
"sandglass_burrow_telegraph",
"sandglass_eruption",
"sandglass_hourglass",
"crab_scuttle_telegraph",
"crab_tidal_burst",
] as const;
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
if ("prototype" in config && config.prototype) {
if (config.prototype) {
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
return config.idle;
}
if (kind === "ember-mantis-duelist") {
if (motionMode === "mantis_sidestep") return config.move;
if (motionMode === "mantis_line_telegraph") return config.attack;
if (motionMode === "mantis_cross_telegraph") return config.special;
if (motionMode === "mantis_recover") return "Recover";
}
if (kind === "obsidian-ram-golem") {
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
if (motionMode === "ram_quake") return config.special;
if (motionMode === "ram_shatter") return "ArmorShatter";
if (motionMode === "ram_recover") return "Stagger";
}
if (kind === "cinderback-ricochet") {
if (motionMode === "cinderback_curl") return config.attack;
if (motionMode === "cinderback_ricochet") return config.move;
if (motionMode === "cinderback_slam") return config.special;
if (motionMode === "cinderback_recover") return "Recover";
}
if (kind === "sandglass-scorpion") {
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
if (motionMode === "sandglass_eruption") return config.attack;
@@ -846,16 +884,18 @@ function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeo
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
}
if (kind === "cindermaw") {
if (motionMode === "skyfall") return config.move;
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
if (kind === "crystal-bat-matriarch") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
if (motionMode === "golem_recover") return "Stagger";
}
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
return config.idle;
}
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
@@ -881,13 +921,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16);
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
const timeScale = archetype === "duelist" && motionMode === "mantis_line_telegraph"
? 0.55
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
: archetype === "duelist" && motionMode === "mantis_cross_telegraph"
? 0.6
: 1;
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
const authoredOneShot = ![config.idle, config.move].includes(clipName);
if (phase === "victory" || defeated || authoredOneShot) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
@@ -895,7 +935,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
}, [actions, archetype, clipName, config.idle, config.move, defeated, motionMode, phase]);
useFrame((_, delta) => {
if (!group.current) return;
@@ -903,9 +943,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const current = encounterBossAt(state, bossIndex);
if (!current) return;
const motion = current.motion;
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
const burrowed = archetype === "burrower" && motion.mode === "sandglass_burrowing";
const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
@@ -913,9 +953,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
state.partyPositions.brann[0] - motion.position[0],
state.partyPositions.brann[1] - motion.position[1],
);
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (kind === "ember-mantis-duelist" && (
} else if (archetype === "duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
@@ -1181,6 +1221,8 @@ function PerformanceProbe() {
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: {
targetFps: 60,
budgetMs: 16.67,
averageMs: total / samples.length,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
@@ -1192,6 +1234,9 @@ function PerformanceProbe() {
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
pixelRatio: gl.getPixelRatio(),
renderWidth: gl.domElement.width,
renderHeight: gl.domElement.height,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
@@ -1237,24 +1282,25 @@ function CombatFx() {
}
export function GameScene() {
const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
const setRenderDpr = useCallback((next: number) => {
setDpr((current) => Math.abs(current - next) < 0.001 ? current : next);
}, []);
return (
<Canvas
shadows
dpr={[1, 1.5]}
frameloop="never"
shadows="basic"
dpr={dpr}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ antialias: true, powerPreference: "high-performance" }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
>
<color attach="background" args={["#07110f"]} />
<fog attach="fog" args={["#07110f", 17, 32]} />
<hemisphereLight args={["#8ac4b5", "#15100b", 1.25]} />
<directionalLight castShadow position={[5, 10, 8]} intensity={2.2} color="#ffe2a9" shadow-mapSize={[1024, 1024]} />
<Arena />
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
<BossRoom />
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Suspense fallback={<PartyFallback />}>
<Party />
</Suspense>
<Party />
<BossActor />
<RangedProjectiles />
<CombatFx />
@@ -1263,8 +1309,9 @@ export function GameScene() {
);
}
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
const loadout = PARTY_WEAPON_URLS[memberId];
useGLTF.preload(loadout.right, false, true);
if (loadout.left) useGLTF.preload(loadout.left, false, true);
}
+10 -4
View File
@@ -1,10 +1,13 @@
import { lazy, Suspense } from "react";
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GameScene } from "./GameScene";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
function CompactParty() {
const party = useGameStore((state) => state.party);
const time = useGameStore((state) => state.time);
@@ -110,15 +113,16 @@ function PhaseOverlay() {
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(" & ");
if (phase === "combat") return null;
const title = phase === "briefing"
? definitions.map((boss) => boss.title).join(" & ")
? room.name
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
const eyebrow = phase === "briefing"
? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial)
? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}`
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
@@ -182,7 +186,9 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
<GameScene />
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
<GameScene />
</Suspense>
<div className="top-vignette" />
<div className="top-hud">
<CompactParty />
+289 -5
View File
@@ -2,17 +2,31 @@ import { useFrame } from "@react-three/fiber";
import { useRef, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
import { CINDER_BREATH } from "../../game/bosses/cindermaw";
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
import { useGameStore } from "../../game/store";
import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const EMPTY_POOL_TELEGRAPHS: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
const SOAK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const WARD_ARROW_DIRECTIONS = [0, Math.PI / 2, Math.PI, Math.PI * 1.5] as const;
const INWARD_ARROW_SHAPE = new THREE.Shape()
.moveTo(-0.13, -0.32)
.lineTo(0.13, -0.32)
.lineTo(0.13, 0.04)
.lineTo(0.29, 0.04)
.lineTo(0, 0.38)
.lineTo(-0.29, 0.04)
.lineTo(-0.13, 0.04)
.lineTo(-0.13, -0.32);
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
@@ -212,12 +226,12 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - CINDER_BREATH.halfAngle]}>
<circleGeometry args={[CINDER_BREATH.range, 64, 0, CINDER_BREATH.halfAngle * 2]} />
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, CINDER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[CINDER_BREATH.range * 0.47, CINDER_BREATH.range * 0.49, 48, 1, -CINDER_BREATH.halfAngle, CINDER_BREATH.halfAngle * 2]} />
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
@@ -276,6 +290,274 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
}
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const wardGroup = useRef<THREE.Group>(null);
const ghostGroup = useRef<THREE.Group>(null);
const outerColumn = useRef<THREE.MeshBasicMaterial>(null);
const innerColumn = useRef<THREE.MeshBasicMaterial>(null);
const groundRing = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
const current = motionAt(useGameStore.getState(), bossIndex);
const siphon = current?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
const active = useGameStore.getState().phase === "combat" && siphon;
if (!active || !siphon) return;
const pulse = (Math.sin(clock.elapsedTime * 5.5) + 1) * 0.5;
if (wardGroup.current) {
wardGroup.current.position.set(siphon.wardPosition[0], 0, siphon.wardPosition[1]);
wardGroup.current.rotation.y += 0.003;
}
if (ghostGroup.current) {
ghostGroup.current.position.set(siphon.ghostPosition[0], 0.85 + Math.sin(clock.elapsedTime * 4.2) * 0.12, siphon.ghostPosition[1]);
ghostGroup.current.rotation.y += 0.018;
}
if (outerColumn.current) outerColumn.current.opacity = 0.15 + pulse * 0.09;
if (innerColumn.current) innerColumn.current.opacity = 0.2 + pulse * 0.16;
if (groundRing.current) groundRing.current.opacity = 0.62 + pulse * 0.28;
});
const siphon = motion?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
if (!siphon || phase !== "combat") return null;
return (
<>
<group ref={wardGroup} position={[siphon.wardPosition[0], 0, siphon.wardPosition[1]]}>
<mesh position={[0, 2.45, 0]}>
<cylinderGeometry args={[0.78, 1.05, 4.9, 28, 1, true]} />
<meshBasicMaterial ref={outerColumn} color="#f8c94e" transparent opacity={0.2} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 2.45, 0]}>
<cylinderGeometry args={[0.38, 0.58, 4.9, 24, 1, true]} />
<meshBasicMaterial ref={innerColumn} color="#fff0a3" transparent opacity={0.32} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 0.045, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[siphon.wardRadius - 0.13, siphon.wardRadius, 40]} />
<meshBasicMaterial ref={groundRing} color="#ffe47d" transparent opacity={0.9} depthWrite={false} />
</mesh>
{WARD_ARROW_DIRECTIONS.map((angle) => (
<group
key={angle}
position={[Math.sin(angle) * 1.58, 0.062, Math.cos(angle) * 1.58]}
rotation={[0, angle, 0]}
>
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial color="#54ff90" transparent opacity={1} depthWrite={false} depthTest={false} />
</mesh>
</group>
))}
</group>
<group ref={ghostGroup} position={[siphon.ghostPosition[0], 0.85, siphon.ghostPosition[1]]}>
<mesh>
<sphereGeometry args={[0.34, 16, 12]} />
<meshBasicMaterial color="#b48aff" transparent opacity={0.68} depthWrite={false} />
</mesh>
<mesh position={[0, -0.52, 0]} scale={[0.82, 1.45, 0.82]}>
<coneGeometry args={[0.34, 0.78, 12, 1, true]} />
<meshBasicMaterial color="#8d62d2" transparent opacity={0.36} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.48, 0.026, 8, 24]} />
<meshBasicMaterial color="#e1c4ff" transparent opacity={0.72} depthWrite={false} />
</mesh>
</group>
</>
);
}
function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySymbolId; size?: number; opacity?: number }) {
const color = MEMORY_SYMBOLS[symbol].color;
if (symbol === "cross") {
return (
<group position={[0, 0.012, 0]}>
<mesh>
<boxGeometry args={[size * 0.22, 0.035, size]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
<mesh>
<boxGeometry args={[size, 0.035, size * 0.22]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
</group>
);
}
return (
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, symbol === "square" ? Math.PI / 4 : -Math.PI / 2]}>
<ringGeometry args={[size * 0.34, size * 0.54, symbol === "circle" ? 36 : symbol === "triangle" ? 3 : 4]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
);
}
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
const color = MEMORY_SYMBOLS[tile.symbol].color;
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
const gridOffsets = [-0.48, 0, 0.48] as const;
return (
<group position={[tile.center[0], 0.075, tile.center[1]]}>
<mesh>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<group key={side}>
<mesh position={[side * halfSize, 0.03, 0]}>
<boxGeometry args={[0.08, 0.06, MEMORY_SEQUENCE.tileSize + 0.08]} />
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
</mesh>
<mesh position={[0, 0.03, side * halfSize]}>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize + 0.08, 0.06, 0.08]} />
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
</mesh>
</group>
))}
{gridOffsets.map((offset) => (
<group key={offset}>
<mesh position={[0, 0.026, offset * MEMORY_SEQUENCE.tileSize]}>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize * 0.84, 0.018, 0.026]} />
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
</mesh>
<mesh position={[offset * MEMORY_SEQUENCE.tileSize, 0.027, 0]}>
<boxGeometry args={[0.026, 0.018, MEMORY_SEQUENCE.tileSize * 0.84]} />
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
</mesh>
</group>
))}
<MemorySymbolMark symbol={tile.symbol} size={0.78} opacity={1} />
</group>
);
}
function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph: PoolTelegraph; bossPosition?: [number, number]; time: number }) {
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined) return null;
const showingSequence = time < telegraph.inputStartsAt;
const flashIndex = Math.min(
telegraph.sequence.length - 1,
Math.max(0, Math.floor((time - telegraph.activatesAt) / MEMORY_SEQUENCE.flashDuration)),
);
const flashSymbol = telegraph.sequence[flashIndex];
const source = bossPosition ?? telegraph.center;
return (
<>
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
{showingSequence && (
<group position={[source[0], 2.5, source[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.88, 32]} />
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
</mesh>
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.8, 0.9, 32]} />
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
</mesh>
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
</group>
)}
</>
);
}
function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: string; bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const time = useGameStore((state) => state.time);
const telegraph = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId));
const bossPosition = useGameStore((state) => motionAt(state, bossIndex)?.position);
const fillMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!fillMaterial.current) return;
const current = motionAt(useGameStore.getState(), bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId);
if (!current) return;
const active = useGameStore.getState().time >= current.activatesAt;
fillMaterial.current.opacity = active ? 0.46 : 0.14 + (Math.sin(clock.elapsedTime * 10) + 1) * 0.08;
});
if (!telegraph || phase !== "combat") return null;
if (telegraph.kind === "memory") return <MemorySequenceIndicator telegraph={telegraph} bossPosition={bossPosition} time={time} />;
if (telegraph.kind === "soul-siphon") return null;
const active = time >= telegraph.activatesAt;
const color = telegraph.kind === "soak" ? "#57e8ff"
: telegraph.kind === "spread" ? "#e869ff"
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
const dx = telegraph.end[0] - telegraph.start[0];
const dz = telegraph.end[1] - telegraph.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
return (
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
);
}
const innerRadius = telegraph.innerRadius ?? 0;
return (
<group position={[telegraph.center[0], 0.07, telegraph.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
{telegraph.kind === "donut"
? <ringGeometry args={[innerRadius, telegraph.radius, 56]} />
: <circleGeometry args={[telegraph.radius, 48]} />}
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[telegraph.radius - 0.1, telegraph.radius + 0.04, 48]} />
<meshBasicMaterial color={color} transparent opacity={0.94} depthWrite={false} />
</mesh>
{telegraph.kind === "donut" && (
<mesh position={[0, 0.021, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[innerRadius - 0.05, innerRadius + 0.05, 40]} />
<meshBasicMaterial color="#f9efcf" transparent opacity={0.8} depthWrite={false} />
</mesh>
)}
{telegraph.kind === "spread" && (
<>
<mesh position={[0, 0.028, 0]}>
<boxGeometry args={[telegraph.radius * 1.35, 0.032, 0.07]} />
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
</mesh>
<mesh position={[0, 0.03, 0]}>
<boxGeometry args={[0.07, 0.032, telegraph.radius * 1.35]} />
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
</mesh>
</>
)}
{telegraph.kind === "soak" && SOAK_DIRECTIONS.map((angle, index) => (
<group key={index} rotation={[0, angle, 0]} position={[0, 0.035, 0]}>
<mesh position={[0, 0, -telegraph.radius + 0.32]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.16, 0.34, 3]} />
<meshBasicMaterial color="#c9fbff" transparent opacity={0.96} />
</mesh>
</group>
))}
</group>
);
}
export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const telegraphs = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs ?? EMPTY_POOL_TELEGRAPHS);
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
}
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator,
SlashLaneIndicators,
@@ -283,6 +565,8 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
BindingWebIndicator,
BreathConeIndicator,
CircleHazardIndicators,
SoulSiphonIndicator,
PooledMechanicIndicators,
];
export function BossMechanicIndicators() {