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 = { "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 PARTY_MODEL_URLS: Record = { 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 = { 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 = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 }; const PARTY_ATTACK_CLIPS: Record = { 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; 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[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 = { 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, { 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; }) { 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(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 ( <> {rightHandSlot && createPortal(, rightHandSlot)} {leftWeaponScene && leftHandSlot && createPortal(, leftHandSlot)} ); } function Arena() { const pillarInstances = useRef(null); const torchInstances = useRef(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 ( ); } function Character({ memberId, selected = false }: { memberId: Exclude; selected?: boolean }) { const group = useRef(null); const animationState = useRef("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 ( {selected && ( )} ); } function PlayerCharacter() { const group = useRef(null); const animationState = useRef("idle"); const keys = useRef(new Set()); 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 ( {selected && ( )} ); } function Party() { const party = useGameStore((state) => state.party); const selected = useGameStore((state) => state.selectedMemberId); return ( <> {party.slice(1).map((member) => ( } selected={selected === member.id} /> ))} ); } function PartyFallback() { const positions = useGameStore((state) => state.partyPositions); return ( <> {(Object.keys(positions) as MemberId[]).map((memberId) => ( ))} ); } 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 ( ); } 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(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 ( ); } type AlternateBossKind = "vexa" | "cindermaw"; 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, }, } 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(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 : 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); next.reset().setEffectiveWeight(1).fadeIn(0.16).play(); if (phase === "victory" || defeated) { next.setLoop(THREE.LoopOnce, 1); next.clampWhenFinished = true; } else { next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); } return () => { next.fadeOut(0.16); }; }, [actions, clipName, defeated, 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; } 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 ( ); } function BarrierField() { const group = useRef(null); const fill = useRef(null); const innerRing = useRef(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 ( {Array.from({ length: 8 }, (_, index) => { const angle = (index / 8) * Math.PI * 2; return ( ); })} ); } function TankAuraField() { const group = useRef(null); const material = useRef(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 ( {Array.from({ length: 6 }, (_, index) => { const angle = (index / 6) * Math.PI * 2; return ; })} ); } function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { const group = useRef(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 ( {memberId === "nia" ? ( <> ) : ( <> )} ); } function RangedProjectiles() { return ( <> ); } 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) => ( }> {boss.id === "bulldrome" ? : } ))} ); } function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) { const ring = useRef(null); const material = useRef(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" ? "#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 ( ); } function CombatFx() { const pulse = useGameStore((state) => state.scenePulse); if (!pulse.id) return null; return ; } export function GameScene() { return ( }> ); } useGLTF.preload(BULL_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); }