3084 lines
130 KiB
TypeScript
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);
|
|
}
|
|
}
|