import { Html } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import { Component, Suspense, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ErrorInfo, ReactNode } from "react"; import { AdditiveBlending, AnimationMixer, DoubleSide, LoopOnce, LoopRepeat, NormalBlending, type AnimationAction, type Group, type Material, type Mesh, type Object3D, Vector3, } from "three"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { createMobAnimationTransform, getMobAnimationPhase, resolveMobAnimationClip, resolveMobCombatAnimationClip, resolveMobAnimationState, sampleMobCombatFallback, sampleMobAnimation, } from "../game/mobAnimation"; import type { MobAnimationState } from "../game/mobAnimation"; import { useCombatStore } from "../game/combatStore"; import { PRIMARY_MOUSE_BUTTON } from "../game/inputManager"; import { TimedEffectStrip } from "../ui/TimedEffectStrip"; import { AuraStrip } from "../ui/AuraStrip"; import { bossHasLoot } from "../game/lootCatalog"; import { usePartyStore } from "../game/partyStore"; import { getPartyRuntimePosition, partyRuntimeDistanceSquared, } from "../game/partyRuntimeRegistry"; import { PLAYER_AGGRO_ID } from "../game/aggro"; import { registerMobRuntime, updateMobRuntimePosition, } from "../game/mobRuntimeRegistry"; import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset"; import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling"; import { stepFearedMobAway } from "../game/mobMotion"; import { clusterStaticMobSpawns, createLoopedPath, samplePackFormation, } from "../game/mobPopulation"; import { useGameStore } from "../game/store"; import { manastormExperienceReward, manastormRuntimeScaling, } from "../game/manastorm"; import { useManastormStore } from "../game/manastormStore"; import type { MobArchetype, MobModelStyle, MobPackDefinition, MobVector3, MutableMobVector3, PopulationDefinitionMap, PopulationEntityDefinition, StaticMobSpawnDefinition, } from "../game/mobPopulation"; import { disposeObject3DResources } from "./threeResourceDisposal"; import { advanceMobAnimationCadence, createMobAnimationCadenceState, resolveMobAnimationInterval, } from "./mobAnimationCadence"; import { useGameGLTF } from "./useGameGLTF"; const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet = new Set([ "stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse", ]); const mobLabelStyle: CSSProperties = { color: "#eaf4dd", fontFamily: "system-ui, sans-serif", fontSize: "11px", fontWeight: 700, lineHeight: 1.1, pointerEvents: "none", textAlign: "center", textShadow: "0 1px 2px #000, 0 0 5px #000", userSelect: "none", whiteSpace: "nowrap", }; const bossLabelStyle: CSSProperties = { ...mobLabelStyle, color: "#ffe69a", fontSize: "13px", }; const lootLabelStyle: CSSProperties = { ...bossLabelStyle, padding: "5px 9px", border: "1px solid rgba(245, 212, 112, 0.72)", borderRadius: "999px", background: "rgba(14, 12, 8, 0.88)", boxShadow: "0 4px 18px rgba(0, 0, 0, 0.55)", }; const POPULATION_ACTIVATION_DISTANCE = 95; function mobTargetPosition( targetActorId: string | null | undefined, playerPosition: MobVector3, ): MobVector3 { if (!targetActorId || targetActorId === PLAYER_AGGRO_ID) return playerPosition; return getPartyRuntimePosition(targetActorId) ?? playerPosition; } const PROXIMITY_CHECK_INTERVAL = 0.3; let preparedCreatureTemplates = new WeakMap(); let preparedCreatureTemplateRoots = new Set(); registerDungeonRuntimeReset(() => { const releasedTemplates = preparedCreatureTemplateRoots; preparedCreatureTemplates = new WeakMap(); preparedCreatureTemplateRoots = new Set(); // Dungeon switches publish the next scene in the same event turn. Defer GPU // disposal until React has removed every old creature clone from the Canvas. queueMicrotask(() => { for (const template of releasedTemplates) disposeObject3DResources(template); }); }); interface ProxyMobProps { readonly definition: PopulationEntityDefinition; readonly instanceId: string; readonly active: boolean; readonly moving: boolean; readonly showLabel: boolean; readonly useOriginalModels: boolean; } interface CreatureModelErrorBoundaryProps { readonly children: ReactNode; readonly fallback: ReactNode; readonly resetKey: string; } class CreatureModelErrorBoundary extends Component< CreatureModelErrorBoundaryProps, { readonly failed: boolean } > { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } componentDidCatch(error: Error, info: ErrorInfo): void { console.warn("Creature model fell back to procedural rendering", error, info.componentStack); } componentDidUpdate(previous: Readonly): void { if (previous.resetKey !== this.props.resetKey && this.state.failed) { this.setState({ failed: false }); } } render(): ReactNode { return this.state.failed ? this.props.fallback : this.props.children; } } function prepareCreatureModel(root: Object3D): Object3D { const cachedTemplate = preparedCreatureTemplates.get(root); if (cachedTemplate) return cachedTemplate; const clone = cloneSkeleton(root); const materials = new Map(); clone.traverse((node) => { const mesh = node as Mesh; if (!mesh.isMesh) return; const sourceMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; const preparedMaterials = sourceMaterials.map((source) => { const cached = materials.get(source.uuid); if (cached) return cached; const material = source.clone(); material.side = DoubleSide; if (/_B1$/i.test(material.name)) { material.transparent = false; material.opacity = 1; material.alphaTest = 0.5; material.depthWrite = true; material.blending = NormalBlending; } else if (/_B2$/i.test(material.name)) { material.transparent = true; material.depthWrite = false; material.blending = NormalBlending; } else if (/_B4$/i.test(material.name)) { material.transparent = true; material.depthWrite = false; material.blending = AdditiveBlending; } else { material.transparent = false; material.opacity = 1; material.alphaTest = 0; material.depthWrite = true; material.blending = NormalBlending; } material.needsUpdate = true; materials.set(source.uuid, material); return material; }); mesh.material = Array.isArray(mesh.material) ? preparedMaterials : preparedMaterials[0]; // Creature shadows were a large extra pass across actors that are usually // hidden by cave bends. The dungeon lighting and emissive encounter markers // preserve silhouettes without paying that cost. mesh.castShadow = false; mesh.receiveShadow = false; }); preparedCreatureTemplates.set(root, clone); preparedCreatureTemplateRoots.add(clone); return clone; } function scheduledMobAnimationDelta( object: Object3D, camera: Object3D, worldPosition: Vector3, cadenceState: ReturnType, delta: number, active: boolean, forceFullFidelity: boolean, ): number { object.getWorldPosition(worldPosition); // A Three camera looks down its local -Z axis. Reading matrixWorld avoids a // second temporary vector and does not allocate inside the render loop. const matrix = camera.matrixWorld.elements; const dx = worldPosition.x - matrix[12]; const dy = worldPosition.y - matrix[13]; const dz = worldPosition.z - matrix[14]; const distanceSquared = dx * dx + dy * dy + dz * dz; const inFrontOfCamera = dx * -matrix[8] + dy * -matrix[9] + dz * -matrix[10] >= 0; const interval = resolveMobAnimationInterval( active, forceFullFidelity, distanceSquared, inFrontOfCamera, ); return advanceMobAnimationCadence(cadenceState, delta, interval); } function OriginalCreatureModel({ model, active, proceduralAnimation, animationState, attackRevision, attackAnimation, woundRevision, forceFullFidelity, }: { readonly model: MobModelStyle; readonly active: boolean; readonly proceduralAnimation: boolean; readonly animationState: MobAnimationState; readonly attackRevision: number; readonly attackAnimation: "attack" | "cast"; readonly woundRevision: number; readonly forceFullFidelity: boolean; }) { const gltf = useGameGLTF(model.url); const template = useMemo(() => prepareCreatureModel(gltf.scene), [gltf.scene]); // SkeletonUtils preserves independent bone bindings when animated creature // exports are present, while still sharing immutable geometry and materials. const scene = useMemo(() => cloneSkeleton(template), [template]); const mixer = useMemo(() => new AnimationMixer(scene), [scene]); const baseActionRef = useRef(null); const combatActionRef = useRef(null); const observedAttackRevisionRef = useRef(attackRevision); const observedWoundRevisionRef = useRef(woundRevision); const fallbackObservedAttackRevisionRef = useRef(attackRevision); const fallbackObservedWoundRevisionRef = useRef(woundRevision); const fallbackAttackElapsedRef = useRef(1); const fallbackWoundElapsedRef = useRef(1); const fallbackPoseRef = useRef(null); const stateElapsedRef = useRef(0); const previousStateRef = useRef(animationState); const terminalCompleteRef = useRef(false); const fallbackTransform = useMemo(() => createMobAnimationTransform(), []); const cadenceState = useMemo(() => createMobAnimationCadenceState(), []); const worldPosition = useMemo(() => new Vector3(), []); const resolution = useMemo( () => proceduralAnimation ? null : resolveMobAnimationClip(gltf.animations, animationState), [animationState, gltf.animations, proceduralAnimation], ); const combatClipAvailability = useMemo(() => ({ attack: resolveMobCombatAnimationClip(gltf.animations, "attack", 0) !== null, cast: resolveMobCombatAnimationClip(gltf.animations, "cast", 0) !== null, wound: resolveMobCombatAnimationClip(gltf.animations, "wound", 0) !== null, }), [gltf.animations]); useFrame(({ camera }, delta) => { if (previousStateRef.current !== animationState) { previousStateRef.current = animationState; stateElapsedRef.current = 0; terminalCompleteRef.current = false; } if (fallbackObservedAttackRevisionRef.current !== attackRevision) { fallbackObservedAttackRevisionRef.current = attackRevision; fallbackAttackElapsedRef.current = 0; } if (fallbackObservedWoundRevisionRef.current !== woundRevision) { fallbackObservedWoundRevisionRef.current = woundRevision; fallbackWoundElapsedRef.current = 0; } if (animationState === "dead" && terminalCompleteRef.current) return; const safeDelta = scheduledMobAnimationDelta( scene, camera, worldPosition, cadenceState, delta, active, forceFullFidelity, ); if (safeDelta <= 0) return; stateElapsedRef.current += safeDelta; fallbackAttackElapsedRef.current += safeDelta; fallbackWoundElapsedRef.current += safeDelta; const fallbackPose = fallbackPoseRef.current; if (fallbackPose) { const missingDeathClip = animationState === "dead" && !resolution?.exact; const missingWoundClip = fallbackWoundElapsedRef.current < 0.36 && !combatClipAvailability.wound; const missingAttackClip = fallbackAttackElapsedRef.current < 0.8 && !combatClipAvailability[attackAnimation]; if (!proceduralAnimation && (missingDeathClip || missingWoundClip || missingAttackClip)) { const fallbackState = missingDeathClip ? "dead" : missingWoundClip ? "wound" : "attacking"; const elapsed = missingDeathClip ? stateElapsedRef.current : missingWoundClip ? fallbackWoundElapsedRef.current : fallbackAttackElapsedRef.current; sampleMobCombatFallback(fallbackState, elapsed, fallbackTransform); fallbackPose.position.set(0, fallbackTransform.offsetY, fallbackTransform.offsetZ); fallbackPose.rotation.set( fallbackTransform.rotationX, fallbackTransform.rotationY, fallbackTransform.rotationZ, ); fallbackPose.scale.set( fallbackTransform.scaleX, fallbackTransform.scaleY, fallbackTransform.scaleZ, ); } else { fallbackPose.position.set(0, 0, 0); fallbackPose.rotation.set(0, 0, 0); fallbackPose.scale.set(1, 1, 1); } } // Do not evaluate hundreds of bone tracks while paused or behind the map. if (!proceduralAnimation) mixer.update(safeDelta); }); useEffect(() => { const clip = resolution?.clip; if (!clip) return; terminalCompleteRef.current = false; stateElapsedRef.current = 0; const action = mixer.clipAction(clip, scene); const terminal = animationState === "dead"; if (terminal) { combatActionRef.current?.fadeOut(0.08); combatActionRef.current = null; } action.clampWhenFinished = terminal; action.setLoop(terminal ? LoopOnce : LoopRepeat, terminal ? 1 : Number.POSITIVE_INFINITY); const handleFinished = () => { if (terminal) terminalCompleteRef.current = true; }; if (terminal) mixer.addEventListener("finished", handleFinished); action.reset().fadeIn(0.15).play(); baseActionRef.current = action; return () => { if (terminal) mixer.removeEventListener("finished", handleFinished); action.fadeOut(0.15); if (baseActionRef.current === action) baseActionRef.current = null; }; }, [animationState, mixer, resolution, scene]); const playCombatAction = (kind: "attack" | "cast" | "wound", revision: number) => { if (animationState === "dead") return; const clip = resolveMobCombatAnimationClip(gltf.animations, kind, revision); if (!clip) return; const action = mixer.clipAction(clip, scene); combatActionRef.current?.fadeOut(0.08); baseActionRef.current?.fadeOut(0.08); action.enabled = true; action.clampWhenFinished = false; action.setLoop(LoopOnce, 1).reset().fadeIn(0.08).play(); combatActionRef.current = action; }; useEffect(() => { if (attackRevision === observedAttackRevisionRef.current) return; observedAttackRevisionRef.current = attackRevision; playCombatAction(attackAnimation, attackRevision); // The model, mixer, and scene remain stable for this mounted creature. // eslint-disable-next-line react-hooks/exhaustive-deps }, [attackAnimation, attackRevision]); useEffect(() => { if (woundRevision === observedWoundRevisionRef.current) return; observedWoundRevisionRef.current = woundRevision; playCombatAction("wound", woundRevision); // The model, mixer, and scene remain stable for this mounted creature. // eslint-disable-next-line react-hooks/exhaustive-deps }, [woundRevision]); useEffect(() => { const handleFinished = (event: { action: AnimationAction }) => { if (event.action !== combatActionRef.current) return; combatActionRef.current = null; event.action.fadeOut(0.1); baseActionRef.current?.reset().fadeIn(0.1).play(); }; mixer.addEventListener("finished", handleFinished); return () => mixer.removeEventListener("finished", handleFinished); }, [mixer]); useEffect(() => () => { mixer.stopAllAction(); mixer.uncacheRoot(scene); disposeObject3DResources(scene, { geometries: false, materials: false, textures: false, }); baseActionRef.current = null; combatActionRef.current = null; }, [mixer, scene]); return ( ); } function AnimatedMobBody({ active, archetype, animationState, attackRevision, woundRevision, instanceId, motionWeight, proceduralCombatFallback, forceFullFidelity, children, }: { readonly active: boolean; readonly archetype: MobArchetype; readonly animationState: MobAnimationState; readonly attackRevision: number; readonly woundRevision: number; readonly instanceId: string; readonly motionWeight: number; readonly proceduralCombatFallback: boolean; readonly forceFullFidelity: boolean; readonly children: ReactNode; }) { const bodyRef = useRef(null); const elapsedRef = useRef(0); const previousStateRef = useRef(animationState); const combatPoseSettledRef = useRef(false); const observedAttackRevisionRef = useRef(attackRevision); const attackElapsedRef = useRef(1); const observedWoundRevisionRef = useRef(woundRevision); const woundElapsedRef = useRef(1); const phase = useMemo(() => getMobAnimationPhase(instanceId), [instanceId]); const transform = useMemo(() => createMobAnimationTransform(), []); const cadenceState = useMemo(() => createMobAnimationCadenceState(), []); const worldPosition = useMemo(() => new Vector3(), []); useFrame(({ camera }, delta) => { const body = bodyRef.current; if (!body) return; if (previousStateRef.current !== animationState) { previousStateRef.current = animationState; elapsedRef.current = 0; combatPoseSettledRef.current = false; } if (observedAttackRevisionRef.current !== attackRevision) { observedAttackRevisionRef.current = attackRevision; attackElapsedRef.current = 0; } if (observedWoundRevisionRef.current !== woundRevision) { observedWoundRevisionRef.current = woundRevision; woundElapsedRef.current = 0; } if (animationState === "dead" && combatPoseSettledRef.current) return; const safeDelta = scheduledMobAnimationDelta( body, camera, worldPosition, cadenceState, delta, active, forceFullFidelity, ); if (safeDelta <= 0) return; elapsedRef.current += safeDelta; attackElapsedRef.current += safeDelta; woundElapsedRef.current += safeDelta; if (animationState === "dead") { if (proceduralCombatFallback) { sampleMobCombatFallback(animationState, elapsedRef.current, transform); // The procedural death pose clamps at 0.72 seconds. Stop touching the // corpse transform after applying that terminal sample once. if (animationState === "dead" && elapsedRef.current >= 0.72) { combatPoseSettledRef.current = true; } } else { transform.offsetY = 0; transform.offsetZ = 0; transform.rotationX = 0; transform.rotationY = 0; transform.rotationZ = 0; transform.scaleX = 1; transform.scaleY = 1; transform.scaleZ = 1; // Extracted model clips animate inside this wrapper. Reset the prior // locomotion root pose once, then leave subsequent frames to its mixer. combatPoseSettledRef.current = true; } } else if (proceduralCombatFallback && woundElapsedRef.current < 0.36) { sampleMobCombatFallback("wound", woundElapsedRef.current, transform); } else if (proceduralCombatFallback && attackElapsedRef.current < 0.8) { sampleMobCombatFallback("attacking", attackElapsedRef.current, transform); } else { sampleMobAnimation(archetype, animationState === "moving", elapsedRef.current, phase, transform); } body.position.y = transform.offsetY * motionWeight; body.position.z = transform.offsetZ * motionWeight; body.rotation.set( transform.rotationX * motionWeight, transform.rotationY * motionWeight, transform.rotationZ * motionWeight, ); body.scale.set( 1 + (transform.scaleX - 1) * motionWeight, 1 + (transform.scaleY - 1) * motionWeight, 1 + (transform.scaleZ - 1) * motionWeight, ); }); return {children}; } function ProxyBody({ archetype, primaryColor, accentColor, }: { readonly archetype: MobArchetype; readonly primaryColor: string; readonly accentColor: string; }) { const primary = ; const accent = ; switch (archetype) { case "raptor": return ( <> {primary} {accent} {primary} {[-0.38, 0.38].map((x) => ( {primary} ))} ); case "crocolisk": return ( <> {primary} {accent} {primary} {[-0.5, 0.5].flatMap((x) => [-0.45, 0.45].map((z) => ( {primary} )))} ); case "ooze": return ( <> {accent} {accent} ); case "plant": return ( <> {primary} {accent} {[-1, 1].map((side) => ( {primary} ))} ); case "serpent": return ( <> {[0, 0.48, 0.92].map((z, index) => ( {primary} ))} {accent} ); case "turtle": return ( <> {primary} {accent} {primary} ); case "lizard": return ( <> {primary} {accent} {primary} {[-0.38, 0, 0.38].map((z) => ( {accent} ))} ); case "murloc": return ( <> {primary} {accent} {[-0.24, 0.24].map((x) => ( ))} ); case "winged": return ( <> {primary} {accent} {[-1, 1].map((side) => ( ))} ); case "humanoid": default: return ( <> {primary} {accent} {[-0.27, 0.27].map((x) => ( {primary} ))} ); } } function isPlayerOrPartyNear(points: readonly MobVector3[], maxDistance: number): boolean { const player = useGameStore.getState().playerPosition; const partyMemberIds = usePartyStore.getState().members.map((member) => member.id); const maxDistanceSquared = maxDistance * maxDistance; return points.some((point) => { const dx = player[0] - point[0]; const dy = player[1] - point[1]; const dz = player[2] - point[2]; return dx * dx + dy * dy + dz * dz <= maxDistanceSquared || partyMemberIds.some((id) => partyRuntimeDistanceSquared(id, point) <= maxDistanceSquared); }); } function stepToward( current: MutableMobVector3, target: MobVector3, maximumStep: number, ): number { const dx = target[0] - current[0]; const dy = target[1] - current[1]; const dz = target[2] - current[2]; const distance = Math.hypot(dx, dy, dz); if (distance <= 0.001) return 0; const step = Math.min(Math.max(0, maximumStep), distance); current[0] += dx / distance * step; current[1] += dy / distance * step; current[2] += dz / distance * step; return distance; } /** * Activates content when the player or an autonomous party member approaches. * The stores are read * imperatively and React state changes only when crossing the distance boundary, * so the player's 10 Hz HUD snapshot does not re-render the population tree. */ function usePopulationProximity(points: readonly MobVector3[]): boolean { const initial = isPlayerOrPartyNear(points, POPULATION_ACTIVATION_DISTANCE); const nearbyRef = useRef(initial); const [nearby, setNearby] = useState(initial); const elapsedRef = useRef(0); useFrame((_, delta) => { elapsedRef.current += delta; if (elapsedRef.current < PROXIMITY_CHECK_INTERVAL) return; elapsedRef.current = 0; // Small hysteresis prevents mount/unmount churn at the boundary. const distance = POPULATION_ACTIVATION_DISTANCE + (nearbyRef.current ? 18 : 0); const next = isPlayerOrPartyNear(points, distance); if (next === nearbyRef.current) return; nearbyRef.current = next; setNearby(next); }); return nearby; } function ProxyMob({ definition, instanceId, active, moving, showLabel, useOriginalModels, }: ProxyMobProps) { const mob = useCombatStore((state) => state.mobs[instanceId]); const selected = useCombatStore((state) => state.selectedTargetId === instanceId); const showAggroRanges = useCombatStore((state) => state.settings.showMobAggroRanges); const gameMode = useGameStore((state) => state.gameMode); const characterLevel = useCombatStore((state) => state.level); const manastormLevel = useManastormStore((state) => state.level); const manastormPartySize = useManastormStore((state) => state.partySize); const manastormModeId = useManastormStore((state) => state.modeId); const manastormMapRewardMultiplier = useManastormStore( (state) => state.currentEncounter?.mapRewardMultiplier ?? 1, ); const boss = definition.kind === "boss"; const manastormModifiers = gameMode === "manastorm" ? manastormRuntimeScaling(manastormLevel, manastormPartySize) : null; const { primaryColor, accentColor, scale, archetype } = definition.visual; const model = useOriginalModels ? definition.visual.model : undefined; const proceduralModelAnimation = model?.animationMode === "procedural"; const labelHeight = model?.labelHeight ?? (boss ? 3.25 : 2.65) * scale; const markerRadius = model?.markerRadius ?? 0.9 * scale; const dead = mob?.dead ?? false; const lootable = Boolean(dead && mob?.hasLoot && !mob.looted); const attackRevision = mob?.attackRevision ?? 0; const attackAnimation = mob?.attackAnimation ?? "attack"; const woundRevision = mob?.woundRevision ?? 0; const statusNow = Date.now(); const movementLocked = mob?.statuses.some((status) => status.endsAt > statusNow && MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind)) ?? false; const feared = mob?.statuses.some((status) => status.endsAt > statusNow && status.kind === "fear") ?? false; const animationState = resolveMobAnimationState({ dead, engaged: Boolean(mob?.engaged), combatPhase: mob?.combatPhase ?? "idle", moving, feared, movementLocked, }); const forceFullFidelity = boss || selected || dead || Boolean(mob?.engaged); useEffect(() => { const baseHealthMultiplier = definition.combat?.healthMultiplier ?? 1; const baseDamageMultiplier = definition.combat?.damageMultiplier ?? 1; useCombatStore.getState().registerMob(instanceId, { name: definition.name, serverEntry: definition.combat?.serverEntry, boss, level: gameMode === "manastorm" ? definition.combat?.level : scaledDungeonEnemyLevel(characterLevel, boss), hasLoot: definition.hasLoot ?? (boss && bossHasLoot(definition.id)), lootSourceId: boss ? definition.id : undefined, healthMultiplier: baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1), damageMultiplier: baseDamageMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1), bonusLootChance: manastormModifiers?.bonusLootChance, moveSpeed: definition.combat?.moveSpeed, aggroRange: definition.combat?.aggroRange, leashRange: definition.combat?.leashRange, attacks: definition.combat?.attacks, xpReward: gameMode === "manastorm" ? manastormExperienceReward( boss ? 110 + characterLevel * 35 : 28 + characterLevel * 7, manastormLevel, manastormPartySize, manastormMapRewardMultiplier, undefined, manastormModeId, ) : undefined, }); }, [ boss, characterLevel, definition.hasLoot, definition.id, definition.name, definition.combat, instanceId, gameMode, manastormLevel, manastormMapRewardMultiplier, manastormModeId, manastormPartySize, manastormModifiers?.bonusLootChance, manastormModifiers?.enemyStatMultiplier, ]); if (mob?.despawned) return null; const proxyBody = ( ); return ( { if (event.button !== PRIMARY_MOUSE_BUTTON) return; event.stopPropagation(); if (dead) { useCombatStore.getState().lootMob(instanceId); return; } usePartyStore.getState().clearSelection(); useCombatStore.getState().selectMob(instanceId); }} > {model ? ( ) : ( proxyBody )} {showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? ( ) : null} {boss && !dead ? ( <> ) : null} {selected && !dead ? ( ) : null} {showLabel && !dead ? (
{definition.name}
{boss ?
{definition.title}
: null}
) : null} {lootable ? (
Loot {definition.name} ยท {mob?.pendingLoot.length ?? 0} item{mob?.pendingLoot.length === 1 ? "" : "s"}
) : null}
); } function MobHealthPlate({ instanceId, height, boss }: { readonly instanceId: string; readonly height: number; readonly boss: boolean; }) { const plateRef = useRef(null); const castFillRef = useRef(null); const mob = useCombatStore((state) => state.mobs[instanceId]); const selected = useCombatStore((state) => state.selectedTargetId === instanceId); const enabled = useCombatStore((state) => state.settings.showMobHealthBars); useFrame(({ camera }) => { if (plateRef.current) plateRef.current.lookAt(camera.position); if (castFillRef.current && mob?.activeCast) { const duration = Math.max(1, mob.activeCast.completesAt - mob.activeCast.startedAt); const now = Date.now(); const progress = Math.max(0.001, Math.min(1, (now - mob.activeCast.startedAt) / duration)); castFillRef.current.scale.x = progress; castFillRef.current.position.x = -width * (1 - progress) / 2; } }); if (!enabled || !mob || mob.dead) return null; const width = boss ? 2.25 : 1.5; const healthPercent = Math.max(0, Math.min(1, mob.health / mob.maxHealth)); const fillWidth = width * healthPercent; return ( {mob.activeCast ? ( <>
{mob.activeCast.name}
) : null}
); } interface RoamingPackProps { readonly definition: MobPackDefinition; readonly entities: PopulationDefinitionMap; readonly active: boolean; readonly showLabels: boolean; readonly useOriginalModels: boolean; readonly allowedRuntimeIds: ReadonlySet | null; } function RoamingPack({ definition, entities, active, showLabels, useOriginalModels, allowedRuntimeIds, }: RoamingPackProps) { const path = useMemo(() => createLoopedPath(definition.waypoints), [definition.waypoints]); const distanceRef = useRef(definition.startDistance ?? 0); const { scratchPositions, scratchForwards, chasePositions } = useMemo(() => { const positions: MutableMobVector3[] = definition.members.map(() => [0, 0, 0]); const forwards: MutableMobVector3[] = definition.members.map(() => [0, 0, 1]); samplePackFormation( path, definition.startDistance ?? 0, definition.members, positions, forwards, ); return { scratchPositions: positions, scratchForwards: forwards, chasePositions: positions.map((position) => [...position] as MutableMobVector3), }; }, [definition.members, definition.startDistance, path]); // Use the pack's sampled positions as wake-up targets. A long route should // not keep actors mounted merely because the player is near another endpoint. const nearby = usePopulationProximity(scratchPositions); const memberRefs = useRef>([]); const chasingRefs = useRef(definition.members.map(() => false)); useEffect(() => { const cleanups = definition.members.map((member, index) => registerMobRuntime( `${definition.id}:${member.id}`, scratchPositions[index], )); return () => cleanups.forEach((cleanup) => cleanup()); }, [definition.id, definition.members, scratchPositions]); useFrame((_, delta) => { if (!nearby) return; const combat = useCombatStore.getState(); const playerPosition = useGameStore.getState().playerPosition; const now = Date.now(); const anyEngaged = definition.members.some((member) => combat.mobs[`${definition.id}:${member.id}`]?.engaged); if (active && !anyEngaged) { let movementMultiplier = 1; for (const member of definition.members) { const statuses = combat.mobs[`${definition.id}:${member.id}`]?.statuses ?? []; if (statuses.some((status) => status.endsAt > now && ( MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind) || status.kind === "fear" ))) { movementMultiplier = 0; break; } const slow = statuses.find((status) => status.endsAt > now && status.kind === "slow"); if (slow) movementMultiplier = Math.min(movementMultiplier, 1 - (slow.magnitude ?? 0.5)); } distanceRef.current = (distanceRef.current + Math.min(delta, 0.05) * definition.speed * movementMultiplier) % path.totalLength; } samplePackFormation( path, distanceRef.current, definition.members, scratchPositions, scratchForwards, ); for (let index = 0; index < definition.members.length; index += 1) { const patrolPosition = scratchPositions[index]; const forward = scratchForwards[index]; const member = definition.members[index]; const instanceId = `${definition.id}:${member.id}`; const mob = combat.mobs[instanceId]; const chasePosition = chasePositions[index]; const engaged = Boolean(mob?.engaged && !mob.dead); const targetPosition = mobTargetPosition(mob?.targetActorId, playerPosition); if (engaged && !chasingRefs.current[index]) { const object = memberRefs.current[index]; const start = object?.position; chasePosition[0] = start?.x ?? patrolPosition[0]; chasePosition[1] = start?.y ?? patrolPosition[1]; chasePosition[2] = start?.z ?? patrolPosition[2]; } chasingRefs.current[index] = engaged; let position = patrolPosition; if (mob?.dead) { // Keep the corpse at the final combat position while its one-shot // death clip reaches and holds the terminal frame. position = chasePosition; } else if (engaged) { const statuses = mob!.statuses; const movementLocked = statuses.some((status) => status.endsAt > now && MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind)); const feared = statuses.some((status) => status.endsAt > now && status.kind === "fear"); const slow = statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0; const maximumStep = Math.min(delta, 0.05) * mob!.moveSpeed * Math.max(0.1, 1 - slow); if (!movementLocked && feared) { stepFearedMobAway( chasePosition, targetPosition, mob!.homePosition ?? patrolPosition, maximumStep * 1.1, mob!.leashRange, chasePosition, ); } else if (!movementLocked && mob!.combatPhase === "chasing") { stepToward( chasePosition, targetPosition, maximumStep, ); } position = chasePosition; const facingSign = feared && !movementLocked ? -1 : 1; forward[0] = (targetPosition[0] - position[0]) * facingSign; forward[2] = (targetPosition[2] - position[2]) * facingSign; } else if (mob?.combatPhase === "returning") { stepToward(chasePosition, patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25); position = chasePosition; } else { chasePosition[0] = patrolPosition[0]; chasePosition[1] = patrolPosition[1]; chasePosition[2] = patrolPosition[2]; } updateMobRuntimePosition( instanceId, position[0], position[1], position[2], ); const object = memberRefs.current[index]; if (!object) continue; object.position.set(position[0], position[1], position[2]); object.rotation.y = Math.atan2(forward[0], forward[2]); } }); if (!nearby) return null; return ( {definition.members.map((member, index) => { if (allowedRuntimeIds && !allowedRuntimeIds.has(`${definition.id}:${member.id}`)) return null; const entity = entities[member.entityId]; if (!entity) return null; return ( { memberRefs.current[index] = object; }} > ); })} ); } interface StaticPopulationProps { readonly spawn: StaticMobSpawnDefinition; readonly entities: PopulationDefinitionMap; readonly active: boolean; readonly showLabels: boolean; readonly useOriginalModels: boolean; } function StaticPopulationMember({ spawn, entities, active, showLabels, useOriginalModels }: StaticPopulationProps) { const entity = entities[spawn.entityId]; const groupRef = useRef(null); const positionRef = useRef([...spawn.position]); useEffect(() => registerMobRuntime(spawn.id, spawn.position), [spawn.id, spawn.position]); useFrame((_, delta) => { const mob = useCombatStore.getState().mobs[spawn.id]; const position = positionRef.current; const player = useGameStore.getState().playerPosition; const targetPosition = mobTargetPosition(mob?.targetActorId, player); const now = Date.now(); let feared = false; let movementLocked = false; if (mob?.dead) { // Preserve the final combat position for the held death pose. } else if (mob?.engaged) { movementLocked = mob.statuses.some((status) => status.endsAt > now && MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind)); feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear"); const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0; const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow); if (!movementLocked && feared) { stepFearedMobAway( position, targetPosition, mob.homePosition ?? spawn.position, maximumStep * 1.1, mob.leashRange, position, ); } else if (!movementLocked && mob.combatPhase === "chasing") { stepToward(position, targetPosition, maximumStep); } } else if (mob?.combatPhase === "returning") { stepToward(position, spawn.position, Math.min(delta, 0.05) * mob.moveSpeed * 1.25); } else { position[0] = spawn.position[0]; position[1] = spawn.position[1]; position[2] = spawn.position[2]; } updateMobRuntimePosition(spawn.id, position[0], position[1], position[2]); if (groupRef.current) { groupRef.current.position.set(position[0], position[1], position[2]); if (mob?.engaged) { const facingSign = feared && !movementLocked ? -1 : 1; groupRef.current.rotation.y = Math.atan2( (targetPosition[0] - position[0]) * facingSign, (targetPosition[2] - position[2]) * facingSign, ); } } }); if (!entity) return null; return ( { const phase = useCombatStore.getState().mobs[spawn.id]?.combatPhase; return phase === "chasing" || phase === "returning"; })()} showLabel={showLabels && entity.kind === "boss"} useOriginalModels={useOriginalModels} /> ); } function StaticPopulationCluster({ id, points, spawns, entities, active, showLabels, useOriginalModels, }: { readonly id: string; readonly points: readonly MobVector3[]; readonly spawns: readonly StaticMobSpawnDefinition[]; readonly entities: PopulationDefinitionMap; readonly active: boolean; readonly showLabels: boolean; readonly useOriginalModels: boolean; }) { const nearby = usePopulationProximity(points); if (!nearby) return null; return ( {spawns.map((spawn) => ( ))} ); } export interface MobPopulationProps { readonly entities: PopulationDefinitionMap; readonly roamingPacks: readonly MobPackDefinition[]; readonly staticSpawns: readonly StaticMobSpawnDefinition[]; readonly active?: boolean; readonly showLabels?: boolean; readonly useOriginalModels?: boolean; /** When supplied, only these runtime spawn ids are presented and registered. */ readonly allowedRuntimeIds?: readonly string[]; } /** * Standalone visualization runtime. It owns no gameplay state and performs all * high-frequency motion by mutating Three object refs inside useFrame. */ export function MobPopulation({ entities, roamingPacks, staticSpawns, active = true, showLabels = true, useOriginalModels = true, allowedRuntimeIds, }: MobPopulationProps) { const allowedSet = useMemo( () => allowedRuntimeIds ? new Set(allowedRuntimeIds) : null, [allowedRuntimeIds], ); const staticClusters = useMemo(() => clusterStaticMobSpawns( allowedSet ? staticSpawns.filter((spawn) => allowedSet.has(spawn.id)) : staticSpawns, ), [allowedSet, staticSpawns]); return ( {roamingPacks.map((pack) => ( ))} {staticClusters.map((cluster) => ( ))} ); }