Release v0.1.3 2026-07-11

This commit is contained in:
Warren H
2026-07-11 23:23:02 -04:00
parent 076f6cf97c
commit b48b3a4f8f
103 changed files with 6708 additions and 454 deletions
+393 -92
View File
@@ -4,20 +4,29 @@ import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "rea
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 {
isActorAnimationOneShot,
shouldStartActorAnimation,
type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
import { useGameStore } from "../game/store";
import type { MemberId, PulseKind } from "../game/types";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
const SPIDER_TEXTURE_URLS: Record<string, string> = {
"Spinnen_Bein_tex_2.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_2.jpg", import.meta.url).href,
"SH3.png": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/SH3.png", import.meta.url).href,
"Spinnen_Bein_tex_COLOR_.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_COLOR_.jpg", import.meta.url).href,
"haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href,
};
const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
const EMBER_MANTIS_URL = new URL("../../game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.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 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,
@@ -54,6 +63,14 @@ const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
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"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
@@ -72,15 +89,6 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
}
const configureSpiderLoader: NonNullable<Parameters<typeof useGLTF>[3]> = (loader) => {
loader.manager.setURLModifier((url) => {
const fileName = url.slice(url.lastIndexOf("/") + 1);
return SPIDER_TEXTURE_URLS[fileName] ?? url;
});
};
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
const PARTY_WEAPON_GRIPS: Record<MemberId, { right: WeaponGrip; left?: WeaponGrip }> = {
@@ -141,9 +149,11 @@ function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" |
function PartyCharacterModel({
memberId,
animationState,
animationTrigger,
}: {
memberId: MemberId;
animationState: MutableRefObject<ActorAnimationState>;
animationTrigger: MutableRefObject<number>;
}) {
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
@@ -163,6 +173,8 @@ function PartyCharacterModel({
);
const { actions } = useAnimations(gltf.animations, actorScene);
const activeClip = useRef<string | undefined>(undefined);
const activeState = useRef<ActorAnimationState | undefined>(undefined);
const activeTrigger = useRef(Number.NaN);
useEffect(() => {
actorScene.traverse((object) => {
@@ -189,6 +201,7 @@ function PartyCharacterModel({
useFrame(() => {
const state = animationState.current;
const trigger = animationTrigger.current;
const clipName = state === "death"
? "Death_A"
: state === "hit"
@@ -202,20 +215,24 @@ function PartyCharacterModel({
: state === "attack"
? PARTY_ATTACK_CLIPS[memberId]
: "Idle";
if (activeClip.current === clipName) return;
if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return;
const next = actions[clipName];
if (!next) return;
if (activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1).fadeIn(0.16);
if (state === "death" || state === "hit" || state === "attack" || state === "cast") {
const clipChanged = activeClip.current !== clipName;
if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1);
if (clipChanged) next.fadeIn(0.16);
if (isActorAnimationOneShot(state)) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = state === "death";
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
next.clampWhenFinished = false;
}
next.play();
activeClip.current = clipName;
activeState.current = state;
activeTrigger.current = trigger;
});
return (
@@ -270,6 +287,7 @@ function Arena() {
<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]} />
@@ -277,9 +295,32 @@ function Arena() {
);
}
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>
);
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
useEffect(() => {
const start = useGameStore.getState().partyPositions[memberId];
group.current?.position.set(start[0], 0.025, start[1]);
@@ -303,6 +344,13 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
const attacking = state.phase === "combat"
&& visualAction !== null
&& visualAction.endsAt > state.time;
animationTrigger.current = member.hp <= 0
? 0
: knocked
? member.knockedUntil
: attacking
? visualAction.startedAt
: 0;
animationState.current = member.hp <= 0
? "death"
: knocked
@@ -328,7 +376,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} animationState={animationState} />
<PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -342,6 +390,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
function PlayerCharacter() {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
const keys = useRef(new Set<string>());
const scenePulse = useGameStore((state) => state.scenePulse);
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
@@ -349,6 +398,7 @@ function PlayerCharacter() {
const { camera } = useThree();
const broadcastTimer = useRef(0);
const castingUntil = useRef(0);
const instantCastTrigger = useRef(0);
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
@@ -359,6 +409,7 @@ function PlayerCharacter() {
useEffect(() => {
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
castingUntil.current = performance.now() + 700;
instantCastTrigger.current = scenePulse.id;
}
}, [scenePulse]);
@@ -372,8 +423,9 @@ function PlayerCharacter() {
const nudgeX = Number(key === "d") - Number(key === "a");
const nudgeZ = Number(key === "s") - Number(key === "w");
if (!nudgeX && !nudgeZ) return;
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + nudgeX * 0.18, -7.2, 7.2);
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + nudgeZ * 0.18, -4.8, 7.2);
const next = clampToArena([group.current.position.x + nudgeX * 0.18, group.current.position.z + nudgeZ * 0.18]);
group.current.position.x = next[0];
group.current.position.z = next[1];
setPlayerPosition([group.current.position.x, group.current.position.z]);
};
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
@@ -401,9 +453,10 @@ function PlayerCharacter() {
}
const length = Math.hypot(inputX, inputZ);
if (length > 0.05) {
const speed = 4.6 * delta / Math.max(1, length);
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + inputX * speed, -7.2, 7.2);
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + inputZ * speed, -4.8, 7.2);
const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
const next = clampToArena([group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]);
group.current.position.x = next[0];
group.current.position.z = next[1];
group.current.rotation.y = Math.atan2(inputX, inputZ);
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
const boss = targetBossMotion(state).position;
@@ -414,6 +467,16 @@ function PlayerCharacter() {
);
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
}
const instantCasting = performance.now() < castingUntil.current;
animationTrigger.current = player.hp <= 0
? 0
: knocked
? player.knockedUntil
: state.activeCast
? state.activeCast.startedAt
: instantCasting
? instantCastTrigger.current
: 0;
animationState.current = player.hp <= 0
? "death"
: knocked
@@ -422,7 +485,7 @@ function PlayerCharacter() {
? "cast"
: length > 0.05
? "run"
: performance.now() < castingUntil.current
: instantCasting
? "cast"
: "idle";
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
@@ -440,7 +503,7 @@ function PlayerCharacter() {
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId="aelia" animationState={animationState} />
<PartyCharacterModel memberId="aelia" animationState={animationState} animationTrigger={animationTrigger} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -453,16 +516,15 @@ function PlayerCharacter() {
}
function Party() {
const party = useGameStore((state) => state.party);
const selected = useGameStore((state) => state.selectedMemberId);
return (
<>
<PlayerCharacter />
{party.slice(1).map((member) => (
{PARTY_MEMBER_IDS.map((memberId) => (
<Character
key={member.id}
memberId={member.id as Exclude<MemberId, "aelia">}
selected={selected === member.id}
key={memberId}
memberId={memberId}
selected={selected === memberId}
/>
))}
</>
@@ -492,7 +554,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 === "ember-mantis-duelist" ? "#a42d18" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
<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} />
</mesh>
);
}
@@ -581,44 +643,217 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
);
}
type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist";
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
const ALTERNATE_BOSS_CONFIG = {
vexa: {
url: SPIDER_URL,
scale: 0.022,
idle: "Spider_Armature|warte_pose",
move: "Spider_Armature|run_ani_vor",
attack: "Spider_Armature|Attack",
special: "Spider_Armature|Jump",
death: "Spider_Armature|die",
url: INSECT_QUEEN_URL,
scale: 9,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#bb67ff",
rotationOffset: Math.PI,
rotationOffset: 0,
prototype: true,
},
cindermaw: {
url: DRAGON_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Headbutt",
special: "Punch",
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: EMBER_MANTIS_URL,
scale: 0.78,
url: GATE_GUARDIAN_URL,
scale: 4.3,
idle: "Idle",
move: "Sidestep",
attack: "LineSlash",
special: "CrossSlash",
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,
},
"sandglass-scorpion": {
url: SANDGLASS_URL,
scale: 0.7,
idle: "Idle",
move: "Burrow",
attack: "Eruption",
special: "Hourglass",
death: "Death",
light: "#e9b94f",
rotationOffset: 0,
},
"cragclaw-crab": {
url: CRAGCLAW_URL,
scale: 1.2,
idle: "Idle",
move: "Walk",
attack: "Bite_Front",
special: "Bite_InPlace",
death: "Death",
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,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#9d72ff",
rotationOffset: 0,
},
"crownshard-golem": {
url: CROWNSHARD_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#e0bd45",
rotationOffset: 0,
},
} as const;
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
] as const;
const PROTOTYPE_ATTACK_MODES = [
"tethering",
"venom_cast",
"breath_telegraph",
"breath_sweeping",
"mantis_line_telegraph",
"mantis_cross_telegraph",
"ram_charge_telegraph",
"ram_quake",
"ram_shatter",
"cinderback_curl",
"cinderback_slam",
"ghost_soul_cross",
"ghost_soul_cross_followup",
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
] 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 ((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;
if (motionMode === "sandglass_hourglass") return config.special;
if (motionMode === "sandglass_recover") return "Stagger";
}
if (kind === "cragclaw-crab") {
if (motionMode === "crab_scuttling") return config.move;
if (motionMode === "crab_scuttle_telegraph") return config.attack;
if (motionMode === "crab_tidal_burst") return config.special;
}
if (kind === "mournveil-ghost") {
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
if (motionMode === "ghost_haunting") return config.special;
}
if (kind === "crownshard-golem") {
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 === "vexa" && (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 phase = useGameStore((state) => state.phase);
@@ -626,7 +861,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
const gltf = useGLTF(config.url, false, true);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
@@ -638,31 +873,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
object.receiveShadow = true;
}
});
if (kind === "vexa") {
const authoredHelperBox = model.getObjectByName("Box");
if (authoredHelperBox) authoredHelperBox.visible = false;
}
}, [kind, model]);
const clipName = phase === "victory" || defeated
? config.death
: kind === "ember-mantis-duelist"
? motionMode === "mantis_sidestep"
? config.move
: motionMode === "mantis_line_telegraph"
? config.attack
: motionMode === "mantis_cross_telegraph"
? config.special
: motionMode === "mantis_recover"
? "Recover"
: config.idle
: motionMode === "skyfall"
? config.move
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
? config.special
: motionMode === "tethering" || motionMode === "venom_cast"
? config.attack
: config.idle;
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
useEffect(() => {
const next = actions[clipName];
@@ -674,8 +887,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
? 0.6
: 1;
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
const emberOneShot = kind === "ember-mantis-duelist" && clipName !== config.idle;
if (phase === "victory" || defeated || emberOneShot) {
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
if (phase === "victory" || defeated || authoredOneShot) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
@@ -691,7 +904,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
if (!current) return;
const motion = current.motion;
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
targetPosition.set(motion.position[0], airborne ? 3.2 : 0.03, motion.position[1]);
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 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));
let targetAngle = Math.atan2(
@@ -707,6 +922,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
)) {
const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
}
const difference = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
@@ -891,19 +1108,104 @@ function RangedProjectiles() {
function BossActor() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const primaryBossId = useGameStore((state) => state.boss.id);
const additionalBossIds = useGameStore((state) => state.additionalBosses.map((entry) => entry.boss.id).join("|"));
if (phase === "briefing") return null;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId];
return (
<>{bosses.map((boss, bossIndex) => (
<Suspense key={`${boss.id}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
{boss.id === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={boss.id} bossIndex={bossIndex} />}
<>{bossIds.map((bossId, bossIndex) => (
<Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
{bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
</Suspense>
))}</>
);
}
type PerformanceMemory = Performance & {
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
};
function percentile(sorted: readonly number[], ratio: number) {
if (!sorted.length) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
}
function PerformanceProbe() {
const { gl } = useThree();
const frameSamples = useRef<number[]>([]);
const longTaskCount = useRef(0);
const longTaskDuration = useRef(0);
const lastPublishAt = useRef(0);
useEffect(() => {
if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
let observer: PerformanceObserver | undefined;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTaskCount.current += 1;
longTaskDuration.current += entry.duration;
}
});
observer.observe({ type: "longtask", buffered: true });
} catch {
// Long Tasks API is optional on Android WebView implementations.
}
return () => {
observer?.disconnect();
delete document.documentElement.dataset.gamePerf;
};
}, []);
useFrame(({ clock }, delta) => {
if (!PERFORMANCE_PROBE_ENABLED) return;
const samples = frameSamples.current;
if (samples.length === 300) samples.shift();
samples.push(delta * 1000);
if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
lastPublishAt.current = clock.elapsedTime;
const sorted = [...samples].sort((left, right) => left - right);
let total = 0;
let overBudget = 0;
for (const duration of samples) {
total += duration;
if (duration > 16.67) overBudget += 1;
}
const memory = performance as PerformanceMemory;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
let transferredBytes = 0;
let decodedBytes = 0;
for (const resource of resources) {
transferredBytes += resource.transferSize;
decodedBytes += resource.decodedBodySize;
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: {
averageMs: total / samples.length,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
overBudget,
samples: samples.length,
},
renderer: {
calls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
usedJSHeapSize: memory.memory.usedJSHeapSize,
totalJSHeapSize: memory.memory.totalJSHeapSize,
jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
} : null,
resources: { transferredBytes, decodedBytes, count: resources.length },
longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
});
});
return null;
}
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
@@ -956,12 +1258,11 @@ export function GameScene() {
<BossActor />
<RangedProjectiles />
<CombatFx />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
</Canvas>
);
}
useGLTF.preload(BULL_URL, false, true);
useGLTF.preload(EMBER_MANTIS_URL, false, true);
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
useGLTF.preload(loadout.right, false, true);