Files
i-want-to-heal-mmo/src/components/GameScene.tsx
T
2026-07-11 00:12:57 -04:00

970 lines
39 KiB
TypeScript

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 * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
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 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,
};
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 },
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,
},
nia: { right: new URL("../../game_assets/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,
},
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,
},
};
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
aelia: "2H_Melee_Attack_Chop",
brann: "1H_Melee_Attack_Chop",
nia: "2H_Ranged_Shoot",
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;
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0
? { boss: state.boss, motion: state.bossMotion }
: state.additionalBosses[bossIndex - 1];
}
function targetBossMotion(state: GameStoreState) {
if (state.boss.hp > 0) return state.bossMotion;
return state.additionalBosses.find((entry) => entry.boss.hp > 0)?.motion ?? state.bossMotion;
}
function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) {
if (!instanceId || instanceId === `boss-0-${state.boss.id}`) return targetBossMotion(state);
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 }> = {
aelia: { right: "staff" },
brann: { right: "sword", left: "prop" },
nia: { right: "crossbow" },
orin: { right: "wand", left: "prop" },
vale: { right: "dagger", left: "dagger" },
};
const VARIANT_GRIPS: Record<Exclude<WeaponGrip, "crossbow" | "prop">, { lift: number; maxHeight: number }> = {
sword: { lift: 0.04, maxHeight: 2 },
dagger: { lift: 0.04, maxHeight: 1.4 },
staff: { lift: 0.18, maxHeight: 2.4 },
wand: { lift: 0.04, maxHeight: 1.2 },
};
function resolveRigNode(root: THREE.Object3D, authoredName: string) {
return root.getObjectByName(authoredName)
?? root.getObjectByName(authoredName.replace(/[[\].:/]/g, ""));
}
function flattenWeaponScene(scene: THREE.Object3D) {
if (scene.children.length !== 1) return scene;
const holder = new THREE.Group();
const child = scene.children[0];
holder.scale.copy(child.scale);
child.position.set(0, 0, 0);
child.rotation.set(0, 0, 0);
child.scale.set(1, 1, 1);
scene.remove(child);
holder.add(child);
return holder;
}
function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" | "l") {
// Shields and spellbooks carry useful authored offsets, so keep their scene transform.
if (grip === "prop") return scene;
const weapon = flattenWeaponScene(scene);
if (grip === "crossbow") {
weapon.position.set(0.3381, 0.058, 0);
weapon.quaternion.set(0, 0.7071068, 0, 0.7071067);
weapon.scale.setScalar(0.7204);
return weapon;
}
const { lift, maxHeight } = VARIANT_GRIPS[grip];
const bounds = new THREE.Box3().setFromObject(weapon);
const height = bounds.max.y - bounds.min.y;
const scale = height > 0.001 ? Math.min(1, maxHeight / height) : 1;
weapon.position.set(0, lift, 0);
weapon.quaternion.set(0, side === "l" ? 0 : 1, 0, side === "l" ? 1 : 0);
weapon.scale.setScalar(scale);
return weapon;
}
function PartyCharacterModel({
memberId,
animationState,
}: {
memberId: MemberId;
animationState: MutableRefObject<ActorAnimationState>;
}) {
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const loadout = PARTY_WEAPON_URLS[memberId];
const grips = PARTY_WEAPON_GRIPS[memberId];
const rightWeapon = useGLTF(loadout.right, false, true);
const leftWeapon = useGLTF(loadout.left ?? loadout.right, false, true);
const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
const rightWeaponScene = useMemo(
() => prepareHeldWeapon(rightWeapon.scene.clone(true), grips.right, "r"),
[grips.right, rightWeapon.scene],
);
const leftWeaponScene = useMemo(
() => loadout.left ? prepareHeldWeapon(leftWeapon.scene.clone(true), grips.left ?? grips.right, "l") : null,
[grips.left, grips.right, leftWeapon.scene, loadout.left],
);
const { actions } = useAnimations(gltf.animations, actorScene);
const activeClip = useRef<string | undefined>(undefined);
useEffect(() => {
actorScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [actorScene]);
useEffect(() => {
for (const weaponScene of [rightWeaponScene, leftWeaponScene]) {
if (!weaponScene) continue;
weaponScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
// Animated hand sockets can cross a mesh's original root-space frustum.
object.frustumCulled = false;
}
});
}
}, [leftWeaponScene, rightWeaponScene]);
useFrame(() => {
const state = animationState.current;
const clipName = state === "death"
? "Death_A"
: state === "hit"
? "Hit_A"
: state === "run"
? "Running_A"
: state === "walk"
? "Walking_A"
: state === "cast"
? "Spellcasting"
: state === "attack"
? PARTY_ATTACK_CLIPS[memberId]
: "Idle";
if (activeClip.current === clipName) 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") {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = state === "death";
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
next.clampWhenFinished = false;
}
next.play();
activeClip.current = clipName;
});
return (
<>
<primitive object={actorScene} scale={PARTY_MODEL_SCALES[memberId]} />
{rightHandSlot && createPortal(<primitive object={rightWeaponScene} />, rightHandSlot)}
{leftWeaponScene && leftHandSlot && createPortal(<primitive object={leftWeaponScene} />, leftHandSlot)}
</>
);
}
function Arena() {
const pillarInstances = useRef<THREE.InstancedMesh>(null);
const torchInstances = useRef<THREE.InstancedMesh>(null);
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;
}, []);
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>
<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>
);
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
useEffect(() => {
const start = useGameStore.getState().partyPositions[memberId];
group.current?.position.set(start[0], 0.025, start[1]);
}, [memberId]);
useFrame((_, delta) => {
if (!group.current) return;
const state = useGameStore.getState();
const target = state.partyPositions[memberId];
const dx = target[0] - group.current.position.x;
const dz = target[1] - group.current.position.z;
const moving = Math.hypot(dx, dz) > 0.015;
const movementBlend = 1 - Math.pow(0.002, delta);
group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], movementBlend);
group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], movementBlend);
const member = state.party.find((entry) => entry.id === memberId)!;
const knocked = member.knockedUntil > state.time;
const visualAction = state.partyCombat.combatants[memberId].visualAction;
const targetMotion = targetBossMotionByInstance(state, visualAction?.targetInstanceId);
const attacking = state.phase === "combat"
&& visualAction !== null
&& visualAction.endsAt > state.time;
animationState.current = member.hp <= 0
? "death"
: knocked
? "hit"
: attacking
? "attack"
: moving
? memberId === "vale" ? "run" : "walk"
: "idle";
if (!knocked && member.hp > 0 && (state.phase === "combat" || moving)) {
const faceBoss = state.phase === "combat";
const facingX = faceBoss ? targetMotion.position[0] - group.current.position.x : dx;
const facingZ = faceBoss ? targetMotion.position[1] - group.current.position.z : dz;
const targetAngle = Math.atan2(facingX, facingZ);
const angleDelta = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
Math.cos(targetAngle - group.current.rotation.y),
);
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
}
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
});
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} animationState={animationState} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
</mesh>
)}
</group>
);
}
function PlayerCharacter() {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const keys = useRef(new Set<string>());
const scenePulse = useGameStore((state) => state.scenePulse);
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
const setPlayerPosition = useGameStore((state) => state.setPlayerPosition);
const { camera } = useThree();
const broadcastTimer = useRef(0);
const castingUntil = useRef(0);
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
const start = useGameStore.getState().partyPositions.aelia;
group.current?.position.set(start[0], 0.025, start[1]);
}, []);
useEffect(() => {
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
castingUntil.current = performance.now() + 700;
}
}, [scenePulse]);
useEffect(() => {
const down = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
keys.current.add(key);
if (event.repeat || !group.current) return;
const state = useGameStore.getState();
if (state.phase !== "combat" || state.paused || state.activeCast || state.party[0].hp <= 0 || state.party[0].knockedUntil > state.time) return;
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);
setPlayerPosition([group.current.position.x, group.current.position.z]);
};
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [setPlayerPosition]);
useFrame((_, delta) => {
if (!group.current) return;
let inputX = 0;
let inputZ = 0;
const state = useGameStore.getState();
const knocked = state.party[0].knockedUntil > state.time;
const player = state.party[0];
if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
const controller = getControllerMovement();
inputX += controller.x;
inputZ += controller.y;
}
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);
group.current.rotation.y = Math.atan2(inputX, inputZ);
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
const boss = targetBossMotion(state).position;
const targetAngle = Math.atan2(boss[0] - group.current.position.x, boss[1] - group.current.position.z);
const angleDelta = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
Math.cos(targetAngle - group.current.rotation.y),
);
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
}
animationState.current = player.hp <= 0
? "death"
: knocked
? "hit"
: state.activeCast
? "cast"
: length > 0.05
? "run"
: performance.now() < castingUntil.current
? "cast"
: "idle";
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
desiredCameraPosition.set(group.current.position.x * 0.45, 5.1, group.current.position.z + 7.7);
camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta));
camera.lookAt(group.current.position.x * 0.55, 0.65, group.current.position.z - 2.8);
broadcastTimer.current += delta;
if (broadcastTimer.current > 0.15) {
setPlayerPosition([group.current.position.x, group.current.position.z]);
broadcastTimer.current = 0;
}
});
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId="aelia" animationState={animationState} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
</mesh>
)}
<pointLight color="#f7d873" intensity={0.8} distance={2.5} position={[0.45, 1.12, 0]} />
</group>
);
}
function Party() {
const party = useGameStore((state) => state.party);
const selected = useGameStore((state) => state.selectedMemberId);
return (
<>
<PlayerCharacter />
{party.slice(1).map((member) => (
<Character
key={member.id}
memberId={member.id as Exclude<MemberId, "aelia">}
selected={selected === member.id}
/>
))}
</>
);
}
function PartyFallback() {
const positions = useGameStore((state) => state.partyPositions);
return (
<>
{(Object.keys(positions) as MemberId[]).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} />
</mesh>
))}
</>
);
}
function BossFallback({ bossIndex }: { bossIndex: number }) {
const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
if (!boss || !motion) return null;
const position = motion.position;
const bossId = boss.id;
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} />
</mesh>
);
}
function BullBoss({ bossIndex }: { bossIndex: number }) {
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);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const gltf = useGLTF(BULL_URL, false, true);
const bullScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, bullScene);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
bullScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [bullScene]);
const clipName = phase === "victory" || defeated
? "Death"
: motionMode === "telegraph"
? "Idle_Headlow"
: motionMode === "pouncing"
? "Gallop_Jump"
: motionMode === "charging" || motionMode === "returning"
? "Gallop"
: motionMode === "stacking"
? "Idle_Headlow"
: "Idle";
useEffect(() => {
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.18);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(motionMode === "charging" ? 1.3 : 1).fadeIn(0.18).play();
if (clipName === "Death") {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.18); };
}, [actions, clipName, motionMode]);
useFrame((_, delta) => {
if (!group.current) return;
const state = useGameStore.getState();
const current = encounterBossAt(state, bossIndex);
if (!current) return;
const motion = current.motion;
targetPosition.set(motion.position[0], 0.03, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (motion.mode === "stacking") {
group.current.rotation.y += (Math.PI * 2 / 5) * delta;
return;
}
let facingX = state.partyPositions.brann[0] - motion.position[0];
let facingZ = state.partyPositions.brann[1] - motion.position[1];
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
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];
}
if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ);
const difference = Math.atan2(Math.sin(targetAngle - group.current.rotation.y), Math.cos(targetAngle - group.current.rotation.y));
group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
}
});
if (phase === "briefing") return null;
return (
<group ref={group}>
<primitive object={bullScene} scale={0.81} />
<pointLight color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
</group>
);
}
type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist";
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",
light: "#bb67ff",
rotationOffset: Math.PI,
},
cindermaw: {
url: DRAGON_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Headbutt",
special: "Punch",
death: "Death",
light: "#ff8742",
rotationOffset: 0,
},
"ember-mantis-duelist": {
url: EMBER_MANTIS_URL,
scale: 0.78,
idle: "Idle",
move: "Sidestep",
attack: "LineSlash",
special: "CrossSlash",
death: "Death",
light: "#ff5a24",
rotationOffset: 0,
},
} as const;
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[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);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
model.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
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;
useEffect(() => {
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"
? 0.55
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
? 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) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
useFrame((_, delta) => {
if (!group.current) return;
const state = useGameStore.getState();
const current = encounterBossAt(state, 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]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
let targetAngle = Math.atan2(
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")) {
targetAngle = motion.breathAngle;
} else if (kind === "ember-mantis-duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
)) {
const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
}
const difference = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
Math.cos(targetAngle - group.current.rotation.y),
);
group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
});
if (phase === "briefing") return null;
return (
<group ref={group}>
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
<pointLight color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
</group>
);
}
function BarrierField() {
const group = useRef<THREE.Group>(null);
const fill = useRef<THREE.MeshBasicMaterial>(null);
const innerRing = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const active = state.phase === "combat" && state.barrier.expiresAt > state.time;
group.current.visible = active;
if (!active) return;
group.current.position.set(state.barrier.center[0], 0.058, state.barrier.center[1]);
group.current.rotation.y = clock.elapsedTime * 0.08;
if (fill.current) fill.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 2.6) + 1) * 0.035;
if (innerRing.current) innerRing.current.opacity = 0.38 + (Math.sin(clock.elapsedTime * 3.2) + 1) * 0.12;
});
return (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[3, 64]} />
<meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.92, 3.05, 64]} />
<meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
</mesh>
<mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.82, 1.9, 48]} />
<meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
</mesh>
{Array.from({ length: 8 }, (_, index) => {
const angle = (index / 8) * Math.PI * 2;
return (
<mesh
key={index}
position={[Math.sin(angle) * 2.35, 0.02, Math.cos(angle) * 2.35]}
rotation={[-Math.PI / 2, 0, angle]}
>
<ringGeometry args={[0.09, 0.16, 6]} />
<meshBasicMaterial color="#ffe89a" transparent opacity={0.62} depthWrite={false} />
</mesh>
);
})}
</group>
);
}
function TankAuraField() {
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && state.party[1].hp > 0;
group.current.visible = active;
if (!active) return;
const brann = state.partyPositions.brann;
group.current.position.set(brann[0], 0.06, brann[1]);
group.current.rotation.y = clock.elapsedTime * -0.22;
if (material.current) material.current.opacity = 0.13 + (Math.sin(clock.elapsedTime * 5) + 1) * 0.05;
});
return (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[3, 48]} />
<meshBasicMaterial ref={material} color="#67bfff" transparent opacity={0.18} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.88, 3.04, 48]} />
<meshBasicMaterial color="#8fd5ff" transparent opacity={0.92} depthWrite={false} />
</mesh>
{Array.from({ length: 6 }, (_, index) => {
const angle = (index / 6) * Math.PI * 2;
return <mesh key={index} position={[Math.sin(angle) * 2.35, 0.025, Math.cos(angle) * 2.35]} rotation={[-Math.PI / 2, 0, angle]}>
<ringGeometry args={[0.08, 0.15, 4]} />
<meshBasicMaterial color="#d2efff" transparent opacity={0.8} depthWrite={false} />
</mesh>;
})}
</group>
);
}
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
const group = useRef<THREE.Group>(null);
const start = useMemo(() => new THREE.Vector3(), []);
const end = useMemo(() => new THREE.Vector3(), []);
const current = useMemo(() => new THREE.Vector3(), []);
const direction = useMemo(() => new THREE.Vector3(), []);
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const action = state.partyCombat.combatants[memberId].visualAction;
const member = state.party.find((entry) => entry.id === memberId)!;
const rapid = action?.abilityId === "rapid_fire";
const active = action !== null
&& state.phase === "combat"
&& member.hp > 0
&& action.abilityId !== "overcharge"
&& state.time >= action.startedAt
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
group.current.visible = active;
if (!active || !action) return;
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
const progress = rapid
? ((state.time - action.startedAt) % 0.4) / 0.4
: Math.min(1, (state.time - action.startedAt) / projectileDuration);
const source = state.partyPositions[memberId];
const target = targetMotion.position;
start.set(source[0], 1.18, source[1]);
end.set(target[0], 1.12, target[1]);
current.copy(start).lerp(end, progress);
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
group.current.position.copy(current);
direction.subVectors(end, start).normalize();
group.current.quaternion.setFromUnitVectors(up, direction);
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12);
});
return (
<group ref={group} visible={false}>
{memberId === "nia" ? (
<>
<mesh>
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
<meshBasicMaterial color="#d7b477" />
</mesh>
<mesh position={[0, 0.5, 0]}>
<coneGeometry args={[0.085, 0.2, 6]} />
<meshBasicMaterial color="#f1ddaa" />
</mesh>
<mesh position={[0, -0.4, 0]}>
<coneGeometry args={[0.1, 0.18, 4]} />
<meshBasicMaterial color="#70cf8e" />
</mesh>
</>
) : (
<>
<mesh>
<sphereGeometry args={[0.19, 12, 10]} />
<meshBasicMaterial color="#bc8cff" />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.025, 6, 20]} />
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} />
</mesh>
</>
)}
</group>
);
}
function RangedProjectiles() {
return (
<>
<RangedProjectile memberId="nia" />
<RangedProjectile memberId="orin" />
</>
);
}
function BossActor() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "briefing") return null;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
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} />}
</Suspense>
))}</>
);
}
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const age = useRef(0);
const isBossFx = kind === "boss";
const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
useFrame((_, delta) => {
age.current += delta;
if (!ring.current || !material.current) return;
const progress = Math.min(1, age.current / 0.7);
ring.current.scale.setScalar(0.5 + progress * 3.6);
material.current.opacity = (1 - progress) * 0.85;
});
return (
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.5, 32]} />
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} />
</mesh>
);
}
function CombatFx() {
const pulse = useGameStore((state) => state.scenePulse);
if (!pulse.id) return null;
return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
}
export function GameScene() {
return (
<Canvas
shadows
dpr={[1, 1.5]}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ antialias: true, 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 />
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Suspense fallback={<PartyFallback />}>
<Party />
</Suspense>
<BossActor />
<RangedProjectiles />
<CombatFx />
</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);
if (loadout.left) useGLTF.preload(loadout.left, false, true);
}