Release v0.1.4 2026-07-12
This commit is contained in:
+1
-2
@@ -6,7 +6,7 @@ import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
||||
import { useGameStore } from "./game/store";
|
||||
import type { BossId } from "./game/types";
|
||||
import type { DifficultySlug } from "./game/progression/loot";
|
||||
import { useActionBindings, useGameLoop } from "./game/useGameLoop";
|
||||
import { useActionBindings } from "./game/useGameLoop";
|
||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
|
||||
|
||||
@@ -25,7 +25,6 @@ function GameLoadingScreen() {
|
||||
export default function App() {
|
||||
useForcedThorDisplays();
|
||||
useAuthoritativeDualScreenSync();
|
||||
useGameLoop();
|
||||
const screen = useFrontendStore((state) => state.screen);
|
||||
const hunter = useActiveHunter();
|
||||
const settings = useFrontendStore((state) => state.settings);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Runtime game assets
|
||||
|
||||
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
||||
|
||||
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
@@ -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
@@ -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>1–3 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>1–3 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
|
||||
+300
-253
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
|
||||
import { selectRandomBossPair } from "../game/roguelike";
|
||||
import { BOSS_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
|
||||
import { BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { GROUP_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
|
||||
describe("game mode configuration", () => {
|
||||
it("separates randomized PVE from selectable Dungeons", () => {
|
||||
@@ -11,24 +11,25 @@ describe("game mode configuration", () => {
|
||||
});
|
||||
|
||||
it("selects a boss across the full encounter pool", () => {
|
||||
for (let index = 0; index < BOSS_ORDER.length; index += 1) {
|
||||
expect(selectRandomBoss(() => (index + 0.5) / BOSS_ORDER.length)).toBe(BOSS_ORDER[index]);
|
||||
for (let index = 0; index < AVAILABLE_BOSS_IDS.length; index += 1) {
|
||||
expect(selectRandomBoss(() => (index + 0.5) / AVAILABLE_BOSS_IDS.length)).toBe(AVAILABLE_BOSS_IDS[index]);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects two distinct bosses for PVE", () => {
|
||||
const values = [0, 0];
|
||||
const pair = selectRandomBossPair([], () => values.shift() ?? 0);
|
||||
expect(pair).toEqual(["bulldrome", "vexa"]);
|
||||
expect(pair).toEqual(["bulldrome", "sandglass-scorpion"]);
|
||||
expect(new Set(pair)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("derives collection entries from canonical boss drop tables", () => {
|
||||
it("derives group collection entries from canonical group drop tables", () => {
|
||||
const collections = buildCollections(createEmptyCollectionLog(), {});
|
||||
expect(collections.map((boss) => boss.bossId)).toEqual(BOSS_ORDER);
|
||||
expect(collections.map((group) => group.groupId)).toEqual(BOSS_GROUPS.map((group) => group.id));
|
||||
expect(collections.flatMap((group) => group.bosses.map((boss) => boss.bossId)).sort()).toEqual([...AVAILABLE_BOSS_IDS].sort());
|
||||
for (const collection of collections) {
|
||||
expect(collection.drops.map((drop) => drop.id)).toEqual(
|
||||
BOSS_DROP_TABLES[collection.bossId as keyof typeof BOSS_DROP_TABLES].entries.map((drop) => drop.id),
|
||||
GROUP_DROP_TABLES[collection.groupId].entries.map((drop) => drop.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+36
-24
@@ -1,15 +1,9 @@
|
||||
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { BossId } from "../game/types";
|
||||
import { createDefaultGearProgress } from "../game/progression/gear";
|
||||
import {
|
||||
BOSS_DROP_TABLES,
|
||||
createEmptyCollectionLog,
|
||||
type CollectionLog,
|
||||
type LootRarity,
|
||||
type MaterialStack,
|
||||
} from "../game/progression/loot";
|
||||
import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot";
|
||||
|
||||
export const DEFAULT_SETTINGS: GameSettings = {
|
||||
masterVolume: 80,
|
||||
@@ -18,7 +12,7 @@ export const DEFAULT_SETTINGS: GameSettings = {
|
||||
largeText: false,
|
||||
};
|
||||
|
||||
const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"]> = {
|
||||
const RARITY_LABELS: Record<LootRarity, BossGroupCollection["drops"][number]["rarity"]> = {
|
||||
common: "Common",
|
||||
uncommon: "Uncommon",
|
||||
rare: "Rare",
|
||||
@@ -26,31 +20,49 @@ const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"
|
||||
legendary: "Legendary",
|
||||
};
|
||||
|
||||
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossCollection[] {
|
||||
return BOSS_ORDER.map((bossId) => {
|
||||
const table = BOSS_DROP_TABLES[bossId];
|
||||
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossGroupCollection[] {
|
||||
return BOSS_GROUPS.map((group) => {
|
||||
const table = GROUP_DROP_TABLES[group.id];
|
||||
const bosses = group.bossIds.map((bossId) => {
|
||||
const pet = BOSS_PET_DROPS[bossId];
|
||||
return {
|
||||
bossId,
|
||||
bossName: BOSS_DEFINITIONS[bossId].name,
|
||||
kills: bossKills[bossId] ?? 0,
|
||||
pet: {
|
||||
id: pet.id,
|
||||
name: pet.name,
|
||||
icon: pet.glyph,
|
||||
rarity: RARITY_LABELS[pet.rarity],
|
||||
count: collectionLog.petsFound[pet.id] ?? 0,
|
||||
chance: pet.chanceLabel,
|
||||
kind: pet.kind,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
bossId,
|
||||
bossName: BOSS_DEFINITIONS[bossId].name,
|
||||
defeated: (bossKills[bossId] ?? 0) > 0,
|
||||
groupId: group.id,
|
||||
groupLetter: group.letter,
|
||||
groupName: group.name,
|
||||
coreMechanic: group.coreMechanic,
|
||||
defeated: bosses.some((boss) => boss.kills > 0),
|
||||
drops: table.entries.map((drop) => ({
|
||||
id: drop.id,
|
||||
name: drop.name,
|
||||
icon: drop.glyph,
|
||||
rarity: RARITY_LABELS[drop.rarity],
|
||||
count: drop.kind === "coin"
|
||||
? collectionLog.dropsFound[drop.id] ?? 0
|
||||
: collectionLog.petsFound[drop.id] ?? 0,
|
||||
count: collectionLog.dropsFound[drop.id] ?? 0,
|
||||
chance: drop.chanceLabel,
|
||||
itemLevel: drop.kind === "coin" ? drop.itemLevel : undefined,
|
||||
itemLevel: drop.itemLevel,
|
||||
kind: drop.kind,
|
||||
})),
|
||||
bosses,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
|
||||
export const DEFAULT_COLLECTIONS: BossCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
|
||||
export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
|
||||
|
||||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||||
"roguelike-pve": {
|
||||
@@ -64,7 +76,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
||||
eyebrow: "1–4 hunters · chosen encounter",
|
||||
title: "Dungeons",
|
||||
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
|
||||
detail: "Ten prototype guardians available",
|
||||
detail: `${AVAILABLE_BOSS_IDS.length} animated guardians available`,
|
||||
status: "Playable now",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
@@ -84,7 +96,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
||||
};
|
||||
|
||||
export function selectRandomBoss(random: () => number = Math.random): BossId {
|
||||
return BOSS_ORDER[Math.floor(random() * BOSS_ORDER.length)] ?? BOSS_ORDER[0];
|
||||
return AVAILABLE_BOSS_IDS[Math.floor(random() * AVAILABLE_BOSS_IDS.length)] ?? AVAILABLE_BOSS_IDS[0];
|
||||
}
|
||||
|
||||
export const MAX_HUNTER_NAME_LENGTH = 20;
|
||||
@@ -101,7 +113,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
const normalizedName = normalizeHunterName(hunterName);
|
||||
if (!normalizedName) throw new Error("Hunter name is required.");
|
||||
return {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
slotId,
|
||||
hunterName: normalizedName,
|
||||
activeClassId: "priest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||
import { buildCollections, DEFAULT_COLLECTIONS } from "./data";
|
||||
import { groupDrop } from "../game/progression/loot";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
@@ -91,39 +91,80 @@ describe("SaveRepository", () => {
|
||||
expect(save.healers.priest.inventory).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("migrates schema v1 saves into Priest progress without losing the hunter name", () => {
|
||||
it("resets every legacy save into fresh v5 progression while preserving identity and timestamp", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Legacy");
|
||||
const legacy = { ...created, schemaVersion: 1, level: 27 } as Record<string, unknown>;
|
||||
delete legacy.activeClassId;
|
||||
delete legacy.healers;
|
||||
const legacy = {
|
||||
...created,
|
||||
schemaVersion: 4,
|
||||
activeClassId: "druid",
|
||||
playSeconds: 999,
|
||||
healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])),
|
||||
stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 } },
|
||||
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 99, rarity: "common", itemLevel: 1, glyph: "R" }],
|
||||
collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } },
|
||||
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
|
||||
...owner,
|
||||
slots: Object.fromEntries(Object.entries(owner.slots).map(([slotId]) => [slotId, { level: 10 }])),
|
||||
infusionAbilityId: "priest-sanctuary",
|
||||
}])),
|
||||
} as Record<string, unknown>;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.hunterName).toBe("Legacy");
|
||||
expect(migrated.activeClassId).toBe("priest");
|
||||
expect(migrated.healers.priest.level).toBe(27);
|
||||
expect(migrated.healers.druid.level).toBe(1);
|
||||
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
|
||||
expect(migrated.playSeconds).toBe(0);
|
||||
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
|
||||
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {} });
|
||||
expect(migrated.materials).toEqual([]);
|
||||
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
|
||||
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
|
||||
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5);
|
||||
});
|
||||
|
||||
it("derives newly shipped bosses from drop tables after migrating schema v2 collections", () => {
|
||||
it("resets and persists legacy cloud saves when they are listed", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Cloud Legacy");
|
||||
const legacy = {
|
||||
...created,
|
||||
schemaVersion: 4,
|
||||
stats: { ...created.stats, totalBossKills: 8, bossKills: { bulldrome: 8 } },
|
||||
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 8, rarity: "common", itemLevel: 1, glyph: "R" }],
|
||||
};
|
||||
const cloudKey = "i-want-to-heal:saves:cloud:v1:cloud@example.com";
|
||||
storage.setItem(cloudKey, JSON.stringify({ 1: legacy }));
|
||||
|
||||
const online = repository.list("cloud@example.com")[0].online!;
|
||||
expect(online.schemaVersion).toBe(5);
|
||||
expect(online.stats.totalBossKills).toBe(0);
|
||||
expect(online.materials).toEqual([]);
|
||||
expect(JSON.parse(storage.getItem(cloudKey) ?? "{}")["1"].schemaVersion).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves valid v5 progression and group-drop inventory", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Veteran");
|
||||
const legacy = { ...created, schemaVersion: 2, collections: DEFAULT_COLLECTIONS.filter((boss) => boss.bossId !== "vexa") } as Record<string, unknown>;
|
||||
delete legacy.collectionLog;
|
||||
delete legacy.materials;
|
||||
delete legacy.gearProgress;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
const drop = groupDrop("charge", "veteran");
|
||||
created.healers.priest.level = 8;
|
||||
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
|
||||
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
||||
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(buildCollections(migrated.collectionLog, migrated.stats.bossKills).some((boss) => boss.bossId === "vexa")).toBe(true);
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.healers.priest.level).toBe(8);
|
||||
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
||||
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
|
||||
expect(migrated.collectionLog).toEqual(created.collectionLog);
|
||||
});
|
||||
|
||||
it("migrates valid infusion choices and discards stale ids", () => {
|
||||
it("normalizes valid v5 infusion choices and discards stale ids", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Infused");
|
||||
@@ -131,10 +172,10 @@ describe("SaveRepository", () => {
|
||||
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
|
||||
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
|
||||
created.gearProgress.brann.passiveInfusionId = "deep-wells";
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 3 } }));
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
|
||||
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
|
||||
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createHunterSave } from "./data";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
|
||||
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
|
||||
import { BOSS_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type MaterialStack } from "../game/progression/loot";
|
||||
import { GROUP_DROP_TABLES, type CollectionLog, type MaterialStack } from "../game/progression/loot";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import type { BossCollection, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
|
||||
export interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
@@ -44,7 +44,6 @@ interface LegacyHunterSave {
|
||||
playSeconds?: number;
|
||||
updatedAt?: string;
|
||||
stats?: HunterSave["stats"];
|
||||
collections?: BossCollection[];
|
||||
materials?: MaterialStack[];
|
||||
collectionLog?: CollectionLog;
|
||||
gearProgress?: GearProgress;
|
||||
@@ -61,26 +60,16 @@ function positiveCounts(value: unknown): Record<string, number> {
|
||||
}
|
||||
|
||||
function normalizeCollectionLog(candidate: LegacyHunterSave): CollectionLog {
|
||||
if (candidate.collectionLog) {
|
||||
return {
|
||||
dropsFound: positiveCounts(candidate.collectionLog.dropsFound),
|
||||
petsFound: positiveCounts(candidate.collectionLog.petsFound),
|
||||
};
|
||||
}
|
||||
const result = createEmptyCollectionLog();
|
||||
for (const legacyBoss of candidate.collections ?? []) {
|
||||
if (!BOSS_ORDER.includes(legacyBoss.bossId as BossId)) continue;
|
||||
const bossId = legacyBoss.bossId as BossId;
|
||||
const quantity = legacyBoss.drops.reduce((sum, drop) => sum + Math.max(0, Math.floor(drop.count || 0)), 0);
|
||||
if (quantity > 0) result.dropsFound[BOSS_DROP_TABLES[bossId].coins.initiate.id] = quantity;
|
||||
}
|
||||
return result;
|
||||
return {
|
||||
dropsFound: positiveCounts(candidate.collectionLog?.dropsFound),
|
||||
petsFound: positiveCounts(candidate.collectionLog?.petsFound),
|
||||
};
|
||||
}
|
||||
|
||||
function knownMaterial(id: string) {
|
||||
for (const bossId of BOSS_ORDER) {
|
||||
const coin = Object.values(BOSS_DROP_TABLES[bossId].coins).find((candidate) => candidate.id === id);
|
||||
if (coin) return coin;
|
||||
for (const table of Object.values(GROUP_DROP_TABLES)) {
|
||||
const drop = Object.values(table.drops).find((candidate) => candidate.id === id);
|
||||
if (drop) return drop;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -99,8 +88,8 @@ function normalizeMaterials(value: unknown, collectionLog: CollectionLog): Mater
|
||||
for (const [id, quantity] of Object.entries(collectionLog.dropsFound)) quantities.set(id, quantity);
|
||||
}
|
||||
return [...quantities].flatMap(([id, quantity]) => {
|
||||
const coin = knownMaterial(id);
|
||||
return coin ? [{ id, quantity, name: coin.name, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }] : [];
|
||||
const drop = knownMaterial(id);
|
||||
return drop ? [{ id, quantity, name: drop.name, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,7 +112,7 @@ function normalizeBossKills(value: unknown): Record<string, number> {
|
||||
const source = positiveCounts(value);
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, quantity] of Object.entries(source)) {
|
||||
const bossId = BOSS_ORDER.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
|
||||
const bossId = AVAILABLE_BOSS_IDS.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
|
||||
result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity;
|
||||
}
|
||||
return result;
|
||||
@@ -133,11 +122,18 @@ function normalizeSave(value: unknown): HunterSave | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as LegacyHunterSave;
|
||||
if (!candidate.slotId || !candidate.hunterName) return null;
|
||||
if (candidate.schemaVersion !== 5) {
|
||||
try {
|
||||
return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const collectionLog = normalizeCollectionLog(candidate);
|
||||
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
|
||||
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
|
||||
return {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
slotId: candidate.slotId,
|
||||
hunterName: candidate.hunterName,
|
||||
activeClassId,
|
||||
@@ -253,7 +249,11 @@ export class SaveRepository {
|
||||
}
|
||||
|
||||
private read(key: string): SaveMap {
|
||||
return parseSaveMap(this.storage.getItem(key));
|
||||
const raw = this.storage.getItem(key);
|
||||
const saves = parseSaveMap(raw);
|
||||
const normalized = JSON.stringify(saves);
|
||||
if (raw !== normalized) this.storage.setItem(key, normalized);
|
||||
return saves;
|
||||
}
|
||||
|
||||
private write(key: string, saves: SaveMap): void {
|
||||
|
||||
@@ -298,7 +298,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
set((state) => ({
|
||||
slots: repository.list(accountId),
|
||||
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
|
||||
notice: awarded ? `${awarded.coin.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
|
||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
|
||||
}));
|
||||
return awarded;
|
||||
},
|
||||
|
||||
+16
-5
@@ -1,4 +1,5 @@
|
||||
import type { HealerClassId, InventoryItem } from "../game/types";
|
||||
import type { BossGroupId } from "../game/bossCatalog";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
import type { GearProgress } from "../game/progression/gear";
|
||||
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
|
||||
|
||||
@@ -14,14 +15,24 @@ export interface CollectionDrop {
|
||||
count: number;
|
||||
chance: string;
|
||||
itemLevel?: number;
|
||||
kind: "coin" | "pet";
|
||||
kind: "group-drop" | "pet";
|
||||
}
|
||||
|
||||
export interface BossCollection {
|
||||
bossId: string;
|
||||
export interface GroupBossCollection {
|
||||
bossId: BossId;
|
||||
bossName: string;
|
||||
kills: number;
|
||||
pet: CollectionDrop;
|
||||
}
|
||||
|
||||
export interface BossGroupCollection {
|
||||
groupId: BossGroupId;
|
||||
groupLetter: string;
|
||||
groupName: string;
|
||||
coreMechanic: string;
|
||||
defeated: boolean;
|
||||
drops: CollectionDrop[];
|
||||
bosses: GroupBossCollection[];
|
||||
}
|
||||
|
||||
export interface HunterStats {
|
||||
@@ -38,7 +49,7 @@ export interface HealerProgress {
|
||||
}
|
||||
|
||||
export interface HunterSave {
|
||||
schemaVersion: 4;
|
||||
schemaVersion: 5;
|
||||
slotId: SaveSlotId;
|
||||
hunterName: string;
|
||||
activeClassId: HealerClassId;
|
||||
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
import type { BossMotionState, WorldPosition } from "./types";
|
||||
|
||||
export const ARENA_CENTER: WorldPosition = [0, -1];
|
||||
export const ARENA_RADIUS = 8.35;
|
||||
export const ARENA_WALL_RADIUS = 9.55;
|
||||
/** Keeps simulation limits and the room renderer in lockstep. */
|
||||
export const ARENA_SIZE_MULTIPLIER = 1.3;
|
||||
export const ARENA_RADIUS = 8.35 * ARENA_SIZE_MULTIPLIER;
|
||||
export const ARENA_WALL_RADIUS = 9.55 * ARENA_SIZE_MULTIPLIER;
|
||||
|
||||
export function clampToArena(position: WorldPosition, padding = 0): WorldPosition {
|
||||
const radius = Math.max(0, ARENA_RADIUS - padding);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createClassInventory } from "./healers";
|
||||
import { BOSS_ORDER } from "./bossCatalog";
|
||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||
import { useGameStore } from "./store";
|
||||
import type { BossId } from "./types";
|
||||
|
||||
@@ -26,15 +26,15 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
|
||||
}
|
||||
|
||||
describe("full-mechanics dual-boss battle simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
|
||||
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
|
||||
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
|
||||
);
|
||||
|
||||
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
||||
const result = simulateControlledBattle([first, second]);
|
||||
expect(result.phase).toBe("victory");
|
||||
expect(result.time).toBeGreaterThan(70);
|
||||
expect(result.time).toBeLessThan(110);
|
||||
expect(result.time).toBeGreaterThanOrEqual(50);
|
||||
expect(result.time).toBeLessThanOrEqual(100);
|
||||
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId].damageDone).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
|
||||
import { createBossMotionState, createBossState } from "./bossMechanics";
|
||||
|
||||
describe("boss catalog", () => {
|
||||
it("derives the available roster from every catalog definition", () => {
|
||||
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
|
||||
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(27);
|
||||
});
|
||||
|
||||
it("assigns every boss to one compatible mechanic group", () => {
|
||||
const groupedBosses = BOSS_GROUPS.flatMap((group) => group.bossIds);
|
||||
expect(new Set(groupedBosses)).toHaveLength(AVAILABLE_BOSS_IDS.length);
|
||||
expect([...groupedBosses].sort()).toEqual([...AVAILABLE_BOSS_IDS].sort());
|
||||
expect(BOSS_GROUPS.find((group) => group.letter === "A")?.bossIds).toEqual([
|
||||
"bulldrome", "stormwool-alpaca", "thorncrown-stag", "bristlequake-boar",
|
||||
]);
|
||||
for (const group of BOSS_GROUPS) {
|
||||
for (const bossId of group.bossIds) expect(group.archetypes).toContain(BOSS_DEFINITIONS[bossId].archetype);
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the retired licensed bosses from the playable roster", () => {
|
||||
expect(AVAILABLE_BOSS_IDS).not.toContain("vexa");
|
||||
expect(AVAILABLE_BOSS_IDS).not.toContain("ember-mantis-duelist");
|
||||
expect(AVAILABLE_BOSS_IDS).not.toContain("obsidian-ram-golem");
|
||||
expect(Object.values(BOSS_DEFINITIONS).map((boss) => boss.name)).not.toEqual(expect.arrayContaining([
|
||||
"Insect Queen",
|
||||
"Gate Guardian",
|
||||
"Gandora the Dragon of Destruction",
|
||||
"Blue-Eyes White Dragon",
|
||||
"Red-Eyes Black Dragon",
|
||||
"Pumpking the King of Ghosts",
|
||||
"Blue-Eyes Ultimate Dragon",
|
||||
]));
|
||||
});
|
||||
|
||||
it.each(AVAILABLE_BOSS_IDS)("creates typed state and modular motion for %s", (bossId) => {
|
||||
const state = createBossState(bossId);
|
||||
const motion = createBossMotionState(bossId);
|
||||
expect(state).toMatchObject({ id: bossId, name: BOSS_DEFINITIONS[bossId].name, maxHp: BOSS_DEFINITIONS[bossId].maxHp });
|
||||
expect(state.hp).toBe(state.maxHp);
|
||||
expect(motion.bossId).toBe(bossId);
|
||||
});
|
||||
});
|
||||
+229
-186
@@ -1,5 +1,39 @@
|
||||
import type { BossId } from "./types";
|
||||
|
||||
export type BossArchetype =
|
||||
| "bull"
|
||||
| "web-caster"
|
||||
| "sky-sweeper"
|
||||
| "duelist"
|
||||
| "ram"
|
||||
| "ricochet"
|
||||
| "burrower"
|
||||
| "crab"
|
||||
| "ghost"
|
||||
| "golem";
|
||||
|
||||
export type BossGroupId =
|
||||
| "charge"
|
||||
| "bind-venom"
|
||||
| "breath-skyfall"
|
||||
| "slash-cross"
|
||||
| "ricochet"
|
||||
| "burrow-eruption"
|
||||
| "scuttle-burst"
|
||||
| "rift-lanes"
|
||||
| "shockwave-fall";
|
||||
|
||||
export type BossGroupLetter = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I";
|
||||
|
||||
export interface BossGroupDefinition {
|
||||
id: BossGroupId;
|
||||
letter: BossGroupLetter;
|
||||
name: string;
|
||||
coreMechanic: string;
|
||||
bossIds: readonly BossId[];
|
||||
archetypes: readonly BossArchetype[];
|
||||
}
|
||||
|
||||
export interface BossDefinition {
|
||||
id: BossId;
|
||||
name: string;
|
||||
@@ -12,204 +46,213 @@ export interface BossDefinition {
|
||||
failure: string;
|
||||
mapTitle: string;
|
||||
mapCopy: string;
|
||||
mechanics: readonly [string, string];
|
||||
mechanics: readonly [string, string, ...string[]];
|
||||
maxHp: number;
|
||||
archetype: BossArchetype;
|
||||
groupId: BossGroupId;
|
||||
}
|
||||
|
||||
export const BOSS_ORDER: readonly BossId[] = [
|
||||
"bulldrome",
|
||||
"vexa",
|
||||
"cindermaw",
|
||||
"ember-mantis-duelist",
|
||||
"obsidian-ram-golem",
|
||||
"cinderback-ricochet",
|
||||
"sandglass-scorpion",
|
||||
"cragclaw-crab",
|
||||
"pumpking-king-of-ghosts",
|
||||
"blue-eyes-ultimate-dragon",
|
||||
"mournveil-ghost",
|
||||
"crownshard-golem",
|
||||
];
|
||||
interface BossSeed {
|
||||
name: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
accent: string;
|
||||
summary: string;
|
||||
mechanics: readonly [string, string, ...string[]];
|
||||
maxHp: number;
|
||||
archetype: BossArchetype;
|
||||
}
|
||||
|
||||
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
|
||||
const [first, second] = seed.mechanics;
|
||||
return {
|
||||
id,
|
||||
...seed,
|
||||
groupId: BOSS_GROUP_BY_BOSS_ID[id],
|
||||
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
|
||||
briefing: `Read ${first}, then preserve open ground for ${second}.`,
|
||||
failure: `Do not overlap ${first}. Re-form only after ${second} resolves.`,
|
||||
mapTitle: seed.title,
|
||||
mapCopy: `${seed.summary} Keep escape lanes open and move before each warning completes.`,
|
||||
};
|
||||
}
|
||||
|
||||
const BOSS_SEEDS: Record<BossId, BossSeed> = {
|
||||
bulldrome: {
|
||||
id: "bulldrome",
|
||||
name: "Bulldrome",
|
||||
title: "The Cinder Bull",
|
||||
trial: "Trial I · Healer Initiation",
|
||||
icon: "♜",
|
||||
accent: "#e2744e",
|
||||
summary: "Charges marked lanes and crushes grouped targets.",
|
||||
briefing: "Keep formation alive. Sidestep the charge lane, then stack tightly for the Bull's pounce.",
|
||||
failure: "Protect Brann and the healer. Purify Ember Brand before it burns through formation.",
|
||||
mapTitle: "Hall of the Bull",
|
||||
mapCopy: "Keep Brann between formation and Bull. Move clear when the charge lane turns red.",
|
||||
mechanics: ["Bull Charge", "Crushing Pounce"],
|
||||
maxHp: 500,
|
||||
},
|
||||
vexa: {
|
||||
id: "vexa",
|
||||
name: "Insect Queen",
|
||||
title: "The Hive Sovereign",
|
||||
trial: "Trial II · Royal Brood",
|
||||
icon: "✣",
|
||||
accent: "#b56cff",
|
||||
summary: "Pins prey with silk before flooding safe ground with venom.",
|
||||
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its brood pool poisons formation.",
|
||||
failure: "Break royal tethers quickly. Cleanse venom only after its target reaches open ground.",
|
||||
mapTitle: "The Royal Hive",
|
||||
mapCopy: "Spread tethered allies toward opposite edges. Keep venom brood pools away from center.",
|
||||
mechanics: ["Binding Web", "Venom Brood"],
|
||||
maxHp: 535,
|
||||
},
|
||||
cindermaw: {
|
||||
id: "cindermaw",
|
||||
name: "Blue-Eyes White Dragon",
|
||||
title: "The White Lightning",
|
||||
trial: "Trial III · Burststream Orbit",
|
||||
icon: "◆",
|
||||
accent: "#ff9b45",
|
||||
summary: "Sweeps the arena with white lightning and dives through targeted ground.",
|
||||
briefing: "Rotate behind Burst Stream. During White Skyfall, leave each numbered impact before it becomes charged ground.",
|
||||
failure: "Follow the safe side of the breath cone. Keep moving as White Skyfall removes sections of arena.",
|
||||
mapTitle: "The Ivory Crown",
|
||||
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between skyfall impacts.",
|
||||
mechanics: ["Burst Stream", "White Skyfall"],
|
||||
maxHp: 410,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
id: "ember-mantis-duelist",
|
||||
name: "Gate Guardian",
|
||||
title: "The Tri-Element Sentinel",
|
||||
trial: "Trial IV · Elements in Motion",
|
||||
icon: "⚔",
|
||||
accent: "#ff6a2a",
|
||||
summary: "Repositions its stacked body before firing single and crossed elemental lanes.",
|
||||
briefing: "Track each sidestep. Clear Elemental Beam, then find a safe quadrant when three powers form Guardian Cross.",
|
||||
failure: "Do not chase the guardian through a telegraph. Preserve space and move perpendicular to each beam lane.",
|
||||
mapTitle: "The Sealed Gate",
|
||||
mapCopy: "Follow the guardian laterally, but cross glowing lanes only after its arms finish firing.",
|
||||
mechanics: ["Elemental Beam", "Guardian Cross"],
|
||||
maxHp: 520,
|
||||
},
|
||||
"obsidian-ram-golem": {
|
||||
id: "obsidian-ram-golem",
|
||||
name: "Gandora the Dragon of Destruction",
|
||||
title: "The Ruin Orb",
|
||||
trial: "Trial V · Destruction March",
|
||||
icon: "♞",
|
||||
accent: "#ff7a38",
|
||||
summary: "Breaks formation with armored rushes, ruin quakes, and radial destruction beams.",
|
||||
briefing: "Clear Destruction Rush, leave Ruin Quake, then step between Gandora's radial destruction lines.",
|
||||
failure: "Do not remain in front of the armored dragon. Treat every glowing orb as an active strike lane.",
|
||||
mapTitle: "The Ruined Causeway",
|
||||
mapCopy: "Hold open flanks for Destruction Rush. Spread between radial fractures when its armor vents.",
|
||||
mechanics: ["Destruction Rush", "Ruin Quake"],
|
||||
maxHp: 540,
|
||||
},
|
||||
"cinderback-ricochet": {
|
||||
id: "cinderback-ricochet",
|
||||
name: "Red-Eyes Black Dragon",
|
||||
title: "The Black Flare",
|
||||
trial: "Trial VI · Inferno Rebound",
|
||||
icon: "⬢",
|
||||
accent: "#ff9345",
|
||||
summary: "Rebounds through marked flight lanes and leaves black-flame impact pools.",
|
||||
briefing: "Clear both Inferno Rush lanes. Meteor Slam blooms into black-flame pools around its landing zone.",
|
||||
failure: "Watch the second rebound before returning to formation. Preserve a clean route around black flame.",
|
||||
mapTitle: "The Inferno Circuit",
|
||||
mapCopy: "Bait the dragon along arena edges. Never cross a marked flight lane before second impact.",
|
||||
mechanics: ["Inferno Rush", "Meteor Slam"],
|
||||
maxHp: 505,
|
||||
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e",
|
||||
summary: "Charges marked lanes and crushes grouped targets.", mechanics: ["Bull Charge", "Crushing Pounce"], maxHp: 500, archetype: "bull",
|
||||
},
|
||||
"sandglass-scorpion": {
|
||||
id: "sandglass-scorpion",
|
||||
name: "Sandglass Scorpion",
|
||||
title: "The Dune Chronarch",
|
||||
trial: "Trial VII · Hour of Venom",
|
||||
icon: "⌛",
|
||||
accent: "#e9b94f",
|
||||
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.",
|
||||
briefing: "Cross the Burrow Rush lane before it dives. Leave Stinger Eruptions, then outrun the active Hourglass zone.",
|
||||
failure: "Move before each timer completes. Sand warnings become damaging ground the instant they fill.",
|
||||
mapTitle: "The Sunken Hour",
|
||||
mapCopy: "Keep the center open. Burrow paths split formation while hourglass zones close escape routes.",
|
||||
mechanics: ["Burrow Rush", "Hourglass Eruption"],
|
||||
maxHp: 515,
|
||||
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f",
|
||||
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanics: ["Burrow Rush", "Hourglass Eruption"], maxHp: 515, archetype: "burrower",
|
||||
},
|
||||
"cragclaw-crab": {
|
||||
id: "cragclaw-crab",
|
||||
name: "Cragclaw",
|
||||
title: "The Breakwater Tyrant",
|
||||
trial: "Trial VIII · Tide in the Claws",
|
||||
icon: "♋",
|
||||
accent: "#49c7d4",
|
||||
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.",
|
||||
briefing: "Clear Sidewinder Rush, then leave every Crushing Tide circle before the claws close.",
|
||||
failure: "Cross the scuttle lane only after Cragclaw passes. Spread targeted circles away from formation.",
|
||||
mapTitle: "The Drowned Breakwater",
|
||||
mapCopy: "Keep open water between party lanes. Tidal marks punish overlapping escape routes.",
|
||||
mechanics: ["Sidewinder Rush", "Crushing Tide"],
|
||||
maxHp: 505,
|
||||
},
|
||||
"pumpking-king-of-ghosts": {
|
||||
id: "pumpking-king-of-ghosts",
|
||||
name: "Pumpking the King of Ghosts",
|
||||
title: "The Haunted Harvest",
|
||||
trial: "Trial XI · Vines Unbound",
|
||||
icon: "♚",
|
||||
accent: "#d87842",
|
||||
summary: "Whips the arena twice with spectral vines and grows hungry rifts beneath allies.",
|
||||
briefing: "Dodge both Vine Scissor patterns. Carry Haunting Rifts away before they sprout.",
|
||||
failure: "The second vine cross rotates. Do not return to the first safe quadrant early.",
|
||||
mapTitle: "The Haunted Patch",
|
||||
mapCopy: "Read both crossing vine patterns, then preserve clear ground for persistent ghost rifts.",
|
||||
mechanics: ["Vine Scissors", "Haunting Rifts"],
|
||||
maxHp: 505,
|
||||
},
|
||||
"blue-eyes-ultimate-dragon": {
|
||||
id: "blue-eyes-ultimate-dragon",
|
||||
name: "Blue-Eyes Ultimate Dragon",
|
||||
title: "The Three-Headed Tyrant",
|
||||
trial: "Trial XII · Ultimate Evolution",
|
||||
icon: "♕",
|
||||
accent: "#8fc8ff",
|
||||
summary: "Three heads fire expanding burst rings before marking allies for ultimate skyfall.",
|
||||
briefing: "Move through each Tri-Burst ring, then spread targeted Ultimate Skyfall circles.",
|
||||
failure: "Three shockwaves expand in sequence. Commit to each safe band before the next head fires.",
|
||||
mapTitle: "The Ultimate Aerie",
|
||||
mapCopy: "Follow expanding safe bands. Spread triple-head skyfall marks toward separate arena edges.",
|
||||
mechanics: ["Tri-Burst Rings", "Ultimate Skyfall"],
|
||||
maxHp: 500,
|
||||
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4",
|
||||
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanics: ["Sidewinder Rush", "Crushing Tide"], maxHp: 505, archetype: "crab",
|
||||
},
|
||||
"mournveil-ghost": {
|
||||
id: "mournveil-ghost",
|
||||
name: "Mournveil",
|
||||
title: "The Hollow Choir",
|
||||
trial: "Trial IX · Echoes Unbound",
|
||||
icon: "◉",
|
||||
accent: "#9d72ff",
|
||||
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.",
|
||||
briefing: "Dodge both Soul Scissor patterns. Carry Haunting Rifts away before they open.",
|
||||
failure: "The second spectral cross rotates. Do not return to the first safe quadrant early.",
|
||||
mapTitle: "The Silent Reliquary",
|
||||
mapCopy: "Read both crossing patterns, then preserve clear ground for persistent soul rifts.",
|
||||
mechanics: ["Soul Scissors", "Haunting Rifts"],
|
||||
maxHp: 505,
|
||||
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff",
|
||||
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanics: ["Soul Scissors", "Haunting Rifts"], maxHp: 505, archetype: "ghost",
|
||||
},
|
||||
"crownshard-golem": {
|
||||
id: "crownshard-golem",
|
||||
name: "Crownshard Golem",
|
||||
title: "The Fallen Idol",
|
||||
trial: "Trial X · Edict of Stone",
|
||||
icon: "♛",
|
||||
accent: "#e0bd45",
|
||||
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.",
|
||||
briefing: "Move through each Royal Shockwave ring, then clear targeted Crownfall circles.",
|
||||
failure: "Shockwaves expand in three steps. Commit to each safe band before the next ring fires.",
|
||||
mapTitle: "The Broken Coronation",
|
||||
mapCopy: "Follow the expanding safe bands. Spread Crownfall marks toward separate arena edges.",
|
||||
mechanics: ["Royal Shockwave", "Crownfall"],
|
||||
maxHp: 500,
|
||||
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45",
|
||||
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanics: ["Royal Shockwave", "Crownfall"], maxHp: 500, archetype: "golem",
|
||||
},
|
||||
"crystal-bat-matriarch": {
|
||||
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff",
|
||||
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanics: ["Sonic Ring", "Mirror Shards"], maxHp: 480, archetype: "golem",
|
||||
},
|
||||
"stormwool-alpaca": {
|
||||
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff",
|
||||
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanics: ["Storm Charge", "Cloudburst Pounce"], maxHp: 480, archetype: "bull",
|
||||
},
|
||||
"cluckhorn-colossus": {
|
||||
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
|
||||
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanics: ["Roost Rush", "Shellburst"], maxHp: 475, archetype: "crab",
|
||||
},
|
||||
"ashwing-demon": {
|
||||
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
|
||||
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanics: ["Ash Scissors", "Cinder Rifts"], maxHp: 515, archetype: "ghost",
|
||||
},
|
||||
"riftclaw-demon": {
|
||||
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff",
|
||||
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanics: ["Rift Blade", "Abyss Cross"], maxHp: 495, archetype: "duelist",
|
||||
},
|
||||
"tempestscale-dragon": {
|
||||
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8",
|
||||
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanics: ["Tempest Breath", "Stormfall"], maxHp: 500, archetype: "sky-sweeper",
|
||||
},
|
||||
emberfox: {
|
||||
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45",
|
||||
summary: "Ricochets across the arena and leaves fire at every landing.", mechanics: ["Foxfire Rush", "Ember Pounce"], maxHp: 465, archetype: "ricochet",
|
||||
},
|
||||
"mirelord-frog": {
|
||||
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
|
||||
summary: "Dives below the mire before erupting through timed bog zones.", mechanics: ["Mire Dive", "Bogglass Eruption"], maxHp: 490, archetype: "burrower",
|
||||
},
|
||||
"stonebreaker-giant": {
|
||||
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
|
||||
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanics: ["Crag Shockwave", "Boulderfall"], maxHp: 500, archetype: "golem",
|
||||
},
|
||||
"glub-sovereign": {
|
||||
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8",
|
||||
summary: "Links two allies with living slime before seeding toxic pools.", mechanics: ["Ooze Tether", "Caustic Brood"], maxHp: 495, archetype: "web-caster",
|
||||
},
|
||||
"scrapking-goblin": {
|
||||
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b",
|
||||
summary: "Repositions between attacks and fires improvised blade lanes.", mechanics: ["Scrap Blade", "Junkyard Cross"], maxHp: 485, archetype: "duelist",
|
||||
},
|
||||
"warcaller-orc": {
|
||||
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f",
|
||||
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanics: ["Warpath Cleave", "Banner Cross"], maxHp: 500, archetype: "duelist",
|
||||
},
|
||||
"tuskmaw-orc": {
|
||||
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d",
|
||||
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanics: ["Tusk Rush", "Groundbreaker"], maxHp: 525, archetype: "crab",
|
||||
},
|
||||
"broodfang-spider": {
|
||||
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff",
|
||||
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanics: ["Binding Web", "Venom Brood"], maxHp: 535, archetype: "web-caster",
|
||||
},
|
||||
"silkfang-spider": {
|
||||
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8",
|
||||
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanics: ["Silk Snare", "Venom Nest"], maxHp: 505, archetype: "web-caster",
|
||||
},
|
||||
"thorncrown-stag": {
|
||||
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b",
|
||||
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanics: ["Briar Charge", "Crown Pounce"], maxHp: 510, archetype: "bull",
|
||||
},
|
||||
"sky-totem": {
|
||||
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1",
|
||||
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanics: ["Spirit Scissors", "Wind Rifts"], maxHp: 505, archetype: "ghost",
|
||||
},
|
||||
"razorcrest-raptor": {
|
||||
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a",
|
||||
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanics: ["Raptor Rush", "Talon Slam"], maxHp: 500, archetype: "ricochet",
|
||||
},
|
||||
"bristlequake-boar": {
|
||||
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45",
|
||||
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanics: ["Tusk Charge", "Bristle Quake"], maxHp: 545, archetype: "ram",
|
||||
},
|
||||
"moonfang-wolf": {
|
||||
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5",
|
||||
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanics: ["Lunar Rush", "Moonfall"], maxHp: 495, archetype: "ricochet",
|
||||
},
|
||||
"frostmaw-yeti": {
|
||||
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef",
|
||||
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanics: ["Avalanche Rush", "Frost Crush"], maxHp: 550, archetype: "crab",
|
||||
},
|
||||
"rimeclaw-yeti": {
|
||||
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
|
||||
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanics: ["Rime Blade", "Glacier Cross"], maxHp: 500, archetype: "duelist",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Loot and encounter organization. Archetypes still select existing combat
|
||||
* scripts; groups describe the shared core mechanic and reward material.
|
||||
*/
|
||||
export const BOSS_GROUPS: readonly BossGroupDefinition[] = [
|
||||
{
|
||||
id: "charge", letter: "A", name: "Charge", coreMechanic: "Charge",
|
||||
bossIds: ["bulldrome", "stormwool-alpaca", "thorncrown-stag", "bristlequake-boar"], archetypes: ["bull", "ram"],
|
||||
},
|
||||
{
|
||||
id: "bind-venom", letter: "B", name: "Bind / Venom", coreMechanic: "Bind and Venom",
|
||||
bossIds: ["glub-sovereign", "broodfang-spider", "silkfang-spider"], archetypes: ["web-caster"],
|
||||
},
|
||||
{
|
||||
id: "breath-skyfall", letter: "C", name: "Breath / Skyfall", coreMechanic: "Breath and Skyfall",
|
||||
bossIds: ["tempestscale-dragon"], archetypes: ["sky-sweeper"],
|
||||
},
|
||||
{
|
||||
id: "slash-cross", letter: "D", name: "Slash / Cross", coreMechanic: "Slash and Cross",
|
||||
bossIds: ["riftclaw-demon", "scrapking-goblin", "warcaller-orc", "rimeclaw-yeti"], archetypes: ["duelist"],
|
||||
},
|
||||
{
|
||||
id: "ricochet", letter: "E", name: "Ricochet", coreMechanic: "Ricochet",
|
||||
bossIds: ["emberfox", "razorcrest-raptor", "moonfang-wolf"], archetypes: ["ricochet"],
|
||||
},
|
||||
{
|
||||
id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption",
|
||||
bossIds: ["sandglass-scorpion", "mirelord-frog"], archetypes: ["burrower"],
|
||||
},
|
||||
{
|
||||
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
|
||||
bossIds: ["cragclaw-crab", "cluckhorn-colossus", "tuskmaw-orc", "frostmaw-yeti"], archetypes: ["crab"],
|
||||
},
|
||||
{
|
||||
id: "rift-lanes", letter: "H", name: "Rift / Lanes", coreMechanic: "Rifts and Lanes",
|
||||
bossIds: ["mournveil-ghost", "ashwing-demon", "sky-totem"], archetypes: ["ghost"],
|
||||
},
|
||||
{
|
||||
id: "shockwave-fall", letter: "I", name: "Shockwave / Fall", coreMechanic: "Shockwave and Fall",
|
||||
bossIds: ["crownshard-golem", "crystal-bat-matriarch", "stonebreaker-giant"], archetypes: ["golem"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const BOSS_GROUP_BY_ID = Object.fromEntries(
|
||||
BOSS_GROUPS.map((group) => [group.id, group]),
|
||||
) as Record<BossGroupId, BossGroupDefinition>;
|
||||
|
||||
export const BOSS_GROUP_BY_BOSS_ID = Object.fromEntries(
|
||||
BOSS_GROUPS.flatMap((group) => group.bossIds.map((bossId) => [bossId, group.id])),
|
||||
) as Record<BossId, BossGroupId>;
|
||||
|
||||
export function bossGroupFor(bossId: BossId): BossGroupDefinition {
|
||||
return BOSS_GROUP_BY_ID[BOSS_GROUP_BY_BOSS_ID[bossId]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical roster. Adding a boss definition makes it eligible for every
|
||||
* encounter surface, including the dungeon selector and roguelike pool.
|
||||
*/
|
||||
export const AVAILABLE_BOSS_IDS = Object.freeze(Object.keys(BOSS_SEEDS) as BossId[]);
|
||||
|
||||
export const BOSS_DEFINITIONS = Object.fromEntries(
|
||||
AVAILABLE_BOSS_IDS.map((id, index) => [id, boss(id, index, BOSS_SEEDS[id])]),
|
||||
) as Record<BossId, BossDefinition>;
|
||||
|
||||
export const BOSS_ARCHETYPE_BY_ID = Object.fromEntries(
|
||||
AVAILABLE_BOSS_IDS.map((id) => [id, BOSS_DEFINITIONS[id].archetype]),
|
||||
) as Record<BossId, BossArchetype>;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ARENA_CENTER } from "./arena";
|
||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||
import { advanceBossMechanics, createBossMotionState, createBossState } from "./bossMechanics";
|
||||
import { freshParty } from "./data";
|
||||
import type { WorldPosition } from "./types";
|
||||
|
||||
const PARTY_POSITIONS = {
|
||||
aelia: [0, 4.5],
|
||||
brann: [7, 5],
|
||||
nia: [-3, 2],
|
||||
orin: [3, 2],
|
||||
vale: [0, -2],
|
||||
} satisfies Record<"aelia" | "brann" | "nia" | "orin" | "vale", WorldPosition>;
|
||||
|
||||
describe("boss home positioning", () => {
|
||||
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
|
||||
const motion = createBossMotionState(bossId);
|
||||
motion.position = [7, 5];
|
||||
motion.nextChargeAt = Number.POSITIVE_INFINITY;
|
||||
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
|
||||
motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY;
|
||||
const before = Math.hypot(
|
||||
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
|
||||
motion.position[1] - ARENA_CENTER[1],
|
||||
);
|
||||
|
||||
const result = advanceBossMechanics({
|
||||
boss: createBossState(bossId),
|
||||
motion,
|
||||
party: freshParty(),
|
||||
partyPositions: structuredClone(PARTY_POSITIONS),
|
||||
time: 0,
|
||||
delta: 0.5,
|
||||
damageMember: (member) => member,
|
||||
});
|
||||
const after = Math.hypot(
|
||||
result.motion.position[0] - (ARENA_CENTER[0] + result.motion.formationOffsetX),
|
||||
result.motion.position[1] - ARENA_CENTER[1],
|
||||
);
|
||||
|
||||
expect(after).toBeLessThan(before);
|
||||
});
|
||||
});
|
||||
+68
-75
@@ -1,14 +1,15 @@
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog";
|
||||
import { clampToArena } from "./arena";
|
||||
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
|
||||
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/cinderbackRicochet";
|
||||
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper";
|
||||
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet";
|
||||
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
|
||||
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
|
||||
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
|
||||
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
|
||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
|
||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
|
||||
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool";
|
||||
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
|
||||
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
|
||||
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
|
||||
@@ -45,54 +46,46 @@ const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia"
|
||||
const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
|
||||
|
||||
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
if (bossId === "vexa") return createVexaState();
|
||||
if (bossId === "cindermaw") return createCindermawState();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
|
||||
if (bossId === "obsidian-ram-golem") return createObsidianRamState();
|
||||
if (bossId === "cinderback-ricochet") return createCinderbackState();
|
||||
if (bossId === "sandglass-scorpion") return createSandglassState();
|
||||
if (bossId === "cragclaw-crab") return createCragclawState();
|
||||
if (bossId === "mournveil-ghost") return createMournveilState();
|
||||
if (bossId === "crownshard-golem") return createCrownshardState();
|
||||
if (bossId === "pumpking-king-of-ghosts" || bossId === "blue-eyes-ultimate-dragon") {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
return {
|
||||
id: definition.id,
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
||||
let state: BossState;
|
||||
if (archetype === "web-caster") state = createVexaState(bossId);
|
||||
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId);
|
||||
else if (archetype === "duelist") state = createEmberMantisState(bossId);
|
||||
else if (archetype === "ram") state = createObsidianRamState(bossId);
|
||||
else if (archetype === "ricochet") state = createCinderbackState(bossId);
|
||||
else if (archetype === "burrower") state = createSandglassState();
|
||||
else if (archetype === "crab") state = createCragclawState();
|
||||
else if (archetype === "ghost") state = createMournveilState();
|
||||
else if (archetype === "golem") state = createCrownshardState();
|
||||
else {
|
||||
state = {
|
||||
id: bossId,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: bossId === "pumpking-king-of-ghosts" ? 2.35 : 2.4,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
|
||||
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
|
||||
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
const definition = BOSS_DEFINITIONS.bulldrome;
|
||||
return {
|
||||
id: "bulldrome",
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
|
||||
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
|
||||
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
|
||||
brandCount: 0,
|
||||
};
|
||||
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
|
||||
}
|
||||
|
||||
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
||||
if (bossId === "vexa") return createVexaMotion();
|
||||
if (bossId === "cindermaw") return createCindermawMotion();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
|
||||
if (bossId === "obsidian-ram-golem") return createObsidianRamMotion();
|
||||
if (bossId === "cinderback-ricochet") return createCinderbackMotion();
|
||||
if (bossId === "sandglass-scorpion") return createSandglassMotion();
|
||||
if (bossId === "cragclaw-crab") return createCragclawMotion();
|
||||
if (bossId === "mournveil-ghost") return createMournveilMotion();
|
||||
if (bossId === "crownshard-golem") return createCrownshardMotion();
|
||||
if (bossId === "pumpking-king-of-ghosts") return { ...createMournveilMotion(), bossId };
|
||||
if (bossId === "blue-eyes-ultimate-dragon") return { ...createCrownshardMotion(), bossId };
|
||||
return {
|
||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
||||
let motion: BossMotionState;
|
||||
if (archetype === "web-caster") motion = createVexaMotion(bossId);
|
||||
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId);
|
||||
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId);
|
||||
else if (archetype === "ram") motion = createObsidianRamMotion(bossId);
|
||||
else if (archetype === "ricochet") motion = createCinderbackMotion(bossId);
|
||||
else if (archetype === "burrower") motion = createSandglassMotion();
|
||||
else if (archetype === "crab") motion = createCragclawMotion();
|
||||
else if (archetype === "ghost") motion = createMournveilMotion();
|
||||
else if (archetype === "golem") motion = createCrownshardMotion();
|
||||
else motion = {
|
||||
...createBaseMotion("bulldrome"),
|
||||
mode: "holding",
|
||||
position: [0, -8.2],
|
||||
@@ -108,6 +101,11 @@ export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionS
|
||||
pounceCenter: [0, 4.5],
|
||||
pounceCount: 0,
|
||||
};
|
||||
return { ...motion, bossId };
|
||||
}
|
||||
|
||||
function mechanicArchetype(bossId: BossId): BossArchetype {
|
||||
return BOSS_ARCHETYPE_BY_ID[bossId];
|
||||
}
|
||||
|
||||
function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
|
||||
@@ -152,8 +150,7 @@ function advanceMotionMechanics(
|
||||
let updatedParty = party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * delta);
|
||||
returnBossToArenaCenter(motion, delta, 1.8);
|
||||
if (time >= motion.nextChargeAt) {
|
||||
const targetId = chooseTarget(updatedParty, CHARGE_TARGET_ORDER, motion.chargeCount);
|
||||
const target = partyPositions[targetId];
|
||||
@@ -207,16 +204,12 @@ function advanceMotionMechanics(
|
||||
motion = { ...motion, position: [motion.chargeEnd[0], motion.chargeEnd[1]], mode: "returning", phaseEndsAt: 0 };
|
||||
}
|
||||
} else if (motion.mode === "returning") {
|
||||
const tank = partyPositions.brann;
|
||||
const returnPoint: WorldPosition = [tank[0] + motion.formationOffsetX, tank[1] - 4.25];
|
||||
motion.position = moveToward(motion.position, returnPoint, 4.4 * delta);
|
||||
if (distance(motion.position, returnPoint) < 0.12) {
|
||||
if (returnBossToArenaCenter(motion, delta, 4.4)) {
|
||||
if (motion.chargesSincePounce >= BULL_POUNCE.afterCharges) {
|
||||
const targetId = chooseTarget(updatedParty, POUNCE_TARGET_ORDER, motion.pounceCount);
|
||||
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
|
||||
motion = {
|
||||
...motion,
|
||||
position: returnPoint,
|
||||
mode: "stacking",
|
||||
phaseEndsAt: time + BULL_POUNCE.stackDuration,
|
||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
||||
@@ -235,7 +228,6 @@ function advanceMotionMechanics(
|
||||
} else {
|
||||
motion = {
|
||||
...motion,
|
||||
position: returnPoint,
|
||||
mode: "holding",
|
||||
nextChargeAt: time + BULL_CHARGE.repeatDelay,
|
||||
};
|
||||
@@ -383,18 +375,18 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe
|
||||
}
|
||||
|
||||
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
|
||||
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
|
||||
if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context);
|
||||
if (context.boss.id === "obsidian-ram-golem") return advanceObsidianRamMechanics(context);
|
||||
if (context.boss.id === "cinderback-ricochet") return advanceCinderbackMechanics(context);
|
||||
if (context.boss.id === "sandglass-scorpion") return advanceSandglassMechanics(context);
|
||||
if (context.boss.id === "cragclaw-crab") return advanceCragclawMechanics(context);
|
||||
if (context.boss.id === "mournveil-ghost") return advanceMournveilMechanics(context);
|
||||
if (context.boss.id === "crownshard-golem") return advanceCrownshardMechanics(context);
|
||||
if (context.boss.id === "pumpking-king-of-ghosts") return advanceMournveilMechanics(context);
|
||||
if (context.boss.id === "blue-eyes-ultimate-dragon") return advanceCrownshardMechanics(context);
|
||||
return advanceBulldromeMechanics(context);
|
||||
const archetype = mechanicArchetype(context.boss.id);
|
||||
const result = archetype === "web-caster" ? advanceVexaMechanics(context)
|
||||
: archetype === "sky-sweeper" ? advanceSkySweeperMechanics(context)
|
||||
: archetype === "duelist" ? advanceEmberMantisMechanics(context)
|
||||
: archetype === "ram" ? advanceObsidianRamMechanics(context)
|
||||
: archetype === "ricochet" ? advanceCinderbackMechanics(context)
|
||||
: archetype === "burrower" ? advanceSandglassMechanics(context)
|
||||
: archetype === "crab" ? advanceCragclawMechanics(context)
|
||||
: archetype === "ghost" ? advanceMournveilMechanics(context)
|
||||
: archetype === "golem" ? advanceCrownshardMechanics(context)
|
||||
: advanceBulldromeMechanics(context);
|
||||
return advancePooledBossMechanics(context, result);
|
||||
}
|
||||
|
||||
export function handleBossDispel(
|
||||
@@ -405,7 +397,7 @@ export function handleBossDispel(
|
||||
time: number,
|
||||
debuffNames: readonly string[],
|
||||
) {
|
||||
if (bossId === "vexa" && debuffNames.includes("Widow Venom")) {
|
||||
if (mechanicArchetype(bossId) === "web-caster" && debuffNames.includes("Widow Venom")) {
|
||||
return {
|
||||
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
|
||||
message: "Widow Venom purged. A venom pool forms where the target stood.",
|
||||
@@ -415,17 +407,18 @@ export function handleBossDispel(
|
||||
}
|
||||
|
||||
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
||||
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
|
||||
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
|
||||
if (boss.id === "ember-mantis-duelist") return upcomingEmberMantisMechanic(boss, motion, time);
|
||||
if (boss.id === "obsidian-ram-golem") return upcomingObsidianRamMechanic(boss, motion, time);
|
||||
if (boss.id === "cinderback-ricochet") return upcomingCinderbackMechanic(boss, motion, time);
|
||||
if (boss.id === "sandglass-scorpion") return upcomingSandglassMechanic(boss, motion, time);
|
||||
if (boss.id === "cragclaw-crab") return upcomingCragclawMechanic(boss, motion, time);
|
||||
if (boss.id === "mournveil-ghost") return upcomingMournveilMechanic(boss, motion, time);
|
||||
if (boss.id === "crownshard-golem") return upcomingCrownshardMechanic(boss, motion, time);
|
||||
if (boss.id === "pumpking-king-of-ghosts") return upcomingMournveilMechanic(boss, motion, time);
|
||||
if (boss.id === "blue-eyes-ultimate-dragon") return upcomingCrownshardMechanic(boss, motion, time);
|
||||
const pooled = upcomingPooledMechanic(motion, time);
|
||||
if (pooled) return pooled;
|
||||
const archetype = mechanicArchetype(boss.id);
|
||||
if (archetype === "web-caster") return upcomingVexaMechanic(boss, motion, time);
|
||||
if (archetype === "sky-sweeper") return upcomingSkySweeperMechanic(boss, motion, time);
|
||||
if (archetype === "duelist") return upcomingEmberMantisMechanic(boss, motion, time);
|
||||
if (archetype === "ram") return upcomingObsidianRamMechanic(boss, motion, time);
|
||||
if (archetype === "ricochet") return upcomingCinderbackMechanic(boss, motion, time);
|
||||
if (archetype === "burrower") return upcomingSandglassMechanic(boss, motion, time);
|
||||
if (archetype === "crab") return upcomingCragclawMechanic(boss, motion, time);
|
||||
if (archetype === "ghost") return upcomingMournveilMechanic(boss, motion, time);
|
||||
if (archetype === "golem") return upcomingCrownshardMechanic(boss, motion, time);
|
||||
if (motion.mode === "telegraph") {
|
||||
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ARENA_RADIUS, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "./arena";
|
||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||
import { BOSS_ROOMS, bossRoomFor } from "./bossRooms";
|
||||
|
||||
describe("boss rooms", () => {
|
||||
it("scales gameplay space and visible walls together by thirty percent", () => {
|
||||
expect(ARENA_SIZE_MULTIPLIER).toBe(1.3);
|
||||
expect(ARENA_RADIUS).toBeCloseTo(8.35 * 1.3);
|
||||
expect(ARENA_WALL_RADIUS).toBeCloseTo(9.55 * 1.3);
|
||||
});
|
||||
|
||||
it("gives every selectable boss its own room definition", () => {
|
||||
expect(Object.keys(BOSS_ROOMS)).toEqual(AVAILABLE_BOSS_IDS);
|
||||
expect(new Set(AVAILABLE_BOSS_IDS.map((bossId) => bossRoomFor(bossId).id))).toHaveLength(AVAILABLE_BOSS_IDS.length);
|
||||
});
|
||||
|
||||
it("spans multiple readable biomes instead of recoloring one arena", () => {
|
||||
expect(new Set(Object.values(BOSS_ROOMS).map((room) => room.floor)).size).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { BossId } from "./types";
|
||||
|
||||
export type BossRoomFloor =
|
||||
| "cinder"
|
||||
| "desert"
|
||||
| "tide"
|
||||
| "reliquary"
|
||||
| "royal"
|
||||
| "prism"
|
||||
| "storm"
|
||||
| "wilds"
|
||||
| "void"
|
||||
| "mire"
|
||||
| "quarry"
|
||||
| "junkyard"
|
||||
| "web"
|
||||
| "moon"
|
||||
| "frost"
|
||||
| "warcamp"
|
||||
| "sky";
|
||||
|
||||
export interface BossRoomDefinition {
|
||||
/** Stable room key. Keep this independent from a boss's combat archetype. */
|
||||
id: string;
|
||||
name: string;
|
||||
biome: string;
|
||||
floor: BossRoomFloor;
|
||||
background: string;
|
||||
fog: string;
|
||||
sky: string;
|
||||
ground: string;
|
||||
floorColor: string;
|
||||
wallColor: string;
|
||||
accent: string;
|
||||
accentSecondary: string;
|
||||
wallHeight: number;
|
||||
}
|
||||
|
||||
function room(
|
||||
id: string,
|
||||
name: string,
|
||||
biome: string,
|
||||
floor: BossRoomFloor,
|
||||
colors: Omit<BossRoomDefinition, "id" | "name" | "biome" | "floor">,
|
||||
): BossRoomDefinition {
|
||||
return { id, name, biome, floor, ...colors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every selectable guardian owns a room identity. Rendering composes this small
|
||||
* data record into low-cost geometry, so encounter mechanics remain independent
|
||||
* from the presentation of the room around them.
|
||||
*/
|
||||
export const BOSS_ROOMS = {
|
||||
bulldrome: room("cinderstock-coliseum", "Cinderstock Coliseum", "Volcanic fortress", "cinder", {
|
||||
background: "#190603", fog: "#351006", sky: "#e36b36", ground: "#120704", floorColor: "#382018", wallColor: "#3d1c15", accent: "#ff7138", accentSecondary: "#ffbd63", wallHeight: 3.8,
|
||||
}),
|
||||
"sandglass-scorpion": room("sunken-hour", "The Sunken Hour", "Buried sun temple", "desert", {
|
||||
background: "#201607", fog: "#51401b", sky: "#e8c56d", ground: "#160f04", floorColor: "#5d4821", wallColor: "#6e5126", accent: "#f6c850", accentSecondary: "#9e672c", wallHeight: 3.2,
|
||||
}),
|
||||
"cragclaw-crab": room("drowned-breakwater", "The Drowned Breakwater", "Storm-tossed coast", "tide", {
|
||||
background: "#041621", fog: "#0b4050", sky: "#7ad9e0", ground: "#031015", floorColor: "#0c3b49", wallColor: "#125264", accent: "#4cd9e7", accentSecondary: "#8ce9c6", wallHeight: 3.4,
|
||||
}),
|
||||
"mournveil-ghost": room("silent-reliquary", "The Silent Reliquary", "Haunted mausoleum", "reliquary", {
|
||||
background: "#11091d", fog: "#332249", sky: "#b89cff", ground: "#0d0714", floorColor: "#2b1c3d", wallColor: "#3e2b52", accent: "#b985ff", accentSecondary: "#e1c1ff", wallHeight: 4.2,
|
||||
}),
|
||||
"crownshard-golem": room("broken-coronation", "The Broken Coronation", "Shattered royal hall", "royal", {
|
||||
background: "#171005", fog: "#463518", sky: "#f0d88b", ground: "#100a03", floorColor: "#493617", wallColor: "#5d441c", accent: "#f1c951", accentSecondary: "#fff0a6", wallHeight: 4.4,
|
||||
}),
|
||||
"crystal-bat-matriarch": room("prism-echo", "The Prism Echo", "Crystal echo cavern", "prism", {
|
||||
background: "#051522", fog: "#174d67", sky: "#92edff", ground: "#041019", floorColor: "#123a52", wallColor: "#1d6078", accent: "#82eaff", accentSecondary: "#d0a5ff", wallHeight: 4.8,
|
||||
}),
|
||||
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
|
||||
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
|
||||
}),
|
||||
"cluckhorn-colossus": room("roostbreaker-yard", "The Roostbreaker Yard", "Ruinous farmstead", "wilds", {
|
||||
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6,
|
||||
}),
|
||||
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
|
||||
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
|
||||
}),
|
||||
"riftclaw-demon": room("broken-duel", "The Broken Duel", "Fractured void arena", "void", {
|
||||
background: "#080315", fog: "#251040", sky: "#c773ff", ground: "#05020e", floorColor: "#1c0f30", wallColor: "#311850", accent: "#d65cff", accentSecondary: "#7458ff", wallHeight: 3.7,
|
||||
}),
|
||||
"tempestscale-dragon": room("living-storm", "The Living Storm", "Eye of the storm", "sky", {
|
||||
background: "#041b2b", fog: "#1d5a73", sky: "#7be4ef", ground: "#041018", floorColor: "#15465d", wallColor: "#24637a", accent: "#57d8ee", accentSecondary: "#d3ffff", wallHeight: 2.1,
|
||||
}),
|
||||
emberfox: room("burning-trail", "The Burning Trail", "Charred twilight grove", "cinder", {
|
||||
background: "#1a0804", fog: "#4a1b0a", sky: "#ff9562", ground: "#0f0603", floorColor: "#412015", wallColor: "#5a2b16", accent: "#ff7d45", accentSecondary: "#ffc56a", wallHeight: 2.9,
|
||||
}),
|
||||
"mirelord-frog": room("drowned-bell", "The Drowned Bell", "Luminous blackwater mire", "mire", {
|
||||
background: "#06170d", fog: "#235c36", sky: "#91dc84", ground: "#030d07", floorColor: "#1c4b2c", wallColor: "#30653b", accent: "#74d66b", accentSecondary: "#bcf08a", wallHeight: 2.5,
|
||||
}),
|
||||
"stonebreaker-giant": room("walking-crag", "The Walking Crag", "Deep mountain quarry", "quarry", {
|
||||
background: "#16100b", fog: "#4a3528", sky: "#d8af82", ground: "#0b0806", floorColor: "#473127", wallColor: "#5b4234", accent: "#d39a62", accentSecondary: "#f0cb9b", wallHeight: 5.5,
|
||||
}),
|
||||
"glub-sovereign": room("binding-ooze", "The Binding Ooze", "Caustic slime caverns", "mire", {
|
||||
background: "#06170f", fog: "#1d5c47", sky: "#76e5bc", ground: "#030c09", floorColor: "#174c3b", wallColor: "#216752", accent: "#66e3b3", accentSecondary: "#d0ffd3", wallHeight: 3.6,
|
||||
}),
|
||||
"scrapking-goblin": room("jagged-throne", "The Jagged Throne", "Scrap-metal forge", "junkyard", {
|
||||
background: "#17100a", fog: "#513b25", sky: "#e1ac62", ground: "#0d0905", floorColor: "#3e3022", wallColor: "#5a462d", accent: "#e0a64c", accentSecondary: "#c9e58d", wallHeight: 3.3,
|
||||
}),
|
||||
"warcaller-orc": room("red-standard", "The Red Standard", "Siege camp", "warcamp", {
|
||||
background: "#1d0807", fog: "#59221c", sky: "#e56b54", ground: "#100505", floorColor: "#48201b", wallColor: "#612a20", accent: "#e8543f", accentSecondary: "#e1b15c", wallHeight: 3.4,
|
||||
}),
|
||||
"tuskmaw-orc": room("breaker-below", "The Breaker Below", "Bone-strewn badlands", "quarry", {
|
||||
background: "#16160b", fog: "#536036", sky: "#b7c67a", ground: "#0a0c05", floorColor: "#3e4525", wallColor: "#596037", accent: "#b6cf6a", accentSecondary: "#e7d08e", wallHeight: 3.5,
|
||||
}),
|
||||
"broodfang-spider": room("silk-tyrant", "The Silk Tyrant", "Venom web caverns", "web", {
|
||||
background: "#140615", fog: "#49234b", sky: "#ce7fff", ground: "#0a030b", floorColor: "#351638", wallColor: "#532052", accent: "#c66cff", accentSecondary: "#88e66f", wallHeight: 4.3,
|
||||
}),
|
||||
"silkfang-spider": room("gloom-weaver", "The Gloom Weaver", "Moonlit silk grove", "web", {
|
||||
background: "#10091b", fog: "#352653", sky: "#b39bff", ground: "#080510", floorColor: "#271b42", wallColor: "#3c2d62", accent: "#a979e7", accentSecondary: "#d1c1ff", wallHeight: 3.8,
|
||||
}),
|
||||
"thorncrown-stag": room("briar-hart", "The Briar Hart", "Ancient thornwood", "wilds", {
|
||||
background: "#071509", fog: "#254e2a", sky: "#9cdb7b", ground: "#040b05", floorColor: "#234326", wallColor: "#355c31", accent: "#7fc96a", accentSecondary: "#d9b965", wallHeight: 4.1,
|
||||
}),
|
||||
"sky-totem": room("hollow-idol", "The Hollow Idol", "Cloudbound spirit plateau", "sky", {
|
||||
background: "#051620", fog: "#1b5960", sky: "#76ded4", ground: "#041014", floorColor: "#154247", wallColor: "#236165", accent: "#66d9cf", accentSecondary: "#e0ffff", wallHeight: 2.3,
|
||||
}),
|
||||
"razorcrest-raptor": room("hunting-circuit", "The Hunting Circuit", "Overgrown predator ruins", "wilds", {
|
||||
background: "#111407", fog: "#4d5522", sky: "#d8d36d", ground: "#080a04", floorColor: "#383d1b", wallColor: "#565a29", accent: "#d8c95c", accentSecondary: "#96d26b", wallHeight: 3.1,
|
||||
}),
|
||||
"bristlequake-boar": room("iron-tusk", "The Iron Tusk", "Ironwood quarry", "junkyard", {
|
||||
background: "#15100a", fog: "#473c2b", sky: "#d89a63", ground: "#090705", floorColor: "#393125", wallColor: "#504636", accent: "#d67d45", accentSecondary: "#d0ba6d", wallHeight: 4.6,
|
||||
}),
|
||||
"moonfang-wolf": room("silver-pursuit", "The Silver Pursuit", "Moonlit ruins", "moon", {
|
||||
background: "#07101d", fog: "#273a61", sky: "#b9d0ff", ground: "#040914", floorColor: "#1f3152", wallColor: "#34476e", accent: "#b3c8ff", accentSecondary: "#ece9ff", wallHeight: 3.9,
|
||||
}),
|
||||
"frostmaw-yeti": room("white-avalanche", "The White Avalanche", "Glacial ravine", "frost", {
|
||||
background: "#061722", fog: "#306779", sky: "#9ceeff", ground: "#031017", floorColor: "#1b5466", wallColor: "#397588", accent: "#8ce5f5", accentSecondary: "#e9ffff", wallHeight: 4.7,
|
||||
}),
|
||||
"rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", {
|
||||
background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9,
|
||||
}),
|
||||
} as const satisfies Record<BossId, BossRoomDefinition>;
|
||||
|
||||
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
|
||||
return BOSS_ROOMS[bossId];
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards } from "./shared";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CRAGCLAW = {
|
||||
@@ -103,8 +103,7 @@ export function advanceCragclawMechanics(context: BossMechanicContext): BossMech
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.05 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 2.05);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { moveToward } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CROWNSHARD = {
|
||||
@@ -96,8 +95,7 @@ export function advanceCrownshardMechanics(context: BossMechanicContext): BossMe
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.55 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 1.55);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
|
||||
|
||||
@@ -36,7 +36,7 @@ function context(
|
||||
};
|
||||
}
|
||||
|
||||
describe("Gate Guardian mechanics", () => {
|
||||
describe("Warcaller mechanics", () => {
|
||||
it("sidesteps, telegraphs Elemental Beam, then damages targets left in the lane", () => {
|
||||
const boss = createEmberMantisState();
|
||||
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName } from "./shared";
|
||||
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const EMBER_MANTIS_SLASH = {
|
||||
@@ -27,8 +27,8 @@ const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "bran
|
||||
const MIN_BOSS_X = -5.8;
|
||||
const MAX_BOSS_X = 5.8;
|
||||
|
||||
export function createEmberMantisState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["ember-mantis-duelist"];
|
||||
export function createEmberMantisState(bossId: BossId = "warcaller-orc"): BossState {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
@@ -41,9 +41,9 @@ export function createEmberMantisState(): BossState {
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmberMantisMotion(): BossMotionState {
|
||||
export function createEmberMantisMotion(bossId: BossId = "warcaller-orc"): BossMotionState {
|
||||
return {
|
||||
...createBaseMotion("ember-mantis-duelist"),
|
||||
...createBaseMotion(bossId),
|
||||
position: [0, -6.6],
|
||||
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
|
||||
};
|
||||
@@ -161,17 +161,12 @@ export function advanceEmberMantisMechanics(context: BossMechanicContext): BossM
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(
|
||||
motion.position,
|
||||
[tank[0] + motion.formationOffsetX, tank[1] - 4.25],
|
||||
2.5 * context.delta,
|
||||
);
|
||||
returnBossToArenaCenter(motion, context.delta, 2.5);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
motion = beginSidestep(motion, party, context.partyPositions, context.time);
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `Gate Guardian shifts toward ${memberName(party, motion.chargeTargetId)}. Track its arms.`,
|
||||
message: `${boss.name} shifts toward ${memberName(party, motion.chargeTargetId)}. Track its attack.`,
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: motion.chargeTargetId,
|
||||
@@ -223,7 +218,6 @@ export function upcomingEmberMantisMechanic(
|
||||
motion: BossMotionState,
|
||||
time: number,
|
||||
): UpcomingMechanic {
|
||||
void boss;
|
||||
if (motion.mode === "mantis_sidestep") {
|
||||
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
|
||||
}
|
||||
@@ -234,7 +228,7 @@ export function upcomingEmberMantisMechanic(
|
||||
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_recover") {
|
||||
return { name: "Gate Guardian exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
|
||||
return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
|
||||
}
|
||||
const nextIsCross = motion.mechanicCount % 2 === 1;
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { freshParty } from "../data";
|
||||
import type { BossMotionState, BossState, WorldPosition } from "../types";
|
||||
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./cinderbackRicochet";
|
||||
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./ricochet";
|
||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
|
||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
|
||||
import type { BossMechanicContext } from "./types";
|
||||
@@ -34,7 +34,7 @@ function context(
|
||||
}
|
||||
|
||||
describe("IWT2 boss trio mechanics", () => {
|
||||
it("telegraphs and resolves Obsidian Ram Plate Charge", () => {
|
||||
it("telegraphs and resolves Bristlequake Tusk Charge", () => {
|
||||
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
|
||||
expect(start.motion.mode).toBe("ram_charge_telegraph");
|
||||
expect(start.motion.slashLanes).toHaveLength(1);
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { freshParty } from "../data";
|
||||
import { createBaseMotion } from "./shared";
|
||||
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool";
|
||||
import type { BossMechanicContext, BossMechanicResult } from "./types";
|
||||
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
|
||||
|
||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
||||
aelia: [0, 4.5],
|
||||
brann: [0, 0],
|
||||
nia: [-3, 2],
|
||||
orin: [3, 2],
|
||||
vale: [0, -2],
|
||||
};
|
||||
|
||||
function state(): BossState {
|
||||
return {
|
||||
id: "bulldrome",
|
||||
name: "Bulldrome",
|
||||
maxHp: 500,
|
||||
hp: 500,
|
||||
nextMeleeAt: Number.POSITIVE_INFINITY,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function context(time: number, positions = POSITIONS): BossMechanicContext {
|
||||
return {
|
||||
boss: state(),
|
||||
motion: createBaseMotion("bulldrome"),
|
||||
party: freshParty(),
|
||||
partyPositions: structuredClone(positions),
|
||||
time,
|
||||
delta: 0.1,
|
||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
||||
};
|
||||
}
|
||||
|
||||
function result(contextValue: BossMechanicContext): BossMechanicResult {
|
||||
return { boss: contextValue.boss, motion: contextValue.motion, party: contextValue.party, events: [] };
|
||||
}
|
||||
|
||||
function memoryTelegraph(): PoolTelegraph {
|
||||
return {
|
||||
id: "test-memory",
|
||||
kind: "memory",
|
||||
name: "Memory Sequence",
|
||||
center: [0, -8],
|
||||
radius: 0,
|
||||
activatesAt: 0,
|
||||
inputStartsAt: 0,
|
||||
expiresAt: 10,
|
||||
damage: 15,
|
||||
targetId: "aelia",
|
||||
sequence: ["triangle", "cross", "circle", "square"],
|
||||
tiles: [
|
||||
{ symbol: "triangle", center: [-3, 2] },
|
||||
{ symbol: "cross", center: [3, 2] },
|
||||
{ symbol: "circle", center: [-3, -2] },
|
||||
{ symbol: "square", center: [3, -2] },
|
||||
],
|
||||
inputIndex: 0,
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function advanceMemory(
|
||||
time: number,
|
||||
telegraph: PoolTelegraph,
|
||||
party = freshParty(),
|
||||
aeliaPosition: WorldPosition = [0, 4.5],
|
||||
) {
|
||||
const positions = structuredClone(POSITIONS);
|
||||
positions.aelia = aeliaPosition;
|
||||
const source = context(time, positions);
|
||||
source.party = party;
|
||||
source.motion = {
|
||||
...source.motion,
|
||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
||||
poolTelegraphs: [telegraph],
|
||||
};
|
||||
return advancePooledBossMechanics(source, result(source));
|
||||
}
|
||||
|
||||
function soulSiphonTelegraph(): PoolTelegraph {
|
||||
return {
|
||||
id: "test-soul-siphon",
|
||||
kind: "soul-siphon",
|
||||
name: "Soul Siphon",
|
||||
center: [0, -7],
|
||||
radius: 0,
|
||||
activatesAt: 0,
|
||||
expiresAt: Number.POSITIVE_INFINITY,
|
||||
damage: 0,
|
||||
targetId: "aelia",
|
||||
soulSiphon: {
|
||||
targetId: "aelia",
|
||||
ghostPosition: [0, 3.6],
|
||||
wardPosition: [0, -7],
|
||||
wardRadius: SOUL_SIPHON.wardRadius,
|
||||
nextDamageAt: SOUL_SIPHON.tickInterval,
|
||||
tickCount: 0,
|
||||
},
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function advanceSoulSiphon(
|
||||
time: number,
|
||||
telegraph: PoolTelegraph,
|
||||
party = freshParty(),
|
||||
aeliaPosition: WorldPosition = [0, 4.5],
|
||||
) {
|
||||
const positions = structuredClone(POSITIONS);
|
||||
positions.aelia = aeliaPosition;
|
||||
const source = context(time, positions);
|
||||
source.party = party;
|
||||
source.motion = {
|
||||
...source.motion,
|
||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
||||
poolTelegraphs: [telegraph],
|
||||
};
|
||||
return advancePooledBossMechanics(source, result(source));
|
||||
}
|
||||
|
||||
describe("shared boss mechanic pool", () => {
|
||||
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => {
|
||||
const source = context(POOLED_MECHANIC_TIMING.firstAt);
|
||||
const started = advancePooledBossMechanics(source, result(source));
|
||||
|
||||
expect(started.motion.mode).toBe("holding");
|
||||
expect(started.motion.poolTelegraphs).not.toHaveLength(0);
|
||||
expect(started.events[0].message).toContain(":");
|
||||
expect(started.motion.nextPoolMechanicAt).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("splits soak damage across allies inside its indicator", () => {
|
||||
const source = context(0);
|
||||
const activatesAt = 1.5;
|
||||
const soak = {
|
||||
id: "test-soak",
|
||||
kind: "soak" as const,
|
||||
name: "Aetheric Soak",
|
||||
center: [0, 0] as WorldPosition,
|
||||
radius: 2.5,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + 0.5,
|
||||
damage: 0,
|
||||
totalDamage: 90,
|
||||
minimumParticipants: 3,
|
||||
targetId: "brann" as const,
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
const motion = { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [soak] };
|
||||
const positions = structuredClone(POSITIONS);
|
||||
positions.aelia = [0.2, 0];
|
||||
positions.brann = [0, 0];
|
||||
positions.nia = [-0.2, 0];
|
||||
positions.vale = [0, -4];
|
||||
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt };
|
||||
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
|
||||
|
||||
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30);
|
||||
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30);
|
||||
expect(resolved.party.find((member) => member.id === "nia")!.hp).toBe(source.party[2].hp - 30);
|
||||
expect(resolved.events[0].message).toContain("splits 90 damage across 3 allies");
|
||||
});
|
||||
|
||||
it("resolves a donut only in its outer ring", () => {
|
||||
const source = context(0);
|
||||
const activatesAt = 1.5;
|
||||
const donut = {
|
||||
id: "test-donut",
|
||||
kind: "donut" as const,
|
||||
name: "Hollow Collapse",
|
||||
center: [0, 0] as WorldPosition,
|
||||
radius: 5,
|
||||
innerRadius: 2,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + 0.5,
|
||||
damage: 25,
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
const positions = structuredClone(POSITIONS);
|
||||
positions.aelia = [0, 0];
|
||||
positions.brann = [3, 0];
|
||||
const atImpact = {
|
||||
...source,
|
||||
motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] },
|
||||
partyPositions: positions,
|
||||
time: activatesAt,
|
||||
};
|
||||
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
|
||||
|
||||
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp);
|
||||
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25);
|
||||
});
|
||||
|
||||
it("requires only the healer to cross four tiles in shown order", () => {
|
||||
let current = advanceMemory(1, memoryTelegraph(), freshParty(), [-3, 2]);
|
||||
expect(current.motion.poolTelegraphs[0].inputIndex).toBe(1);
|
||||
|
||||
current = advanceMemory(1.1, current.motion.poolTelegraphs[0], current.party, [0, 4.5]);
|
||||
current = advanceMemory(1.2, current.motion.poolTelegraphs[0], current.party, [3, 2]);
|
||||
expect(current.motion.poolTelegraphs[0].inputIndex).toBe(2);
|
||||
|
||||
current = advanceMemory(1.3, current.motion.poolTelegraphs[0], current.party, [0, 4.5]);
|
||||
current = advanceMemory(1.4, current.motion.poolTelegraphs[0], current.party, [-3, -2]);
|
||||
current = advanceMemory(1.5, current.motion.poolTelegraphs[0], current.party, [0, 4.5]);
|
||||
current = advanceMemory(1.6, current.motion.poolTelegraphs[0], current.party, [3, -2]);
|
||||
|
||||
expect(current.motion.poolTelegraphs).toHaveLength(0);
|
||||
expect(current.events[0].message).toBe("Memory Sequence cleared by healer.");
|
||||
expect(current.party).toEqual(freshParty());
|
||||
});
|
||||
|
||||
it("ignores NPC positions but deals 15 raidwide damage when healer selects a wrong tile", () => {
|
||||
const waiting = advanceMemory(1, memoryTelegraph(), freshParty(), [0, 4.5]);
|
||||
expect(waiting.events).toEqual([]);
|
||||
expect(waiting.motion.poolTelegraphs[0].inputIndex).toBe(0);
|
||||
|
||||
const failed = advanceMemory(1, memoryTelegraph(), freshParty(), [3, 2]);
|
||||
for (const member of failed.party) {
|
||||
expect(member.hp).toBe(member.maxHp - 15);
|
||||
}
|
||||
expect(failed.events[0].message).toContain("15 raidwide damage");
|
||||
});
|
||||
|
||||
it("drains the healer until they enter the opposite cleansing ward", () => {
|
||||
const started = soulSiphonTelegraph();
|
||||
const firstTick = advanceSoulSiphon(SOUL_SIPHON.tickInterval + 0.01, started);
|
||||
expect(firstTick.party.find((member) => member.id === "aelia")!.hp).toBe(
|
||||
freshParty()[0].hp - SOUL_SIPHON.tickDamage,
|
||||
);
|
||||
expect(firstTick.motion.poolTelegraphs[0].soulSiphon?.ghostPosition).not.toEqual(started.soulSiphon?.ghostPosition);
|
||||
|
||||
const wardPosition = firstTick.motion.poolTelegraphs[0].soulSiphon!.wardPosition;
|
||||
const cleansed = advanceSoulSiphon(1, firstTick.motion.poolTelegraphs[0], firstTick.party, wardPosition);
|
||||
expect(cleansed.motion.poolTelegraphs).toHaveLength(0);
|
||||
expect(cleansed.events[0].message).toContain("cleansing ward");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const BOSS_MECHANIC_POOL = [
|
||||
{
|
||||
id: "meteor-spread",
|
||||
name: "Meteor Spread",
|
||||
instruction: "Break formation before the marked circles land.",
|
||||
},
|
||||
{
|
||||
id: "hollow-collapse",
|
||||
name: "Hollow Collapse",
|
||||
instruction: "Move inside the inner safe circle.",
|
||||
},
|
||||
{
|
||||
id: "aetheric-soak",
|
||||
name: "Aetheric Soak",
|
||||
instruction: "Stack in the marked circle to split the hit.",
|
||||
},
|
||||
{
|
||||
id: "prism-beam",
|
||||
name: "Prism Beam",
|
||||
instruction: "Clear the marked beam lane.",
|
||||
},
|
||||
{
|
||||
id: "memory-sequence",
|
||||
name: "Memory Sequence",
|
||||
instruction: "Healer only: watch four symbols, then cross matching tiles in order.",
|
||||
},
|
||||
{
|
||||
id: "soul-siphon",
|
||||
name: "Soul Siphon",
|
||||
instruction: "Healer only: run through the gold cleansing ward before the shade drains you.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"];
|
||||
|
||||
export const POOLED_MECHANIC_TIMING = {
|
||||
firstAt: 15,
|
||||
repeatDelay: 26,
|
||||
activeDuration: 0.42,
|
||||
warningDuration: 1.4,
|
||||
} as const;
|
||||
|
||||
export const MEMORY_SEQUENCE = {
|
||||
sequenceLength: 4,
|
||||
flashDuration: 0.68,
|
||||
inputDuration: 7,
|
||||
tileSize: 2.15,
|
||||
raidwideDamage: 15,
|
||||
} as const;
|
||||
|
||||
export const SOUL_SIPHON = {
|
||||
wardRadius: 1.3,
|
||||
wardDistance: ARENA_RADIUS - 1.05,
|
||||
ghostSpeed: 2.6,
|
||||
tickInterval: 0.7,
|
||||
tickDamage: 6,
|
||||
tickRamp: 2,
|
||||
} as const;
|
||||
|
||||
export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
|
||||
triangle: { label: "Triangle", color: "#ff4d55" },
|
||||
cross: { label: "Cross", color: "#4fa8ff" },
|
||||
circle: { label: "Circle", color: "#65d67a" },
|
||||
square: { label: "Square", color: "#ffd34d" },
|
||||
};
|
||||
|
||||
const MEMORY_TILE_LAYOUT: readonly { symbol: MemorySymbolId; center: WorldPosition }[] = [
|
||||
{ symbol: "triangle", center: [-2.35, 1.35] },
|
||||
{ symbol: "cross", center: [2.35, 1.35] },
|
||||
{ symbol: "circle", center: [-2.35, -3.15] },
|
||||
{ symbol: "square", center: [2.35, -3.15] },
|
||||
];
|
||||
|
||||
const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
|
||||
["triangle", "cross", "circle", "square"],
|
||||
["circle", "triangle", "square", "cross"],
|
||||
["square", "circle", "cross", "triangle"],
|
||||
["cross", "square", "triangle", "circle"],
|
||||
];
|
||||
|
||||
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
|
||||
|
||||
function bossPoolOffset(bossId: string) {
|
||||
let value = 0;
|
||||
for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length;
|
||||
return value;
|
||||
}
|
||||
|
||||
function liveTarget(party: PartyMember[], targetId: MemberId) {
|
||||
return party.some((member) => member.id === targetId && member.hp > 0)
|
||||
? targetId
|
||||
: party.find((member) => member.hp > 0)?.id ?? targetId;
|
||||
}
|
||||
|
||||
function circleTelegraph({
|
||||
id,
|
||||
kind,
|
||||
name,
|
||||
center,
|
||||
radius,
|
||||
innerRadius,
|
||||
activatesAt,
|
||||
damage,
|
||||
targetId,
|
||||
}: {
|
||||
id: string;
|
||||
kind: "spread" | "donut" | "soak";
|
||||
name: string;
|
||||
center: WorldPosition;
|
||||
radius: number;
|
||||
innerRadius?: number;
|
||||
activatesAt: number;
|
||||
damage: number;
|
||||
targetId?: MemberId;
|
||||
}): PoolTelegraph {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
name,
|
||||
center: [center[0], center[1]],
|
||||
radius,
|
||||
innerRadius,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
|
||||
damage,
|
||||
targetId,
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function beamTelegraph(id: string, center: WorldPosition, target: WorldPosition, activatesAt: number): PoolTelegraph {
|
||||
const angle = angleTo(center, target);
|
||||
const halfLength = 9.2;
|
||||
const dx = Math.sin(angle) * halfLength;
|
||||
const dz = Math.cos(angle) * halfLength;
|
||||
return {
|
||||
id,
|
||||
kind: "beam",
|
||||
name: "Prism Beam",
|
||||
center: [center[0], center[1]],
|
||||
radius: 0,
|
||||
start: [center[0] - dx, center[1] - dz],
|
||||
end: [center[0] + dx, center[1] + dz],
|
||||
width: 1.45,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
|
||||
damage: 29,
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function memorySequenceTelegraph(id: string, bossPosition: WorldPosition, count: number, inputStartsAt: number): PoolTelegraph {
|
||||
const sequence = MEMORY_SEQUENCES[count % MEMORY_SEQUENCES.length];
|
||||
return {
|
||||
id,
|
||||
kind: "memory",
|
||||
name: "Memory Sequence",
|
||||
center: [bossPosition[0], bossPosition[1]],
|
||||
radius: 0,
|
||||
activatesAt: inputStartsAt,
|
||||
inputStartsAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration,
|
||||
expiresAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration + MEMORY_SEQUENCE.inputDuration,
|
||||
damage: MEMORY_SEQUENCE.raidwideDamage,
|
||||
targetId: "aelia",
|
||||
sequence: [...sequence],
|
||||
tiles: MEMORY_TILE_LAYOUT.map((tile) => ({ symbol: tile.symbol, center: [tile.center[0], tile.center[1]] })),
|
||||
inputIndex: 0,
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function oppositeWardPosition(healerPosition: WorldPosition, count: number): WorldPosition {
|
||||
const offsetX = healerPosition[0] - ARENA_CENTER[0];
|
||||
const offsetZ = healerPosition[1] - ARENA_CENTER[1];
|
||||
const offsetLength = Math.hypot(offsetX, offsetZ);
|
||||
const fallbackAngle = count % 2 === 0 ? 0 : Math.PI / 2;
|
||||
const directionX = offsetLength > 0.05 ? -offsetX / offsetLength : Math.sin(fallbackAngle);
|
||||
const directionZ = offsetLength > 0.05 ? -offsetZ / offsetLength : Math.cos(fallbackAngle);
|
||||
return clampToArena([
|
||||
ARENA_CENTER[0] + directionX * SOUL_SIPHON.wardDistance,
|
||||
ARENA_CENTER[1] + directionZ * SOUL_SIPHON.wardDistance,
|
||||
], 0.25);
|
||||
}
|
||||
|
||||
function soulSiphonTelegraph(id: string, healerPosition: WorldPosition, count: number, time: number): PoolTelegraph {
|
||||
const wardPosition = oppositeWardPosition(healerPosition, count);
|
||||
return {
|
||||
id,
|
||||
kind: "soul-siphon",
|
||||
name: "Soul Siphon",
|
||||
center: [wardPosition[0], wardPosition[1]],
|
||||
radius: 0,
|
||||
activatesAt: time,
|
||||
expiresAt: Number.POSITIVE_INFINITY,
|
||||
damage: 0,
|
||||
targetId: "aelia",
|
||||
soulSiphon: {
|
||||
targetId: "aelia",
|
||||
ghostPosition: clampToArena([healerPosition[0] - 0.85, healerPosition[1] + 0.85], 0.2),
|
||||
wardPosition,
|
||||
wardRadius: SOUL_SIPHON.wardRadius,
|
||||
nextDamageAt: time + SOUL_SIPHON.tickInterval,
|
||||
tickCount: 0,
|
||||
},
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function beginPoolMechanic(
|
||||
motion: BossMotionState,
|
||||
party: PartyMember[],
|
||||
positions: BossMechanicContext["partyPositions"],
|
||||
time: number,
|
||||
) {
|
||||
const count = motion.poolMechanicCount + 1;
|
||||
const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length];
|
||||
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
|
||||
let telegraphs: PoolTelegraph[];
|
||||
let targetId: MemberId | undefined;
|
||||
|
||||
if (entry.id === "meteor-spread") {
|
||||
const targets = [0, 1, 2].map((offset) => liveTarget(party, TARGET_ORDER[(count + offset) % TARGET_ORDER.length]));
|
||||
telegraphs = targets.map((id, index) => circleTelegraph({
|
||||
id: `pool-spread-${count}-${index}`,
|
||||
kind: "spread",
|
||||
name: "Meteor Spread",
|
||||
center: positions[id],
|
||||
radius: 1.75,
|
||||
activatesAt: activatesAt + index * 0.42,
|
||||
damage: 24,
|
||||
targetId: id,
|
||||
}));
|
||||
targetId = targets[0];
|
||||
} else if (entry.id === "hollow-collapse") {
|
||||
telegraphs = [circleTelegraph({
|
||||
id: `pool-donut-${count}`,
|
||||
kind: "donut",
|
||||
name: "Hollow Collapse",
|
||||
center: motion.position,
|
||||
radius: 7.25,
|
||||
innerRadius: 2.15,
|
||||
activatesAt,
|
||||
damage: 27,
|
||||
})];
|
||||
} else if (entry.id === "aetheric-soak") {
|
||||
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
|
||||
const soak = circleTelegraph({
|
||||
id: `pool-soak-${count}`,
|
||||
kind: "soak",
|
||||
name: "Aetheric Soak",
|
||||
center: positions[targetId],
|
||||
radius: 2.25,
|
||||
activatesAt: time + 1.75,
|
||||
damage: 0,
|
||||
targetId,
|
||||
});
|
||||
soak.totalDamage = 78;
|
||||
soak.minimumParticipants = 3;
|
||||
telegraphs = [soak];
|
||||
} else if (entry.id === "memory-sequence") {
|
||||
targetId = "aelia";
|
||||
telegraphs = [memorySequenceTelegraph(`pool-memory-${count}`, motion.position, count, time)];
|
||||
} else if (entry.id === "soul-siphon") {
|
||||
targetId = "aelia";
|
||||
telegraphs = [soulSiphonTelegraph(`pool-soul-siphon-${count}`, positions.aelia, count, time)];
|
||||
} else {
|
||||
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
|
||||
telegraphs = [beamTelegraph(`pool-beam-${count}`, motion.position, positions[targetId], activatesAt)];
|
||||
}
|
||||
|
||||
return {
|
||||
motion: {
|
||||
...motion,
|
||||
poolMechanicCount: count,
|
||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
||||
poolTelegraphs: telegraphs,
|
||||
},
|
||||
event: {
|
||||
at: time,
|
||||
message: `${entry.name}: ${entry.instruction}`,
|
||||
tone: "danger" as const,
|
||||
pulseKind: "boss" as const,
|
||||
targetId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function memoryTileAt(telegraph: PoolTelegraph, position: WorldPosition) {
|
||||
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
|
||||
return telegraph.tiles?.find((tile) =>
|
||||
Math.abs(position[0] - tile.center[0]) <= halfSize && Math.abs(position[1] - tile.center[1]) <= halfSize,
|
||||
);
|
||||
}
|
||||
|
||||
function failMemorySequence(
|
||||
telegraph: PoolTelegraph,
|
||||
party: PartyMember[],
|
||||
positions: BossMechanicContext["partyPositions"],
|
||||
context: BossMechanicContext,
|
||||
events: BossMechanicResult["events"],
|
||||
) {
|
||||
telegraph.resolved = true;
|
||||
telegraph.expiresAt = context.time;
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `${telegraph.name} fails — every party member takes ${telegraph.damage} raidwide damage.`,
|
||||
tone: "danger",
|
||||
pulseKind: "boss",
|
||||
targetId: "aelia",
|
||||
});
|
||||
return party.map((member) => member.hp > 0
|
||||
? context.damageMember(member, telegraph.damage, positions[member.id], context.time)
|
||||
: member);
|
||||
}
|
||||
|
||||
function resolveMemorySequence(
|
||||
telegraph: PoolTelegraph,
|
||||
party: PartyMember[],
|
||||
positions: BossMechanicContext["partyPositions"],
|
||||
context: BossMechanicContext,
|
||||
events: BossMechanicResult["events"],
|
||||
) {
|
||||
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined || telegraph.inputIndex === undefined) {
|
||||
return failMemorySequence(telegraph, party, positions, context, events);
|
||||
}
|
||||
if (context.time < telegraph.inputStartsAt) return party;
|
||||
if (context.time >= telegraph.expiresAt) return failMemorySequence(telegraph, party, positions, context, events);
|
||||
|
||||
const selectedTile = memoryTileAt(telegraph, positions.aelia);
|
||||
if (!selectedTile) {
|
||||
telegraph.lastHealerTileId = undefined;
|
||||
} else if (selectedTile.symbol !== telegraph.lastHealerTileId) {
|
||||
telegraph.lastHealerTileId = selectedTile.symbol;
|
||||
if (selectedTile.symbol !== telegraph.sequence[telegraph.inputIndex]) {
|
||||
return failMemorySequence(telegraph, party, positions, context, events);
|
||||
}
|
||||
telegraph.inputIndex += 1;
|
||||
if (telegraph.inputIndex === telegraph.sequence.length) {
|
||||
telegraph.resolved = true;
|
||||
telegraph.expiresAt = context.time;
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: "Memory Sequence cleared by healer.",
|
||||
tone: "neutral",
|
||||
pulseKind: "boss",
|
||||
targetId: "aelia",
|
||||
});
|
||||
return party;
|
||||
}
|
||||
}
|
||||
|
||||
return party;
|
||||
}
|
||||
|
||||
function resolveSoulSiphon(
|
||||
telegraph: PoolTelegraph,
|
||||
party: PartyMember[],
|
||||
positions: BossMechanicContext["partyPositions"],
|
||||
context: BossMechanicContext,
|
||||
events: BossMechanicResult["events"],
|
||||
) {
|
||||
const siphon = telegraph.soulSiphon;
|
||||
if (!siphon) {
|
||||
telegraph.resolved = true;
|
||||
telegraph.expiresAt = context.time;
|
||||
return party;
|
||||
}
|
||||
const healerPosition = positions[siphon.targetId];
|
||||
if (distance(healerPosition, siphon.wardPosition) <= siphon.wardRadius) {
|
||||
telegraph.resolved = true;
|
||||
telegraph.expiresAt = context.time;
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: "Aelia reaches the cleansing ward. Soul Siphon collapses.",
|
||||
tone: "neutral",
|
||||
pulseKind: "purify",
|
||||
targetId: "aelia",
|
||||
});
|
||||
return party;
|
||||
}
|
||||
|
||||
const nextSiphon = {
|
||||
...siphon,
|
||||
ghostPosition: moveToward(siphon.ghostPosition, healerPosition, SOUL_SIPHON.ghostSpeed * context.delta),
|
||||
};
|
||||
let nextParty = party;
|
||||
const healerIndex = nextParty.findIndex((member) => member.id === siphon.targetId);
|
||||
if (healerIndex >= 0 && nextParty[healerIndex].hp > 0) {
|
||||
let nextDamageAt = nextSiphon.nextDamageAt;
|
||||
let tickCount = nextSiphon.tickCount;
|
||||
let healer = nextParty[healerIndex];
|
||||
while (nextDamageAt <= context.time + 0.001) {
|
||||
healer = context.damageMember(
|
||||
healer,
|
||||
SOUL_SIPHON.tickDamage + Math.min(tickCount, 4) * SOUL_SIPHON.tickRamp,
|
||||
healerPosition,
|
||||
nextDamageAt,
|
||||
);
|
||||
nextDamageAt += SOUL_SIPHON.tickInterval;
|
||||
tickCount += 1;
|
||||
}
|
||||
nextSiphon.nextDamageAt = nextDamageAt;
|
||||
nextSiphon.tickCount = tickCount;
|
||||
if (healer !== nextParty[healerIndex]) {
|
||||
nextParty = [...nextParty];
|
||||
nextParty[healerIndex] = healer;
|
||||
}
|
||||
}
|
||||
telegraph.soulSiphon = nextSiphon;
|
||||
return nextParty;
|
||||
}
|
||||
|
||||
function isHit(telegraph: PoolTelegraph, position: WorldPosition) {
|
||||
if (telegraph.kind === "beam") {
|
||||
return !!telegraph.start && !!telegraph.end
|
||||
&& pointToSegmentDistance(position, telegraph.start, telegraph.end) <= (telegraph.width ?? 0) * 0.5;
|
||||
}
|
||||
const distance = Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
|
||||
return distance <= telegraph.radius && distance >= (telegraph.innerRadius ?? 0);
|
||||
}
|
||||
|
||||
function resolveSoak(
|
||||
telegraph: PoolTelegraph,
|
||||
party: PartyMember[],
|
||||
positions: BossMechanicContext["partyPositions"],
|
||||
context: BossMechanicContext,
|
||||
events: BossMechanicResult["events"],
|
||||
) {
|
||||
const participants = party.filter((member) => member.hp > 0 && isHit(telegraph, positions[member.id]));
|
||||
const minimum = telegraph.minimumParticipants ?? 1;
|
||||
const failed = participants.length < minimum;
|
||||
const totalDamage = (telegraph.totalDamage ?? 0) * (failed ? 1.5 : 1);
|
||||
|
||||
if (!participants.length) {
|
||||
const marked = party.find((member) => member.id === telegraph.targetId && member.hp > 0);
|
||||
if (!marked) return party;
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `${telegraph.name} fails — ${marked.name} takes ${Math.round(totalDamage)} damage alone.`,
|
||||
tone: "danger",
|
||||
pulseKind: "boss",
|
||||
targetId: marked.id,
|
||||
});
|
||||
return party.map((member) => member.id === marked.id
|
||||
? context.damageMember(member, totalDamage, positions[member.id], context.time)
|
||||
: member);
|
||||
}
|
||||
|
||||
const splitDamage = totalDamage / participants.length;
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: failed
|
||||
? `${telegraph.name} is under-soaked by ${participants.length}. ${Math.round(splitDamage)} damage each.`
|
||||
: `${telegraph.name} splits ${Math.round(totalDamage)} damage across ${participants.length} allies.`,
|
||||
tone: "danger",
|
||||
pulseKind: "boss",
|
||||
targetId: telegraph.targetId,
|
||||
});
|
||||
const participantIds = new Set(participants.map((member) => member.id));
|
||||
return party.map((member) => participantIds.has(member.id)
|
||||
? context.damageMember(member, splitDamage, positions[member.id], context.time)
|
||||
: member);
|
||||
}
|
||||
|
||||
function resolveTelegraph(
|
||||
telegraph: PoolTelegraph,
|
||||
party: PartyMember[],
|
||||
positions: BossMechanicContext["partyPositions"],
|
||||
context: BossMechanicContext,
|
||||
events: BossMechanicResult["events"],
|
||||
) {
|
||||
if (telegraph.kind === "soak") return resolveSoak(telegraph, party, positions, context, events);
|
||||
const hitIds: MemberId[] = [];
|
||||
const next = party.map((member) => {
|
||||
if (member.hp <= 0 || !isHit(telegraph, positions[member.id])) return member;
|
||||
hitIds.push(member.id);
|
||||
return context.damageMember(member, telegraph.damage, positions[member.id], context.time, "hazard");
|
||||
});
|
||||
telegraph.hitIds = hitIds;
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: hitIds.length
|
||||
? `${telegraph.name} catches ${hitIds.length} ${hitIds.length === 1 ? "ally" : "allies"}.`
|
||||
: `${telegraph.name} misses the party.`,
|
||||
tone: "danger",
|
||||
pulseKind: "boss",
|
||||
targetId: telegraph.targetId,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Composes the shared mechanic pool after a boss's signature mechanic update. */
|
||||
export function advancePooledBossMechanics(
|
||||
context: BossMechanicContext,
|
||||
result: BossMechanicResult,
|
||||
): BossMechanicResult {
|
||||
if (context.allowPooledMechanics === false) return result;
|
||||
const source = result.motion;
|
||||
if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result;
|
||||
|
||||
let motion: BossMotionState = {
|
||||
...source,
|
||||
poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({
|
||||
...telegraph,
|
||||
center: [telegraph.center[0], telegraph.center[1]],
|
||||
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
|
||||
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
|
||||
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
|
||||
soulSiphon: telegraph.soulSiphon && {
|
||||
...telegraph.soulSiphon,
|
||||
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
|
||||
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
|
||||
},
|
||||
hitIds: [...telegraph.hitIds],
|
||||
})),
|
||||
};
|
||||
let party = result.party;
|
||||
const events = [...result.events];
|
||||
|
||||
for (const telegraph of motion.poolTelegraphs) {
|
||||
if (telegraph.kind === "memory") {
|
||||
if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events);
|
||||
continue;
|
||||
}
|
||||
if (telegraph.kind === "soul-siphon") {
|
||||
if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events);
|
||||
continue;
|
||||
}
|
||||
if (telegraph.resolved || context.time < telegraph.activatesAt) continue;
|
||||
party = resolveTelegraph(telegraph, party, context.partyPositions, context, events);
|
||||
telegraph.resolved = true;
|
||||
}
|
||||
motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time);
|
||||
|
||||
if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) {
|
||||
const started = beginPoolMechanic(motion, party, context.partyPositions, context.time);
|
||||
motion = started.motion;
|
||||
events.push(started.event);
|
||||
}
|
||||
if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) {
|
||||
motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay;
|
||||
}
|
||||
|
||||
return { ...result, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
|
||||
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
|
||||
if (!telegraphs.length) return null;
|
||||
const next = telegraphs.reduce((earliest, telegraph) => telegraph.activatesAt < earliest.activatesAt ? telegraph : earliest);
|
||||
if (next.kind === "memory") {
|
||||
const inputStartsAt = next.inputStartsAt ?? next.activatesAt;
|
||||
const showingSequence = time < inputStartsAt;
|
||||
return {
|
||||
name: showingSequence ? "Memory Sequence — watch boss" : "Memory Sequence — match tiles",
|
||||
remaining: Math.max(0, (showingSequence ? inputStartsAt : next.expiresAt) - time),
|
||||
cycle: showingSequence ? Math.max(0.01, inputStartsAt - next.activatesAt) : MEMORY_SEQUENCE.inputDuration,
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
if (next.kind === "soul-siphon" && next.soulSiphon) {
|
||||
return {
|
||||
name: "Soul Siphon — reach cleansing ward",
|
||||
remaining: Math.max(0, next.soulSiphon.nextDamageAt - time),
|
||||
cycle: SOUL_SIPHON.tickInterval,
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: next.kind === "soak" ? `${next.name} — stack` : next.kind === "donut" ? `${next.name} — move in` : next.kind === "spread" ? `${next.name} — spread` : `${next.name} — clear lane`,
|
||||
remaining: Math.max(0, next.activatesAt - time),
|
||||
cycle: Math.max(0.01, next.activatesAt - (next.activatesAt - POOLED_MECHANIC_TIMING.warningDuration)),
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import { angleTo, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const MOURNVEIL = {
|
||||
@@ -113,8 +113,7 @@ export function advanceMournveilMechanics(context: BossMechanicContext): BossMec
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.7 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 1.7);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
|
||||
const resolved = resolveCross(motion, { ...context, party }, party, events);
|
||||
@@ -145,11 +144,11 @@ export function advanceMournveilMechanics(context: BossMechanicContext): BossMec
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingMournveilMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
export function upcomingMournveilMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
|
||||
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
|
||||
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
|
||||
if (motion.mode === "ghost_recover") return { name: "Pumpking exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
|
||||
if (motion.mode === "ghost_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const OBSIDIAN_RAM = {
|
||||
@@ -24,8 +24,8 @@ export const OBSIDIAN_RAM = {
|
||||
|
||||
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
|
||||
|
||||
export function createObsidianRamState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["obsidian-ram-golem"];
|
||||
export function createObsidianRamState(bossId: BossId = "bristlequake-boar"): BossState {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
@@ -38,8 +38,8 @@ export function createObsidianRamState(): BossState {
|
||||
};
|
||||
}
|
||||
|
||||
export function createObsidianRamMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("obsidian-ram-golem"), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
|
||||
export function createObsidianRamMotion(bossId: BossId = "bristlequake-boar"): BossMotionState {
|
||||
return { ...createBaseMotion(bossId), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
|
||||
}
|
||||
|
||||
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
|
||||
@@ -142,8 +142,7 @@ export function advanceObsidianRamMechanics(context: BossMechanicContext): BossM
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 1.8);
|
||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
||||
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
|
||||
@@ -172,11 +171,11 @@ export function advanceObsidianRamMechanics(context: BossMechanicContext): BossM
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingObsidianRamMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
export function upcomingObsidianRamMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
|
||||
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
|
||||
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
|
||||
if (motion.mode === "ram_recover") return { name: "Gandora exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
|
||||
if (motion.mode === "ram_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
|
||||
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import type { BossId, BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CINDERBACK = {
|
||||
@@ -24,13 +24,13 @@ export const CINDERBACK = {
|
||||
|
||||
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
|
||||
|
||||
export function createCinderbackState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["cinderback-ricochet"];
|
||||
export function createCinderbackState(bossId: BossId = "emberfox"): BossState {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
|
||||
}
|
||||
|
||||
export function createCinderbackMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("cinderback-ricochet"), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
|
||||
export function createCinderbackMotion(bossId: BossId = "emberfox"): BossMotionState {
|
||||
return { ...createBaseMotion(bossId), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
|
||||
}
|
||||
|
||||
function rushEnd(start: WorldPosition, target: WorldPosition) {
|
||||
@@ -53,15 +53,14 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 2);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
const count = motion.mechanicCount + 1;
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
|
||||
const end = rushEnd(motion.position, context.partyPositions[targetId]);
|
||||
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
|
||||
events.push({ at: context.time, message: `Red-Eyes dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
|
||||
events.push({ at: context.time, message: `${boss.name} dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
|
||||
} else {
|
||||
const activatesAt = context.time + CINDERBACK.slamWarning;
|
||||
const pools = [0, 1, 2].map((index) => {
|
||||
@@ -69,7 +68,7 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
|
||||
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
|
||||
});
|
||||
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
|
||||
events.push({ at: context.time, message: "Meteor Slam! Leave Red-Eyes and spreading black flame.", tone: "danger", pulseKind: "boss" });
|
||||
events.push({ at: context.time, message: "Meteor slam! Clear the spreading flame.", tone: "danger", pulseKind: "boss" });
|
||||
}
|
||||
}
|
||||
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
|
||||
@@ -89,7 +88,7 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
|
||||
const start = [...motion.chargeEnd] as WorldPosition;
|
||||
const end = rushEnd(start, context.partyPositions[targetId]);
|
||||
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
|
||||
events.push({ at: context.time, message: `Inferno Rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
|
||||
events.push({ at: context.time, message: `Ricochet rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
|
||||
} else {
|
||||
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
|
||||
}
|
||||
@@ -105,11 +104,11 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingCinderbackMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "cinderback_curl") return { name: "Inferno Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
|
||||
export function upcomingCinderbackMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
if (motion.mode === "cinderback_curl") return { name: "Ricochet Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
|
||||
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
|
||||
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
|
||||
if (motion.mode === "cinderback_recover") return { name: "Red-Eyes exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
|
||||
if (motion.mode === "cinderback_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Inferno Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
|
||||
return { name: motion.mechanicCount % 2 === 0 ? "Ricochet Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { clampToArena } from "../arena";
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const SANDGLASS = {
|
||||
@@ -50,8 +50,7 @@ export function advanceSandglassMechanics(context: BossMechanicContext): BossMec
|
||||
let party = context.party;
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 2.1);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
const count = motion.mechanicCount + 1;
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { distance } from "../geometry";
|
||||
import type { BossId, BossMotionState, BossState, CircleHazard, CircleHazardKind, MemberId, PartyMember, WorldPosition } from "../types";
|
||||
import { ARENA_CENTER } from "../arena";
|
||||
import type { BossId, BossMotionState, BossState, CircleHazard, CircleHazardKind, MemberId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
|
||||
import type { BossMechanicContext, BossMechanicEvent } from "./types";
|
||||
|
||||
const PERSISTENT_HAZARD_KINDS = new Set<CircleHazardKind>(["venom_pool", "lava_pool", "hourglass", "soul_rift"]);
|
||||
@@ -16,6 +17,28 @@ const HAZARD_LABELS = {
|
||||
royal_shockwave: "Royal Shockwave",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Standard idle location after a mechanic. Dual encounters keep distinct
|
||||
* center-adjacent slots through formationOffsetX instead of drifting to a
|
||||
* displaced tank at the edge of the room.
|
||||
*/
|
||||
export function returnBossToArenaCenter(motion: BossMotionState, delta: number, speed: number) {
|
||||
const targetX = ARENA_CENTER[0] + motion.formationOffsetX;
|
||||
const targetZ = ARENA_CENTER[1];
|
||||
const dx = targetX - motion.position[0];
|
||||
const dz = targetZ - motion.position[1];
|
||||
const remaining = Math.hypot(dx, dz);
|
||||
const step = speed * delta;
|
||||
if (remaining < 0.0001 || remaining <= step) {
|
||||
motion.position[0] = targetX;
|
||||
motion.position[1] = targetZ;
|
||||
return true;
|
||||
}
|
||||
motion.position[0] += (dx / remaining) * step;
|
||||
motion.position[1] += (dz / remaining) * step;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
|
||||
return {
|
||||
id: bossId,
|
||||
@@ -95,6 +118,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
|
||||
breathEndAngle: 0,
|
||||
hazards: [],
|
||||
slashLanes: [],
|
||||
nextPoolMechanicAt: 15,
|
||||
poolMechanicCount: 0,
|
||||
poolTelegraphs: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,6 +146,19 @@ export function cloneMotion(source: BossMotionState): BossMotionState {
|
||||
start: [lane.start[0], lane.start[1]],
|
||||
end: [lane.end[0], lane.end[1]],
|
||||
})),
|
||||
poolTelegraphs: source.poolTelegraphs.map((telegraph): PoolTelegraph => ({
|
||||
...telegraph,
|
||||
center: [telegraph.center[0], telegraph.center[1]],
|
||||
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
|
||||
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
|
||||
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
|
||||
soulSiphon: telegraph.soulSiphon && {
|
||||
...telegraph.soulSiphon,
|
||||
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
|
||||
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
|
||||
},
|
||||
hitIds: [...telegraph.hitIds],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointInCone } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards } from "./shared";
|
||||
import { angleTo, pointInCone } from "../geometry";
|
||||
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const CINDER_BREATH = {
|
||||
export const SKY_SWEEPER_BREATH = {
|
||||
firstAt: 6,
|
||||
telegraphDuration: 2,
|
||||
sweepDuration: 3.2,
|
||||
@@ -15,7 +15,7 @@ export const CINDER_BREATH = {
|
||||
tickInterval: 0.45,
|
||||
} as const;
|
||||
|
||||
export const CINDER_SKYFALL = {
|
||||
export const SKY_SWEEPER_SKYFALL = {
|
||||
warning: 2,
|
||||
stagger: 0.9,
|
||||
radius: 1.8,
|
||||
@@ -29,10 +29,10 @@ const SKYFALL_TARGETS: readonly MemberId[][] = [
|
||||
["vale", "nia", "aelia"],
|
||||
];
|
||||
|
||||
export function createCindermawState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS.cindermaw;
|
||||
export function createSkySweeperState(bossId: BossId): BossState {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
return {
|
||||
id: "cindermaw",
|
||||
id: bossId,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
@@ -43,11 +43,11 @@ export function createCindermawState(): BossState {
|
||||
};
|
||||
}
|
||||
|
||||
export function createCindermawMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("cindermaw"), position: [0, -2.8], nextMechanicAt: CINDER_BREATH.firstAt };
|
||||
export function createSkySweeperMotion(bossId: BossId): BossMotionState {
|
||||
return { ...createBaseMotion(bossId), position: [0, -2.8], nextMechanicAt: SKY_SWEEPER_BREATH.firstAt };
|
||||
}
|
||||
|
||||
export function advanceCindermawMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
export function advanceSkySweeperMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
let party = context.party;
|
||||
@@ -56,41 +56,40 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.9 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 1.9);
|
||||
}
|
||||
|
||||
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
|
||||
if (motion.mechanicCount % 2 === 0) {
|
||||
const aimedAngle = angleTo(motion.position, context.partyPositions.brann);
|
||||
const direction = motion.mechanicCount % 4 === 0 ? 1 : -1;
|
||||
const startAngle = aimedAngle - direction * CINDER_BREATH.sweepArc * 0.5;
|
||||
const startAngle = aimedAngle - direction * SKY_SWEEPER_BREATH.sweepArc * 0.5;
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "breath_telegraph",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + CINDER_BREATH.telegraphDuration,
|
||||
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.telegraphDuration,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
breathAngle: startAngle,
|
||||
breathStartAngle: startAngle,
|
||||
breathEndAngle: startAngle + direction * CINDER_BREATH.sweepArc,
|
||||
breathEndAngle: startAngle + direction * SKY_SWEEPER_BREATH.sweepArc,
|
||||
mechanicHitIds: [],
|
||||
mechanicNextDamageAt: {},
|
||||
};
|
||||
events.push({ at: context.time, message: "Blue-Eyes draws a sweeping Burst Stream. Rotate behind it!", tone: "danger", pulseKind: "breath" });
|
||||
events.push({ at: context.time, message: `${boss.name} gathers a sweeping storm breath. Rotate behind it!`, tone: "danger", pulseKind: "breath" });
|
||||
} else {
|
||||
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
|
||||
const hazards = set.map((memberId, index) => {
|
||||
const activatesAt = context.time + CINDER_SKYFALL.warning + index * CINDER_SKYFALL.stagger;
|
||||
const activatesAt = context.time + SKY_SWEEPER_SKYFALL.warning + index * SKY_SWEEPER_SKYFALL.stagger;
|
||||
return {
|
||||
id: `skyfall-${motion.mechanicCount}-${index}`,
|
||||
kind: "skyfall" as const,
|
||||
center: [context.partyPositions[memberId][0], context.partyPositions[memberId][1]] as [number, number],
|
||||
radius: CINDER_SKYFALL.radius,
|
||||
radius: SKY_SWEEPER_SKYFALL.radius,
|
||||
activatesAt,
|
||||
expiresAt: activatesAt + CINDER_SKYFALL.fireDuration,
|
||||
damage: CINDER_SKYFALL.damage,
|
||||
expiresAt: activatesAt + SKY_SWEEPER_SKYFALL.fireDuration,
|
||||
damage: SKY_SWEEPER_SKYFALL.damage,
|
||||
nextDamageAt: {},
|
||||
resolved: false,
|
||||
hitIds: [],
|
||||
@@ -105,23 +104,23 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
hazards: [...motion.hazards, ...hazards],
|
||||
};
|
||||
events.push({ at: context.time, message: "Blue-Eyes takes flight. Three White Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
|
||||
events.push({ at: context.time, message: `${boss.name} takes flight. Three skyfalls incoming!`, tone: "danger", pulseKind: "skyfall", targetId: set[0] });
|
||||
}
|
||||
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "breath_sweeping",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
|
||||
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.sweepDuration,
|
||||
breathAngle: motion.breathStartAngle,
|
||||
};
|
||||
events.push({ at: context.time, message: "Burst Stream crosses the arena!", tone: "danger", pulseKind: "breath" });
|
||||
events.push({ at: context.time, message: "Storm breath crosses the arena!", tone: "danger", pulseKind: "breath" });
|
||||
} else if (motion.mode === "breath_sweeping") {
|
||||
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / CINDER_BREATH.sweepDuration));
|
||||
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
|
||||
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
|
||||
party = party.map((member) => {
|
||||
if (member.hp <= 0) return member;
|
||||
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, CINDER_BREATH.halfAngle, CINDER_BREATH.range);
|
||||
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.range);
|
||||
if (!exposed) {
|
||||
motion.mechanicNextDamageAt[member.id] = context.time;
|
||||
return member;
|
||||
@@ -129,13 +128,13 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
let next = member;
|
||||
let tickAt = motion.mechanicNextDamageAt[member.id] ?? motion.phaseStartedAt;
|
||||
while (tickAt <= context.time + 0.001) {
|
||||
next = context.damageMember(next, CINDER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
|
||||
tickAt += CINDER_BREATH.tickInterval;
|
||||
next = context.damageMember(next, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
|
||||
tickAt += SKY_SWEEPER_BREATH.tickInterval;
|
||||
}
|
||||
motion.mechanicNextDamageAt[member.id] = tickAt;
|
||||
if (!motion.mechanicHitIds.includes(member.id)) {
|
||||
motion.mechanicHitIds.push(member.id);
|
||||
events.push({ at: context.time, message: `${member.name} is struck by Burst Stream.`, tone: "danger", pulseKind: "breath", targetId: member.id });
|
||||
events.push({ at: context.time, message: `${member.name} is struck by storm breath.`, tone: "danger", pulseKind: "breath", targetId: member.id });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -150,12 +149,12 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingCindermawMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
export function upcomingSkySweeperMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||
void boss;
|
||||
if (motion.mode === "breath_telegraph") return { name: "Burst Stream", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
|
||||
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.sweepDuration, urgent: true };
|
||||
if (motion.mode === "skyfall") return { name: "White Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
|
||||
if (motion.mode === "breath_telegraph") return { name: "Storm Breath", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.telegraphDuration, urgent: true };
|
||||
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.sweepDuration, urgent: true };
|
||||
if (motion.mode === "skyfall") return { name: "Stormfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_SKYFALL.warning + SKY_SWEEPER_SKYFALL.stagger * 2, urgent: true };
|
||||
const nextIsBreath = motion.mechanicCount % 2 === 0;
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return { name: nextIsBreath ? "Burst Stream" : "White Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
|
||||
return { name: nextIsBreath ? "Storm Breath" : "Stormfall", remaining, cycle: 8, urgent: remaining < 2.5 };
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export interface BossMechanicContext {
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
time: number;
|
||||
delta: number;
|
||||
/** Shared pool mechanics are reserved for solo encounters to avoid unreadable overlap in multi-boss fights. */
|
||||
allowPooledMechanics?: boolean;
|
||||
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -1,7 +1,7 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { distance, moveToward } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
|
||||
import { distance } from "../geometry";
|
||||
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
|
||||
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const VEXA_TETHER = {
|
||||
@@ -32,10 +32,10 @@ const VENOM_TARGETS: readonly MemberId[][] = [
|
||||
["aelia", "vale"],
|
||||
];
|
||||
|
||||
export function createVexaState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS.vexa;
|
||||
export function createVexaState(bossId: BossId = "broodfang-spider"): BossState {
|
||||
const definition = BOSS_DEFINITIONS[bossId];
|
||||
return {
|
||||
id: "vexa",
|
||||
id: bossId,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
@@ -46,8 +46,8 @@ export function createVexaState(): BossState {
|
||||
};
|
||||
}
|
||||
|
||||
export function createVexaMotion(): BossMotionState {
|
||||
return { ...createBaseMotion("vexa"), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
|
||||
export function createVexaMotion(bossId: BossId = "broodfang-spider"): BossMotionState {
|
||||
return { ...createBaseMotion(bossId), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
|
||||
}
|
||||
|
||||
export function advanceVexaMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
@@ -59,8 +59,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
|
||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
|
||||
returnBossToArenaCenter(motion, context.delta, 2.1);
|
||||
}
|
||||
|
||||
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
|
||||
@@ -78,7 +77,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
|
||||
tetherBreakDistance: VEXA_TETHER.breakDistance,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
};
|
||||
events.push({ at: context.time, message: `Insect Queen binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
|
||||
events.push({ at: context.time, message: `${boss.name} binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
|
||||
}
|
||||
} else {
|
||||
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
|
||||
@@ -103,7 +102,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
mechanicCount: motion.mechanicCount + 1,
|
||||
};
|
||||
events.push({ at: context.time, message: "Insect Queen injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
|
||||
events.push({ at: context.time, message: `${boss.name} injects Widow Venom. Move away before cleansing!`, tone: "danger", pulseKind: "venom", targetId: targets[0] });
|
||||
}
|
||||
} else if (motion.mode === "tethering") {
|
||||
const [first, second] = motion.tetherIds;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import { ARENA_CENTER } from "./arena";
|
||||
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
|
||||
import { freshParty } from "./data";
|
||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||
import type { MemberId, WorldPosition } from "./types";
|
||||
|
||||
describe("party boss positioning", () => {
|
||||
it("brings each boss back to its center slot instead of following an edge-displaced tank", () => {
|
||||
const motion = { ...createBaseMotion("bulldrome"), formationOffsetX: 2.65, position: [7, 5] as WorldPosition };
|
||||
|
||||
expect(returnBossToArenaCenter(motion, 10, 2)).toBe(true);
|
||||
expect(motion.position).toEqual([ARENA_CENTER[0] + 2.65, ARENA_CENTER[1]]);
|
||||
});
|
||||
|
||||
it("places Brann in front of the boss and Vale behind it", () => {
|
||||
const formation = combatFormation([2, -1]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { clampToArena } from "./arena";
|
||||
import { CINDER_BREATH } from "./bosses/cindermaw";
|
||||
import { SKY_SWEEPER_BREATH } from "./bosses/skySweeper";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
@@ -87,6 +87,21 @@ export const stackForPounceBehavior: PartyBehavior = {
|
||||
},
|
||||
};
|
||||
|
||||
export const stackForPooledSoakBehavior: PartyBehavior = {
|
||||
id: "stack-for-pooled-soak",
|
||||
decide: ({ memberId, bossMotion, time }) => {
|
||||
const soak = bossMotion.poolTelegraphs.find((telegraph) =>
|
||||
telegraph.kind === "soak" && !telegraph.resolved && telegraph.activatesAt > time && telegraph.activatesAt - time <= 2.2,
|
||||
);
|
||||
if (!soak) return null;
|
||||
const offset = STACK_OFFSETS[memberId];
|
||||
return {
|
||||
target: [soak.center[0] + offset[0], soak.center[1] + offset[1]],
|
||||
speed: 3.8,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const breakTetherBehavior: PartyBehavior = {
|
||||
id: "break-tether",
|
||||
decide: ({ memberId, current, bossMotion, partyPositions }) => {
|
||||
@@ -131,7 +146,7 @@ export const avoidBreathBehavior: PartyBehavior = {
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null;
|
||||
const side = EVADE_SIDES[memberId];
|
||||
const safeAngle = bossMotion.breathAngle + side * (CINDER_BREATH.halfAngle + Math.PI * 0.42);
|
||||
const safeAngle = bossMotion.breathAngle + side * (SKY_SWEEPER_BREATH.halfAngle + Math.PI * 0.42);
|
||||
const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4;
|
||||
return {
|
||||
target: clampToArena([
|
||||
@@ -164,9 +179,10 @@ export const evadeSlashLanesBehavior: PartyBehavior = {
|
||||
origin[0] + Math.sin(angle) * radius,
|
||||
origin[1] + Math.cos(angle) * radius,
|
||||
]);
|
||||
const laneDistance = Math.min(...bossMotion.slashLanes.map((lane) =>
|
||||
pointToSegmentDistance(candidate, lane.start, lane.end),
|
||||
));
|
||||
let laneDistance = Number.POSITIVE_INFINITY;
|
||||
for (const lane of bossMotion.slashLanes) {
|
||||
laneDistance = Math.min(laneDistance, pointToSegmentDistance(candidate, lane.start, lane.end));
|
||||
}
|
||||
const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08;
|
||||
const score = laneDistance - travelPenalty;
|
||||
if (score > bestScore) {
|
||||
@@ -197,6 +213,57 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
},
|
||||
};
|
||||
|
||||
export const reactToPooledTelegraphsBehavior: PartyBehavior = {
|
||||
id: "react-to-pooled-telegraphs",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
|
||||
for (const telegraph of bossMotion.poolTelegraphs) {
|
||||
if (telegraph.resolved || telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.activatesAt - time > 2.2) continue;
|
||||
const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
|
||||
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
|
||||
const clearance = (telegraph.width ?? 0) * 0.5 + 0.7;
|
||||
const currentUnsafe = pointToSegmentDistance(current, telegraph.start, telegraph.end) < clearance;
|
||||
const formationUnsafe = pointToSegmentDistance(formationTarget, telegraph.start, telegraph.end) < clearance;
|
||||
if (!currentUnsafe && !formationUnsafe) continue;
|
||||
const source = formationUnsafe ? formationTarget : current;
|
||||
return {
|
||||
target: clampToArena(pointOutsideLane(source, telegraph.start, telegraph.end, clearance, EVADE_SIDES[memberId])),
|
||||
speed: 4.8,
|
||||
};
|
||||
}
|
||||
|
||||
const distanceFromCenter = (position: WorldPosition) => Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
|
||||
if (telegraph.kind === "donut") {
|
||||
const innerSafeRadius = Math.max(0.5, (telegraph.innerRadius ?? 0) - 0.45);
|
||||
const isUnsafe = (position: WorldPosition) => {
|
||||
const distance = distanceFromCenter(position);
|
||||
return distance >= innerSafeRadius && distance <= telegraph.radius + 0.45;
|
||||
};
|
||||
if (!isUnsafe(current) && !isUnsafe(formationTarget)) continue;
|
||||
const source = isUnsafe(formationTarget) ? formationTarget : current;
|
||||
const sourceDistance = distanceFromCenter(source);
|
||||
const direction = sourceDistance > 0.01
|
||||
? [(source[0] - telegraph.center[0]) / sourceDistance, (source[1] - telegraph.center[1]) / sourceDistance] as WorldPosition
|
||||
: [Math.sin(fallbackAngle), Math.cos(fallbackAngle)] as WorldPosition;
|
||||
return {
|
||||
target: clampToArena([
|
||||
telegraph.center[0] + direction[0] * innerSafeRadius,
|
||||
telegraph.center[1] + direction[1] * innerSafeRadius,
|
||||
]),
|
||||
speed: 4.3,
|
||||
};
|
||||
}
|
||||
|
||||
const clearance = telegraph.radius + 0.55;
|
||||
const currentUnsafe = distanceFromCenter(current) < clearance;
|
||||
const formationUnsafe = distanceFromCenter(formationTarget) < clearance;
|
||||
if (!currentUnsafe && !formationUnsafe) continue;
|
||||
const source = formationUnsafe ? formationTarget : current;
|
||||
return { target: clampToArena(pointOutsideCircle(source, telegraph.center, clearance, fallbackAngle)), speed: 4.1 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export const maintainFormationBehavior: PartyBehavior = {
|
||||
id: "maintain-formation",
|
||||
decide: ({ formationTarget, bossMotion, memberId }) => {
|
||||
@@ -208,9 +275,11 @@ export const maintainFormationBehavior: PartyBehavior = {
|
||||
export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
|
||||
breakTetherBehavior,
|
||||
stackForPounceBehavior,
|
||||
stackForPooledSoakBehavior,
|
||||
evadeChargeBehavior,
|
||||
evadeSlashLanesBehavior,
|
||||
avoidBreathBehavior,
|
||||
reactToPooledTelegraphsBehavior,
|
||||
avoidCircleHazardsBehavior,
|
||||
maintainFormationBehavior,
|
||||
];
|
||||
@@ -234,10 +303,14 @@ export function updatePartyPositions(
|
||||
vale: [current.vale[0], current.vale[1]],
|
||||
};
|
||||
if (!activeMotions.length) return next;
|
||||
const formationOrigin: WorldPosition = [
|
||||
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
|
||||
];
|
||||
let formationX = 0;
|
||||
let formationZ = 0;
|
||||
for (const motion of activeMotions) {
|
||||
const origin = DASH_MODES.includes(motion.mode) ? motion.chargeStart : motion.position;
|
||||
formationX += origin[0];
|
||||
formationZ += origin[1];
|
||||
}
|
||||
const formationOrigin: WorldPosition = [formationX / activeMotions.length, formationZ / activeMotions.length];
|
||||
const formation = combatFormation(formationOrigin);
|
||||
|
||||
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBossMotionState, createBossState } from "./bossMechanics";
|
||||
import { BOSS_ORDER } from "./bossCatalog";
|
||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||
import { freshParty } from "./data";
|
||||
import {
|
||||
advancePartyCombat,
|
||||
@@ -110,8 +110,8 @@ describe("party ability combat", () => {
|
||||
});
|
||||
|
||||
it("lets Vale cleave only when both bosses are inside melee radius", () => {
|
||||
const clustered = simulate(["vexa", "cindermaw"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
|
||||
const separated = simulate(["vexa", "cindermaw"], {
|
||||
const clustered = simulate(["broodfang-spider", "tempestscale-dragon"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
|
||||
const separated = simulate(["broodfang-spider", "tempestscale-dragon"], {
|
||||
duration: 30,
|
||||
activeIds: ["vale"],
|
||||
targetPositions: [[0, -4], [7, -4]],
|
||||
@@ -132,7 +132,7 @@ describe("party ability combat", () => {
|
||||
});
|
||||
|
||||
it("uses every button in each five-ability DPS rotation during a full fight", () => {
|
||||
const result = simulate(["bulldrome", "vexa"]);
|
||||
const result = simulate(["bulldrome", "broodfang-spider"]);
|
||||
const movementPhase = simulate(["bulldrome"], { duration: 5, activeIds: ["nia"], movingIds: ["nia"], stopOnVictory: false });
|
||||
const usedAbilities = new Set([...result.usedAbilities, ...movementPhase.usedAbilities]);
|
||||
for (const memberId of ["nia", "orin", "vale"] as const) {
|
||||
@@ -142,8 +142,8 @@ describe("party ability combat", () => {
|
||||
});
|
||||
|
||||
describe("dual-boss damage simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
|
||||
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
|
||||
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
|
||||
);
|
||||
|
||||
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
|
||||
|
||||
@@ -128,7 +128,8 @@ const RANGED_IDS: readonly AiCombatantId[] = ["nia", "orin"];
|
||||
const VALE_CLEAVE_RADIUS = 3.6;
|
||||
const VALE_MELEE_RANGE = 3.65;
|
||||
const BRANN_MELEE_RANGE = 4.8;
|
||||
const PARTY_DAMAGE_SCALE = 1.65;
|
||||
// Calibrated against full two-boss rotations: intended 50–90 seconds, never over 100.
|
||||
const PARTY_DAMAGE_SCALE = 2;
|
||||
|
||||
function combatant(id: AiCombatantId, hp: number): PartyCombatantState {
|
||||
return {
|
||||
@@ -312,7 +313,7 @@ function resolveImpact(state: PartyCombatState, actor: PartyCombatantState, acti
|
||||
const source = context.positions[actor.id];
|
||||
const range = actor.id === "vale" ? VALE_MELEE_RANGE : actor.id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
|
||||
let target = targets.find((entry) => entry.instanceId === action.targetInstanceId && entry.boss.hp > 0 && distance(source, entry.motion.position) <= range);
|
||||
target ??= targetFor(actor.id, { ...context, targets }, range);
|
||||
target ??= targetFor(actor.id, context, range);
|
||||
if (!target) return;
|
||||
|
||||
if (action.abilityId === "fan_of_blades" || action.abilityId === "sweeping_guard") {
|
||||
@@ -342,6 +343,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
|
||||
nextEventId: source.nextEventId,
|
||||
};
|
||||
const targets = context.targets.map((target) => ({ ...target, boss: { ...target.boss } }));
|
||||
const combatContext: PartyCombatContext = { ...context, targets };
|
||||
const events: PartyDamageEvent[] = [];
|
||||
const elapsed = context.time - context.oldTime;
|
||||
|
||||
@@ -357,7 +359,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
|
||||
if (id === "vale") actor.resource = Math.min(100, actor.resource + 12 * elapsed);
|
||||
if (id === "brann" && member.hp < actor.lastHp) actor.revengeReadyUntil = context.time + 5;
|
||||
actor.lastHp = member.hp;
|
||||
const isMoving = moving(id, context);
|
||||
const isMoving = moving(id, combatContext);
|
||||
if (isMoving && actor.activeAction?.requiresStationary) {
|
||||
actor.activeAction = null;
|
||||
actor.readyAt = Math.max(actor.readyAt, context.oldTime + 0.2);
|
||||
@@ -368,7 +370,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
|
||||
const action = actor.activeAction;
|
||||
if (action) {
|
||||
while (action.nextImpactIndex < action.impactTimes.length && action.impactTimes[action.nextImpactIndex] <= context.time + 0.001) {
|
||||
resolveImpact(state, actor, action, action.impactTimes[action.nextImpactIndex], context, targets, events);
|
||||
resolveImpact(state, actor, action, action.impactTimes[action.nextImpactIndex], combatContext, targets, events);
|
||||
action.nextImpactIndex += 1;
|
||||
}
|
||||
if (action.completesAt > context.time + 0.001) break;
|
||||
@@ -378,7 +380,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
|
||||
|
||||
const startAt = Math.max(context.oldTime, actor.readyAt);
|
||||
if (startAt > context.time + 0.001) break;
|
||||
const baseSpec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
|
||||
const baseSpec = chooseAbility(actor, startAt, isMoving, combatContext);
|
||||
const modifier = context.gearModifiers?.[id];
|
||||
const spec = baseSpec && modifier ? {
|
||||
...baseSpec,
|
||||
@@ -390,7 +392,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
|
||||
} : baseSpec;
|
||||
if (!spec) { actor.readyAt = context.time + 0.1; break; }
|
||||
const range = id === "vale" ? VALE_MELEE_RANGE : id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
|
||||
const target = targetFor(id, { ...context, targets }, range);
|
||||
const target = targetFor(id, combatContext, range);
|
||||
if (!target) { actor.readyAt = context.time + 0.1; break; }
|
||||
startAction(actor, spec, target, startAt, state);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useGameStore } from "./store";
|
||||
describe("runtime performance budgets", () => {
|
||||
it("keeps ten minutes of dual-boss simulation bounded", () => {
|
||||
const store = useGameStore.getState();
|
||||
store.configureHealer("priest", "Perf", [], ["cinderback-ricochet", "sandglass-scorpion"]);
|
||||
store.configureHealer("priest", "Perf", [], ["emberfox", "sandglass-scorpion"]);
|
||||
store.startEncounter();
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 },
|
||||
|
||||
@@ -7,28 +7,38 @@ import {
|
||||
gearUpgradeCosts,
|
||||
upgradeGearSlot,
|
||||
} from "./gear";
|
||||
import { bossCoinDrop, type MaterialStack } from "./loot";
|
||||
import { groupDrop, type MaterialStack } from "./loot";
|
||||
|
||||
describe("IWT2-style gear progression", () => {
|
||||
it("uses exact rank-one and rank-two boss coin costs", () => {
|
||||
it("uses exact rank-one and rank-two group drop costs", () => {
|
||||
const rankOne = gearUpgradeCosts("priest", "weapon", 0);
|
||||
const rankTwo = gearUpgradeCosts("priest", "weapon", 1);
|
||||
expect(rankOne.map((cost) => cost.quantity)).toEqual([2]);
|
||||
expect(rankTwo.map((cost) => cost.quantity)).toEqual([3, 1]);
|
||||
});
|
||||
|
||||
it("preserves IWT2 Veteran coin parity for ranks six through ten", () => {
|
||||
it("preserves IWT2 Veteran drop parity for ranks six through ten", () => {
|
||||
const recipe = GEAR_RECIPES.nia.weapon;
|
||||
const costs = gearUpgradeCosts("nia", "weapon", 5);
|
||||
expect(costs[0].itemId).toBe(bossCoinDrop(recipe.primaryBossId, "veteran").id);
|
||||
expect(costs[0].itemId).toBe(groupDrop(recipe.primaryGroupId, "veteran").id);
|
||||
expect(costs.map((cost) => cost.quantity)).toEqual([6, 5]);
|
||||
});
|
||||
|
||||
it("requires two distinct mechanic groups for every gear recipe", () => {
|
||||
for (const owner of Object.values(GEAR_RECIPES)) {
|
||||
for (const recipe of Object.values(owner)) expect(recipe.primaryGroupId).not.toBe(recipe.secondaryGroupId);
|
||||
}
|
||||
expect(GEAR_RECIPES.priest.chest).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "shockwave-fall" });
|
||||
expect(GEAR_RECIPES.brann.weapon).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "scuttle-burst" });
|
||||
expect(GEAR_RECIPES.nia.chest).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "rift-lanes" });
|
||||
expect(GEAR_RECIPES.vale.chest).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "rift-lanes" });
|
||||
});
|
||||
|
||||
it("spends all costs atomically and advances one rank", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
const costs = gearUpgradeCosts("brann", "weapon", 0);
|
||||
const coin = bossCoinDrop(GEAR_RECIPES.brann.weapon.primaryBossId, "initiate");
|
||||
const inventory: MaterialStack[] = [{ id: coin.id, name: coin.name, quantity: 3, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
|
||||
const drop = groupDrop(GEAR_RECIPES.brann.weapon.primaryGroupId, "initiate");
|
||||
const inventory: MaterialStack[] = [{ id: drop.id, name: drop.name, quantity: 3, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
||||
expect(canAffordGearUpgrade(inventory, costs)).toBe(true);
|
||||
const result = upgradeGearSlot(progress, inventory, "brann", "weapon");
|
||||
expect(result.gearProgress.brann.slots.weapon.level).toBe(1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HealerClassId, MemberId, BossId, RunBuffId } from "../types";
|
||||
import { bossCoinDrop, type DifficultySlug, type MaterialStack } from "./loot";
|
||||
import type { BossGroupId } from "../bossCatalog";
|
||||
import type { HealerClassId, MemberId, RunBuffId } from "../types";
|
||||
import { groupDrop, type DifficultySlug, type MaterialStack } from "./loot";
|
||||
|
||||
export type GearOwnerId = HealerClassId | Exclude<MemberId, "aelia">;
|
||||
export type GearSlotId = "weapon" | "helmet" | "chest" | "legs" | "feet";
|
||||
@@ -22,8 +23,8 @@ export interface GearRecipe {
|
||||
ownerId: GearOwnerId;
|
||||
slotId: GearSlotId;
|
||||
statId: GearStatId;
|
||||
primaryBossId: BossId;
|
||||
secondaryBossId: BossId;
|
||||
primaryGroupId: BossGroupId;
|
||||
secondaryGroupId: BossGroupId;
|
||||
}
|
||||
|
||||
export interface GearUpgradeCost {
|
||||
@@ -64,14 +65,14 @@ export const GEAR_STAT_LABELS: Record<GearStatId, string> = {
|
||||
hazardDamageTaken: "Hazard Damage Taken",
|
||||
};
|
||||
|
||||
type RecipeSeed = Record<GearSlotId, readonly [BossId, BossId]>;
|
||||
type RecipeSeed = Record<GearSlotId, readonly [BossGroupId, BossGroupId]>;
|
||||
|
||||
const HEALER_RECIPES: RecipeSeed = {
|
||||
weapon: ["vexa", "cindermaw"],
|
||||
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
|
||||
chest: ["obsidian-ram-golem", "bulldrome"],
|
||||
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
|
||||
feet: ["vexa", "sandglass-scorpion"],
|
||||
weapon: ["bind-venom", "breath-skyfall"],
|
||||
helmet: ["ricochet", "burrow-eruption"],
|
||||
chest: ["charge", "shockwave-fall"],
|
||||
legs: ["slash-cross", "ricochet"],
|
||||
feet: ["bind-venom", "burrow-eruption"],
|
||||
};
|
||||
|
||||
const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
|
||||
@@ -79,32 +80,32 @@ const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
|
||||
druid: HEALER_RECIPES,
|
||||
shaman: HEALER_RECIPES,
|
||||
brann: {
|
||||
weapon: ["obsidian-ram-golem", "bulldrome"],
|
||||
helmet: ["ember-mantis-duelist", "sandglass-scorpion"],
|
||||
chest: ["obsidian-ram-golem", "cindermaw"],
|
||||
legs: ["bulldrome", "cinderback-ricochet"],
|
||||
feet: ["sandglass-scorpion", "vexa"],
|
||||
weapon: ["charge", "scuttle-burst"],
|
||||
helmet: ["slash-cross", "burrow-eruption"],
|
||||
chest: ["charge", "breath-skyfall"],
|
||||
legs: ["charge", "ricochet"],
|
||||
feet: ["burrow-eruption", "bind-venom"],
|
||||
},
|
||||
nia: {
|
||||
weapon: ["cinderback-ricochet", "cindermaw"],
|
||||
helmet: ["vexa", "sandglass-scorpion"],
|
||||
chest: ["bulldrome", "obsidian-ram-golem"],
|
||||
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
|
||||
feet: ["sandglass-scorpion", "vexa"],
|
||||
weapon: ["ricochet", "breath-skyfall"],
|
||||
helmet: ["bind-venom", "burrow-eruption"],
|
||||
chest: ["charge", "rift-lanes"],
|
||||
legs: ["ricochet", "slash-cross"],
|
||||
feet: ["burrow-eruption", "bind-venom"],
|
||||
},
|
||||
orin: {
|
||||
weapon: ["cindermaw", "vexa"],
|
||||
helmet: ["sandglass-scorpion", "cinderback-ricochet"],
|
||||
chest: ["vexa", "obsidian-ram-golem"],
|
||||
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
|
||||
feet: ["sandglass-scorpion", "bulldrome"],
|
||||
weapon: ["breath-skyfall", "bind-venom"],
|
||||
helmet: ["burrow-eruption", "ricochet"],
|
||||
chest: ["bind-venom", "charge"],
|
||||
legs: ["ricochet", "slash-cross"],
|
||||
feet: ["burrow-eruption", "charge"],
|
||||
},
|
||||
vale: {
|
||||
weapon: ["ember-mantis-duelist", "cindermaw"],
|
||||
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
|
||||
chest: ["bulldrome", "obsidian-ram-golem"],
|
||||
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
|
||||
feet: ["vexa", "sandglass-scorpion"],
|
||||
weapon: ["slash-cross", "breath-skyfall"],
|
||||
helmet: ["ricochet", "burrow-eruption"],
|
||||
chest: ["charge", "rift-lanes"],
|
||||
legs: ["slash-cross", "ricochet"],
|
||||
feet: ["bind-venom", "burrow-eruption"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,8 +120,8 @@ function statFor(ownerId: GearOwnerId, slotId: GearSlotId): GearStatId {
|
||||
export const GEAR_RECIPES = Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
|
||||
ownerId,
|
||||
Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => {
|
||||
const [primaryBossId, secondaryBossId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
|
||||
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryBossId, secondaryBossId }];
|
||||
const [primaryGroupId, secondaryGroupId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
|
||||
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryGroupId, secondaryGroupId }];
|
||||
})),
|
||||
])) as Record<GearOwnerId, Record<GearSlotId, GearRecipe>>;
|
||||
|
||||
@@ -140,8 +141,8 @@ export function gearUpgradeCosts(ownerId: GearOwnerId, slotId: GearSlotId, curre
|
||||
const nextLevel = currentLevel + 1 as Exclude<GearLevel, 0>;
|
||||
const recipe = GEAR_RECIPES[ownerId][slotId];
|
||||
const difficultySlug = upgradeDifficultySlug(nextLevel);
|
||||
const primary = bossCoinDrop(recipe.primaryBossId, difficultySlug);
|
||||
const secondary = bossCoinDrop(recipe.secondaryBossId, difficultySlug);
|
||||
const primary = groupDrop(recipe.primaryGroupId, difficultySlug);
|
||||
const secondary = groupDrop(recipe.secondaryGroupId, difficultySlug);
|
||||
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }];
|
||||
const primaryQuantity = nextLevel <= 3 ? 3 : nextLevel <= 5 ? nextLevel : nextLevel;
|
||||
const secondaryQuantity = nextLevel === 2 ? 1 : nextLevel === 3 ? 2 : nextLevel - 1;
|
||||
@@ -204,7 +205,7 @@ function formatPercent(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
}
|
||||
|
||||
// IWT2 parity: +6 through +10 return to Veteran coins while infusion costs use higher tiers.
|
||||
// IWT2 parity: +6 through +10 return to Veteran drops while infusion costs use higher tiers.
|
||||
function upgradeDifficultySlug(level: Exclude<GearLevel, 0>): DifficultySlug {
|
||||
if (level <= 2) return "initiate";
|
||||
if (level >= 6) return "veteran";
|
||||
|
||||
@@ -2,27 +2,27 @@ import { describe, expect, it } from "vitest";
|
||||
import { createClassInventory } from "../healers";
|
||||
import { useGameStore } from "../store";
|
||||
import { createEncounterGearModifiers } from "./gearEffects";
|
||||
import { createDefaultGearProgress } from "./gear";
|
||||
import { GEAR_RECIPES, createDefaultGearProgress } from "./gear";
|
||||
import {
|
||||
equipActiveInfusion,
|
||||
equipPassiveInfusion,
|
||||
infusionCosts,
|
||||
passiveInfusionUnlocked,
|
||||
} from "./infusions";
|
||||
import { bossCoinDrop, type MaterialStack } from "./loot";
|
||||
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
|
||||
|
||||
function stack(item: ReturnType<typeof bossCoinDrop>, quantity: number): MaterialStack {
|
||||
function stack(item: ReturnType<typeof groupDrop>, quantity: number): MaterialStack {
|
||||
return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
|
||||
}
|
||||
|
||||
describe("IWT2-style gear infusions", () => {
|
||||
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic coins", () => {
|
||||
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic group drops", () => {
|
||||
const progress = createDefaultGearProgress();
|
||||
progress.brann.slots.weapon.level = 5;
|
||||
const costs = infusionCosts("brann", "weapon", "brann-unbreakable");
|
||||
const inventory = [
|
||||
stack(bossCoinDrop("obsidian-ram-golem", "ascendant"), 6),
|
||||
stack(bossCoinDrop("obsidian-ram-golem", "mythic"), 6),
|
||||
stack(bossGroupDrop("bristlequake-boar", "ascendant"), 6),
|
||||
stack(groupDrop(GEAR_RECIPES.brann.weapon.primaryGroupId, "mythic"), 6),
|
||||
];
|
||||
|
||||
const result = equipActiveInfusion(progress, inventory, "brann", "weapon", "brann-unbreakable");
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type GearSlotId,
|
||||
type GearUpgradeCost,
|
||||
} from "./gear";
|
||||
import { bossCoinDrop, type MaterialStack } from "./loot";
|
||||
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
|
||||
|
||||
export const ACTIVE_INFUSION_MIN_GEAR_LEVEL = 5;
|
||||
export const PASSIVE_INFUSION_MIN_GEAR_LEVEL = 10;
|
||||
@@ -38,39 +38,39 @@ type InfusionSeed = Omit<ActiveInfusionDefinition, "ownerId">;
|
||||
|
||||
const INFUSION_SEEDS: Record<GearOwnerId, readonly InfusionSeed[]> = {
|
||||
priest: [
|
||||
{ id: "priest-sanctuary", name: "Sanctuary", icon: "✦", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
|
||||
{ id: "priest-guardian-grace", name: "Guardian Grace", icon: "✚", description: "8% more healing power.", linkedBossId: "cindermaw", effectKey: "healing-power" },
|
||||
{ id: "priest-miracle-ward", name: "Miracle Ward", icon: "◇", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
|
||||
{ id: "priest-sanctuary", name: "Sanctuary", icon: "✦", description: "15% less hazard damage.", linkedBossId: "broodfang-spider", effectKey: "hazard-shield" },
|
||||
{ id: "priest-guardian-grace", name: "Guardian Grace", icon: "✚", description: "8% more healing power.", linkedBossId: "tempestscale-dragon", effectKey: "healing-power" },
|
||||
{ id: "priest-miracle-ward", name: "Miracle Ward", icon: "◇", description: "8% more maximum health.", linkedBossId: "bristlequake-boar", effectKey: "max-health" },
|
||||
],
|
||||
druid: [
|
||||
{ id: "druid-barkskin", name: "Barkskin", icon: "♧", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
|
||||
{ id: "druid-verdant-pulse", name: "Verdant Pulse", icon: "❈", description: "8% more healing power.", linkedBossId: "ember-mantis-duelist", effectKey: "healing-power" },
|
||||
{ id: "druid-barkskin", name: "Barkskin", icon: "♧", description: "8% more maximum health.", linkedBossId: "bristlequake-boar", effectKey: "max-health" },
|
||||
{ id: "druid-verdant-pulse", name: "Verdant Pulse", icon: "❈", description: "8% more healing power.", linkedBossId: "warcaller-orc", effectKey: "healing-power" },
|
||||
{ id: "druid-wildstep", name: "Wildstep", icon: "⌁", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
|
||||
],
|
||||
shaman: [
|
||||
{ id: "shaman-spirit-ward", name: "Spirit Ward", icon: "◈", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
|
||||
{ id: "shaman-ancestral-surge", name: "Ancestral Surge", icon: "ϟ", description: "8% more healing power.", linkedBossId: "cinderback-ricochet", effectKey: "healing-power" },
|
||||
{ id: "shaman-spirit-ward", name: "Spirit Ward", icon: "◈", description: "15% less hazard damage.", linkedBossId: "broodfang-spider", effectKey: "hazard-shield" },
|
||||
{ id: "shaman-ancestral-surge", name: "Ancestral Surge", icon: "ϟ", description: "8% more healing power.", linkedBossId: "emberfox", effectKey: "healing-power" },
|
||||
{ id: "shaman-windwalk", name: "Windwalk", icon: "≋", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
|
||||
],
|
||||
brann: [
|
||||
{ id: "brann-unbreakable", name: "Unbreakable", icon: "▣", description: "Immune to stuns.", linkedBossId: "obsidian-ram-golem", effectKey: "stun-immune" },
|
||||
{ id: "brann-unbreakable", name: "Unbreakable", icon: "▣", description: "Immune to stuns.", linkedBossId: "bristlequake-boar", effectKey: "stun-immune" },
|
||||
{ id: "brann-bulwark", name: "Bulwark", icon: "⬡", description: "8% more maximum health.", linkedBossId: "bulldrome", effectKey: "max-health" },
|
||||
{ id: "brann-vanguard", name: "Vanguard", icon: "➶", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
|
||||
{ id: "brann-vanguard", name: "Vanguard", icon: "➶", description: "8% faster movement.", linkedBossId: "emberfox", effectKey: "move-speed" },
|
||||
],
|
||||
nia: [
|
||||
{ id: "nia-hunters-mark", name: "Hunter's Mark", icon: "◎", description: "8% more damage.", linkedBossId: "cinderback-ricochet", effectKey: "damage" },
|
||||
{ id: "nia-quickdraw", name: "Quickdraw", icon: "➳", description: "8% faster attacks.", linkedBossId: "ember-mantis-duelist", effectKey: "cooldown" },
|
||||
{ id: "nia-decoy", name: "Decoy", icon: "♙", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
|
||||
{ id: "nia-hunters-mark", name: "Hunter's Mark", icon: "◎", description: "8% more damage.", linkedBossId: "emberfox", effectKey: "damage" },
|
||||
{ id: "nia-quickdraw", name: "Quickdraw", icon: "➳", description: "8% faster attacks.", linkedBossId: "warcaller-orc", effectKey: "cooldown" },
|
||||
{ id: "nia-decoy", name: "Decoy", icon: "♙", description: "8% more maximum health.", linkedBossId: "broodfang-spider", effectKey: "max-health" },
|
||||
],
|
||||
orin: [
|
||||
{ id: "orin-overcharge", name: "Overcharge", icon: "ϟ", description: "8% more damage.", linkedBossId: "cindermaw", effectKey: "damage" },
|
||||
{ id: "orin-overcharge", name: "Overcharge", icon: "ϟ", description: "8% more damage.", linkedBossId: "tempestscale-dragon", effectKey: "damage" },
|
||||
{ id: "orin-temporal-flow", name: "Temporal Flow", icon: "◷", description: "8% faster attacks.", linkedBossId: "sandglass-scorpion", effectKey: "cooldown" },
|
||||
{ id: "orin-arcane-barrier", name: "Arcane Barrier", icon: "◇", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
|
||||
{ id: "orin-arcane-barrier", name: "Arcane Barrier", icon: "◇", description: "8% more maximum health.", linkedBossId: "broodfang-spider", effectKey: "max-health" },
|
||||
],
|
||||
vale: [
|
||||
{ id: "vale-expose", name: "Expose", icon: "†", description: "8% more damage.", linkedBossId: "ember-mantis-duelist", effectKey: "damage" },
|
||||
{ id: "vale-shadow-step", name: "Shadow Step", icon: "⌁", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
|
||||
{ id: "vale-vanish", name: "Vanish", icon: "◌", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
|
||||
{ id: "vale-expose", name: "Expose", icon: "†", description: "8% more damage.", linkedBossId: "warcaller-orc", effectKey: "damage" },
|
||||
{ id: "vale-shadow-step", name: "Shadow Step", icon: "⌁", description: "8% faster movement.", linkedBossId: "emberfox", effectKey: "move-speed" },
|
||||
{ id: "vale-vanish", name: "Vanish", icon: "◌", description: "15% less hazard damage.", linkedBossId: "broodfang-spider", effectKey: "hazard-shield" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -100,8 +100,8 @@ export function passiveInfusionUnlocked(progress: GearProgress): boolean {
|
||||
export function infusionCosts(ownerId: GearOwnerId, slotId: GearSlotId, infusionId: string): GearUpgradeCost[] {
|
||||
const definition = ACTIVE_INFUSIONS[infusionId];
|
||||
if (!definition || definition.ownerId !== ownerId) return [];
|
||||
const ascendant = bossCoinDrop(definition.linkedBossId, "ascendant");
|
||||
const mythic = bossCoinDrop(GEAR_RECIPES[ownerId][slotId].primaryBossId, "mythic");
|
||||
const ascendant = bossGroupDrop(definition.linkedBossId, "ascendant");
|
||||
const mythic = groupDrop(GEAR_RECIPES[ownerId][slotId].primaryGroupId, "mythic");
|
||||
return [
|
||||
{ itemId: ascendant.id, itemName: ascendant.name, quantity: 5 },
|
||||
{ itemId: mythic.id, itemName: mythic.name, quantity: 5 },
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import type { BossId } from "../types";
|
||||
|
||||
export type LootRarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
export type DifficultySlug = "initiate" | "veteran" | "champion" | "mythic" | "ascendant";
|
||||
|
||||
export interface DifficultyDefinition {
|
||||
slug: DifficultySlug;
|
||||
name: string;
|
||||
itemLevel: number;
|
||||
rarity: LootRarity;
|
||||
glyph: string;
|
||||
coinPrefix: string;
|
||||
healthMultiplier: number;
|
||||
damageMultiplier: number;
|
||||
}
|
||||
|
||||
export interface MaterialStack {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rarity: LootRarity;
|
||||
itemLevel: number;
|
||||
glyph: string;
|
||||
}
|
||||
|
||||
export interface BossCoinDrop {
|
||||
kind: "coin";
|
||||
id: string;
|
||||
bossId: BossId;
|
||||
difficultySlug: DifficultySlug;
|
||||
name: string;
|
||||
rarity: LootRarity;
|
||||
itemLevel: number;
|
||||
glyph: string;
|
||||
chanceLabel: string;
|
||||
}
|
||||
|
||||
export interface BossPetDrop {
|
||||
kind: "pet";
|
||||
id: string;
|
||||
bossId: BossId;
|
||||
name: string;
|
||||
rarity: "legendary";
|
||||
glyph: string;
|
||||
dropRate: number;
|
||||
chanceLabel: string;
|
||||
}
|
||||
|
||||
export interface BossDropTable {
|
||||
bossId: BossId;
|
||||
coins: Record<DifficultySlug, BossCoinDrop>;
|
||||
pet: BossPetDrop;
|
||||
entries: readonly (BossCoinDrop | BossPetDrop)[];
|
||||
}
|
||||
|
||||
export interface CollectionLog {
|
||||
dropsFound: Record<string, number>;
|
||||
petsFound: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BossRewardAward {
|
||||
bossId: BossId;
|
||||
coin: BossCoinDrop;
|
||||
quantity: number;
|
||||
quantityAfter: number;
|
||||
duplicate: boolean;
|
||||
pet: BossPetDrop | null;
|
||||
petQuantityAfter: number;
|
||||
}
|
||||
|
||||
export const BOSS_PET_DROP_RATE = 1 / 500;
|
||||
|
||||
export const DIFFICULTIES: readonly DifficultyDefinition[] = [
|
||||
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", coinPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
|
||||
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", coinPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
|
||||
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", coinPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
|
||||
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", coinPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
|
||||
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", coinPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
|
||||
] as const;
|
||||
|
||||
export const DIFFICULTY_BY_SLUG = Object.fromEntries(
|
||||
DIFFICULTIES.map((difficulty) => [difficulty.slug, difficulty]),
|
||||
) as Record<DifficultySlug, DifficultyDefinition>;
|
||||
|
||||
const DIFFICULTY_ID_PREFIX: Record<DifficultySlug, string> = {
|
||||
initiate: "raw",
|
||||
veteran: "green",
|
||||
champion: "blue",
|
||||
mythic: "purple",
|
||||
ascendant: "orange",
|
||||
};
|
||||
|
||||
function createBossDropTable(bossId: BossId): BossDropTable {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
const coins = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
|
||||
difficulty.slug,
|
||||
{
|
||||
kind: "coin" as const,
|
||||
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${bossId}-coin`,
|
||||
bossId,
|
||||
difficultySlug: difficulty.slug,
|
||||
name: `${difficulty.coinPrefix} ${boss.name} Coin`,
|
||||
rarity: difficulty.rarity,
|
||||
itemLevel: difficulty.itemLevel,
|
||||
glyph: difficulty.glyph,
|
||||
chanceLabel: "Guaranteed 1-3",
|
||||
},
|
||||
])) as Record<DifficultySlug, BossCoinDrop>;
|
||||
const pet: BossPetDrop = {
|
||||
kind: "pet",
|
||||
id: `${bossId}-pet`,
|
||||
bossId,
|
||||
name: `${boss.name} Pet`,
|
||||
rarity: "legendary",
|
||||
glyph: boss.icon,
|
||||
dropRate: BOSS_PET_DROP_RATE,
|
||||
chanceLabel: "1 in 500",
|
||||
};
|
||||
return {
|
||||
bossId,
|
||||
coins,
|
||||
pet,
|
||||
entries: [...DIFFICULTIES.map((difficulty) => coins[difficulty.slug]), pet],
|
||||
};
|
||||
}
|
||||
|
||||
export const BOSS_DROP_TABLES = Object.fromEntries(
|
||||
AVAILABLE_BOSS_IDS.map((bossId) => [bossId, createBossDropTable(bossId)]),
|
||||
) as Record<BossId, BossDropTable>;
|
||||
|
||||
export function bossDropTable(bossId: BossId): BossDropTable {
|
||||
return BOSS_DROP_TABLES[bossId];
|
||||
}
|
||||
|
||||
export function bossCoinDrop(bossId: BossId, difficultySlug: DifficultySlug): BossCoinDrop {
|
||||
return BOSS_DROP_TABLES[bossId].coins[difficultySlug];
|
||||
}
|
||||
|
||||
export function coinDropQuantity(random: () => number = Math.random): number {
|
||||
const roll = random();
|
||||
if (roll < 0.15) return 3;
|
||||
if (roll < 0.5) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function rollBossReward(
|
||||
bossId: BossId,
|
||||
difficultySlug: DifficultySlug,
|
||||
inventory: readonly MaterialStack[],
|
||||
collectionLog: CollectionLog,
|
||||
random: () => number = Math.random,
|
||||
): { award: BossRewardAward; inventory: MaterialStack[]; collectionLog: CollectionLog } {
|
||||
const table = bossDropTable(bossId);
|
||||
const coin = table.coins[difficultySlug];
|
||||
const quantity = coinDropQuantity(random);
|
||||
const existing = inventory.find((item) => item.id === coin.id);
|
||||
const inventoryAfter = existing
|
||||
? inventory.map((item) => item.id === coin.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
|
||||
: [...inventory.map((item) => ({ ...item })), { id: coin.id, name: coin.name, quantity, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
|
||||
const petAwarded = random() < table.pet.dropRate;
|
||||
const petQuantityAfter = (collectionLog.petsFound[table.pet.id] ?? 0) + Number(petAwarded);
|
||||
return {
|
||||
award: {
|
||||
bossId,
|
||||
coin,
|
||||
quantity,
|
||||
quantityAfter: (existing?.quantity ?? 0) + quantity,
|
||||
duplicate: Boolean(existing),
|
||||
pet: petAwarded ? table.pet : null,
|
||||
petQuantityAfter,
|
||||
},
|
||||
inventory: inventoryAfter,
|
||||
collectionLog: {
|
||||
dropsFound: {
|
||||
...collectionLog.dropsFound,
|
||||
[coin.id]: (collectionLog.dropsFound[coin.id] ?? 0) + quantity,
|
||||
},
|
||||
petsFound: petAwarded
|
||||
? { ...collectionLog.petsFound, [table.pet.id]: petQuantityAfter }
|
||||
: { ...collectionLog.petsFound },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDifficultySlug(value: unknown): DifficultySlug {
|
||||
return typeof value === "string" && value in DIFFICULTY_BY_SLUG
|
||||
? value as DifficultySlug
|
||||
: "initiate";
|
||||
}
|
||||
|
||||
export function createEmptyCollectionLog(): CollectionLog {
|
||||
return { dropsFound: {}, petsFound: {} };
|
||||
}
|
||||
@@ -1,25 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BOSS_ORDER } from "../bossCatalog";
|
||||
import { BOSS_DROP_TABLES, coinDropQuantity, createEmptyCollectionLog, rollBossReward } from "./loot";
|
||||
import { BOSS_GROUPS } from "../bossCatalog";
|
||||
import { GROUP_DROP_TABLES, bossGroupDrop, createEmptyCollectionLog, groupDropQuantity, rollBossReward } from "./loot";
|
||||
|
||||
describe("boss drop tables", () => {
|
||||
it("defines tiered coins and a pet for every shipped boss", () => {
|
||||
for (const bossId of BOSS_ORDER) {
|
||||
const table = BOSS_DROP_TABLES[bossId];
|
||||
expect(Object.keys(table.coins)).toEqual(["initiate", "veteran", "champion", "mythic", "ascendant"]);
|
||||
expect(table.pet.dropRate).toBe(1 / 500);
|
||||
describe("group drop tables", () => {
|
||||
it("defines five shared drops for every mechanic group", () => {
|
||||
for (const group of BOSS_GROUPS) {
|
||||
const table = GROUP_DROP_TABLES[group.id];
|
||||
expect(Object.keys(table.drops)).toEqual(["initiate", "veteran", "champion", "mythic", "ascendant"]);
|
||||
expect(table.drops.veteran.name).toBe(`Green Group ${group.letter} Drop`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls IWT2 coin quantities at exact boundaries", () => {
|
||||
expect(coinDropQuantity(() => 0)).toBe(3);
|
||||
expect(coinDropQuantity(() => 0.149999)).toBe(3);
|
||||
expect(coinDropQuantity(() => 0.15)).toBe(2);
|
||||
expect(coinDropQuantity(() => 0.499999)).toBe(2);
|
||||
expect(coinDropQuantity(() => 0.5)).toBe(1);
|
||||
it("awards the same material from every boss in a group", () => {
|
||||
expect(bossGroupDrop("bulldrome", "veteran").id).toBe(bossGroupDrop("bristlequake-boar", "veteran").id);
|
||||
expect(bossGroupDrop("bulldrome", "veteran").name).toBe("Green Group A Drop");
|
||||
});
|
||||
|
||||
it("stacks duplicate coins while collection counts remain lifetime totals", () => {
|
||||
it("rolls group drop quantities at exact boundaries", () => {
|
||||
expect(groupDropQuantity(() => 0)).toBe(3);
|
||||
expect(groupDropQuantity(() => 0.149999)).toBe(3);
|
||||
expect(groupDropQuantity(() => 0.15)).toBe(2);
|
||||
expect(groupDropQuantity(() => 0.499999)).toBe(2);
|
||||
expect(groupDropQuantity(() => 0.5)).toBe(1);
|
||||
});
|
||||
|
||||
it("stacks duplicate group drops while collection counts remain lifetime totals", () => {
|
||||
const rolls = [0.1, 1, 0.2, 0];
|
||||
const random = () => rolls.shift() ?? 1;
|
||||
const first = rollBossReward("bulldrome", "initiate", [], createEmptyCollectionLog(), random);
|
||||
@@ -28,7 +33,7 @@ describe("boss drop tables", () => {
|
||||
expect(second.award.quantity).toBe(2);
|
||||
expect(second.award.duplicate).toBe(true);
|
||||
expect(second.inventory[0].quantity).toBe(5);
|
||||
expect(second.collectionLog.dropsFound[second.award.coin.id]).toBe(5);
|
||||
expect(second.collectionLog.dropsFound[second.award.drop.id]).toBe(5);
|
||||
expect(second.award.pet?.id).toBe("bulldrome-pet");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../bossCatalog";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, bossGroupFor, type BossGroupId } from "../bossCatalog";
|
||||
import type { BossId } from "../types";
|
||||
|
||||
export type LootRarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
|
||||
@@ -10,7 +10,7 @@ export interface DifficultyDefinition {
|
||||
itemLevel: number;
|
||||
rarity: LootRarity;
|
||||
glyph: string;
|
||||
coinPrefix: string;
|
||||
dropPrefix: string;
|
||||
healthMultiplier: number;
|
||||
damageMultiplier: number;
|
||||
}
|
||||
@@ -24,10 +24,10 @@ export interface MaterialStack {
|
||||
glyph: string;
|
||||
}
|
||||
|
||||
export interface BossCoinDrop {
|
||||
kind: "coin";
|
||||
export interface GroupDrop {
|
||||
kind: "group-drop";
|
||||
id: string;
|
||||
bossId: BossId;
|
||||
groupId: BossGroupId;
|
||||
difficultySlug: DifficultySlug;
|
||||
name: string;
|
||||
rarity: LootRarity;
|
||||
@@ -47,11 +47,10 @@ export interface BossPetDrop {
|
||||
chanceLabel: string;
|
||||
}
|
||||
|
||||
export interface BossDropTable {
|
||||
bossId: BossId;
|
||||
coins: Record<DifficultySlug, BossCoinDrop>;
|
||||
pet: BossPetDrop;
|
||||
entries: readonly (BossCoinDrop | BossPetDrop)[];
|
||||
export interface GroupDropTable {
|
||||
groupId: BossGroupId;
|
||||
drops: Record<DifficultySlug, GroupDrop>;
|
||||
entries: readonly GroupDrop[];
|
||||
}
|
||||
|
||||
export interface CollectionLog {
|
||||
@@ -61,7 +60,7 @@ export interface CollectionLog {
|
||||
|
||||
export interface BossRewardAward {
|
||||
bossId: BossId;
|
||||
coin: BossCoinDrop;
|
||||
drop: GroupDrop;
|
||||
quantity: number;
|
||||
quantityAfter: number;
|
||||
duplicate: boolean;
|
||||
@@ -72,11 +71,11 @@ export interface BossRewardAward {
|
||||
export const BOSS_PET_DROP_RATE = 1 / 500;
|
||||
|
||||
export const DIFFICULTIES: readonly DifficultyDefinition[] = [
|
||||
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", coinPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
|
||||
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", coinPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
|
||||
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", coinPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
|
||||
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", coinPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
|
||||
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", coinPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
|
||||
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", dropPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
|
||||
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", dropPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
|
||||
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", dropPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
|
||||
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", dropPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
|
||||
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", dropPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
|
||||
] as const;
|
||||
|
||||
export const DIFFICULTY_BY_SLUG = Object.fromEntries(
|
||||
@@ -91,53 +90,68 @@ const DIFFICULTY_ID_PREFIX: Record<DifficultySlug, string> = {
|
||||
ascendant: "orange",
|
||||
};
|
||||
|
||||
function createBossDropTable(bossId: BossId): BossDropTable {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
const coins = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
|
||||
function createGroupDropTable(groupId: BossGroupId): GroupDropTable {
|
||||
const group = BOSS_GROUP_BY_ID[groupId];
|
||||
const drops = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
|
||||
difficulty.slug,
|
||||
{
|
||||
kind: "coin" as const,
|
||||
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${bossId}-coin`,
|
||||
bossId,
|
||||
kind: "group-drop" as const,
|
||||
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${groupId}-drop`,
|
||||
groupId,
|
||||
difficultySlug: difficulty.slug,
|
||||
name: `${difficulty.coinPrefix} ${boss.name} Coin`,
|
||||
name: `${difficulty.dropPrefix} Group ${group.letter} Drop`,
|
||||
rarity: difficulty.rarity,
|
||||
itemLevel: difficulty.itemLevel,
|
||||
glyph: difficulty.glyph,
|
||||
chanceLabel: "Guaranteed 1-3",
|
||||
},
|
||||
])) as Record<DifficultySlug, BossCoinDrop>;
|
||||
const pet: BossPetDrop = {
|
||||
kind: "pet",
|
||||
id: `${bossId}-pet`,
|
||||
bossId,
|
||||
name: `${boss.name} Pet`,
|
||||
rarity: "legendary",
|
||||
glyph: boss.icon,
|
||||
dropRate: BOSS_PET_DROP_RATE,
|
||||
chanceLabel: "1 in 500",
|
||||
};
|
||||
])) as Record<DifficultySlug, GroupDrop>;
|
||||
return {
|
||||
bossId,
|
||||
coins,
|
||||
pet,
|
||||
entries: [...DIFFICULTIES.map((difficulty) => coins[difficulty.slug]), pet],
|
||||
groupId,
|
||||
drops,
|
||||
entries: DIFFICULTIES.map((difficulty) => drops[difficulty.slug]),
|
||||
};
|
||||
}
|
||||
|
||||
export const BOSS_DROP_TABLES = Object.fromEntries(
|
||||
BOSS_ORDER.map((bossId) => [bossId, createBossDropTable(bossId)]),
|
||||
) as Record<BossId, BossDropTable>;
|
||||
|
||||
export function bossDropTable(bossId: BossId): BossDropTable {
|
||||
return BOSS_DROP_TABLES[bossId];
|
||||
function createBossPetDrop(bossId: BossId): BossPetDrop {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
return {
|
||||
kind: "pet",
|
||||
id: `${bossId}-pet`,
|
||||
bossId,
|
||||
name: `${boss.name} Pet`,
|
||||
rarity: "legendary",
|
||||
glyph: boss.icon,
|
||||
dropRate: BOSS_PET_DROP_RATE,
|
||||
chanceLabel: "1 in 500",
|
||||
};
|
||||
}
|
||||
|
||||
export function bossCoinDrop(bossId: BossId, difficultySlug: DifficultySlug): BossCoinDrop {
|
||||
return BOSS_DROP_TABLES[bossId].coins[difficultySlug];
|
||||
export const GROUP_DROP_TABLES = Object.fromEntries(
|
||||
Object.keys(BOSS_GROUP_BY_ID).map((groupId) => [groupId, createGroupDropTable(groupId as BossGroupId)]),
|
||||
) as Record<BossGroupId, GroupDropTable>;
|
||||
|
||||
export const BOSS_PET_DROPS = Object.fromEntries(
|
||||
AVAILABLE_BOSS_IDS.map((bossId) => [bossId, createBossPetDrop(bossId)]),
|
||||
) as Record<BossId, BossPetDrop>;
|
||||
|
||||
export function groupDropTable(groupId: BossGroupId): GroupDropTable {
|
||||
return GROUP_DROP_TABLES[groupId];
|
||||
}
|
||||
|
||||
export function coinDropQuantity(random: () => number = Math.random): number {
|
||||
export function groupDrop(groupId: BossGroupId, difficultySlug: DifficultySlug): GroupDrop {
|
||||
return GROUP_DROP_TABLES[groupId].drops[difficultySlug];
|
||||
}
|
||||
|
||||
export function bossGroupDrop(bossId: BossId, difficultySlug: DifficultySlug): GroupDrop {
|
||||
return groupDrop(bossGroupFor(bossId).id, difficultySlug);
|
||||
}
|
||||
|
||||
export function bossPetDrop(bossId: BossId): BossPetDrop {
|
||||
return BOSS_PET_DROPS[bossId];
|
||||
}
|
||||
|
||||
export function groupDropQuantity(random: () => number = Math.random): number {
|
||||
const roll = random();
|
||||
if (roll < 0.15) return 3;
|
||||
if (roll < 0.5) return 2;
|
||||
@@ -151,33 +165,33 @@ export function rollBossReward(
|
||||
collectionLog: CollectionLog,
|
||||
random: () => number = Math.random,
|
||||
): { award: BossRewardAward; inventory: MaterialStack[]; collectionLog: CollectionLog } {
|
||||
const table = bossDropTable(bossId);
|
||||
const coin = table.coins[difficultySlug];
|
||||
const quantity = coinDropQuantity(random);
|
||||
const existing = inventory.find((item) => item.id === coin.id);
|
||||
const drop = bossGroupDrop(bossId, difficultySlug);
|
||||
const pet = bossPetDrop(bossId);
|
||||
const quantity = groupDropQuantity(random);
|
||||
const existing = inventory.find((item) => item.id === drop.id);
|
||||
const inventoryAfter = existing
|
||||
? inventory.map((item) => item.id === coin.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
|
||||
: [...inventory.map((item) => ({ ...item })), { id: coin.id, name: coin.name, quantity, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
|
||||
const petAwarded = random() < table.pet.dropRate;
|
||||
const petQuantityAfter = (collectionLog.petsFound[table.pet.id] ?? 0) + Number(petAwarded);
|
||||
? inventory.map((item) => item.id === drop.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
|
||||
: [...inventory.map((item) => ({ ...item })), { id: drop.id, name: drop.name, quantity, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
||||
const petAwarded = random() < pet.dropRate;
|
||||
const petQuantityAfter = (collectionLog.petsFound[pet.id] ?? 0) + Number(petAwarded);
|
||||
return {
|
||||
award: {
|
||||
bossId,
|
||||
coin,
|
||||
drop,
|
||||
quantity,
|
||||
quantityAfter: (existing?.quantity ?? 0) + quantity,
|
||||
duplicate: Boolean(existing),
|
||||
pet: petAwarded ? table.pet : null,
|
||||
pet: petAwarded ? pet : null,
|
||||
petQuantityAfter,
|
||||
},
|
||||
inventory: inventoryAfter,
|
||||
collectionLog: {
|
||||
dropsFound: {
|
||||
...collectionLog.dropsFound,
|
||||
[coin.id]: (collectionLog.dropsFound[coin.id] ?? 0) + quantity,
|
||||
[drop.id]: (collectionLog.dropsFound[drop.id] ?? 0) + quantity,
|
||||
},
|
||||
petsFound: petAwarded
|
||||
? { ...collectionLog.petsFound, [table.pet.id]: petQuantityAfter }
|
||||
? { ...collectionLog.petsFound, [pet.id]: petQuantityAfter }
|
||||
: { ...collectionLog.petsFound },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,11 +27,11 @@ describe("roguelike progression", () => {
|
||||
|
||||
it("selects a distinct pair that excludes both bosses from the prior round", () => {
|
||||
const values = [0, 0];
|
||||
const pair = selectRandomBossPair(["bulldrome", "vexa"], () => values.shift() ?? 0);
|
||||
const pair = selectRandomBossPair(["bulldrome", "broodfang-spider"], () => values.shift() ?? 0);
|
||||
|
||||
expect(pair).toEqual(["cindermaw", "ember-mantis-duelist"]);
|
||||
expect(pair).toEqual(["sandglass-scorpion", "cragclaw-crab"]);
|
||||
expect(new Set(pair)).toHaveLength(2);
|
||||
expect(pair).not.toContain("bulldrome");
|
||||
expect(pair).not.toContain("vexa");
|
||||
expect(pair).not.toContain("broodfang-spider");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BOSS_ORDER } from "./bossCatalog";
|
||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||
import type { BossId, PartyMember, RunBuffId } from "./types";
|
||||
|
||||
export interface RunBuffDefinition {
|
||||
@@ -68,8 +68,8 @@ export function selectRandomBossPair(
|
||||
random: () => number = Math.random,
|
||||
): readonly [BossId, BossId] {
|
||||
const excluded = new Set(excludedBossIds);
|
||||
const eligibleBosses = BOSS_ORDER.filter((bossId) => !excluded.has(bossId));
|
||||
const pool = eligibleBosses.length >= 2 ? eligibleBosses : BOSS_ORDER;
|
||||
const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId));
|
||||
const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS;
|
||||
const firstIndex = Math.floor(random() * pool.length) % pool.length;
|
||||
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1));
|
||||
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]];
|
||||
|
||||
+17
-14
@@ -4,7 +4,7 @@ import { distance, pointToSegmentDistance } from "./geometry";
|
||||
import { barrierProtects, useGameStore } from "./store";
|
||||
import { createClassInventory, HEALER_CLASSES } from "./healers";
|
||||
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
|
||||
import { isInsideArena } from "./arena";
|
||||
import { ARENA_CENTER, isInsideArena } from "./arena";
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
|
||||
describe("Disc Priest combat simulation", () => {
|
||||
@@ -272,7 +272,7 @@ describe("Disc Priest combat simulation", () => {
|
||||
bossMotion: {
|
||||
...state.bossMotion,
|
||||
mode: "returning",
|
||||
position: [0, -4.25],
|
||||
position: [0, -1],
|
||||
chargesSincePounce: 3,
|
||||
},
|
||||
}));
|
||||
@@ -300,9 +300,9 @@ describe("Disc Priest combat simulation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vexa encounter", () => {
|
||||
describe("Broodfang encounter", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "vexa");
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
|
||||
});
|
||||
@@ -364,20 +364,23 @@ describe("Vexa encounter", () => {
|
||||
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
|
||||
});
|
||||
|
||||
it("follows Brann when the tank is displaced", () => {
|
||||
it("returns to the room center when the tank is displaced", () => {
|
||||
useGameStore.setState((state) => ({
|
||||
partyPositions: { ...state.partyPositions, brann: [3, 1] },
|
||||
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
|
||||
bossMotion: { ...state.bossMotion, position: [5, 5], nextMechanicAt: 999 },
|
||||
}));
|
||||
const startX = useGameStore.getState().bossMotion.position[0];
|
||||
const start = useGameStore.getState().bossMotion.position;
|
||||
useGameStore.getState().tick(0.5);
|
||||
expect(useGameStore.getState().bossMotion.position[0]).toBeGreaterThan(startX);
|
||||
const result = useGameStore.getState().bossMotion.position;
|
||||
expect(Math.hypot(result[0] - ARENA_CENTER[0], result[1] - ARENA_CENTER[1])).toBeLessThan(
|
||||
Math.hypot(start[0] - ARENA_CENTER[0], start[1] - ARENA_CENTER[1]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PVE dual-boss encounter", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["vexa", "cindermaw"]);
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["broodfang-spider", "tempestscale-dragon"]);
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.setState((state) => ({
|
||||
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||
@@ -387,8 +390,8 @@ describe("PVE dual-boss encounter", () => {
|
||||
|
||||
it("runs two distinct bosses concurrently and requires both to fall", () => {
|
||||
const initial = useGameStore.getState();
|
||||
expect(initial.boss.id).toBe("vexa");
|
||||
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["cindermaw"]);
|
||||
expect(initial.boss.id).toBe("broodfang-spider");
|
||||
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["tempestscale-dragon"]);
|
||||
expect(initial.bossMotion.position[0]).toBeLessThan(initial.additionalBosses[0].motion.position[0]);
|
||||
|
||||
useGameStore.getState().tick(1);
|
||||
@@ -421,7 +424,7 @@ describe("Roguelike rounds", () => {
|
||||
"priest",
|
||||
"Aelia",
|
||||
createClassInventory("priest"),
|
||||
["bulldrome", "vexa"],
|
||||
["bulldrome", "broodfang-spider"],
|
||||
"roguelike",
|
||||
);
|
||||
useGameStore.getState().startEncounter();
|
||||
@@ -489,9 +492,9 @@ describe("shared arena boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cindermaw encounter", () => {
|
||||
describe("Tempestscale encounter", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "cindermaw");
|
||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "tempestscale-dragon");
|
||||
useGameStore.getState().startEncounter();
|
||||
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
|
||||
});
|
||||
|
||||
+6
-6
@@ -436,12 +436,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
case "purify":
|
||||
{
|
||||
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
|
||||
if (state.boss.id === "vexa") {
|
||||
const dispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
|
||||
bossMotion = dispel.motion;
|
||||
}
|
||||
const primaryDispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
|
||||
bossMotion = primaryDispel.motion;
|
||||
additionalBosses = state.additionalBosses.map((entry) => {
|
||||
if (entry.boss.id !== "vexa") return entry;
|
||||
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
|
||||
return { ...entry, motion: dispel.motion };
|
||||
});
|
||||
@@ -491,7 +488,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
|
||||
const oldTime = state.time;
|
||||
const time = oldTime + Math.min(delta, 2);
|
||||
let party = state.party.map((member) => ({ ...member, debuffs: member.debuffs.map((debuff) => ({ ...debuff })) }));
|
||||
// Debuffs are copied only by the periodic-debuff pass below. Copying them here
|
||||
// as well doubled short-lived allocations for every simulation step.
|
||||
let party = state.party.map((member) => ({ ...member }));
|
||||
let boss = { ...state.boss };
|
||||
let bossMotion = { ...state.bossMotion };
|
||||
let additionalBosses = state.additionalBosses.map((entry) => ({
|
||||
@@ -575,6 +574,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
partyPositions,
|
||||
time,
|
||||
delta: time - oldTime,
|
||||
allowPooledMechanics: encounterBosses.length === 1,
|
||||
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind),
|
||||
});
|
||||
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
|
||||
|
||||
+79
-7
@@ -2,17 +2,32 @@ export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
|
||||
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier";
|
||||
export type BossId =
|
||||
| "bulldrome"
|
||||
| "vexa"
|
||||
| "cindermaw"
|
||||
| "ember-mantis-duelist"
|
||||
| "obsidian-ram-golem"
|
||||
| "cinderback-ricochet"
|
||||
| "sandglass-scorpion"
|
||||
| "cragclaw-crab"
|
||||
| "mournveil-ghost"
|
||||
| "crownshard-golem"
|
||||
| "pumpking-king-of-ghosts"
|
||||
| "blue-eyes-ultimate-dragon";
|
||||
| "crystal-bat-matriarch"
|
||||
| "stormwool-alpaca"
|
||||
| "cluckhorn-colossus"
|
||||
| "ashwing-demon"
|
||||
| "riftclaw-demon"
|
||||
| "tempestscale-dragon"
|
||||
| "emberfox"
|
||||
| "mirelord-frog"
|
||||
| "stonebreaker-giant"
|
||||
| "glub-sovereign"
|
||||
| "scrapking-goblin"
|
||||
| "warcaller-orc"
|
||||
| "tuskmaw-orc"
|
||||
| "broodfang-spider"
|
||||
| "silkfang-spider"
|
||||
| "thorncrown-stag"
|
||||
| "sky-totem"
|
||||
| "razorcrest-raptor"
|
||||
| "bristlequake-boar"
|
||||
| "moonfang-wolf"
|
||||
| "frostmaw-yeti"
|
||||
| "rimeclaw-yeti";
|
||||
export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat";
|
||||
export type RunMode = "encounter" | "roguelike";
|
||||
export type RunBuffId = "vital-bloom" | "deep-wells" | "restoring-grace";
|
||||
@@ -73,6 +88,47 @@ export type CircleHazardKind =
|
||||
| "crownfall"
|
||||
| "royal_shockwave";
|
||||
|
||||
export type PoolTelegraphKind = "spread" | "donut" | "soak" | "beam" | "memory" | "soul-siphon";
|
||||
|
||||
export type MemorySymbolId = "triangle" | "cross" | "circle" | "square";
|
||||
|
||||
export interface MemoryTile {
|
||||
symbol: MemorySymbolId;
|
||||
center: WorldPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived, encounter-agnostic boss mechanic. Individual boss scripts keep
|
||||
* ownership of their signature moves; this shape lets an encounter add a
|
||||
* balanced mechanic from the shared pool without adding another boss mode.
|
||||
*/
|
||||
export interface PoolTelegraph {
|
||||
id: string;
|
||||
kind: PoolTelegraphKind;
|
||||
name: string;
|
||||
center: WorldPosition;
|
||||
radius: number;
|
||||
innerRadius?: number;
|
||||
start?: WorldPosition;
|
||||
end?: WorldPosition;
|
||||
width?: number;
|
||||
activatesAt: number;
|
||||
expiresAt: number;
|
||||
damage: number;
|
||||
totalDamage?: number;
|
||||
minimumParticipants?: number;
|
||||
targetId?: MemberId;
|
||||
/** Healer-only Simon Says state. Present only for the Memory Sequence pool mechanic. */
|
||||
sequence?: MemorySymbolId[];
|
||||
tiles?: MemoryTile[];
|
||||
inputStartsAt?: number;
|
||||
inputIndex?: number;
|
||||
lastHealerTileId?: MemorySymbolId;
|
||||
soulSiphon?: SoulSiphonState;
|
||||
resolved: boolean;
|
||||
hitIds: MemberId[];
|
||||
}
|
||||
|
||||
export interface CircleHazard {
|
||||
id: string;
|
||||
kind: CircleHazardKind;
|
||||
@@ -96,6 +152,19 @@ export interface SlashLane {
|
||||
damage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Healer-only movement objective. The encounter owns the rules; renderers use
|
||||
* this compact state to show the pursuing shade and its cleansing destination.
|
||||
*/
|
||||
export interface SoulSiphonState {
|
||||
targetId: "aelia";
|
||||
ghostPosition: WorldPosition;
|
||||
wardPosition: WorldPosition;
|
||||
wardRadius: number;
|
||||
nextDamageAt: number;
|
||||
tickCount: number;
|
||||
}
|
||||
|
||||
export interface Debuff {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -158,6 +227,9 @@ export interface BossMotionState {
|
||||
breathEndAngle: number;
|
||||
hazards: CircleHazard[];
|
||||
slashLanes: SlashLane[];
|
||||
nextPoolMechanicAt: number;
|
||||
poolMechanicCount: number;
|
||||
poolTelegraphs: PoolTelegraph[];
|
||||
}
|
||||
|
||||
export interface AbilityDefinition {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
import { ABILITY_ORDER } from "./data";
|
||||
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick } from "./performance";
|
||||
import { useGameStore } from "./store";
|
||||
import type { AbilityId } from "./types";
|
||||
|
||||
@@ -21,28 +20,6 @@ const gamepadAbilityMap: Record<number, AbilityId> = {
|
||||
5: "barrier",
|
||||
};
|
||||
|
||||
export function useGameLoop() {
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
let previous = performance.now();
|
||||
let accumulator = 0;
|
||||
const loop = (now: number) => {
|
||||
const delta = Math.min((now - previous) / 1000, 0.25);
|
||||
previous = now;
|
||||
accumulator += delta;
|
||||
if (accumulator >= 0.1) {
|
||||
const tickStartedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
|
||||
useGameStore.getState().tick(accumulator);
|
||||
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - tickStartedAt);
|
||||
accumulator = 0;
|
||||
}
|
||||
frame = requestAnimationFrame(loop);
|
||||
};
|
||||
frame = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
const exitRef = useRef(onExit);
|
||||
exitRef.current = onExit;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { diffBottomGameSnapshot, type BottomGameSnapshot } from "./dualScreenSync";
|
||||
|
||||
function snapshot(): BottomGameSnapshot {
|
||||
return {
|
||||
bossId: "bulldrome",
|
||||
paused: false,
|
||||
healerClassId: "priest",
|
||||
phase: "combat",
|
||||
round: 1,
|
||||
time: 10,
|
||||
party: [],
|
||||
boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 },
|
||||
additionalBosses: [],
|
||||
partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] },
|
||||
bossMotion: {
|
||||
bossId: "bulldrome", formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0,
|
||||
nextChargeAt: Infinity, chargeCount: 0, chargesSincePounce: 0, pounceTargetId: "aelia", pounceCenter: [0, 0], pounceCount: 0,
|
||||
nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0,
|
||||
breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], nextPoolMechanicAt: Infinity, poolMechanicCount: 0, poolTelegraphs: [],
|
||||
},
|
||||
partyCombat: {
|
||||
combatants: {
|
||||
brann: { id: "brann", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
|
||||
nia: { id: "nia", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
|
||||
orin: { id: "orin", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
|
||||
vale: { id: "vale", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
|
||||
},
|
||||
tankAura: { expiresAt: 0, radius: 3, damageReduction: 0.3 },
|
||||
nextEventId: 1,
|
||||
},
|
||||
mana: 100,
|
||||
maxMana: 100,
|
||||
selectedMemberId: "brann",
|
||||
cooldowns: { mend: 0, renew: 0, shield: 0, purify: 0, radiance: 0, barrier: 0 },
|
||||
globalCooldownUntil: 0,
|
||||
activeTab: "combat",
|
||||
selectedItemId: "",
|
||||
inventory: [],
|
||||
playerPosition: [0, 0],
|
||||
activeCast: null,
|
||||
barrier: { center: [0, 0], expiresAt: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("dual-screen game snapshots", () => {
|
||||
it("sends a full initial projection, then only semantic changes", () => {
|
||||
const initial = snapshot();
|
||||
expect(diffBottomGameSnapshot(undefined, initial)).toBe(initial);
|
||||
|
||||
const clonedWithoutChanges = structuredClone(initial);
|
||||
expect(diffBottomGameSnapshot(initial, clonedWithoutChanges)).toEqual({});
|
||||
|
||||
const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 };
|
||||
expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ time: 10.1, mana: 97 });
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { AppScreen } from "../frontend/types";
|
||||
import type { FrontendSnapshot } from "../frontend/store";
|
||||
import { useFrontendStore } from "../frontend/store";
|
||||
import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller";
|
||||
import { getGameSnapshot, type GameSnapshot, useGameStore } from "../game/store";
|
||||
import { type GameState, useGameStore } from "../game/store";
|
||||
import type { AbilityId, BottomTab, MemberId, RunBuffId } from "../game/types";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
|
||||
@@ -54,7 +54,7 @@ export type FrontendCommand =
|
||||
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
|
||||
|
||||
export type DualScreenMessage =
|
||||
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: GameSnapshot }
|
||||
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
|
||||
| { type: "controller-token"; id: string; event: ControllerTokenEvent }
|
||||
| { type: "controller-echo"; id: string; event: ControllerTokenEvent }
|
||||
| { type: "controller-motion"; movement: ControllerMovement }
|
||||
@@ -123,6 +123,93 @@ export function receiveAuthoritativeMessage(message: DualScreenMessage) {
|
||||
if (message.type === "frontend-command") executeFrontendCommand(message.command);
|
||||
}
|
||||
|
||||
export function currentGameSnapshot() {
|
||||
return getGameSnapshot();
|
||||
/** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */
|
||||
export type BottomGameSnapshot = Pick<GameState,
|
||||
| "bossId"
|
||||
| "paused"
|
||||
| "healerClassId"
|
||||
| "phase"
|
||||
| "round"
|
||||
| "time"
|
||||
| "party"
|
||||
| "boss"
|
||||
| "additionalBosses"
|
||||
| "partyPositions"
|
||||
| "bossMotion"
|
||||
| "partyCombat"
|
||||
| "mana"
|
||||
| "maxMana"
|
||||
| "selectedMemberId"
|
||||
| "cooldowns"
|
||||
| "globalCooldownUntil"
|
||||
| "activeTab"
|
||||
| "selectedItemId"
|
||||
| "inventory"
|
||||
| "playerPosition"
|
||||
| "activeCast"
|
||||
| "barrier"
|
||||
>;
|
||||
|
||||
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
|
||||
"bossId", "paused", "healerClassId", "phase", "round", "time", "party", "boss", "additionalBosses",
|
||||
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
|
||||
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
|
||||
];
|
||||
|
||||
function structurallyEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (!structurallyEqual(left[index], right[index])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const keys = Object.keys(leftRecord);
|
||||
if (keys.length !== Object.keys(rightRecord).length) return false;
|
||||
return keys.every((key) => Object.prototype.hasOwnProperty.call(rightRecord, key) && structurallyEqual(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
|
||||
export function currentBottomGameSnapshot(): BottomGameSnapshot {
|
||||
const state = useGameStore.getState();
|
||||
return {
|
||||
bossId: state.bossId,
|
||||
paused: state.paused,
|
||||
healerClassId: state.healerClassId,
|
||||
phase: state.phase,
|
||||
round: state.round,
|
||||
time: state.time,
|
||||
party: state.party,
|
||||
boss: state.boss,
|
||||
additionalBosses: state.additionalBosses,
|
||||
partyPositions: state.partyPositions,
|
||||
bossMotion: state.bossMotion,
|
||||
partyCombat: state.partyCombat,
|
||||
mana: state.mana,
|
||||
maxMana: state.maxMana,
|
||||
selectedMemberId: state.selectedMemberId,
|
||||
cooldowns: state.cooldowns,
|
||||
globalCooldownUntil: state.globalCooldownUntil,
|
||||
activeTab: state.activeTab,
|
||||
selectedItemId: state.selectedItemId,
|
||||
inventory: state.inventory,
|
||||
playerPosition: state.playerPosition,
|
||||
activeCast: state.activeCast,
|
||||
barrier: state.barrier,
|
||||
};
|
||||
}
|
||||
|
||||
export function diffBottomGameSnapshot(
|
||||
previous: BottomGameSnapshot | undefined,
|
||||
next: BottomGameSnapshot,
|
||||
): Partial<BottomGameSnapshot> {
|
||||
if (!previous) return next;
|
||||
const changes: Partial<BottomGameSnapshot> = {};
|
||||
for (const key of BOTTOM_GAME_SNAPSHOT_KEYS) {
|
||||
if (!structurallyEqual(previous[key], next[key])) Object.assign(changes, { [key]: next[key] });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import { useEffect } from "react";
|
||||
import { emitControllerToken, setExternalControllerMovement, subscribeControllerToken } from "../input/controller";
|
||||
import { getFrontendSnapshot, useFrontendStore } from "../frontend/store";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { createDualScreenChannel, currentGameSnapshot, receiveAuthoritativeMessage, type DualScreenMessage } from "./dualScreenSync";
|
||||
import {
|
||||
createDualScreenChannel,
|
||||
currentBottomGameSnapshot,
|
||||
diffBottomGameSnapshot,
|
||||
receiveAuthoritativeMessage,
|
||||
type BottomGameSnapshot,
|
||||
type DualScreenMessage,
|
||||
} from "./dualScreenSync";
|
||||
import { forceBothThorDisplays, listenForDisplayDisconnect, shouldOwnNativeDisplays } from "./nativeDualScreen";
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
|
||||
@@ -38,25 +45,30 @@ export function useAuthoritativeDualScreenSync() {
|
||||
let companionReady = false;
|
||||
let receivingRelayedToken = false;
|
||||
let topTokenSequence = 0;
|
||||
let lastPublishedGame: BottomGameSnapshot | undefined;
|
||||
const publish = (includeFrontend: boolean) => {
|
||||
const hunter = latest.activeSlotId
|
||||
? latest.slots.find((slot) => slot.id === latest.activeSlotId)?.local ?? null
|
||||
: null;
|
||||
const snapshot = latest.screen === "game" ? currentBottomGameSnapshot() : undefined;
|
||||
const game = snapshot ? diffBottomGameSnapshot(lastPublishedGame, snapshot) : undefined;
|
||||
lastPublishedGame = snapshot;
|
||||
channel.postMessage({
|
||||
type: "app-state",
|
||||
screen: latest.screen,
|
||||
hunterName: hunter?.hunterName ?? null,
|
||||
notice: latest.notice,
|
||||
frontend: includeFrontend ? getFrontendSnapshot() : undefined,
|
||||
game: latest.screen === "game" ? currentGameSnapshot() : undefined,
|
||||
game,
|
||||
} satisfies DualScreenMessage);
|
||||
};
|
||||
// Player motion and the simulation can update the same store several times per
|
||||
// frame interval. One 10 Hz companion snapshot is enough for tactical UI and
|
||||
// avoids repeatedly structured-cloning the full game state across displays.
|
||||
// Player motion and simulation can update the same store several times per
|
||||
// frame interval. The lower display receives a 10 Hz tactical projection and
|
||||
// only fields that changed semantically since the last publication.
|
||||
const gamePublisher = createRateLimitedPublisher(() => publish(false), GAME_SYNC_INTERVAL_MS);
|
||||
const unsubscribeFrontend = useFrontendStore.subscribe((state) => {
|
||||
latest = state;
|
||||
if (state.screen !== "game") lastPublishedGame = undefined;
|
||||
if (companionReady) publish(true);
|
||||
});
|
||||
const unsubscribeGame = useGameStore.subscribe(() => {
|
||||
@@ -74,9 +86,11 @@ export function useAuthoritativeDualScreenSync() {
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "companion-ready") {
|
||||
companionReady = true;
|
||||
lastPublishedGame = undefined;
|
||||
publish(true);
|
||||
} else if (event.data.type === "companion-closing") {
|
||||
companionReady = false;
|
||||
lastPublishedGame = undefined;
|
||||
gamePublisher.cancel();
|
||||
setExternalControllerMovement({ x: 0, y: 0 });
|
||||
} else if (event.data.type === "controller-token") {
|
||||
|
||||
+17
-3
@@ -200,6 +200,12 @@ button:focus-visible {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.scene-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 42%, #17241f, #07110f 68%);
|
||||
}
|
||||
|
||||
.top-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1897,8 +1903,12 @@ button:focus-visible {
|
||||
.mode-hero p { margin: 0; color: #98aca4; font-size: 13px; line-height: 1.45; }
|
||||
.mode-hero > b { display: block; margin-top: 15px; color: #cbd9d4; font-size: 10px; font-weight: 600; }
|
||||
.boss-picker { position: absolute; top: 84px; right: 38px; left: 38px; display: grid; gap: 4px; }
|
||||
.boss-picker > span { color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||
.boss-choice-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; }
|
||||
.boss-picker-heading { min-height: 18px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.boss-picker-heading > span { color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||
.boss-picker-heading > div { display: flex; gap: 4px; }
|
||||
.boss-picker-heading button { min-width: 72px; padding: 2px 6px; border: 1px solid var(--line); color: #a9bbb4; background: rgba(6,18,16,0.82); font-size: 7px; text-transform: uppercase; }
|
||||
.boss-picker-heading button:disabled { opacity: 0.32; }
|
||||
.boss-choice-grid { display: grid; grid-template-columns: repeat(var(--boss-grid-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; }
|
||||
.boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; }
|
||||
.boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; }
|
||||
.boss-choice > span { display: grid; min-width: 0; }
|
||||
@@ -2049,7 +2059,9 @@ button:focus-visible {
|
||||
.mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; }
|
||||
.boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; }
|
||||
.boss-choice-grid { grid-template-rows: repeat(var(--boss-grid-rows), minmax(27px, auto)); gap: 3px; }
|
||||
.boss-picker > span { font-size: 4px; }
|
||||
.boss-picker-heading { min-height: 11px; }
|
||||
.boss-picker-heading > span { font-size: 4px; }
|
||||
.boss-picker-heading button { min-width: 42px; padding: 1px 3px; font-size: 4px; }
|
||||
.boss-choice { min-height: 27px; grid-template-columns: 18px 1fr 9px; gap: 4px; padding: 3px 4px; }
|
||||
.boss-choice > i { width: 16px; height: 16px; font-size: 7px; }
|
||||
.boss-choice strong { font-size: 6px; }
|
||||
@@ -2077,6 +2089,8 @@ button:focus-visible {
|
||||
.difficulty-picker button strong { font-size: 8px; }
|
||||
.difficulty-picker button small { color: #71867e; font-size: 6px; }
|
||||
.difficulty-picker button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(96,76,27,.22); }
|
||||
.mode-dungeons .difficulty-picker { bottom: 75px; }
|
||||
.mode-dungeons .mode-launch { bottom: 68px; }
|
||||
.mode-loot-preview { position: absolute; right: 5.5%; bottom: 89px; left: 5.5%; display: grid; padding: 9px 12px; border-left: 2px solid var(--gold); background: rgba(69,55,19,.14); }
|
||||
.mode-loot-preview span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.mode-loot-preview b { font-size: 10px; }
|
||||
|
||||
Reference in New Issue
Block a user