import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber"; import { useAnimations, useGLTF } from "@react-three/drei"; import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react"; import * as THREE from "three"; import { getControllerMovement } from "../input/controller"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { ARENA_CENTER, clampToArena, clampToHockeyArena, clampToHockeyHealerHalf } from "../game/arena"; import { BOSS_ARCHETYPE_BY_ID, type BossArchetype } from "../game/bossCatalog"; import { ALTERNATE_BOSS_CONFIG, BULL_BOSS_ANIMATION_CONFIG, BULL_URL, type AlternateBossKind } from "../game/bossVisuals"; import { bossAnimationCue } from "../game/bosses/mechanicPool"; import { selectMeleeTargetIndex } from "../game/bosses/shared"; import { BOSS_HIT_ANIMATION_SECONDS, BOSS_HIT_REACTION_COOLDOWN_SECONDS, BOSS_MELEE_ANIMATION_SECONDS, bossAnimationClipName, bossAnimationTrigger, isBossAnimationOneShot, selectBossAnimationState, shouldStartBossAnimation, writeBossProceduralPose, type BossAnimationClips, type BossAnimationState, type BossProceduralPose, } from "../game/bossAnimation"; import { CAMERA_FOCUS_HEIGHT, CAMERA_LOOK_AHEAD, CAMERA_ORBIT_DISTANCE, DEFAULT_CAMERA_PITCH, DEFAULT_CAMERA_YAW, setCameraRelativeMovement, updateCameraOrbit, type CameraOrbitState, type PlanarMovement, } from "../game/cameraOrbit"; import { isActorAnimationOneShot, shouldStartActorAnimation, type ActorAnimationState, } from "../game/actorAnimation"; import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance"; import { HOCKEY_ARENA_MAX_Z, HOCKEY_NPC_PADDLE_WIDTH, HOCKEY_NPC_PADDLE_Z, HOCKEY_PUCK_RADIUS, hockeyAimPreviewVisible, hockeyReturnDirection, } from "../game/hockeyHealing"; import type { AiCombatantId, PartyAbilityId } from "../game/partyCombat"; import { partyAttackVfxProfile } from "../game/partyAttackVisuals"; import { STAFF_CAST_AFTERGLOW_SECONDS, STAFF_CAST_GLOW_PROFILES, isHealerPulseKind, staffCastGlowStrength, } from "../game/staffCastGlow"; import { HEALER_VISUAL_PROFILES, type HealerVisualProfile, } from "../game/healerVisuals"; import { isBeaconOfLightTarget } from "../game/healerMechanics"; import { CHARACTER_MODEL_MODE, type CharacterAppearanceV1, type CharacterModelMode, } from "../game/characterAppearance"; import { weaponDefinition, weaponUsesBothHands, type CharacterWeaponGrip, type CharacterWeaponModelId, } from "../game/weaponCatalog"; import { resolveCharacterEquipment } from "../game/characterEquipment"; import { HEALER_CLASS_ORDER } from "../game/healers"; import { BARRIER_RADIUS, useGameStore } from "../game/store"; import type { BossId, GamePhase, HealerClassId, MemberId, PulseKind } from "../game/types"; import { BossRoom } from "./BossRoom"; import { HealerClassAccessory } from "./HealerClassAccessory"; import { ModularCharacterBody } from "./ModularCharacterBody"; import { BossMechanicIndicators } from "./boss/BossMechanicIndicators"; import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals"; import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals"; import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider"; import { characterEquipmentAssetUrl } from "./CharacterEquipmentAssets"; import { HOCKEY_PVP_PUCK_RADIUS, HOCKEY_PVP_SIDE_OFFSET_Z, hockeyPvpLocalToWorld, } from "../game/hockeyHealingPvp"; import { BLOCKBREAKER_BRICK_DEPTH, BLOCKBREAKER_BRICK_WIDTH, BLOCKBREAKER_DANGER_Z, BLOCKBREAKER_MAX_BRICKS, BLOCKBREAKER_PUCK_RADIUS, blockbreakerAimPreviewVisible, blockbreakerColumnX, blockbreakerRowZ, type BlockbreakerBrickColor, } from "../game/blockbreaker"; import { blockbreakerBiomeForSeed } from "../game/blockbreakerBiomes"; import { AETHER_MAX_ENEMY_SHOTS, AETHER_MAX_PLAYER_SHOTS, AETHER_MAX_SHIPS, } from "../game/aetherAssault"; import { AETHER_STANDARD_SHIP_COLORS, aetherShipColorIndex, } from "./aetherAssaultVisuals"; import { clampToBossArenaWithPortals } from "../game/rpgRoguelike/playSpace"; import { activePlayfieldKind, consumeSimulationSteps, FRAME_INTERVAL_JITTER_MS, GAMEPLAY_FRAME_INTERVAL_MS, isOutcomePhase, outcomeElapsedAfterPhaseChange, resetSceneClockForMode, sceneCanvasFrameloop, SIMULATION_STEP_SECONDS, selectSceneRenderMode, startSceneFrameLoop, summarizeFramePerformance, type OutcomePhase, type SceneRenderMode, } from "./sceneFramePolicy"; const PARTY_MODEL_LEGACY_URLS: Record = { aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href, nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href, orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href, vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href, }; const PARTY_MODEL_OPTIMIZED_URLS: Record = { aelia: new URL("../assets/game/models/claudecraft/chars/players/druid-uastc.glb", import.meta.url).href, brann: new URL("../assets/game/models/claudecraft/chars/players/knight-uastc.glb", import.meta.url).href, nia: new URL("../assets/game/models/claudecraft/chars/players/ranger-uastc.glb", import.meta.url).href, orin: new URL("../assets/game/models/claudecraft/chars/players/mage-uastc.glb", import.meta.url).href, vale: new URL("../assets/game/models/claudecraft/chars/players/rogue-uastc.glb", import.meta.url).href, }; const PARTY_MODEL_URLS = Object.fromEntries(Object.keys(PARTY_MODEL_LEGACY_URLS).map((memberId) => [ memberId, selectedGameAssetUrl( PARTY_MODEL_LEGACY_URLS[memberId as MemberId], PARTY_MODEL_OPTIMIZED_URLS[memberId as MemberId], ), ])) as Record; const PARTY_WEAPON_LEGACY_URLS: Record = { aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href }, brann: { right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href, left: new URL("../assets/game/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href, }, nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href }, orin: { right: new URL("../assets/game/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href, left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href, }, vale: { right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href, left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href, }, }; const PARTY_WEAPON_OPTIMIZED_URLS: Record = { aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb", import.meta.url).href }, brann: { right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb", import.meta.url).href, left: new URL("../assets/game/models/claudecraft/weapons/shield_badge-uastc.glb", import.meta.url).href, }, nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb", import.meta.url).href }, orin: { right: new URL("../assets/game/models/claudecraft/weapons/adv_wand-uastc.glb", import.meta.url).href, left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb", import.meta.url).href, }, vale: { right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href, left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href, }, }; const PARTY_WEAPON_URLS = Object.fromEntries(Object.keys(PARTY_WEAPON_LEGACY_URLS).map((memberId) => { const legacy = PARTY_WEAPON_LEGACY_URLS[memberId as MemberId]; const optimized = PARTY_WEAPON_OPTIMIZED_URLS[memberId as MemberId]; return [memberId, { right: selectedGameAssetUrl(legacy.right, optimized.right), left: legacy.left && optimized.left ? selectedGameAssetUrl(legacy.left, optimized.left) : undefined, }]; })) as Record; 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 CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"]; const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude[] = ["nia", "orin", "vale"]; type GameStoreState = ReturnType; interface BossFadeMaterial { material: THREE.Material; baseOpacity: number; baseTransparent: boolean; baseDepthWrite: boolean; } function createBossRenderModel(source: THREE.Object3D) { const model = cloneSkeleton(source); const materialClones = new Map(); model.traverse((object) => { if (!(object instanceof THREE.Mesh)) return; object.castShadow = true; object.receiveShadow = true; const cloneMaterial = (material: THREE.Material) => { const existing = materialClones.get(material); if (existing) return existing; const clone = material.clone(); materialClones.set(material, clone); return clone; }; object.material = Array.isArray(object.material) ? object.material.map(cloneMaterial) : cloneMaterial(object.material); }); model.updateMatrixWorld(true); const modelTopY = new THREE.Box3().setFromObject(model).max.y; return { model, modelTopY, fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({ material, baseOpacity: material.opacity, baseTransparent: material.transparent, baseDepthWrite: material.depthWrite, })), }; } function applyBossOpacity(materials: readonly BossFadeMaterial[], opacity: number) { const fading = opacity < 0.999; for (const entry of materials) { const transparent = entry.baseTransparent || fading; if (entry.material.transparent !== transparent) { entry.material.transparent = transparent; entry.material.needsUpdate = true; } entry.material.opacity = entry.baseOpacity * opacity; entry.material.depthWrite = fading ? false : entry.baseDepthWrite; } } function useBossDeathFade( group: RefObject, light: RefObject, materials: readonly BossFadeMaterial[], defeated: boolean, baseLightIntensity: number, bossId: BossId, ) { const elapsed = useRef(0); const lastOpacity = useRef(1); useFrame((_, delta) => { if (!defeated) { elapsed.current = 0; if (lastOpacity.current !== 1) { lastOpacity.current = 1; if (group.current) group.current.visible = true; if (light.current) light.current.intensity = baseLightIntensity; applyBossOpacity(materials, 1); } return; } elapsed.current += delta; const opacity = bossDeathOpacity(elapsed.current, bossId); if (opacity === lastOpacity.current) return; lastOpacity.current = opacity; if (group.current) group.current.visible = opacity > 0; if (light.current) light.current.intensity = baseLightIntensity * opacity; applyBossOpacity(materials, opacity); }); } function encounterBossAt(state: GameStoreState, bossIndex: number, opponent = false) { if (opponent) return { boss: state.hockeyPvpOpponent.boss, motion: state.hockeyPvpOpponent.bossMotion }; return bossIndex === 0 ? { boss: state.boss, motion: state.bossMotion } : state.additionalBosses[bossIndex - 1]; } function useBossAnimationPlayback({ actions, clips, archetype, bossIndex, opponent, modelRoot, }: { actions: Record; clips: BossAnimationClips; archetype: BossArchetype; bossIndex: number; opponent: boolean; modelRoot: RefObject; }) { const activeClip = useRef(undefined); const activeState = useRef(undefined); const activeTrigger = useRef(Number.NaN); const previousHp = useRef(undefined); const observedMeleeAt = useRef(-1); const meleeElapsed = useRef(Number.POSITIVE_INFINITY); const hitElapsed = useRef(Number.POSITIVE_INFINITY); const hitReadyAt = useRef(0); const hitTrigger = useRef(0); const observedPhaseStartedAt = useRef(Number.NaN); const phaseElapsed = useRef(0); const pose = useRef({ x: 0, y: 0, z: 0, pitch: 0, yaw: 0, roll: 0, scaleX: 1, scaleY: 1, scaleZ: 1, }); useFrame((_, delta) => { const state = useGameStore.getState(); const current = encounterBossAt(state, bossIndex, opponent); if (!current) return; const { boss, motion } = current; if (previousHp.current !== undefined && boss.hp > 0 && boss.hp < previousHp.current && state.time >= hitReadyAt.current) { hitElapsed.current = 0; hitReadyAt.current = state.time + BOSS_HIT_REACTION_COOLDOWN_SECONDS; hitTrigger.current += 1; } previousHp.current = boss.hp; if (motion.lastMeleeAt >= 0 && motion.lastMeleeAt !== observedMeleeAt.current) { observedMeleeAt.current = motion.lastMeleeAt; meleeElapsed.current = 0; } if (motion.phaseStartedAt !== observedPhaseStartedAt.current) { observedPhaseStartedAt.current = motion.phaseStartedAt; phaseElapsed.current = 0; } const mechanicCue = bossAnimationCue(motion); const animationState = selectBossAnimationState({ defeated: boss.hp <= 0 || state.phase === "victory", activeMechanic: motion.activeMechanicId !== null, mechanicCue, meleeElapsed: meleeElapsed.current, hitElapsed: hitElapsed.current, }); const trigger = bossAnimationTrigger( animationState, motion.phaseStartedAt, motion.lastMeleeAt, hitTrigger.current, ); const clipName = bossAnimationClipName(clips, animationState); if (shouldStartBossAnimation(activeState.current, activeTrigger.current, animationState, trigger)) { const next = actions[clipName]; if (next) { const clipChanged = activeClip.current !== clipName; if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16); const timeScale = archetype === "duelist" && motion.mode === "mantis_line_telegraph" ? 0.55 : archetype === "duelist" && motion.mode === "mantis_cross_telegraph" ? 0.6 : motion.mode === "charging" ? 1.3 : 1; next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale); if (clipChanged) next.fadeIn(0.16); if (isBossAnimationOneShot(animationState)) { next.setLoop(THREE.LoopOnce, 1); next.clampWhenFinished = true; } else { next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); next.clampWhenFinished = false; } next.play(); activeClip.current = clipName; activeState.current = animationState; activeTrigger.current = trigger; } } const animationElapsed = animationState === "melee" ? meleeElapsed.current : animationState === "hit" ? hitElapsed.current : phaseElapsed.current; const phaseDuration = Number.isFinite(motion.phaseEndsAt) && motion.phaseEndsAt > motion.phaseStartedAt ? motion.phaseEndsAt - motion.phaseStartedAt : 1; writeBossProceduralPose( pose.current, archetype, motion.mode, animationState, animationElapsed, phaseDuration, ); if (modelRoot.current) { const nextPose = pose.current; modelRoot.current.position.set(nextPose.x, nextPose.y, nextPose.z); modelRoot.current.rotation.set(nextPose.pitch, nextPose.yaw, nextPose.roll); modelRoot.current.scale.set(nextPose.scaleX, nextPose.scaleY, nextPose.scaleZ); } meleeElapsed.current = Math.min(BOSS_MELEE_ANIMATION_SECONDS, meleeElapsed.current + delta); hitElapsed.current = Math.min(BOSS_HIT_ANIMATION_SECONDS, hitElapsed.current + delta); phaseElapsed.current += delta; }); } 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 PARTY_WEAPON_MODEL_IDS: Record = { aelia: { right: "cc/adv_druid_staff" }, brann: { right: "cc/adv_sword_1handed", left: "cc/shield_badge" }, nia: { right: "cc/crossbow_2handed" }, orin: { right: "cc/adv_wand", left: "cc/spellbook_open" }, vale: { right: "cc/adv_dagger", left: "cc/adv_dagger" }, }; const CROSSBOW_QUIVER_URL = characterEquipmentAssetUrl("cc/quiver"); const VARIANT_GRIPS: Record, { lift: number; maxHeight: number }> = { upright: { 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 }, polearm: { lift: 0.12, maxHeight: 2.75 }, }; const CROSSBOW_MOUNTS: Record, { x: number; y: number; scale: number }> = { "cc/crossbow_1handed": { x: 0.255, y: 0.04, scale: 0.6109 }, "cc/crossbow_2handed": { x: 0.3381, y: 0.058, scale: 0.7204 }, "cc/skeleton_crossbow": { x: 0.33, y: 0.064, scale: 0.7094 }, }; function resolveRigNode(root: THREE.Object3D, authoredName: string) { return root.getObjectByName(authoredName) ?? root.getObjectByName(authoredName.replace(/[[\].:/]/g, "")); } function flattenCrossbowScene(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, modelId: CharacterWeaponModelId, grip: CharacterWeaponGrip, side: "r" | "l", ) { // Shields and spellbooks carry useful authored offsets, so keep their scene transform. if (grip === "prop") return scene; if (grip === "crossbow") { const weapon = flattenCrossbowScene(scene); const mount = CROSSBOW_MOUNTS[modelId as keyof typeof CROSSBOW_MOUNTS] ?? CROSSBOW_MOUNTS["cc/crossbow_2handed"]; weapon.position.set(mount.x, mount.y, 0); weapon.quaternion.set(0, 0.7071068, 0, 0.7071067); weapon.scale.setScalar(mount.scale); return weapon; } const weapon = new THREE.Group(); weapon.add(scene); const profile = VARIANT_GRIPS[grip]; const maxHeight = grip === "upright" && weaponUsesBothHands(modelId) ? 2.75 : profile.maxHeight; 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, profile.lift, 0); weapon.quaternion.set(0, side === "l" ? 0 : 1, 0, side === "l" ? 1 : 0); weapon.scale.multiplyScalar(scale); return weapon; } function prepareBackQuiver(scene: THREE.Object3D) { const quiver = new THREE.Group(); quiver.add(scene); const bounds = new THREE.Box3().setFromObject(quiver); const height = bounds.max.y - bounds.min.y; quiver.scale.multiplyScalar(height > 0.001 ? 0.92 / height : 1); quiver.position.set(0.16, 0.08, -0.24); quiver.rotation.set(0.08, Math.PI, -0.16); return quiver; } interface WeaponAssetReference { count: number; scene: THREE.Object3D; releaseTimer: ReturnType | null; } const WEAPON_ASSET_REFERENCES = new Map(); const PENDING_WEAPON_ASSET_RELEASE_MS = 5_000; const MAX_PENDING_WEAPON_ASSETS = 16; const PENDING_WEAPON_ASSETS = new Map>(); function releasePendingWeaponAsset(url: string) { const releaseTimer = PENDING_WEAPON_ASSETS.get(url); if (releaseTimer === undefined) return; clearTimeout(releaseTimer); PENDING_WEAPON_ASSETS.delete(url); // An abandoned Suspense render never reaches useEffect, but useGLTF still caches // its request/result. Clear that cache entry once no committed consumer owns it. if (!WEAPON_ASSET_REFERENCES.has(url)) useGLTF.clear(url); } function registerPendingWeaponAsset(url: string) { if (WEAPON_ASSET_REFERENCES.has(url) || PENDING_WEAPON_ASSETS.has(url)) return; PENDING_WEAPON_ASSETS.set(url, setTimeout( () => releasePendingWeaponAsset(url), PENDING_WEAPON_ASSET_RELEASE_MS, )); while (PENDING_WEAPON_ASSETS.size > MAX_PENDING_WEAPON_ASSETS) { const oldestUrl = PENDING_WEAPON_ASSETS.keys().next().value as string | undefined; if (!oldestUrl) break; releasePendingWeaponAsset(oldestUrl); } } function commitPendingWeaponAsset(url: string) { const releaseTimer = PENDING_WEAPON_ASSETS.get(url); if (releaseTimer === undefined) return; clearTimeout(releaseTimer); PENDING_WEAPON_ASSETS.delete(url); } function disposeWeaponAssetScene(scene: THREE.Object3D) { const geometries = new Set(); const materials = new Set(); const textures = new Set(); scene.traverse((object) => { if (!(object instanceof THREE.Mesh)) return; geometries.add(object.geometry); for (const material of Array.isArray(object.material) ? object.material : [object.material]) { materials.add(material); for (const value of Object.values(material)) { if (value instanceof THREE.Texture) textures.add(value); } } }); for (const geometry of geometries) geometry.dispose(); for (const material of materials) material.dispose(); for (const texture of textures) texture.dispose(); } /** * Drei caches parsed GLBs forever by default. Weapon browsing can touch eleven large * embedded atlases, so release an asset after its last mounted user disappears. */ function useWeaponGLTF(url: string) { registerPendingWeaponAsset(url); const gltf = useGameGLTF(url); useEffect(() => { commitPendingWeaponAsset(url); const existing = WEAPON_ASSET_REFERENCES.get(url); if (existing) { existing.count += 1; if (existing.releaseTimer !== null) { clearTimeout(existing.releaseTimer); existing.releaseTimer = null; } } else { WEAPON_ASSET_REFERENCES.set(url, { count: 1, scene: gltf.scene, releaseTimer: null }); } return () => { const reference = WEAPON_ASSET_REFERENCES.get(url); if (!reference) return; reference.count = Math.max(0, reference.count - 1); if (reference.count > 0 || reference.releaseTimer !== null) return; reference.releaseTimer = setTimeout(() => { const current = WEAPON_ASSET_REFERENCES.get(url); if (!current || current.count > 0) return; disposeWeaponAssetScene(current.scene); useGLTF.clear(url); WEAPON_ASSET_REFERENCES.delete(url); }, 0); }; }, [gltf.scene, url]); return gltf; } interface StaffGlowMaterialBinding { material: THREE.Material & { emissive: THREE.Color; emissiveIntensity: number }; baseEmissive: THREE.Color; baseEmissiveIntensity: number; } function supportsEmissiveGlow(material: THREE.Material): material is StaffGlowMaterialBinding["material"] { return "emissive" in material && material.emissive instanceof THREE.Color && "emissiveIntensity" in material && typeof material.emissiveIntensity === "number"; } function prepareStaffGlow(scene: THREE.Object3D) { const materialClones = new Map(); const bindings: StaffGlowMaterialBinding[] = []; scene.traverse((object) => { if (!(object instanceof THREE.Mesh)) return; const cloneMaterial = (source: THREE.Material) => { const existing = materialClones.get(source); if (existing) return existing; const clone = source.clone(); materialClones.set(source, clone); if (supportsEmissiveGlow(clone)) { bindings.push({ material: clone, baseEmissive: clone.emissive.clone(), baseEmissiveIntensity: clone.emissiveIntensity, }); } return clone; }; object.material = Array.isArray(object.material) ? object.material.map(cloneMaterial) : cloneMaterial(object.material); }); scene.updateMatrixWorld(true); const bounds = new THREE.Box3().setFromObject(scene); const height = bounds.max.y - bounds.min.y; const tipPosition: [number, number, number] = [ (bounds.min.x + bounds.max.x) * 0.5, bounds.max.y - height * 0.06, (bounds.min.z + bounds.max.z) * 0.5, ]; return { bindings, materials: [...materialClones.values()], tipPosition }; } function createActorScene(source: THREE.Object3D, profile: HealerVisualProfile | null) { const scene = cloneSkeleton(source); if (!profile) return scene; // Preserve each GLB's authored material and texture palette. Class identity belongs // in geometry, equipment, and small accents; whole-body tinting erases surface detail. for (const nodeName of profile.hiddenNodes) { const node = resolveRigNode(scene, nodeName); if (node) node.visible = false; } return scene; } function createRigActorScene(source: THREE.Object3D) { const scene = cloneSkeleton(source); const renderNodes: THREE.Object3D[] = []; scene.traverse((object) => { if (object instanceof THREE.Mesh) renderNodes.push(object); }); for (const renderNode of renderNodes) renderNode.parent?.remove(renderNode); return scene; } function disposeActorSkeletons(scene: THREE.Object3D) { const skeletons = new Set(); scene.traverse((object) => { if (object instanceof THREE.SkinnedMesh) skeletons.add(object.skeleton); }); for (const skeleton of skeletons) skeleton.dispose(); } function StaffCastGlow({ bindings, position, }: { bindings: readonly StaffGlowMaterialBinding[]; position: readonly [number, number, number]; }) { const aura = useRef(null); const auraMaterial = useRef(null); const light = useRef(null); const glowColor = useMemo(() => new THREE.Color(), []); const lastClassId = useRef(null); const lastPulseId = useRef(useGameStore.getState().scenePulse.id); const afterglowAge = useRef(null); const lastStrength = useRef(Number.NaN); useFrame(({ clock }, delta) => { const state = useGameStore.getState(); if (state.scenePulse.id !== lastPulseId.current) { lastPulseId.current = state.scenePulse.id; if (isHealerPulseKind(state.scenePulse.kind)) afterglowAge.current = 0; } else if (afterglowAge.current !== null) { afterglowAge.current += delta; if (afterglowAge.current >= STAFF_CAST_AFTERGLOW_SECONDS) afterglowAge.current = null; } const activeCast = state.activeCast; const castingProgress = activeCast ? (state.time - activeCast.startedAt) / Math.max(0.001, activeCast.completesAt - activeCast.startedAt) : null; const baseStrength = staffCastGlowStrength({ castingProgress, afterglowAge: afterglowAge.current }); const strength = Math.min(1, baseStrength * (0.94 + Math.sin(clock.elapsedTime * 11) * 0.06)); const classChanged = lastClassId.current !== state.healerClassId; if (classChanged) { lastClassId.current = state.healerClassId; glowColor.set(STAFF_CAST_GLOW_PROFILES[state.healerClassId].color); if (auraMaterial.current) auraMaterial.current.color.copy(glowColor); if (light.current) light.current.color.copy(glowColor); } if (!classChanged && Math.abs(strength - lastStrength.current) < 0.001) return; lastStrength.current = strength; for (const binding of bindings) { binding.material.emissive.copy(binding.baseEmissive).lerp(glowColor, strength); binding.material.emissiveIntensity = binding.baseEmissiveIntensity + strength * 3.2; } if (aura.current) { aura.current.visible = strength > 0.01; aura.current.scale.setScalar(0.82 + strength * 0.3); } if (auraMaterial.current) auraMaterial.current.opacity = strength * 0.62; if (light.current) light.current.intensity = strength * 2.8; }); return ( ); } function PartyCharacterModel({ memberId, visualMemberId = memberId, healerClassId, appearanceOverride, modelMode = CHARACTER_MODEL_MODE, animationState, animationTrigger, }: { memberId: MemberId; visualMemberId?: MemberId; healerClassId?: HealerClassId; appearanceOverride?: CharacterAppearanceV1; modelMode?: CharacterModelMode; animationState: MutableRefObject; animationTrigger: MutableRefObject; }) { const healerVisual = memberId === "aelia" && healerClassId ? HEALER_VISUAL_PROFILES[healerClassId] : null; const healerAppearance = healerVisual ? modelMode === "modular" && appearanceOverride ? appearanceOverride : healerVisual.appearance : null; const usesModularRenderer = Boolean(healerAppearance && modelMode === "modular"); const modularAppearance = usesModularRenderer ? healerAppearance : null; const bodyMemberId = healerVisual?.bodyMemberId ?? visualMemberId; const scaleMemberId = modularAppearance?.scaleSourceMemberId ?? bodyMemberId; const animationMemberId = healerVisual?.animationMemberId ?? visualMemberId; const bodyGltf = useGameGLTF(PARTY_MODEL_URLS[bodyMemberId]); const animationGltf = useGameGLTF(PARTY_MODEL_URLS[animationMemberId]); const actorScene = useMemo( () => usesModularRenderer ? createRigActorScene(animationGltf.scene) : createActorScene(bodyGltf.scene, healerVisual), [animationGltf.scene, bodyGltf.scene, healerVisual, usesModularRenderer], ); const partyLoadout = PARTY_WEAPON_URLS[visualMemberId]; const partyModelIds = PARTY_WEAPON_MODEL_IDS[visualMemberId]; const resolvedHealerEquipment = healerAppearance ? resolveCharacterEquipment(healerAppearance) : null; const rightModelId = resolvedHealerEquipment?.mainHand.modelId ?? partyModelIds.right; const effectiveLeftModelId = resolvedHealerEquipment?.offHand?.modelId ?? (healerAppearance ? undefined : partyModelIds.left); const wearsCrossbowQuiver = Boolean(usesModularRenderer && resolvedHealerEquipment?.backPropModelId); const loadout = healerAppearance ? { right: characterEquipmentAssetUrl(rightModelId), left: effectiveLeftModelId ? characterEquipmentAssetUrl(effectiveLeftModelId) : undefined, } : partyLoadout; const grips = { right: weaponDefinition(rightModelId).grip as CharacterWeaponGrip, left: effectiveLeftModelId ? weaponDefinition(effectiveLeftModelId).grip as CharacterWeaponGrip : undefined, }; const rightWeapon = useWeaponGLTF(loadout.right); const leftWeapon = useWeaponGLTF(loadout.left ?? loadout.right); const quiverWeapon = useWeaponGLTF(wearsCrossbowQuiver ? CROSSBOW_QUIVER_URL : loadout.right); const rightHandSlot = resolveRigNode(actorScene, "handslot.r"); const leftHandSlot = resolveRigNode(actorScene, "handslot.l"); const backSlot = resolveRigNode(actorScene, "chest") ?? resolveRigNode(actorScene, "spine"); const accessorySlot = healerVisual ? resolveRigNode(actorScene, "head") : null; const renderedAppearance = useMemo( () => modularAppearance && resolvedHealerEquipment?.suppressSkinnedBack ? { ...modularAppearance, backPartId: null } : modularAppearance, [modularAppearance, resolvedHealerEquipment?.suppressSkinnedBack], ); const rightWeaponScene = useMemo( () => prepareHeldWeapon(rightWeapon.scene.clone(true), rightModelId, grips.right, "r"), [grips.right, rightModelId, rightWeapon.scene], ); const leftWeaponScene = useMemo( () => loadout.left && effectiveLeftModelId ? prepareHeldWeapon(leftWeapon.scene.clone(true), effectiveLeftModelId, grips.left ?? grips.right, "l") : null, [effectiveLeftModelId, grips.left, grips.right, leftWeapon.scene, loadout.left], ); const quiverScene = useMemo( () => wearsCrossbowQuiver ? prepareBackQuiver(quiverWeapon.scene.clone(true)) : null, [quiverWeapon.scene, wearsCrossbowQuiver], ); const staffGlow = useMemo( () => memberId === "aelia" && grips.right !== "crossbow" ? prepareStaffGlow(rightWeaponScene) : null, [grips.right, memberId, rightWeaponScene], ); const { actions } = useAnimations(animationGltf.animations, actorScene); const activeClip = useRef(undefined); const activeState = useRef(undefined); const activeTrigger = useRef(Number.NaN); useEffect(() => { // A newly selected weapon can suspend this subtree while its GLB loads. Force // the actor action to restart after it resumes instead of leaving the shared // healer rig in its bind pose. activeState.current = undefined; activeTrigger.current = Number.NaN; }, [effectiveLeftModelId, rightModelId]); useEffect(() => { actorScene.traverse((object) => { if (object instanceof THREE.Mesh) { object.castShadow = true; object.receiveShadow = true; } }); }, [actorScene]); useEffect(() => () => disposeActorSkeletons(actorScene), [actorScene]); useEffect(() => { for (const weaponScene of [rightWeaponScene, leftWeaponScene, quiverScene]) { 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, quiverScene, rightWeaponScene]); useEffect(() => () => { for (const material of staffGlow?.materials ?? []) material.dispose(); }, [staffGlow]); useFrame(() => { const state = animationState.current; const trigger = animationTrigger.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[visualMemberId] : "Idle"; if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return; const next = actions[clipName]; if (!next) return; 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 = true; } else { next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); next.clampWhenFinished = false; } next.play(); activeClip.current = clipName; activeState.current = state; activeTrigger.current = trigger; }); return ( <> {renderedAppearance && ( )} {rightHandSlot && createPortal(, rightHandSlot)} {rightHandSlot && staffGlow && createPortal( , rightHandSlot, )} {leftWeaponScene && leftHandSlot && createPortal(, leftHandSlot)} {quiverScene && backSlot && createPortal(, backSlot)} {accessorySlot && healerVisual && createPortal( , accessorySlot, )} ); } const MIN_RENDER_DPR = 1; const MAX_RENDER_DPR = 1.25; /** * Requests demand frames only while gameplay or a finite outcome animation is * active. Keeping one R3F clock domain avoids demand/manual RAF handoff races. */ function SceneFrameScheduler({ dpr, mode, outcomePhase, onDprChange, onOutcomeComplete, }: { dpr: number; mode: SceneRenderMode; outcomePhase: OutcomePhase | null; onDprChange: (next: number) => void; onOutcomeComplete: () => void; }) { const { clock, get, invalidate } = useThree(); const simulationAccumulator = useRef(0); const outcomeElapsedSeconds = useRef(0); const previousOutcomePhase = useRef(null); const slowFrameMs = useRef(0); const stableFrameMs = useRef(0); const dprRef = useRef(dpr); dprRef.current = dpr; useLayoutEffect(() => { if (mode === "suspended") get().internal.frames = 0; resetSceneClockForMode(clock, mode); outcomeElapsedSeconds.current = outcomeElapsedAfterPhaseChange( previousOutcomePhase.current, outcomePhase, outcomeElapsedSeconds.current, ); previousOutcomePhase.current = outcomePhase; if (mode !== "active") { simulationAccumulator.current = 0; slowFrameMs.current = 0; stableFrameMs.current = 0; } if (mode === "static") { invalidate(); return; } if (mode === "suspended") return; return startSceneFrameLoop({ mode, initialOutcomeElapsedSeconds: outcomeElapsedSeconds.current, requestFrame: requestAnimationFrame, cancelFrame: cancelAnimationFrame, onFrame: (sample) => { if (mode === "active") { const stepResult = consumeSimulationSteps(simulationAccumulator.current, sample.elapsedSeconds); simulationAccumulator.current = stepResult.remainderSeconds; for (let step = 0; step < stepResult.steps; step += 1) { const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0; useGameStore.getState().tick(SIMULATION_STEP_SECONDS); if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt); } if (sample.elapsedMs > 20) { slowFrameMs.current += sample.elapsedMs; stableFrameMs.current = 0; if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) { onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125)); slowFrameMs.current = 0; } } else if (sample.elapsedMs > 0) { slowFrameMs.current = 0; stableFrameMs.current += sample.elapsedMs; if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) { onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125)); stableFrameMs.current = 0; } } } else { outcomeElapsedSeconds.current = sample.outcomeElapsedSeconds; } invalidate(); }, onOutcomeComplete, }); }, [clock, get, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]); return null; } function BeaconOfLightMarker() { const marker = useRef(null); const glow = useRef(null); useFrame(({ clock }) => { if (!marker.current) return; const pulse = Math.sin(clock.elapsedTime * 4.4); marker.current.position.y = 2.62 + pulse * 0.06; marker.current.rotation.y = clock.elapsedTime * 0.7; if (glow.current) glow.current.opacity = 0.78 + pulse * 0.12; }); return ( ); } function Character({ memberId, selected = false }: { memberId: Exclude; selected?: boolean }) { const group = useRef(null); const animationState = useRef("idle"); const animationTrigger = useRef(0); const beaconed = useGameStore((state) => isBeaconOfLightTarget(memberId, state.healerMechanic, state.time)); const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype); const visualMemberId: MemberId = visualArchetype === "knight" ? "brann" : visualArchetype === "ranger" ? "nia" : visualArchetype === "mage" ? "orin" : visualArchetype === "rogue" ? "vale" : memberId; 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; animationTrigger.current = member.hp <= 0 ? 0 : knocked ? member.knockedUntil : attacking ? visualAction.startedAt : 0; animationState.current = member.hp <= 0 ? "death" : knocked ? "hit" : attacking ? "attack" : moving ? (member.runProfile?.combatKitId === "melee" || !member.runProfile && 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 ( {beaconed && } {selected && ( )} ); } function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 }) { const group = useRef(null); const animationState = useRef("idle"); const animationTrigger = useRef(0); const keys = useRef(new Set()); const scenePulse = useGameStore((state) => state.scenePulse); const healerClassId = useGameStore((state) => state.healerClassId); const selected = useGameStore((state) => state.selectedMemberId === "aelia"); const beaconed = useGameStore((state) => isBeaconOfLightTarget("aelia", state.healerMechanic, state.time)); const rpgEncounterKey = useGameStore((state) => { const phase = state.rpgRun?.phase; if (state.runMode !== "rpg-roguelike") return null; const bossIndex = state.rpgRun?.bossIndex ?? 0; if (phase === "challenge-active") return bossIndex * 2 + 1; if (phase === "boss-combat") return bossIndex * 2 + 2; return null; }); const setPlayerPosition = useGameStore((state) => state.setPlayerPosition); const setHockeyAimDirection = useGameStore((state) => state.setHockeyAimDirection); const { camera } = useThree(); const broadcastTimer = useRef(0); const castingUntil = useRef(0); const instantCastTrigger = useRef(0); const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []); const cameraOrbit = useRef({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH }); const cameraRelativeMovement = useRef({ x: 0, z: 0 }); const hockeyAimMovement = useRef({ x: 0, z: -1 }); useLayoutEffect(() => { const start = useGameStore.getState().partyPositions.aelia; group.current?.position.set(start[0], 0.025, start[1]); const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE; const sinYaw = Math.sin(cameraOrbit.current.yaw); const cosYaw = Math.cos(cameraOrbit.current.yaw); desiredCameraPosition.set( start[0] + sinYaw * horizontalDistance, CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE, start[1] + cosYaw * horizontalDistance, ); camera.position.copy(desiredCameraPosition); camera.lookAt( start[0] - sinYaw * CAMERA_LOOK_AHEAD, CAMERA_FOCUS_HEIGHT, start[1] - cosYaw * CAMERA_LOOK_AHEAD, ); keys.current.clear(); broadcastTimer.current = 0; }, [camera, desiredCameraPosition, rpgEncounterKey]); useEffect(() => { if (["periodic-heal", "protective", "cleanse", "group-heal", "field"].includes(scenePulse.kind)) { castingUntil.current = performance.now() + 700; instantCastTrigger.current = scenePulse.id; } }, [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; setCameraRelativeMovement(cameraRelativeMovement.current, nudgeX, nudgeZ, cameraOrbit.current.yaw); const aetherAssaultMode = state.activityMode === "aether-assault"; const hockeyMode = state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker"; const requestedPosition: [number, number] = [ group.current.position.x + cameraRelativeMovement.current.x * 0.18, group.current.position.z + cameraRelativeMovement.current.z * 0.18, ]; const next = state.runMode === "rpg-roguelike" && state.activityMode === "boss" ? clampToBossArenaWithPortals(requestedPosition, { north: state.rpgRun?.phase === "boss-cleared", south: false }) : aetherAssaultMode ? clampToHockeyArena(requestedPosition, 0.65) : hockeyMode ? clampToHockeyHealerHalf(requestedPosition, 0.65) : clampToArena(requestedPosition); 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()); 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]; const controller = getControllerMovement(); const rawInputX = Number(keys.current.has("d")) - Number(keys.current.has("a")) + controller.moveX; const rawInputZ = Number(keys.current.has("s")) - Number(keys.current.has("w")) + controller.moveY; if ((state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker") && state.phase === "combat" && !state.paused) { setCameraRelativeMovement(hockeyAimMovement.current, rawInputX, rawInputZ, cameraOrbit.current.yaw); setHockeyAimDirection([hockeyAimMovement.current.x, hockeyAimMovement.current.z]); } if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) { inputX = rawInputX; inputZ = rawInputZ; } if (state.activityMode === "aether-assault") { cameraOrbit.current.yaw = DEFAULT_CAMERA_YAW; cameraOrbit.current.pitch = DEFAULT_CAMERA_PITCH; } else if (state.phase === "combat" && !state.paused) { updateCameraOrbit(cameraOrbit.current, controller.lookX, controller.lookY, delta); } setCameraRelativeMovement(cameraRelativeMovement.current, inputX, inputZ, cameraOrbit.current.yaw); inputX = cameraRelativeMovement.current.x; inputZ = cameraRelativeMovement.current.z; const length = Math.hypot(inputX, inputZ); if (length > 0.05) { const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length); const requestedPosition: [number, number] = [group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]; const next = state.runMode === "rpg-roguelike" && state.activityMode === "boss" ? clampToBossArenaWithPortals(requestedPosition, { north: state.rpgRun?.phase === "boss-cleared", south: false }) : state.activityMode === "aether-assault" ? clampToHockeyArena(requestedPosition, 0.65) : state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker" ? clampToHockeyHealerHalf(requestedPosition, 0.65) : clampToArena(requestedPosition); 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; 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)); } 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 ? "hit" : state.activeCast ? "cast" : length > 0.05 ? "run" : instantCasting ? "cast" : "idle"; group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16); const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE; const sinYaw = Math.sin(cameraOrbit.current.yaw); const cosYaw = Math.cos(cameraOrbit.current.yaw); const pvpOffsetZ = state.activityMode === "hockey-healing-pvp" ? HOCKEY_PVP_SIDE_OFFSET_Z : 0; desiredCameraPosition.set( group.current.position.x + sinYaw * horizontalDistance, CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE, group.current.position.z + pvpOffsetZ + cosYaw * horizontalDistance, ); camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta)); camera.lookAt( group.current.position.x - sinYaw * CAMERA_LOOK_AHEAD, CAMERA_FOCUS_HEIGHT, group.current.position.z + pvpOffsetZ - cosYaw * CAMERA_LOOK_AHEAD, ); broadcastTimer.current += delta; const activeRunMode = useGameStore.getState().runMode; const positionSyncInterval = activeRunMode === "hockey-healing" || activeRunMode === "hockey-healing-pvp" || activeRunMode === "blockbreaker" || activeRunMode === "aether-assault" ? 0.08 : 0.15; if (broadcastTimer.current > positionSyncInterval) { setPlayerPosition([group.current.position.x, group.current.position.z]); broadcastTimer.current = 0; } }); return ( {beaconed && } {selected && ( )} ); } function HockeyHealingPlayfield() { const puck = useRef(null); const puckGlow = useRef(null); const npcPaddle = useRef(null); const npcPaddleMaterial = useRef(null); const arrow = useRef(null); const arrowMaterial = useRef(null); useFrame(({ clock }, delta) => { const state = useGameStore.getState(); const hockeyMode = state.activityMode === "hockey-healing"; if (puck.current) { puck.current.visible = hockeyMode; if (hockeyMode) { puck.current.position.set(state.hockey.puckPosition[0], 0.48, state.hockey.puckPosition[1]); puck.current.rotation.y += 0.07; const pulse = 1 + Math.sin(clock.elapsedTime * 7) * 0.08; puck.current.scale.setScalar(pulse); if (puckGlow.current) puckGlow.current.opacity = 0.6 + Math.sin(clock.elapsedTime * 7) * 0.18; } } if (npcPaddle.current) { npcPaddle.current.visible = hockeyMode; if (hockeyMode) { npcPaddle.current.position.x = THREE.MathUtils.damp(npcPaddle.current.position.x, state.hockey.paddleX, 18, delta); const hitPulse = Math.max(0, 1 - (state.time - state.hockey.paddleHitAt) / 0.22); npcPaddle.current.scale.set(1 + hitPulse * 0.04, 1 + hitPulse * 0.16, 1); if (npcPaddleMaterial.current) npcPaddleMaterial.current.emissiveIntensity = 1.8 + hitPulse * 3.4; } } if (!arrow.current) return; const arrowVisible = hockeyMode && state.phase === "combat" && hockeyAimPreviewVisible(state.hockey, state.partyPositions.aelia); arrow.current.visible = arrowVisible; if (!arrowVisible) return; const direction = hockeyReturnDirection(state.hockey.aimDirection); arrow.current.position.set(state.hockey.puckPosition[0], 0.09, state.hockey.puckPosition[1]); arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]); if (arrowMaterial.current) arrowMaterial.current.opacity = 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2; }); return ( <> ); } const BLOCKBREAKER_COLOR_KEYS: readonly BlockbreakerBrickColor[] = ["cyan", "amber", "magenta", "lime"]; function BlockbreakerPlayfield() { const biome = useGameStore((state) => blockbreakerBiomeForSeed(state.blockbreaker.seed)); const root = useRef(null); const bricks = useRef>({ cyan: null, amber: null, magenta: null, lime: null, }); const marksA = useRef(null); const marksB = useRef(null); const puck = useRef(null); const puckGlow = useRef(null); const arrow = useRef(null); const arrowMaterial = useRef(null); const breakFx = useRef(null); const breakFxMaterial = useRef(null); const transform = useMemo(() => new THREE.Object3D(), []); const layout = useRef({ active: false, seed: 0, rows: -1, broken: -1 }); useFrame(({ clock }, delta) => { const state = useGameStore.getState(); const active = state.activityMode === "blockbreaker"; if (root.current) root.current.visible = active; if (!active) { layout.current.active = false; return; } const blockbreaker = state.blockbreaker; const reducedMotion = document.documentElement.classList.contains("force-reduced-motion"); if (puck.current) { puck.current.visible = blockbreaker.status === "live"; puck.current.position.set(blockbreaker.puckPosition[0], 0.48, blockbreaker.puckPosition[1]); if (!reducedMotion) puck.current.rotation.y += delta * 5.5; puck.current.scale.setScalar(reducedMotion ? 1 : 1 + Math.sin(clock.elapsedTime * 7) * 0.08); if (puckGlow.current) puckGlow.current.opacity = reducedMotion ? 0.58 : 0.58 + Math.sin(clock.elapsedTime * 7) * 0.18; } const needsLayout = !layout.current.active || layout.current.seed !== blockbreaker.seed || layout.current.rows !== blockbreaker.rowsSpawned || layout.current.broken !== blockbreaker.bricksBroken; const brickMeshesReady = bricks.current.cyan && bricks.current.amber && bricks.current.magenta && bricks.current.lime; if (needsLayout && brickMeshesReady && marksA.current && marksB.current) { const count = Math.min(BLOCKBREAKER_MAX_BRICKS, blockbreaker.bricks.length); const colorCounts: Record = { cyan: 0, amber: 0, magenta: 0, lime: 0 }; marksA.current.count = count; marksB.current.count = count; for (let index = 0; index < count; index += 1) { const brick = blockbreaker.bricks[index]; const x = blockbreakerColumnX(brick.column); const z = blockbreakerRowZ(brick.row); const colorIndex = colorCounts[brick.color]; transform.position.set(x, 0.72, z); transform.rotation.set(0, 0, 0); transform.scale.set(BLOCKBREAKER_BRICK_WIDTH, 1.24, BLOCKBREAKER_BRICK_DEPTH); transform.updateMatrix(); bricks.current[brick.color]?.setMatrixAt(colorIndex, transform.matrix); colorCounts[brick.color] = colorIndex + 1; transform.position.set(x, 0.72, z + BLOCKBREAKER_BRICK_DEPTH * 0.535); transform.rotation.set(0, 0, brick.color === "amber" ? Math.PI / 4 : 0); if (brick.color === "cyan") transform.scale.set(0.14, 0.38, 0.035); else if (brick.color === "amber") transform.scale.set(0.27, 0.27, 0.035); else if (brick.color === "magenta") transform.scale.set(0.35, 0.09, 0.035); else transform.scale.set(0.34, 0.075, 0.035); if (brick.color === "lime") transform.position.y += 0.16; transform.updateMatrix(); marksA.current.setMatrixAt(index, transform.matrix); transform.position.set(x, 0.72, z + BLOCKBREAKER_BRICK_DEPTH * 0.54); transform.rotation.set(0, 0, 0); if (brick.color === "magenta") transform.scale.set(0.09, 0.35, 0.035); else if (brick.color === "lime") { transform.position.y -= 0.16; transform.scale.set(0.34, 0.075, 0.035); } else transform.scale.setScalar(0.0001); transform.updateMatrix(); marksB.current.setMatrixAt(index, transform.matrix); } for (const color of BLOCKBREAKER_COLOR_KEYS) { const mesh = bricks.current[color]; if (!mesh) continue; mesh.count = colorCounts[color]; mesh.instanceMatrix.needsUpdate = true; mesh.computeBoundingSphere(); } marksA.current.instanceMatrix.needsUpdate = true; marksB.current.instanceMatrix.needsUpdate = true; marksA.current.computeBoundingSphere(); marksB.current.computeBoundingSphere(); layout.current = { active: true, seed: blockbreaker.seed, rows: blockbreaker.rowsSpawned, broken: blockbreaker.bricksBroken, }; } if (arrow.current) { const visible = state.phase === "combat" && blockbreakerAimPreviewVisible(blockbreaker, state.partyPositions.aelia); arrow.current.visible = visible; if (visible) { const direction = hockeyReturnDirection(blockbreaker.aimDirection); arrow.current.position.set(blockbreaker.puckPosition[0], 0.09, blockbreaker.puckPosition[1]); arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]); if (arrowMaterial.current) arrowMaterial.current.opacity = reducedMotion ? 0.72 : 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2; } } if (breakFx.current) { const age = state.time - blockbreaker.lastBreakAt; const visible = age >= 0 && age < 0.42; breakFx.current.visible = visible; if (visible) { const progress = age / 0.42; breakFx.current.position.set(blockbreaker.puckPosition[0], 0.2, blockbreaker.puckPosition[1]); breakFx.current.scale.setScalar(reducedMotion ? 1.4 : 0.6 + progress * 4.2); if (breakFxMaterial.current) breakFxMaterial.current.opacity = reducedMotion ? 0.5 : (1 - progress) * 0.9; } } }); return ( {BLOCKBREAKER_COLOR_KEYS.map((color) => ( { bricks.current[color] = mesh; }} args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]} castShadow receiveShadow frustumCulled={false} > ))} ); } function AetherAssaultPlayfield() { const root = useRef(null); const hulls = useRef(null); const wings = useRef(null); const trails = useRef(null); const warnings = useRef(null); const playerShots = useRef(null); const enemyShots = useRef(null); const hullMaterial = useRef(null); const wingMaterial = useRef(null); const trailMaterial = useRef(null); const hitRing = useRef(null); const hitMaterial = useRef(null); const transform = useMemo(() => new THREE.Object3D(), []); const standardColors = useMemo(() => AETHER_STANDARD_SHIP_COLORS.map((color) => new THREE.Color(color)), []); useFrame(({ clock }) => { const state = useGameStore.getState(); const active = state.activityMode === "aether-assault"; if (root.current) root.current.visible = active; if (!active || !hulls.current || !wings.current || !trails.current || !warnings.current || !playerShots.current || !enemyShots.current) return; const reducedMotion = document.documentElement.classList.contains("force-reduced-motion"); const aether = state.aetherAssault; const shipCount = Math.min(AETHER_MAX_SHIPS, aether.ships.length); const formationColor = standardColors[aetherShipColorIndex(aether.seed, aether.wave, 0)]; hullMaterial.current?.color.copy(formationColor); wingMaterial.current?.color.copy(formationColor); trailMaterial.current?.color.copy(formationColor); let trailCount = 0; let warningCount = 0; hulls.current.count = shipCount; wings.current.count = shipCount; for (let index = 0; index < shipCount; index += 1) { const ship = aether.ships[index]; const armored = ship.kind === "armored"; const hover = reducedMotion ? 0 : Math.sin(clock.elapsedTime * 3.2 + index * 0.7) * 0.1; const diveTilt = ship.phase === "diving" ? Math.sin(clock.elapsedTime * 5 + index) * 0.38 : 0; transform.position.set(ship.position[0], 2.25 + hover, ship.position[1]); transform.rotation.set(diveTilt, Math.PI, diveTilt * 0.45); transform.scale.setScalar(armored ? 0.82 : 0.66); transform.updateMatrix(); hulls.current.setMatrixAt(index, transform.matrix); transform.position.set(ship.position[0], 2.12 + hover, ship.position[1] + 0.08); transform.rotation.set(diveTilt, Math.PI, diveTilt * 0.45); transform.scale.set(armored ? 1.38 : 1.12, armored ? 0.12 : 0.09, armored ? 0.76 : 0.62); transform.updateMatrix(); wings.current.setMatrixAt(index, transform.matrix); if (ship.phase === "entering" || ship.phase === "diving" || ship.phase === "returning") { transform.position.set(ship.position[0], 2.18 + hover, ship.position[1] + 0.85); transform.rotation.set(Math.PI / 2, 0, 0); transform.scale.set(0.11, 0.11, ship.phase === "diving" ? 1.5 : 0.92); transform.updateMatrix(); trails.current.setMatrixAt(trailCount, transform.matrix); trailCount += 1; } if (ship.phase === "diving") { transform.position.set(ship.targetPosition[0], 0.045, HOCKEY_ARENA_MAX_Z - 0.9); transform.rotation.set(-Math.PI / 2, 0, 0); transform.scale.setScalar(reducedMotion ? 1 : 0.82 + Math.sin(clock.elapsedTime * 8) * 0.14); transform.updateMatrix(); warnings.current.setMatrixAt(warningCount, transform.matrix); warningCount += 1; } } hulls.current.instanceMatrix.needsUpdate = true; wings.current.instanceMatrix.needsUpdate = true; trails.current.count = trailCount; warnings.current.count = warningCount; trails.current.instanceMatrix.needsUpdate = true; warnings.current.instanceMatrix.needsUpdate = true; const playerShotCount = Math.min(AETHER_MAX_PLAYER_SHOTS, aether.playerShots.length); playerShots.current.count = playerShotCount; for (let index = 0; index < playerShotCount; index += 1) { const shot = aether.playerShots[index]; transform.position.set(shot.position[0], 1.15, shot.position[1]); transform.rotation.set(Math.PI / 2, 0, 0); transform.scale.set(0.14, 0.14, 0.58); transform.updateMatrix(); playerShots.current.setMatrixAt(index, transform.matrix); } playerShots.current.instanceMatrix.needsUpdate = true; const enemyShotCount = Math.min(AETHER_MAX_ENEMY_SHOTS, aether.enemyShots.length); enemyShots.current.count = enemyShotCount; for (let index = 0; index < enemyShotCount; index += 1) { const shot = aether.enemyShots[index]; transform.position.set(shot.position[0], 0.82, shot.position[1]); transform.rotation.set(0, 0, 0); transform.scale.setScalar(0.24); transform.updateMatrix(); enemyShots.current.setMatrixAt(index, transform.matrix); } enemyShots.current.instanceMatrix.needsUpdate = true; if (hitRing.current) { const age = state.time - aether.lastPlayerHitAt; const visible = age >= 0 && age < 0.6; hitRing.current.visible = visible; if (visible) { const progress = age / 0.6; const player = state.partyPositions.aelia; hitRing.current.position.set(player[0], 0.08, player[1]); hitRing.current.scale.setScalar(0.7 + progress * 2.8); if (hitMaterial.current) hitMaterial.current.opacity = reducedMotion ? 0.68 : (1 - progress) * 0.9; } } }); return ( ); } function Party({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) { const selected = useGameStore((state) => state.selectedMemberId); const [loadSupportModels, setLoadSupportModels] = useState(false); useEffect(() => { const timer = window.setTimeout(() => setLoadSupportModels(true), 750); return () => window.clearTimeout(timer); }, []); return ( <> }> {loadSupportModels ? ( }> {SUPPORT_PARTY_MEMBER_IDS.map((memberId) => ( ))} ) : } ); } function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) { const positions = useGameStore((state) => state.partyPositions); return ( <> {memberIds.map((memberId) => ( ))} ); } function OpponentCharacter({ memberId }: { memberId: MemberId }) { const group = useRef(null); const animationState = useRef("idle"); const animationTrigger = useRef(0); useEffect(() => { const start = useGameStore.getState().hockeyPvpOpponent.partyPositions[memberId]; group.current?.position.set(start[0], 0.025, start[1]); }, [memberId]); useFrame((_, delta) => { if (!group.current) return; const state = useGameStore.getState(); const opponent = state.hockeyPvpOpponent; const target = opponent.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 blend = 1 - Math.pow(0.002, delta); group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], blend); group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], blend); const member = opponent.party.find((entry) => entry.id === memberId); const actor = memberId === "aelia" ? null : opponent.partyCombat.combatants[memberId]; const knocked = Boolean(member && member.knockedUntil > state.time); const attacking = Boolean(actor?.visualAction && actor.visualAction.endsAt > state.time); animationTrigger.current = !member || member.hp <= 0 ? 0 : knocked ? member.knockedUntil : attacking ? actor?.visualAction?.startedAt ?? 0 : 0; animationState.current = !member || member.hp <= 0 ? "death" : knocked ? "hit" : attacking ? "attack" : moving ? "walk" : "idle"; if (member && member.hp > 0 && !knocked) { const boss = opponent.bossMotion.position; const facingX = boss[0] - group.current.position.x; const facingZ = boss[1] - group.current.position.z; 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)); } }); return ( ); } function OpponentPartyFallback() { const positions = useGameStore((state) => state.hockeyPvpOpponent.partyPositions); return ( <> {(Object.keys(positions) as MemberId[]).map((memberId) => ( ))} ); } function OpponentParty() { return ( }> {(["aelia", "brann", "nia", "orin", "vale"] as MemberId[]).map((memberId) => ( ))} ); } function BossFallback({ bossIndex, opponent = false }: { bossIndex: number; opponent?: boolean }) { const boss = useGameStore((state) => opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss); const motion = useGameStore((state) => opponent ? state.hockeyPvpOpponent.bossMotion : bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); const group = useRef(null); const material = useRef(null); const deathElapsed = useRef(0); useFrame((_, delta) => { const defeated = (boss?.hp ?? 1) <= 0; deathElapsed.current = defeated ? deathElapsed.current + delta : 0; const opacity = bossDeathOpacity(deathElapsed.current, boss?.id); if (group.current) group.current.visible = opacity > 0; if (material.current) { const transparent = opacity < 0.999; if (material.current.transparent !== transparent) { material.current.transparent = transparent; material.current.needsUpdate = true; } material.current.opacity = opacity; material.current.depthWrite = opacity >= 0.999; } }); if (!boss || !motion) return null; const position = motion.position; const bossId = boss.id; return ( ); } function BullBoss({ bossIndex, opponent = false }: { bossIndex: number; opponent?: boolean }) { const phase = useGameStore((state) => state.phase); const bossHp = useGameStore((state) => (opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const defeated = bossHp <= 0; const group = useRef(null); const modelRoot = useRef(null); const light = useRef(null); const gltf = useGLTF(BULL_URL, false, true); const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]); const { actions } = useAnimations(gltf.animations, bullScene); const targetPosition = useMemo(() => new THREE.Vector3(), []); useBossAnimationPlayback({ actions, clips: BULL_BOSS_ANIMATION_CONFIG, archetype: "bull", bossIndex, opponent, modelRoot, }); useEffect(() => { return () => { for (const entry of fadeMaterials) entry.material.dispose(); }; }, [fadeMaterials]); useBossDeathFade(group, light, fadeMaterials, defeated, 2.8, "bulldrome"); useFrame((_, delta) => { if (!group.current) return; const state = useGameStore.getState(); const current = encounterBossAt(state, bossIndex, opponent); 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 (!bossCanTrackTarget(current.boss.hp)) return; if (motion.mode === "stacking") { group.current.rotation.y += (Math.PI * 2 / 5) * delta; return; } const partyPositions = opponent ? state.hockeyPvpOpponent.partyPositions : state.partyPositions; const party = opponent ? state.hockeyPvpOpponent.party : state.party; const targetIndex = selectMeleeTargetIndex(party); const targetId = targetIndex >= 0 ? party[targetIndex].id : "brann"; let facingX = partyPositions[targetId][0] - motion.position[0]; let facingZ = partyPositions[targetId][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]; } 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 ( ); } function AlternateBoss({ kind, bossIndex, opponent = false }: { kind: AlternateBossKind; bossIndex: number; opponent?: boolean }) { const config = ALTERNATE_BOSS_CONFIG[kind]; const archetype = BOSS_ARCHETYPE_BY_ID[kind]; const phase = useGameStore((state) => state.phase); const bossHp = useGameStore((state) => (opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const defeated = bossHp <= 0; const group = useRef(null); const modelRoot = useRef(null); const light = useRef(null); const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url); const gltf = useGameGLTF(assetUrl); const { model, modelTopY, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]); const { actions } = useAnimations(gltf.animations, model); const targetPosition = useMemo(() => new THREE.Vector3(), []); const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale); useBossAnimationPlayback({ actions, clips: config, archetype, bossIndex, opponent, modelRoot, }); useEffect(() => { return () => { for (const entry of fadeMaterials) entry.material.dispose(); }; }, [fadeMaterials]); useBossDeathFade(group, light, fadeMaterials, defeated, 2.5, kind); useFrame((_, delta) => { if (!group.current) return; const state = useGameStore.getState(); const current = encounterBossAt(state, bossIndex, opponent); if (!current) return; const motion = current.motion; const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall"; const burrowing = archetype === "burrower" && bossIsBurrowing(motion.activeMechanicId, motion.mode); const floatingHeight = config.floating ? 0.2 : 0.03; targetPosition.set(motion.position[0], airborne ? 3.2 : burrowing ? burrowPositionY : floatingHeight, motion.position[1]); group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta)); if (!bossCanTrackTarget(current.boss.hp)) return; const partyPositions = opponent ? state.hockeyPvpOpponent.partyPositions : state.partyPositions; const party = opponent ? state.hockeyPvpOpponent.party : state.party; const meleeTargetIndex = selectMeleeTargetIndex(party); const meleeTargetId = meleeTargetIndex >= 0 ? party[meleeTargetIndex].id : "brann"; let targetAngle = Math.atan2( partyPositions[meleeTargetId][0] - motion.position[0], partyPositions[meleeTargetId][1] - motion.position[1], ); if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) { targetAngle = motion.breathAngle; } else if ( motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph" ) { const target = partyPositions[motion.chargeTargetId]; targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]); } else if (motion.mode === "telegraph" || motion.mode === "charging") { 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), 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; const fieldColor = state.barrier.kind === "spirit-link" ? "#9d8cf2" : "#e7bf46"; if (fill.current) fill.current.color.set(fieldColor); if (innerRing.current) innerRing.current.color.set(fieldColor); 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 sourceId = state.partyCombat.tankAura.sourceId; const source = state.party.find((member) => member.id === sourceId); const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && Boolean(source && source.hp > 0); group.current.visible = active; if (!active) return; const tankPosition = state.partyPositions[sourceId]; group.current.position.set(tankPosition[0], 0.06, tankPosition[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: AiCombatantId }) { const projectile = useRef(null); const impact = useRef(null); const coreMaterial = useRef(null); const accentMaterial = useRef(null); const trailMaterial = useRef(null); const trail = useRef(null); const impactMaterial = useRef(null); const impactCoreMaterial = useRef(null); const lastAbilityId = 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), []); const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype); const arrowStyle = visualArchetype === "ranger" || !visualArchetype && memberId === "nia"; useFrame(({ clock }) => { if (!projectile.current || !impact.current) return; const state = useGameStore.getState(); const action = state.partyCombat.combatants[memberId].visualAction; const member = state.party.find((entry) => entry.id === memberId)!; const profile = action ? partyAttackVfxProfile(action.abilityId) : null; const rapid = action?.abilityId === "rapid_fire"; const active = action !== null && profile?.style === "projectile" && state.phase === "combat" && member.hp > 0 && action.abilityId !== "overcharge" && state.time >= action.startedAt && (rapid ? state.time <= action.endsAt : state.time <= action.impactAt); projectile.current.visible = active; impact.current.visible = false; if (!action || !profile || profile.style !== "projectile" || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return; if (lastAbilityId.current !== action.abilityId) { lastAbilityId.current = action.abilityId; coreMaterial.current?.color.set(profile.primary); accentMaterial.current?.color.set(profile.accent); trailMaterial.current?.color.set(profile.primary); impactMaterial.current?.color.set(profile.accent); impactCoreMaterial.current?.color.set(profile.primary); } 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]); if (active) { current.copy(start).lerp(end, progress); current.y += Math.sin(progress * Math.PI) * (arrowStyle ? 0.34 : 0.95); projectile.current.position.copy(current); direction.subVectors(end, start).normalize(); projectile.current.quaternion.setFromUnitVectors(up, direction); const pulseScale = arrowStyle ? 1 : 1 + Math.sin(clock.elapsedTime * 14) * 0.12; projectile.current.scale.setScalar(profile.scale * pulseScale); trail.current?.scale.set(1, profile.trail, 1); if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08; } const impactProgress = rapid ? progress > 0.72 ? (progress - 0.72) / 0.28 : -1 : (state.time - action.impactAt) / 0.3; const impactVisible = impactProgress >= 0 && impactProgress <= 1 && state.time <= action.endsAt + 0.3; impact.current.visible = impactVisible; if (impactVisible) { impact.current.position.copy(end); impact.current.scale.setScalar(profile.scale * (0.45 + impactProgress * 2.15)); if (impactMaterial.current) impactMaterial.current.opacity = (1 - impactProgress) * 0.9; if (impactCoreMaterial.current) impactCoreMaterial.current.opacity = (1 - impactProgress) * 0.72; } }); return ( <> {arrowStyle ? ( <> ) : ( <> )} ); } function RangedProjectiles() { return ( <> ); } function CloseAttackVfx({ memberId }: { memberId: AiCombatantId }) { const group = useRef(null); const firstArc = useRef(null); const secondArc = useRef(null); const groundRing = useRef(null); const core = useRef(null); const primaryMaterial = useRef(null); const secondaryMaterial = useRef(null); const ringMaterial = useRef(null); const coreMaterial = useRef(null); const lastAbilityId = useRef(null); useFrame(({ clock }) => { if (!group.current || !firstArc.current || !secondArc.current || !groundRing.current || !core.current) return; const state = useGameStore.getState(); const actor = state.partyCombat.combatants[memberId]; const action = actor.visualAction; const member = state.party.find((entry) => entry.id === memberId)!; const visible = action !== null && state.phase === "combat" && member.hp > 0 && state.time >= action.startedAt && state.time <= action.endsAt + 0.18 && partyAttackVfxProfile(action.abilityId).style !== "projectile"; group.current.visible = visible; if (!visible || !action) return; const profile = partyAttackVfxProfile(action.abilityId); if (lastAbilityId.current !== action.abilityId) { lastAbilityId.current = action.abilityId; primaryMaterial.current?.color.set(profile.primary); secondaryMaterial.current?.color.set(profile.accent); ringMaterial.current?.color.set(profile.primary); coreMaterial.current?.color.set(profile.accent); } const duration = Math.max(0.2, action.endsAt - action.startedAt); const progress = THREE.MathUtils.clamp((state.time - action.startedAt) / duration, 0, 1); const impactProgress = THREE.MathUtils.clamp((state.time - action.impactAt + 0.08) / 0.3, 0, 1); const source = state.partyPositions[memberId]; const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId); const target = targetMotion.position; const sourceStyle = profile.style === "buff" || profile.style === "spin"; const effectHeight = sourceStyle ? 0.22 : profile.style === "slam" ? 0.18 : 1.05; group.current.position.set(sourceStyle ? source[0] : target[0], effectHeight, sourceStyle ? source[1] : target[1]); group.current.rotation.y = sourceStyle ? clock.elapsedTime * 0.8 : Math.atan2(target[0] - source[0], target[1] - source[1]); const slashStyle = profile.style === "slash" || profile.style === "double-slash"; firstArc.current.visible = slashStyle; secondArc.current.visible = profile.style === "double-slash"; groundRing.current.visible = profile.style === "slam" || profile.style === "spin" || profile.style === "buff"; core.current.visible = profile.style === "slam" || profile.style === "buff"; const actionScale = profile.scale * (0.65 + Math.sin(progress * Math.PI) * 0.75); firstArc.current.scale.setScalar(actionScale); firstArc.current.rotation.z = -Math.PI * (0.82 - progress * 0.34); secondArc.current.scale.setScalar(actionScale * 0.92); secondArc.current.rotation.z = -Math.PI * (0.15 + progress * 0.36); const burstScale = profile.scale * (0.5 + impactProgress * 2.2); groundRing.current.scale.setScalar(burstScale); groundRing.current.rotation.z = clock.elapsedTime * (memberId === "vale" ? -1.6 : 0.85); core.current.scale.setScalar(profile.scale * (0.6 + Math.sin(progress * Math.PI) * 1.1)); core.current.rotation.set(clock.elapsedTime, clock.elapsedTime * 1.4, 0); if (primaryMaterial.current) primaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.9; if (secondaryMaterial.current) secondaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.84; if (ringMaterial.current) ringMaterial.current.opacity = (1 - impactProgress * 0.7) * 0.76; if (coreMaterial.current) coreMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.72; }); return ( ); } function PartyPowerAuraVfx({ memberId }: { memberId: AiCombatantId }) { const group = useRef(null); const material = useRef(null); useFrame(({ clock }) => { if (!group.current) return; const state = useGameStore.getState(); const actor = state.partyCombat.combatants[memberId]; const member = state.party.find((entry) => entry.id === memberId)!; const overcharged = actor.overchargeStacks > 0; const active = state.phase === "combat" && member.hp > 0 && (overcharged || actor.bladeFlurryUntil > state.time); group.current.visible = active; if (!active) return; const position = state.partyPositions[memberId]; group.current.position.set(position[0], 0.16, position[1]); group.current.rotation.y = clock.elapsedTime * (overcharged ? 1.4 : -1.8); const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12; group.current.scale.setScalar(pulse); if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1; }); const color = memberId === "orin" ? "#bc72ff" : "#9a86ff"; return ( ); } function PartyCombatVfx() { return ( <> ); } function BossActor() { const phase = useGameStore((state) => state.phase); 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 bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId]; return ( <>{bossIds.map((bossId, bossIndex) => ( }> {bossId === "bulldrome" ? : } ))} ); } function OpponentBossActor() { const phase = useGameStore((state) => state.phase); const bossId = useGameStore((state) => state.hockeyPvpOpponent.boss.id); if (phase === "briefing") return null; return ( }> {bossId === "bulldrome" ? : } ); } function HockeyHealingPvpPlayfield() { const puck = useRef(null); const puckGlow = useRef(null); const arrow = useRef(null); const arrowMaterial = useRef(null); useFrame(({ clock }) => { const state = useGameStore.getState(); const active = state.activityMode === "hockey-healing-pvp"; if (puck.current) { puck.current.visible = active; if (active) { puck.current.position.set(state.hockeyPvp.puckPosition[0], 0.48, state.hockeyPvp.puckPosition[1]); puck.current.rotation.y += 0.07; puck.current.scale.setScalar(1 + Math.sin(clock.elapsedTime * 7) * 0.08); if (puckGlow.current) puckGlow.current.opacity = 0.6 + Math.sin(clock.elapsedTime * 7) * 0.18; } } if (!arrow.current) return; const localPlayer = hockeyPvpLocalToWorld(state.partyPositions.aelia); const distance = Math.hypot( state.hockeyPvp.puckPosition[0] - localPlayer[0], state.hockeyPvp.puckPosition[1] - localPlayer[1], ); const visible = active && state.phase === "combat" && state.hockeyPvp.puckVelocity[1] > 0 && distance < 8; arrow.current.visible = visible; if (!visible) return; const direction = hockeyReturnDirection(state.hockeyPvp.aimDirection); arrow.current.position.set(state.hockeyPvp.puckPosition[0], 0.09, state.hockeyPvp.puckPosition[1]); arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]); if (arrowMaterial.current) arrowMaterial.current.opacity = 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2; }); return ( <> ); } function EncounterActors({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) { const pvp = useGameStore((state) => state.activityMode === "hockey-healing-pvp"); const localOffset = pvp ? HOCKEY_PVP_SIDE_OFFSET_Z : 0; return ( <> {pvp && ( )} ); } type PerformanceMemory = Performance & { memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number }; }; function PerformanceProbe({ mode }: { mode: SceneRenderMode }) { const { gl } = useThree(); const frameSamples = useRef([]); const observedFrames = useRef(0); const longTaskCount = useRef(0); const longTaskDuration = useRef(0); const lastPublishAt = useRef(0); const skipNextFrameSample = useRef(true); const publishSnapshot = useCallback((currentMode: SceneRenderMode, samples: readonly number[]) => { 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: summarizeFramePerformance(currentMode, samples), renderer: { observedFrames: observedFrames.current, calls: gl.info.render.calls, triangles: gl.info.render.triangles, geometries: gl.info.memory.geometries, textures: gl.info.memory.textures, pixelRatio: gl.getPixelRatio(), renderWidth: gl.domElement.width, renderHeight: gl.domElement.height, }, simulation: simulationTickSnapshot(), memory: memory.memory ? { 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 }, }); }, [gl]); 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; }; }, []); useEffect(() => { frameSamples.current = []; lastPublishAt.current = 0; skipNextFrameSample.current = true; publishSnapshot(mode, []); }, [mode, publishSnapshot]); useFrame(({ clock }, delta) => { if (!PERFORMANCE_PROBE_ENABLED) return; observedFrames.current += 1; if (mode === "static" || mode === "suspended") { publishSnapshot(mode, []); return; } if (skipNextFrameSample.current) { skipNextFrameSample.current = false; lastPublishAt.current = clock.elapsedTime; return; } const samples = frameSamples.current; if (samples.length === 300) samples.shift(); if (delta > 0) samples.push(delta * 1000); if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return; lastPublishAt.current = clock.elapsedTime; publishSnapshot(mode, samples); }); return null; } const FX_BURST_PARTICLE_COUNT = 8; function scenePulseColor(kind: PulseKind) { if (kind === "protective") return "#62bdff"; if (kind === "cleanse") return "#c39bff"; if (kind === "periodic-heal") return "#72e0a1"; if (kind === "field") return "#d8c16e"; if (kind === "breath") return "#66dcff"; if (kind === "venom") return "#8fdb4f"; if (kind === "tether") return "#d482ff"; if (kind === "skyfall") return "#ffd36b"; if (kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash") return "#ff643c"; return "#ffe087"; } function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) { const group = useRef(null); const ring = useRef(null); const material = useRef(null); const particles = useRef(null); const particleMaterial = useRef(null); const core = useRef(null); const coreMaterial = useRef(null); const transform = useMemo(() => new THREE.Object3D(), []); 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 = scenePulseColor(kind); useFrame(({ clock }, delta) => { age.current += delta; if (!group.current || !ring.current || !material.current || !particles.current || !core.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; core.current.scale.setScalar(0.45 + Math.sin(progress * Math.PI) * 1.8); core.current.rotation.set(clock.elapsedTime * 1.5, clock.elapsedTime * 2, 0); if (coreMaterial.current) coreMaterial.current.opacity = (1 - progress) * 0.72; for (let index = 0; index < FX_BURST_PARTICLE_COUNT; index += 1) { const angle = index / FX_BURST_PARTICLE_COUNT * Math.PI * 2 + clock.elapsedTime * 0.6; const radial = progress * (kind === "boss" ? 3.2 : 1.65); const scale = (1 - progress) * (kind === "boss" ? 1.4 : 0.9); transform.position.set(Math.sin(angle) * radial, 0.18 + Math.sin(progress * Math.PI) * (0.7 + (index % 2) * 0.45), Math.cos(angle) * radial); transform.rotation.set(angle, progress * Math.PI * 2 + index, clock.elapsedTime); transform.scale.setScalar(scale); transform.updateMatrix(); particles.current.setMatrixAt(index, transform.matrix); } particles.current.instanceMatrix.needsUpdate = true; if (particleMaterial.current) particleMaterial.current.opacity = (1 - progress) * 0.82; }); return ( ); } function CombatFx() { const pulse = useGameStore((state) => state.scenePulse); if (!pulse.id) return null; return ; } function ActiveModePlayfield() { const activityMode = useGameStore((state) => state.activityMode); const playfield = activePlayfieldKind(activityMode); if (playfield === "hockey-healing") return ; if (playfield === "blockbreaker") return ; if (playfield === "aether-assault") return ; if (playfield === "hockey-healing-pvp") return ; return null; } function useDocumentVisible() { const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState !== "hidden"); useEffect(() => { const updateVisibility = () => setVisible(document.visibilityState !== "hidden"); const suspend = () => setVisible(false); document.addEventListener("visibilitychange", updateVisibility); window.addEventListener("pagehide", suspend); window.addEventListener("pageshow", updateVisibility); return () => { document.removeEventListener("visibilitychange", updateVisibility); window.removeEventListener("pagehide", suspend); window.removeEventListener("pageshow", updateVisibility); }; }, []); return visible; } export function GameScene({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) { const phase = useGameStore((state) => state.phase); const paused = useGameStore((state) => state.paused); const visible = useDocumentVisible(); const outcomePhaseActive = isOutcomePhase(phase); const [completedOutcomePhase, setCompletedOutcomePhase] = useState(null); const outcomeTailComplete = completedOutcomePhase === phase; const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1))); const setRenderDpr = useCallback((next: number) => { setDpr((current) => Math.abs(current - next) < 0.001 ? current : next); }, []); const completeOutcomeTail = useCallback(() => setCompletedOutcomePhase(phase), [phase]); const renderMode = selectSceneRenderMode({ phase, paused, visible, outcomeTailComplete }); useEffect(() => { if (!outcomePhaseActive && completedOutcomePhase !== null) setCompletedOutcomePhase(null); }, [completedOutcomePhase, outcomePhaseActive]); return ( {PERFORMANCE_PROBE_ENABLED && } ); } const MIXED_GALLERY_APPEARANCE: CharacterAppearanceV1 = { version: 1, rigId: "medium", scaleSourceMemberId: "brann", headPartId: "rogue-head", upperBodyPartId: "knight-upper", lowerBodyPartId: "ranger-lower", headwearPartId: "mage-hat", backPartId: "druid-backpack", mainHand: { modelId: "cc/adv_wand", grip: "wand" }, offHand: { modelId: "cc/spellbook_open", grip: "prop" }, }; function galleryAppearanceOverride(classId: HealerClassId, mix: string | null) { if (!mix || CHARACTER_MODEL_MODE !== "modular") return undefined; const base = HEALER_VISUAL_PROFILES[classId].appearance; if (mix === "head") return { ...base, headPartId: "rogue-head" } satisfies CharacterAppearanceV1; if (mix === "upper") return { ...base, upperBodyPartId: "knight-upper" } satisfies CharacterAppearanceV1; if (mix === "lower") return { ...base, lowerBodyPartId: "ranger-lower" } satisfies CharacterAppearanceV1; if (mix === "headwear") return { ...base, headwearPartId: "knight-helmet" } satisfies CharacterAppearanceV1; if (mix === "back") return { ...base, backPartId: "druid-backpack" } satisfies CharacterAppearanceV1; if (mix === "weapons") return { ...base, mainHand: { modelId: "cc/adv_wand", grip: "wand" }, offHand: { modelId: "cc/spellbook_open", grip: "prop" }, } satisfies CharacterAppearanceV1; return mix === "all" || mix === "1" ? MIXED_GALLERY_APPEARANCE : undefined; } export type HealerPreviewAnimation = Extract; function PreviewHealerActor({ appearanceOverride, animation = "idle", classId, modelMode = CHARACTER_MODEL_MODE, positionX, }: { appearanceOverride?: CharacterAppearanceV1; animation?: HealerPreviewAnimation; classId: HealerClassId; modelMode?: CharacterModelMode; positionX: number; }) { const animationState = useRef(animation); const animationTrigger = useRef(0); const profile = HEALER_VISUAL_PROFILES[classId]; useEffect(() => { if (animationState.current === animation) return; animationState.current = animation; animationTrigger.current += 1; }, [animation]); return ( ); } function GalleryCamera() { const { camera } = useThree(); useEffect(() => { camera.lookAt(0, 1.15, 0); camera.updateProjectionMatrix(); }, [camera]); return null; } function PreviewFrameScheduler() { const { advance } = useThree(); const frameId = useRef(null); const lastRenderedAt = useRef(null); useEffect(() => { const schedule = (now: number) => { const previous = lastRenderedAt.current; if (previous === null || now - previous + FRAME_INTERVAL_JITTER_MS >= GAMEPLAY_FRAME_INTERVAL_MS) { lastRenderedAt.current = now; advance(now / 1000, true); } frameId.current = window.requestAnimationFrame(schedule); }; frameId.current = window.requestAnimationFrame(schedule); return () => { if (frameId.current !== null) window.cancelAnimationFrame(frameId.current); frameId.current = null; lastRenderedAt.current = null; }; }, [advance]); return null; } export function HealerAppearancePreview({ animation = "idle", appearance, classId, modelMode = CHARACTER_MODEL_MODE, }: { animation?: HealerPreviewAnimation; appearance: CharacterAppearanceV1; classId: HealerClassId; modelMode?: CharacterModelMode; }) { return ( ); } /** Development-only deterministic view used for rig, socket, and silhouette QA. */ export function HealerModelGallery() { const query = new URLSearchParams(window.location.search); const requestedClass = query.get("class"); const requestedMix = query.get("mix"); const galleryClasses = HEALER_CLASS_ORDER.filter((classId) => !requestedClass || classId === requestedClass); const visibleClasses = galleryClasses.length > 0 ? galleryClasses : HEALER_CLASS_ORDER; const solo = visibleClasses.length === 1; return (
{visibleClasses.map((classId, index) => ( ))}
{visibleClasses.map((classId) => {classId})}
); } if (LEGACY_GAME_ASSETS_FORCED) { for (const memberId of CRITICAL_PARTY_MEMBER_IDS) { useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true); const loadout = PARTY_WEAPON_URLS[memberId]; useGLTF.preload(loadout.right, false, true); if (loadout.left) useGLTF.preload(loadout.left, false, true); } }