Release v0.1.4 2026-07-12

This commit is contained in:
Warren H
2026-07-12 13:49:46 -04:00
parent b48b3a4f8f
commit bef71d391a
803 changed files with 3800 additions and 358322 deletions
+300 -253
View File
@@ -1,53 +1,79 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
import { useGameStore } from "../game/store";
import type { MemberId, PulseKind } from "../game/types";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "bulldrome"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
>, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
"warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href,
"tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href,
"broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href,
"sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href,
"razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href,
"bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href,
"moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href,
"frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href,
"rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href,
};
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
nia: new URL("../../game_assets/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../../game_assets/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../../game_assets/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
};
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
aelia: { right: new URL("../../game_assets/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
brann: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
},
nia: { right: new URL("../../game_assets/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
orin: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
},
vale: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
},
};
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
@@ -58,19 +84,8 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
orin: "Spellcast_Shoot",
vale: "Dualwield_Melee_Attack_Chop",
};
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
});
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
const angle = (index / 16) * Math.PI * 2;
return {
angle,
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
};
});
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
@@ -244,77 +259,89 @@ function PartyCharacterModel({
);
}
function Arena() {
const pillarInstances = useRef<THREE.InstancedMesh>(null);
const torchInstances = useRef<THREE.InstancedMesh>(null);
const GAMEPLAY_FRAME_INTERVAL_MS = 1000 / 60;
const BACKGROUND_FRAME_INTERVAL_MS = 1000 / 30;
const FRAME_INTERVAL_JITTER_MS = 1.5;
const SIMULATION_STEP_SECONDS = 0.1;
const MAX_SIMULATION_STEPS_PER_FRAME = 3;
const MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25;
/**
* Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains
* in the store and is advanced at its existing fixed 10 Hz cadence.
*/
function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) {
const { advance } = useThree();
const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused);
const frameId = useRef<number | null>(null);
const lastRenderedAt = useRef<number | null>(null);
const simulationAccumulator = useRef(0);
const slowFrameMs = useRef(0);
const stableFrameMs = useRef(0);
const dprRef = useRef(dpr);
dprRef.current = dpr;
useEffect(() => {
const pillars = pillarInstances.current;
const torches = torchInstances.current;
if (!pillars || !torches) return;
const matrix = new THREE.Matrix4();
ARENA_COLUMNS.forEach(([x, z], index) => {
matrix.makeTranslation(x, 1.1, z - 1);
pillars.setMatrixAt(index, matrix);
matrix.makeTranslation(x, 2.6, z - 1);
torches.setMatrixAt(index, matrix);
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
});
pillars.instanceMatrix.needsUpdate = true;
torches.instanceMatrix.needsUpdate = true;
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
}, []);
const schedule = (now: number) => {
const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS;
const previous = lastRenderedAt.current;
if (previous === null) {
lastRenderedAt.current = now;
advance(now / 1000, true);
} else {
const elapsedMs = now - previous;
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) {
lastRenderedAt.current = now;
const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25);
return (
<group>
<mesh position={[0, -0.45, -1]} receiveShadow>
<cylinderGeometry args={[10.5, 11.2, 0.8, 48]} />
<meshStandardMaterial color="#182420" roughness={0.92} />
</mesh>
<mesh position={[0, -0.015, -1]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<ringGeometry args={[4.8, 5.05, 64]} />
<meshBasicMaterial color="#765c32" transparent opacity={0.55} />
</mesh>
<mesh position={[0, -0.01, -1]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[2.1, 48]} />
<meshStandardMaterial color="#27342d" roughness={1} />
</mesh>
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
<meshStandardMaterial color="#26342f" roughness={0.8} />
</instancedMesh>
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
<octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial />
</instancedMesh>
<ArenaWalls />
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
</group>
);
}
if (gameplayActive) {
simulationAccumulator.current += elapsedSeconds;
let steps = 0;
while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) {
const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
simulationAccumulator.current -= SIMULATION_STEP_SECONDS;
steps += 1;
}
function ArenaWalls() {
const walls = useRef<THREE.Group>(null);
useFrame(({ camera }) => {
if (!walls.current) return;
for (const child of walls.current.children) {
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
}
});
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
<boxGeometry args={[3.86, 2.3, 0.18]} />
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
</mesh>
))}
</group>
);
if (elapsedMs > 20) {
slowFrameMs.current += elapsedMs;
stableFrameMs.current = 0;
if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
slowFrameMs.current = 0;
}
} else {
slowFrameMs.current = 0;
stableFrameMs.current += elapsedMs;
if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
stableFrameMs.current = 0;
}
}
} else {
simulationAccumulator.current = 0;
slowFrameMs.current = 0;
stableFrameMs.current = 0;
}
advance(now / 1000, true);
}
}
frameId.current = requestAnimationFrame(schedule);
};
frameId.current = requestAnimationFrame(schedule);
return () => {
if (frameId.current !== null) cancelAnimationFrame(frameId.current);
frameId.current = null;
lastRenderedAt.current = null;
simulationAccumulator.current = 0;
};
}, [advance, gameplayActive, onDprChange]);
return null;
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
@@ -517,25 +544,35 @@ function PlayerCharacter() {
function Party() {
const selected = useGameStore((state) => state.selectedMemberId);
const [loadSupportModels, setLoadSupportModels] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => setLoadSupportModels(true), 750);
return () => window.clearTimeout(timer);
}, []);
return (
<>
<PlayerCharacter />
{PARTY_MEMBER_IDS.map((memberId) => (
<Character
key={memberId}
memberId={memberId}
selected={selected === memberId}
/>
))}
<Suspense fallback={<PartyFallback memberIds={CRITICAL_PARTY_MEMBER_IDS} />}>
<PlayerCharacter />
<Character memberId="brann" selected={selected === "brann"} />
</Suspense>
{loadSupportModels ? (
<Suspense fallback={<PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}>
{SUPPORT_PARTY_MEMBER_IDS.map((memberId) => (
<Character key={memberId} memberId={memberId} selected={selected === memberId} />
))}
</Suspense>
) : <PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}
</>
);
}
function PartyFallback() {
function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
const positions = useGameStore((state) => state.partyPositions);
return (
<>
{(Object.keys(positions) as MemberId[]).map((memberId) => (
{memberIds.map((memberId) => (
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
<meshStandardMaterial color="#79998c" roughness={0.8} />
@@ -554,7 +591,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
);
}
@@ -625,8 +662,8 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
} else if (motion.mode === "returning") {
facingX = state.partyPositions.brann[0] + motion.formationOffsetX - motion.position[0];
facingZ = state.partyPositions.brann[1] - 4.25 - motion.position[1];
facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0];
facingZ = ARENA_CENTER[1] - motion.position[1];
}
if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ);
@@ -645,67 +682,21 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
const ALTERNATE_BOSS_CONFIG = {
vexa: {
url: INSECT_QUEEN_URL,
scale: 9,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#bb67ff",
rotationOffset: 0,
prototype: true,
},
cindermaw: {
url: BLUE_EYES_WHITE_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8742",
rotationOffset: 0,
prototype: true,
},
"ember-mantis-duelist": {
url: GATE_GUARDIAN_URL,
scale: 4.3,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff5a24",
rotationOffset: 0,
prototype: true,
},
"obsidian-ram-golem": {
url: GANDORA_URL,
scale: 6.2,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff7438",
rotationOffset: 0,
prototype: true,
},
"cinderback-ricochet": {
url: RED_EYES_BLACK_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8b3d",
rotationOffset: 0,
prototype: true,
},
interface AlternateBossConfig {
url: string;
scale: number;
idle: string;
move: string;
attack: string;
special: string;
death: string;
light: string;
rotationOffset: number;
prototype?: boolean;
floating?: boolean;
}
const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
"sandglass-scorpion": {
url: SANDGLASS_URL,
scale: 0.7,
@@ -728,30 +719,6 @@ const ALTERNATE_BOSS_CONFIG = {
light: "#49d5df",
rotationOffset: 0,
},
"pumpking-king-of-ghosts": {
url: PUMPKING_URL,
scale: 1.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#d87842",
rotationOffset: 0,
prototype: true,
},
"blue-eyes-ultimate-dragon": {
url: BLUE_EYES_ULTIMATE_URL,
scale: 4.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#8fc8ff",
rotationOffset: 0,
prototype: true,
},
"mournveil-ghost": {
url: MOURNVEIL_URL,
scale: 1.1,
@@ -762,6 +729,7 @@ const ALTERNATE_BOSS_CONFIG = {
death: "Death",
light: "#9d72ff",
rotationOffset: 0,
floating: true,
},
"crownshard-golem": {
url: CROWNSHARD_URL,
@@ -773,14 +741,94 @@ const ALTERNATE_BOSS_CONFIG = {
death: "Death",
light: "#e0bd45",
rotationOffset: 0,
floating: true,
},
} as const;
"crystal-bat-matriarch": {
url: CRYSTAL_BAT_MATRIARCH_URL,
scale: 1.04,
idle: "Idle",
move: "Swoop",
attack: "SonicPulse",
special: "MirrorShatter",
death: "Death",
light: "#8eeaff",
rotationOffset: 0,
floating: true,
},
"stormwool-alpaca": {
url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0, prototype: true,
},
"cluckhorn-colossus": {
url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0, prototype: true,
},
"ashwing-demon": {
url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, prototype: true, floating: true,
},
"riftclaw-demon": {
url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0, prototype: true,
},
"tempestscale-dragon": {
url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, prototype: true, floating: true,
},
emberfox: {
url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0, prototype: true,
},
"mirelord-frog": {
url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0, prototype: true,
},
"stonebreaker-giant": {
url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0, prototype: true,
},
"glub-sovereign": {
url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, prototype: true, floating: true,
},
"scrapking-goblin": {
url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0, prototype: true,
},
"warcaller-orc": {
url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0, prototype: true,
},
"tuskmaw-orc": {
url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0, prototype: true,
},
"broodfang-spider": {
url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0, prototype: true,
},
"silkfang-spider": {
url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0, prototype: true,
},
"thorncrown-stag": {
url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0, prototype: true,
},
"sky-totem": {
url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, prototype: true, floating: true,
},
"razorcrest-raptor": {
url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0, prototype: true,
},
"bristlequake-boar": {
url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0, prototype: true,
},
"moonfang-wolf": {
url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0, prototype: true,
},
"frostmaw-yeti": {
url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0, prototype: true,
},
"rimeclaw-yeti": {
url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0, prototype: true,
},
};
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
"charging",
"returning",
"sandglass_burrowing",
"crab_scuttling",
] as const;
const PROTOTYPE_ATTACK_MODES = [
@@ -800,33 +848,23 @@ const PROTOTYPE_ATTACK_MODES = [
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
"telegraph",
"stacking",
"pouncing",
"sandglass_burrow_telegraph",
"sandglass_eruption",
"sandglass_hourglass",
"crab_scuttle_telegraph",
"crab_tidal_burst",
] as const;
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
if ("prototype" in config && config.prototype) {
if (config.prototype) {
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
return config.idle;
}
if (kind === "ember-mantis-duelist") {
if (motionMode === "mantis_sidestep") return config.move;
if (motionMode === "mantis_line_telegraph") return config.attack;
if (motionMode === "mantis_cross_telegraph") return config.special;
if (motionMode === "mantis_recover") return "Recover";
}
if (kind === "obsidian-ram-golem") {
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
if (motionMode === "ram_quake") return config.special;
if (motionMode === "ram_shatter") return "ArmorShatter";
if (motionMode === "ram_recover") return "Stagger";
}
if (kind === "cinderback-ricochet") {
if (motionMode === "cinderback_curl") return config.attack;
if (motionMode === "cinderback_ricochet") return config.move;
if (motionMode === "cinderback_slam") return config.special;
if (motionMode === "cinderback_recover") return "Recover";
}
if (kind === "sandglass-scorpion") {
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
if (motionMode === "sandglass_eruption") return config.attack;
@@ -846,16 +884,18 @@ function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeo
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
}
if (kind === "cindermaw") {
if (motionMode === "skyfall") return config.move;
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
if (kind === "crystal-bat-matriarch") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
if (motionMode === "golem_recover") return "Stagger";
}
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
return config.idle;
}
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
@@ -881,13 +921,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16);
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
const timeScale = archetype === "duelist" && motionMode === "mantis_line_telegraph"
? 0.55
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
: archetype === "duelist" && motionMode === "mantis_cross_telegraph"
? 0.6
: 1;
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
const authoredOneShot = ![config.idle, config.move].includes(clipName);
if (phase === "victory" || defeated || authoredOneShot) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
@@ -895,7 +935,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
}, [actions, archetype, clipName, config.idle, config.move, defeated, motionMode, phase]);
useFrame((_, delta) => {
if (!group.current) return;
@@ -903,9 +943,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const current = encounterBossAt(state, bossIndex);
if (!current) return;
const motion = current.motion;
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
const burrowed = archetype === "burrower" && motion.mode === "sandglass_burrowing";
const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
@@ -913,9 +953,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
state.partyPositions.brann[0] - motion.position[0],
state.partyPositions.brann[1] - motion.position[1],
);
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (kind === "ember-mantis-duelist" && (
} else if (archetype === "duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
@@ -1181,6 +1221,8 @@ function PerformanceProbe() {
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: {
targetFps: 60,
budgetMs: 16.67,
averageMs: total / samples.length,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
@@ -1192,6 +1234,9 @@ function PerformanceProbe() {
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
pixelRatio: gl.getPixelRatio(),
renderWidth: gl.domElement.width,
renderHeight: gl.domElement.height,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
@@ -1237,24 +1282,25 @@ function CombatFx() {
}
export function GameScene() {
const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
const setRenderDpr = useCallback((next: number) => {
setDpr((current) => Math.abs(current - next) < 0.001 ? current : next);
}, []);
return (
<Canvas
shadows
dpr={[1, 1.5]}
frameloop="never"
shadows="basic"
dpr={dpr}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ antialias: true, powerPreference: "high-performance" }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
>
<color attach="background" args={["#07110f"]} />
<fog attach="fog" args={["#07110f", 17, 32]} />
<hemisphereLight args={["#8ac4b5", "#15100b", 1.25]} />
<directionalLight castShadow position={[5, 10, 8]} intensity={2.2} color="#ffe2a9" shadow-mapSize={[1024, 1024]} />
<Arena />
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
<BossRoom />
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Suspense fallback={<PartyFallback />}>
<Party />
</Suspense>
<Party />
<BossActor />
<RangedProjectiles />
<CombatFx />
@@ -1263,8 +1309,9 @@ export function GameScene() {
);
}
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
const loadout = PARTY_WEAPON_URLS[memberId];
useGLTF.preload(loadout.right, false, true);
if (loadout.left) useGLTF.preload(loadout.left, false, true);
}