Files
i-want-to-heal-mmo/src/components/GameScene.tsx
T

3084 lines
130 KiB
TypeScript

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<MemberId, string> = {
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<MemberId, string> = {
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<MemberId, string>;
const PARTY_WEAPON_LEGACY_URLS: Record<MemberId, { right: string; left?: string }> = {
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<MemberId, { right: string; left?: string }> = {
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<MemberId, { right: string; left?: string }>;
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
aelia: "2H_Melee_Attack_Chop",
brann: "1H_Melee_Attack_Chop",
nia: "2H_Ranged_Shoot",
orin: "Spellcast_Shoot",
vale: "Dualwield_Melee_Attack_Chop",
};
const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
interface BossFadeMaterial {
material: THREE.Material;
baseOpacity: number;
baseTransparent: boolean;
baseDepthWrite: boolean;
}
function createBossRenderModel(source: THREE.Object3D) {
const model = cloneSkeleton(source);
const materialClones = new Map<THREE.Material, THREE.Material>();
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<THREE.Group | null>,
light: RefObject<THREE.PointLight | null>,
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<string, THREE.AnimationAction | null>;
clips: BossAnimationClips;
archetype: BossArchetype;
bossIndex: number;
opponent: boolean;
modelRoot: RefObject<THREE.Group | null>;
}) {
const activeClip = useRef<string | undefined>(undefined);
const activeState = useRef<BossAnimationState | undefined>(undefined);
const activeTrigger = useRef(Number.NaN);
const previousHp = useRef<number | undefined>(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<BossProceduralPose>({
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<MemberId, { right: CharacterWeaponModelId; left?: CharacterWeaponModelId }> = {
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<Exclude<CharacterWeaponGrip, "crossbow" | "prop">, { 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<Extract<CharacterWeaponModelId, `cc/${string}crossbow${string}`>, { 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<typeof setTimeout> | null;
}
const WEAPON_ASSET_REFERENCES = new Map<string, WeaponAssetReference>();
const PENDING_WEAPON_ASSET_RELEASE_MS = 5_000;
const MAX_PENDING_WEAPON_ASSETS = 16;
const PENDING_WEAPON_ASSETS = new Map<string, ReturnType<typeof setTimeout>>();
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<THREE.BufferGeometry>();
const materials = new Set<THREE.Material>();
const textures = new Set<THREE.Texture>();
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<THREE.Material, THREE.Material>();
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<THREE.Skeleton>();
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<THREE.Group>(null);
const auraMaterial = useRef<THREE.MeshBasicMaterial>(null);
const light = useRef<THREE.PointLight>(null);
const glowColor = useMemo(() => new THREE.Color(), []);
const lastClassId = useRef<string | null>(null);
const lastPulseId = useRef(useGameStore.getState().scenePulse.id);
const afterglowAge = useRef<number | null>(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 (
<group ref={aura} position={position} visible={false}>
<mesh>
<sphereGeometry args={[0.18, 12, 8]} />
<meshBasicMaterial
ref={auraMaterial}
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<pointLight ref={light} intensity={0} distance={2.8} decay={2} />
</group>
);
}
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<ActorAnimationState>;
animationTrigger: MutableRefObject<number>;
}) {
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<string | undefined>(undefined);
const activeState = useRef<ActorAnimationState | undefined>(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 && (
<ModularCharacterBody
actorScene={actorScene}
appearance={renderedAppearance}
modelUrls={PARTY_MODEL_URLS}
/>
)}
<primitive object={actorScene} scale={PARTY_MODEL_SCALES[scaleMemberId]} />
{rightHandSlot && createPortal(<primitive object={rightWeaponScene} />, rightHandSlot)}
{rightHandSlot && staffGlow && createPortal(
<StaffCastGlow bindings={staffGlow.bindings} position={staffGlow.tipPosition} />,
rightHandSlot,
)}
{leftWeaponScene && leftHandSlot && createPortal(<primitive object={leftWeaponScene} />, leftHandSlot)}
{quiverScene && backSlot && createPortal(<primitive object={quiverScene} />, backSlot)}
{accessorySlot && healerVisual && createPortal(
<HealerClassAccessory profile={healerVisual} />,
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<OutcomePhase | null>(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<THREE.Group>(null);
const glow = useRef<THREE.MeshBasicMaterial>(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 (
<group ref={marker} position={[0, 2.62, 0]}>
<pointLight color="#ffe989" intensity={3.2} distance={3.2} decay={2} />
<mesh>
<octahedronGeometry args={[0.18, 0]} />
<meshBasicMaterial
ref={glow}
color="#fff4af"
transparent
opacity={0.9}
blending={THREE.AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.29, 0.025, 8, 24]} />
<meshBasicMaterial
color="#ffd45e"
transparent
opacity={0.8}
blending={THREE.AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</mesh>
<mesh position={[0, -0.38, 0]}>
<coneGeometry args={[0.23, 0.72, 12, 1, true]} />
<meshBasicMaterial
color="#ffe68a"
side={THREE.DoubleSide}
transparent
opacity={0.16}
blending={THREE.AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</mesh>
</group>
);
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
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 (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} visualMemberId={visualMemberId} animationState={animationState} animationTrigger={animationTrigger} />
{beaconed && <BeaconOfLightMarker />}
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
</mesh>
)}
</group>
);
}
function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
const keys = useRef(new Set<string>());
const scenePulse = useGameStore((state) => state.scenePulse);
const 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<CameraOrbitState>({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH });
const cameraRelativeMovement = useRef<PlanarMovement>({ x: 0, z: 0 });
const hockeyAimMovement = useRef<PlanarMovement>({ 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 (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel
memberId="aelia"
healerClassId={healerClassId}
appearanceOverride={appearance}
animationState={animationState}
animationTrigger={animationTrigger}
/>
{beaconed && <BeaconOfLightMarker />}
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
</mesh>
)}
</group>
);
}
function HockeyHealingPlayfield() {
const puck = useRef<THREE.Group>(null);
const puckGlow = useRef<THREE.MeshBasicMaterial>(null);
const npcPaddle = useRef<THREE.Group>(null);
const npcPaddleMaterial = useRef<THREE.MeshStandardMaterial>(null);
const arrow = useRef<THREE.Group>(null);
const arrowMaterial = useRef<THREE.MeshBasicMaterial>(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 (
<>
<group ref={puck} visible={false}>
<mesh castShadow>
<sphereGeometry args={[HOCKEY_PUCK_RADIUS, 16, 12]} />
<meshStandardMaterial color="#eafcff" emissive="#42d9ff" emissiveIntensity={2.4} roughness={0.18} metalness={0.28} />
</mesh>
<mesh scale={1.75}>
<sphereGeometry args={[HOCKEY_PUCK_RADIUS, 12, 8]} />
<meshBasicMaterial ref={puckGlow} color="#67e8ff" transparent opacity={0.68} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<pointLight color="#5ce4ff" intensity={2.2} distance={4.2} />
</group>
<group ref={npcPaddle} visible={false} position={[0, 0.68, HOCKEY_NPC_PADDLE_Z]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[HOCKEY_NPC_PADDLE_WIDTH, 1.12, 0.56]} />
<meshStandardMaterial ref={npcPaddleMaterial} color="#ff8a5b" emissive="#ff4f38" emissiveIntensity={1.8} roughness={0.24} metalness={0.5} />
</mesh>
<mesh position={[0, 0, 0.3]}>
<boxGeometry args={[HOCKEY_NPC_PADDLE_WIDTH - 0.26, 0.74, 0.04]} />
<meshBasicMaterial color="#ffe0a8" transparent opacity={0.34} depthWrite={false} toneMapped={false} />
</mesh>
<pointLight color="#ff7659" intensity={2.4} distance={5.5} position={[0, 0.2, 0.45]} />
</group>
<group ref={arrow} visible={false}>
<mesh position={[0, 0, -1.05]}>
<boxGeometry args={[0.16, 0.045, 1.75]} />
<meshBasicMaterial ref={arrowMaterial} color="#ffe478" transparent opacity={0.8} depthWrite={false} toneMapped={false} />
</mesh>
<mesh position={[0, 0, -2.05]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.38, 0.72, 12]} />
<meshBasicMaterial color="#fff0a3" transparent opacity={0.88} depthWrite={false} toneMapped={false} />
</mesh>
</group>
</>
);
}
const BLOCKBREAKER_COLOR_KEYS: readonly BlockbreakerBrickColor[] = ["cyan", "amber", "magenta", "lime"];
function BlockbreakerPlayfield() {
const biome = useGameStore((state) => blockbreakerBiomeForSeed(state.blockbreaker.seed));
const root = useRef<THREE.Group>(null);
const bricks = useRef<Record<BlockbreakerBrickColor, THREE.InstancedMesh | null>>({
cyan: null,
amber: null,
magenta: null,
lime: null,
});
const marksA = useRef<THREE.InstancedMesh>(null);
const marksB = useRef<THREE.InstancedMesh>(null);
const puck = useRef<THREE.Group>(null);
const puckGlow = useRef<THREE.MeshBasicMaterial>(null);
const arrow = useRef<THREE.Group>(null);
const arrowMaterial = useRef<THREE.MeshBasicMaterial>(null);
const breakFx = useRef<THREE.Group>(null);
const breakFxMaterial = useRef<THREE.MeshBasicMaterial>(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<BlockbreakerBrickColor, number> = { 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 (
<group ref={root} visible={false}>
{BLOCKBREAKER_COLOR_KEYS.map((color) => (
<instancedMesh
key={color}
ref={(mesh) => { bricks.current[color] = mesh; }}
args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]}
castShadow
receiveShadow
frustumCulled={false}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial
color={biome.bricks[color]}
emissive={biome.bricks[color]}
emissiveIntensity={0.55}
roughness={0.44}
metalness={0.03}
toneMapped={false}
/>
</instancedMesh>
))}
<instancedMesh ref={marksA} args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]} frustumCulled={false}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
</instancedMesh>
<instancedMesh ref={marksB} args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]} frustumCulled={false}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
</instancedMesh>
<mesh position={[0, 0.025, BLOCKBREAKER_DANGER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[19.5, 0.18]} />
<meshBasicMaterial color="#ff496f" transparent opacity={0.9} toneMapped={false} />
</mesh>
<mesh position={[0, 0.03, BLOCKBREAKER_DANGER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[19.5, 0.8]} />
<meshBasicMaterial color="#ff315e" transparent opacity={0.12} depthWrite={false} toneMapped={false} />
</mesh>
<group ref={puck}>
<mesh castShadow>
<sphereGeometry args={[BLOCKBREAKER_PUCK_RADIUS, 16, 12]} />
<meshStandardMaterial color="#f4fbff" emissive={biome.fillLightA} emissiveIntensity={2.5} roughness={0.16} metalness={0.3} />
</mesh>
<mesh scale={1.75}>
<sphereGeometry args={[BLOCKBREAKER_PUCK_RADIUS, 12, 8]} />
<meshBasicMaterial ref={puckGlow} color={biome.boundary} transparent opacity={0.65} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<pointLight color={biome.fillLightA} intensity={2.2} distance={4.2} />
</group>
<group ref={arrow} visible={false}>
<mesh position={[0, 0, -1.05]}>
<boxGeometry args={[0.16, 0.045, 1.75]} />
<meshBasicMaterial ref={arrowMaterial} color={biome.railB} transparent opacity={0.8} depthWrite={false} toneMapped={false} />
</mesh>
<mesh position={[0, 0, -2.05]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.38, 0.72, 12]} />
<meshBasicMaterial color={biome.midline} transparent opacity={0.88} depthWrite={false} toneMapped={false} />
</mesh>
</group>
<group ref={breakFx} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
<mesh>
<ringGeometry args={[0.35, 0.5, 28]} />
<meshBasicMaterial ref={breakFxMaterial} color={biome.railB} transparent opacity={0.9} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
</group>
);
}
function AetherAssaultPlayfield() {
const root = useRef<THREE.Group>(null);
const hulls = useRef<THREE.InstancedMesh>(null);
const wings = useRef<THREE.InstancedMesh>(null);
const trails = useRef<THREE.InstancedMesh>(null);
const warnings = useRef<THREE.InstancedMesh>(null);
const playerShots = useRef<THREE.InstancedMesh>(null);
const enemyShots = useRef<THREE.InstancedMesh>(null);
const hullMaterial = useRef<THREE.MeshBasicMaterial>(null);
const wingMaterial = useRef<THREE.MeshBasicMaterial>(null);
const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
const hitRing = useRef<THREE.Mesh>(null);
const hitMaterial = useRef<THREE.MeshBasicMaterial>(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 (
<group ref={root} visible={false}>
<instancedMesh ref={hulls} args={[undefined, undefined, AETHER_MAX_SHIPS]} castShadow frustumCulled={false}>
<octahedronGeometry args={[1, 0]} />
<meshBasicMaterial ref={hullMaterial} color={AETHER_STANDARD_SHIP_COLORS[0]} toneMapped={false} />
</instancedMesh>
<instancedMesh ref={wings} args={[undefined, undefined, AETHER_MAX_SHIPS]} castShadow frustumCulled={false}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial ref={wingMaterial} color={AETHER_STANDARD_SHIP_COLORS[0]} toneMapped={false} />
</instancedMesh>
<instancedMesh ref={trails} args={[undefined, undefined, AETHER_MAX_SHIPS]} frustumCulled={false}>
<coneGeometry args={[1, 1, 8]} />
<meshBasicMaterial ref={trailMaterial} color={AETHER_STANDARD_SHIP_COLORS[0]} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</instancedMesh>
<instancedMesh ref={warnings} args={[undefined, undefined, AETHER_MAX_SHIPS]} frustumCulled={false}>
<ringGeometry args={[0.75, 1, 28]} />
<meshBasicMaterial color="#ffb85c" transparent opacity={0.78} depthWrite={false} toneMapped={false} />
</instancedMesh>
<instancedMesh ref={playerShots} args={[undefined, undefined, AETHER_MAX_PLAYER_SHOTS]} frustumCulled={false}>
<capsuleGeometry args={[1, 2, 3, 6]} />
<meshBasicMaterial color="#d9ffff" transparent opacity={0.92} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</instancedMesh>
<instancedMesh ref={enemyShots} args={[undefined, undefined, AETHER_MAX_ENEMY_SHOTS]} frustumCulled={false}>
<octahedronGeometry args={[1, 0]} />
<meshBasicMaterial color="#ff1f14" depthWrite={false} toneMapped={false} />
</instancedMesh>
<mesh ref={hitRing} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.42, 0.62, 32]} />
<meshBasicMaterial ref={hitMaterial} color="#ff4778" transparent opacity={0.9} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
);
}
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 (
<>
<Suspense fallback={<PartyFallback memberIds={CRITICAL_PARTY_MEMBER_IDS} />}>
<PlayerCharacter appearance={playerAppearance} />
<Character memberId="brann" selected={selected === "brann"} />
</Suspense>
{loadSupportModels ? (
<Suspense fallback={<PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}>
{SUPPORT_PARTY_MEMBER_IDS.map((memberId) => (
<Character key={memberId} memberId={memberId} selected={selected === memberId} />
))}
</Suspense>
) : <PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}
</>
);
}
function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
const positions = useGameStore((state) => state.partyPositions);
return (
<>
{memberIds.map((memberId) => (
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
<meshStandardMaterial color="#79998c" roughness={0.8} />
</mesh>
))}
</>
);
}
function OpponentCharacter({ memberId }: { memberId: MemberId }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("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 (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.5, 0.57, 24]} />
<meshBasicMaterial color="#ff685c" transparent opacity={0.72} />
</mesh>
</group>
);
}
function OpponentPartyFallback() {
const positions = useGameStore((state) => state.hockeyPvpOpponent.partyPositions);
return (
<>
{(Object.keys(positions) as MemberId[]).map((memberId) => (
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
<meshStandardMaterial color="#a45d57" roughness={0.8} />
</mesh>
))}
</>
);
}
function OpponentParty() {
return (
<Suspense fallback={<OpponentPartyFallback />}>
{(["aelia", "brann", "nia", "orin", "vale"] as MemberId[]).map((memberId) => (
<OpponentCharacter key={memberId} memberId={memberId} />
))}
</Suspense>
);
}
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<THREE.Group>(null);
const material = useRef<THREE.MeshStandardMaterial>(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 (
<group ref={group} position={[position[0], 1.1, position[1]]}>
<mesh castShadow>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial ref={material} color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
</group>
);
}
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<THREE.Group>(null);
const modelRoot = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(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 (
<group ref={group}>
<group ref={modelRoot}>
<primitive object={bullScene} scale={0.81} />
</group>
<pointLight ref={light} color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
</group>
);
}
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<THREE.Group>(null);
const modelRoot = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(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 (
<group ref={group}>
<group ref={modelRoot}>
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
</group>
<pointLight ref={light} color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
</group>
);
}
function BarrierField() {
const group = useRef<THREE.Group>(null);
const fill = useRef<THREE.MeshBasicMaterial>(null);
const innerRing = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const active = state.phase === "combat" && state.barrier.expiresAt > state.time;
group.current.visible = active;
if (!active) return;
group.current.position.set(state.barrier.center[0], 0.058, state.barrier.center[1]);
group.current.rotation.y = clock.elapsedTime * 0.08;
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 (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[BARRIER_RADIUS, 64]} />
<meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[BARRIER_RADIUS - 0.08, BARRIER_RADIUS + 0.05, 64]} />
<meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
</mesh>
<mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[BARRIER_RADIUS * 0.607, BARRIER_RADIUS * 0.633, 48]} />
<meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
</mesh>
{Array.from({ length: 8 }, (_, index) => {
const angle = (index / 8) * Math.PI * 2;
return (
<mesh
key={index}
position={[Math.sin(angle) * BARRIER_RADIUS * 0.783, 0.02, Math.cos(angle) * BARRIER_RADIUS * 0.783]}
rotation={[-Math.PI / 2, 0, angle]}
>
<ringGeometry args={[0.09, 0.16, 6]} />
<meshBasicMaterial color="#ffe89a" transparent opacity={0.62} depthWrite={false} />
</mesh>
);
})}
</group>
);
}
function TankAuraField() {
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!group.current) return;
const state = useGameStore.getState();
const 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 (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[3, 48]} />
<meshBasicMaterial ref={material} color="#67bfff" transparent opacity={0.18} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.88, 3.04, 48]} />
<meshBasicMaterial color="#8fd5ff" transparent opacity={0.92} depthWrite={false} />
</mesh>
{Array.from({ length: 6 }, (_, index) => {
const angle = (index / 6) * Math.PI * 2;
return <mesh key={index} position={[Math.sin(angle) * 2.35, 0.025, Math.cos(angle) * 2.35]} rotation={[-Math.PI / 2, 0, angle]}>
<ringGeometry args={[0.08, 0.15, 4]} />
<meshBasicMaterial color="#d2efff" transparent opacity={0.8} depthWrite={false} />
</mesh>;
})}
</group>
);
}
function RangedProjectile({ memberId }: { memberId: AiCombatantId }) {
const projectile = useRef<THREE.Group>(null);
const impact = useRef<THREE.Group>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const accentMaterial = useRef<THREE.MeshBasicMaterial>(null);
const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
const trail = useRef<THREE.Mesh>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactCoreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const lastAbilityId = useRef<PartyAbilityId | null>(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 (
<>
<group ref={projectile} visible={false}>
{arrowStyle ? (
<>
<mesh>
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
<meshBasicMaterial ref={coreMaterial} color="#77d596" />
</mesh>
<mesh position={[0, 0.5, 0]}>
<coneGeometry args={[0.085, 0.2, 6]} />
<meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
</mesh>
<mesh position={[0, -0.4, 0]}>
<coneGeometry args={[0.1, 0.18, 4]} />
<meshBasicMaterial color="#d7b477" />
</mesh>
<mesh ref={trail} position={[0, -0.62, 0]}>
<coneGeometry args={[0.12, 0.72, 6, 1, true]} />
<meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
</mesh>
</>
) : (
<>
<mesh>
<sphereGeometry args={[0.19, 12, 10]} />
<meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.25, 0.025, 6, 20]} />
<meshBasicMaterial ref={accentMaterial} color="#ead9ff" transparent opacity={0.8} depthWrite={false} />
</mesh>
<mesh ref={trail} position={[0, -0.48, 0]}>
<coneGeometry args={[0.18, 0.95, 8, 1, true]} />
<meshBasicMaterial ref={trailMaterial} color="#a87cff" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
</mesh>
</>
)}
</group>
<group ref={impact} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.28, 0.45, 24]} />
<meshBasicMaterial ref={impactMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh>
<octahedronGeometry args={[0.24, 0]} />
<meshBasicMaterial ref={impactCoreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
</>
);
}
function RangedProjectiles() {
return (
<>
<RangedProjectile memberId="brann" />
<RangedProjectile memberId="nia" />
<RangedProjectile memberId="orin" />
<RangedProjectile memberId="vale" />
</>
);
}
function CloseAttackVfx({ memberId }: { memberId: AiCombatantId }) {
const group = useRef<THREE.Group>(null);
const firstArc = useRef<THREE.Mesh>(null);
const secondArc = useRef<THREE.Mesh>(null);
const groundRing = useRef<THREE.Mesh>(null);
const core = useRef<THREE.Mesh>(null);
const primaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
const secondaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
const ringMaterial = useRef<THREE.MeshBasicMaterial>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
const lastAbilityId = useRef<PartyAbilityId | null>(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 (
<group ref={group} visible={false}>
<mesh ref={firstArc} rotation={[0, 0, -Math.PI * 0.72]}>
<torusGeometry args={[0.72, 0.055, 6, 28, Math.PI * 1.28]} />
<meshBasicMaterial ref={primaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={secondArc} position={[0.16, 0.02, 0.05]} rotation={[0, 0, -Math.PI * 0.2]}>
<torusGeometry args={[0.64, 0.045, 6, 26, Math.PI * 1.18]} />
<meshBasicMaterial ref={secondaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={groundRing} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.54, 28]} />
<meshBasicMaterial ref={ringMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={core} position={[0, 0.3, 0]}>
<octahedronGeometry args={[0.26, 0]} />
<meshBasicMaterial ref={coreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
);
}
function PartyPowerAuraVfx({ memberId }: { memberId: AiCombatantId }) {
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshBasicMaterial>(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 (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.7, 0.035, 6, 28]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh position={[0, 0.72, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.46, 0.025, 6, 24]} />
<meshBasicMaterial color={color} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
</group>
);
}
function PartyCombatVfx() {
return (
<>
<RangedProjectiles />
<CloseAttackVfx memberId="brann" />
<CloseAttackVfx memberId="nia" />
<CloseAttackVfx memberId="orin" />
<CloseAttackVfx memberId="vale" />
<PartyPowerAuraVfx memberId="brann" />
<PartyPowerAuraVfx memberId="nia" />
<PartyPowerAuraVfx memberId="orin" />
<PartyPowerAuraVfx memberId="vale" />
</>
);
}
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) => (
<Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
{bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
</Suspense>
))}</>
);
}
function OpponentBossActor() {
const phase = useGameStore((state) => state.phase);
const bossId = useGameStore((state) => state.hockeyPvpOpponent.boss.id);
if (phase === "briefing") return null;
return (
<Suspense key={`opponent-${bossId}`} fallback={<BossFallback bossIndex={0} opponent />}>
{bossId === "bulldrome"
? <BullBoss bossIndex={0} opponent />
: <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={0} opponent />}
</Suspense>
);
}
function HockeyHealingPvpPlayfield() {
const puck = useRef<THREE.Group>(null);
const puckGlow = useRef<THREE.MeshBasicMaterial>(null);
const arrow = useRef<THREE.Group>(null);
const arrowMaterial = useRef<THREE.MeshBasicMaterial>(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 (
<>
<group ref={puck} visible={false}>
<mesh castShadow>
<sphereGeometry args={[HOCKEY_PVP_PUCK_RADIUS, 16, 12]} />
<meshStandardMaterial color="#f4fbff" emissive="#45cfff" emissiveIntensity={2.4} roughness={0.18} metalness={0.28} />
</mesh>
<mesh scale={1.75}>
<sphereGeometry args={[HOCKEY_PVP_PUCK_RADIUS, 12, 8]} />
<meshBasicMaterial ref={puckGlow} color="#67e8ff" transparent opacity={0.68} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<pointLight color="#5ce4ff" intensity={2.2} distance={4.2} />
</group>
<group ref={arrow} visible={false}>
<mesh position={[0, 0, -1.05]}>
<boxGeometry args={[0.16, 0.045, 1.75]} />
<meshBasicMaterial ref={arrowMaterial} color="#ffe478" transparent opacity={0.8} depthWrite={false} toneMapped={false} />
</mesh>
<mesh position={[0, 0, -2.05]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.38, 0.72, 12]} />
<meshBasicMaterial color="#fff0a3" transparent opacity={0.88} depthWrite={false} toneMapped={false} />
</mesh>
</group>
</>
);
}
function EncounterActors({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) {
const pvp = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
const localOffset = pvp ? HOCKEY_PVP_SIDE_OFFSET_Z : 0;
return (
<>
<group position={[0, 0, localOffset]}>
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Party playerAppearance={playerAppearance} />
<BossActor />
<PartyCombatVfx />
<CombatFx />
</group>
{pvp && (
<group position={[0, 0, -HOCKEY_PVP_SIDE_OFFSET_Z]} rotation={[0, Math.PI, 0]}>
<OpponentParty />
<OpponentBossActor />
</group>
)}
</>
);
}
type PerformanceMemory = Performance & {
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
};
function PerformanceProbe({ mode }: { mode: SceneRenderMode }) {
const { gl } = useThree();
const frameSamples = useRef<number[]>([]);
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<THREE.Group>(null);
const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const particles = useRef<THREE.InstancedMesh>(null);
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
const core = useRef<THREE.Mesh>(null);
const coreMaterial = useRef<THREE.MeshBasicMaterial>(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 (
<group ref={group} position={position}>
<mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.38, 0.5, 32]} />
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<mesh ref={core} position={[0, 0.42, 0]}>
<octahedronGeometry args={[0.24, 0]} />
<meshBasicMaterial ref={coreMaterial} color={color} transparent opacity={0.72} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</mesh>
<instancedMesh ref={particles} args={[undefined, undefined, FX_BURST_PARTICLE_COUNT]} frustumCulled={false}>
<tetrahedronGeometry args={[0.18, 0]} />
<meshBasicMaterial ref={particleMaterial} color={color} transparent opacity={0.82} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
</instancedMesh>
</group>
);
}
function CombatFx() {
const pulse = useGameStore((state) => state.scenePulse);
if (!pulse.id) return null;
return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
}
function ActiveModePlayfield() {
const activityMode = useGameStore((state) => state.activityMode);
const playfield = activePlayfieldKind(activityMode);
if (playfield === "hockey-healing") return <HockeyHealingPlayfield />;
if (playfield === "blockbreaker") return <BlockbreakerPlayfield />;
if (playfield === "aether-assault") return <AetherAssaultPlayfield />;
if (playfield === "hockey-healing-pvp") return <HockeyHealingPvpPlayfield />;
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<GamePhase | null>(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 (
<Canvas
frameloop={sceneCanvasFrameloop(renderMode)}
shadows="basic"
dpr={dpr}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
>
<GameAssetProvider>
<SceneFrameScheduler
dpr={dpr}
mode={renderMode}
outcomePhase={outcomePhaseActive ? phase : null}
onDprChange={setRenderDpr}
onOutcomeComplete={completeOutcomeTail}
/>
<BossRoom />
<ActiveModePlayfield />
<EncounterActors playerAppearance={playerAppearance} />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe mode={renderMode} />}
</GameAssetProvider>
</Canvas>
);
}
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<ActorAnimationState, "idle" | "walk" | "cast">;
function PreviewHealerActor({
appearanceOverride,
animation = "idle",
classId,
modelMode = CHARACTER_MODEL_MODE,
positionX,
}: {
appearanceOverride?: CharacterAppearanceV1;
animation?: HealerPreviewAnimation;
classId: HealerClassId;
modelMode?: CharacterModelMode;
positionX: number;
}) {
const animationState = useRef<ActorAnimationState>(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 (
<group position={[positionX, 0.025, 0]}>
<PartyCharacterModel
memberId="aelia"
healerClassId={classId}
appearanceOverride={appearanceOverride}
modelMode={modelMode}
animationState={animationState}
animationTrigger={animationTrigger}
/>
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.48, 0.56, 24]} />
<meshBasicMaterial color={profile.accentColor} transparent opacity={0.82} />
</mesh>
</group>
);
}
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<number | null>(null);
const lastRenderedAt = useRef<number | null>(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 (
<Canvas
frameloop="never"
shadows="basic"
dpr={1}
camera={{ position: [0, 2.2, 4.2], fov: 43, near: 0.1, far: 20 }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
>
<GameAssetProvider>
<PreviewFrameScheduler />
<GalleryCamera />
<color attach="background" args={["#07100e"]} />
<ambientLight intensity={0.7} />
<directionalLight position={[4, 7, 5]} intensity={1.8} castShadow />
<directionalLight position={[-5, 3, 2]} color="#72b8ff" intensity={0.55} />
<mesh position={[0, -0.02, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[7, 6]} />
<meshStandardMaterial color="#10201b" roughness={0.96} />
</mesh>
<Suspense fallback={null}>
<PreviewHealerActor
appearanceOverride={appearance}
animation={animation}
classId={classId}
modelMode={modelMode}
positionX={0}
/>
</Suspense>
</GameAssetProvider>
</Canvas>
);
}
/** 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 (
<main style={{ width: "100vw", height: "100vh", background: "#07100e", position: "relative", overflow: "hidden" }}>
<Canvas
shadows="basic"
dpr={1}
camera={{ position: [0, solo ? 2.2 : 2.8, solo ? 4.2 : 9.5], fov: 43, near: 0.1, far: 40 }}
gl={{ alpha: false, antialias: true, powerPreference: "high-performance" }}
>
<GameAssetProvider>
<GalleryCamera />
<color attach="background" args={["#07100e"]} />
<ambientLight intensity={0.7} />
<directionalLight position={[4, 7, 5]} intensity={1.8} castShadow />
<directionalLight position={[-5, 3, 2]} color="#72b8ff" intensity={0.55} />
<mesh position={[0, -0.02, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[14, 8]} />
<meshStandardMaterial color="#10201b" roughness={0.96} />
</mesh>
<Suspense fallback={null}>
{visibleClasses.map((classId, index) => (
<PreviewHealerActor
key={classId}
classId={classId}
appearanceOverride={index === 0 ? galleryAppearanceOverride(classId, requestedMix) : undefined}
positionX={(index - (visibleClasses.length - 1) * 0.5) * 1.65}
/>
))}
</Suspense>
</GameAssetProvider>
</Canvas>
<section style={{ position: "absolute", inset: "auto 5% 5%", display: "grid", gridTemplateColumns: `repeat(${visibleClasses.length}, 1fr)`, color: "#e9f8ef", font: "600 12px/1.2 system-ui", letterSpacing: "0.08em", textAlign: "center", textTransform: "uppercase", pointerEvents: "none" }}>
{visibleClasses.map((classId) => <span key={classId}>{classId}</span>)}
</section>
</main>
);
}
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);
}
}