Files
healer-man/src/scene/MobPopulation.tsx
T
2026-08-18 12:06:04 -04:00

1462 lines
52 KiB
TypeScript

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<string> = 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<Object3D, Object3D>();
let preparedCreatureTemplateRoots = new Set<Object3D>();
registerDungeonRuntimeReset(() => {
const releasedTemplates = preparedCreatureTemplateRoots;
preparedCreatureTemplates = new WeakMap<Object3D, Object3D>();
preparedCreatureTemplateRoots = new Set<Object3D>();
// 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<CreatureModelErrorBoundaryProps>): 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<string, Material>();
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<typeof createMobAnimationCadenceState>,
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<AnimationAction | null>(null);
const combatActionRef = useRef<AnimationAction | null>(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<Group>(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 (
<group ref={fallbackPoseRef}>
<primitive
object={scene}
position={[0, model.groundOffset, 0]}
rotation={[0, model.rotationY, 0]}
/>
</group>
);
}
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<Group>(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 <group ref={bodyRef}>{children}</group>;
}
function ProxyBody({
archetype,
primaryColor,
accentColor,
}: {
readonly archetype: MobArchetype;
readonly primaryColor: string;
readonly accentColor: string;
}) {
const primary = <meshStandardMaterial color={primaryColor} roughness={0.84} />;
const accent = <meshStandardMaterial color={accentColor} roughness={0.72} />;
switch (archetype) {
case "raptor":
return (
<>
<mesh position={[0, 0.9, 0]} scale={[0.75, 0.58, 1.2]}>
<dodecahedronGeometry args={[0.7, 0]} />{primary}
</mesh>
<mesh position={[0, 1.12, 0.88]} scale={[0.7, 0.62, 0.9]}>
<icosahedronGeometry args={[0.44, 0]} />{accent}
</mesh>
<mesh position={[0, 0.83, -1.05]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.32, 1.5, 6]} />{primary}
</mesh>
{[-0.38, 0.38].map((x) => (
<mesh key={x} position={[x, 0.34, 0.12]}>
<cylinderGeometry args={[0.12, 0.16, 0.65, 5]} />{primary}
</mesh>
))}
</>
);
case "crocolisk":
return (
<>
<mesh position={[0, 0.5, 0]} scale={[0.72, 0.4, 1.55]}>
<dodecahedronGeometry args={[0.72, 0]} />{primary}
</mesh>
<mesh position={[0, 0.48, 1.16]} scale={[0.72, 0.42, 0.9]}>
<boxGeometry args={[0.9, 0.55, 0.9]} />{accent}
</mesh>
<mesh position={[0, 0.48, -1.35]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.38, 1.65, 6]} />{primary}
</mesh>
{[-0.5, 0.5].flatMap((x) => [-0.45, 0.45].map((z) => (
<mesh key={`${x}-${z}`} position={[x, 0.18, z]} scale={[1, 0.55, 1]}>
<sphereGeometry args={[0.2, 6, 4]} />{primary}
</mesh>
)))}
</>
);
case "ooze":
return (
<>
<mesh position={[0, 0.62, 0]} scale={[1, 0.82, 1]}>
<dodecahedronGeometry args={[0.78, 1]} />
<meshStandardMaterial color={primaryColor} emissive={accentColor} emissiveIntensity={0.18} roughness={0.45} />
</mesh>
<mesh position={[-0.25, 0.82, 0.58]}><sphereGeometry args={[0.09, 8, 6]} />{accent}</mesh>
<mesh position={[0.25, 0.82, 0.58]}><sphereGeometry args={[0.09, 8, 6]} />{accent}</mesh>
</>
);
case "plant":
return (
<>
<mesh position={[0, 0.8, 0]}>
<cylinderGeometry args={[0.35, 0.52, 1.5, 7]} />{primary}
</mesh>
<mesh position={[0, 1.65, 0]} scale={[0.9, 0.72, 0.9]}>
<dodecahedronGeometry args={[0.68, 0]} />{accent}
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * 0.58, 1.05, 0]} rotation={[0, 0, side * 0.75]}>
<coneGeometry args={[0.18, 1.1, 5]} />{primary}
</mesh>
))}
</>
);
case "serpent":
return (
<>
{[0, 0.48, 0.92].map((z, index) => (
<mesh key={z} position={[0, 0.38 + index * 0.16, z - 0.55]} scale={[1, 0.82, 1.15]}>
<sphereGeometry args={[0.42 - index * 0.045, 8, 6]} />{primary}
</mesh>
))}
<mesh position={[0, 1.08, 0.92]} scale={[0.88, 0.72, 1]}>
<icosahedronGeometry args={[0.48, 0]} />{accent}
</mesh>
</>
);
case "turtle":
return (
<>
<mesh position={[0, 0.58, 0]} scale={[1.18, 0.58, 1.42]}>
<dodecahedronGeometry args={[0.72, 1]} />{primary}
</mesh>
<mesh position={[0, 0.72, 0]} scale={[1.05, 0.42, 1.27]}>
<sphereGeometry args={[0.72, 10, 6]} />{accent}
</mesh>
<mesh position={[0, 0.52, 1.14]}>
<icosahedronGeometry args={[0.34, 0]} />{primary}
</mesh>
</>
);
case "lizard":
return (
<>
<mesh position={[0, 0.72, 0]} scale={[0.88, 0.64, 1.42]}>
<dodecahedronGeometry args={[0.75, 0]} />{primary}
</mesh>
<mesh position={[0, 0.88, 1.1]} scale={[0.8, 0.72, 0.95]}>
<icosahedronGeometry args={[0.5, 0]} />{accent}
</mesh>
<mesh position={[0, 0.63, -1.32]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.38, 1.8, 6]} />{primary}
</mesh>
{[-0.38, 0, 0.38].map((z) => (
<mesh key={z} position={[0, 1.25, z]}>
<coneGeometry args={[0.12, 0.42, 4]} />{accent}
</mesh>
))}
</>
);
case "murloc":
return (
<>
<mesh position={[0, 0.95, 0]} scale={[1, 1.08, 0.72]}>
<dodecahedronGeometry args={[0.68, 0]} />{primary}
</mesh>
<mesh position={[0, 1.55, 0.12]} scale={[1.05, 0.78, 0.72]}>
<icosahedronGeometry args={[0.5, 1]} />{accent}
</mesh>
{[-0.24, 0.24].map((x) => (
<mesh key={x} position={[x, 1.72, 0.47]}>
<sphereGeometry args={[0.11, 8, 6]} />
<meshStandardMaterial color="#f4f2bc" emissive="#d8e88b" emissiveIntensity={0.4} />
</mesh>
))}
</>
);
case "winged":
return (
<>
<mesh position={[0, 1.05, 0]} scale={[0.68, 0.88, 0.9]}>
<icosahedronGeometry args={[0.62, 1]} />{primary}
</mesh>
<mesh position={[0, 1.62, 0.28]}><sphereGeometry args={[0.34, 10, 7]} />{accent}</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * 0.72, 1.17, -0.08]} rotation={[0, 0, side * 0.72]} scale={[0.45, 1.2, 0.22]}>
<dodecahedronGeometry args={[0.55, 0]} />
<meshStandardMaterial color={accentColor} emissive={accentColor} emissiveIntensity={0.22} roughness={0.58} />
</mesh>
))}
</>
);
case "humanoid":
default:
return (
<>
<mesh position={[0, 1.25, 0]}><dodecahedronGeometry args={[0.62, 0]} />{primary}</mesh>
<mesh position={[0, 1.92, 0]}><icosahedronGeometry args={[0.42, 1]} />{accent}</mesh>
{[-0.27, 0.27].map((x) => (
<mesh key={x} position={[x, 0.48, 0]}>
<cylinderGeometry args={[0.14, 0.18, 0.75, 6]} />{primary}
</mesh>
))}
</>
);
}
}
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 = (
<group scale={scale}>
<ProxyBody archetype={archetype} primaryColor={primaryColor} accentColor={accentColor} />
</group>
);
return (
<group
onPointerDown={(event) => {
if (event.button !== PRIMARY_MOUSE_BUTTON) return;
event.stopPropagation();
if (dead) {
useCombatStore.getState().lootMob(instanceId);
return;
}
usePartyStore.getState().clearSelection();
useCombatStore.getState().selectMob(instanceId);
}}
>
<AnimatedMobBody
active={active}
archetype={archetype}
animationState={animationState}
attackRevision={attackRevision}
woundRevision={woundRevision}
instanceId={instanceId}
motionWeight={model ? (proceduralModelAnimation ? 1 : 0.18) : 1}
proceduralCombatFallback={!model || proceduralModelAnimation}
forceFullFidelity={forceFullFidelity}
>
{model ? (
<CreatureModelErrorBoundary fallback={proxyBody} resetKey={model.url}>
<Suspense fallback={proxyBody}>
<OriginalCreatureModel
model={model}
active={active}
proceduralAnimation={proceduralModelAnimation}
animationState={animationState}
attackRevision={attackRevision}
attackAnimation={attackAnimation}
woundRevision={woundRevision}
forceFullFidelity={forceFullFidelity}
/>
</Suspense>
</CreatureModelErrorBoundary>
) : (
proxyBody
)}
</AnimatedMobBody>
{showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? (
<mesh
name={`mob-aggro-range-${instanceId}`}
position={[0, 0.045, 0]}
rotation={[Math.PI / 2, 0, 0]}
renderOrder={2}
>
<torusGeometry args={[mob.aggroRange, boss ? 0.075 : 0.05, 4, 56]} />
<meshBasicMaterial
color={boss ? "#e59a55" : "#c96b55"}
transparent
opacity={boss ? 0.62 : 0.5}
depthTest={false}
depthWrite={false}
/>
</mesh>
) : null}
{boss && !dead ? (
<>
<mesh position={[0, 0.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[markerRadius, Math.min(0.12, markerRadius * 0.08), 6, 24]} />
<meshStandardMaterial color={accentColor} emissive={accentColor} emissiveIntensity={1.3} />
</mesh>
<mesh position={[0, Math.max(2.43 * scale, labelHeight - 0.65), 0]}>
<coneGeometry args={[0.48, 0.55, 5]} />
<meshStandardMaterial color={accentColor} emissive={accentColor} emissiveIntensity={0.35} />
</mesh>
</>
) : null}
{selected && !dead ? (
<mesh position={[0, 0.095, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[markerRadius * 1.12, Math.min(0.09, markerRadius * 0.07), 6, 28]} />
<meshBasicMaterial color="#f7df84" transparent opacity={0.95} depthWrite={false} />
</mesh>
) : null}
<MobHealthPlate instanceId={instanceId} height={labelHeight - (showLabel ? 0.38 : 0)} boss={boss} />
{showLabel && !dead ? (
<Html
center
position={[0, labelHeight, 0]}
distanceFactor={13}
style={boss ? bossLabelStyle : mobLabelStyle}
>
<div>
<div>{definition.name}</div>
{boss ? <div style={{ fontSize: "0.78em", fontWeight: 600, opacity: 0.85 }}>{definition.title}</div> : null}
</div>
</Html>
) : null}
{lootable ? (
<Html
center
position={[0, Math.max(2.2 * scale, labelHeight - 0.35), 0]}
distanceFactor={12}
style={lootLabelStyle}
zIndexRange={[80, 0]}
>
<div>Loot {definition.name} · {mob?.pendingLoot.length ?? 0} item{mob?.pendingLoot.length === 1 ? "" : "s"}</div>
</Html>
) : null}
</group>
);
}
function MobHealthPlate({ instanceId, height, boss }: {
readonly instanceId: string;
readonly height: number;
readonly boss: boolean;
}) {
const plateRef = useRef<Group>(null);
const castFillRef = useRef<Mesh>(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 (
<group ref={plateRef} position={[0, height, 0]} renderOrder={7}>
<mesh position={[0, 0, 0]}>
<planeGeometry args={[width + 0.12, boss ? 0.24 : 0.18]} />
<meshBasicMaterial color={selected ? "#e8cf7d" : "#10100d"} depthTest depthWrite={false} />
</mesh>
<mesh position={[-(width - fillWidth) / 2, 0, 0.008]}>
<planeGeometry args={[Math.max(0.001, fillWidth), boss ? 0.14 : 0.1]} />
<meshBasicMaterial color={boss ? "#b54c37" : "#8f322d"} depthTest depthWrite={false} />
</mesh>
{mob.activeCast ? (
<>
<mesh position={[0, boss ? -0.22 : -0.18, 0.004]}>
<planeGeometry args={[width + 0.08, boss ? 0.13 : 0.11]} />
<meshBasicMaterial color="#0b1014" depthTest depthWrite={false} />
</mesh>
<mesh ref={castFillRef} position={[-width / 2, boss ? -0.22 : -0.18, 0.012]}>
<planeGeometry args={[width, boss ? 0.085 : 0.07]} />
<meshBasicMaterial
color={mob.activeCast.interruptible ? "#e0ae42" : "#777b82"}
depthTest
depthWrite={false}
/>
</mesh>
<Html
center
position={[0, boss ? -0.44 : -0.37, 0.014]}
distanceFactor={18}
style={{ ...mobLabelStyle, color: mob.activeCast.interruptible ? "#ffe6a2" : "#c7cbd0", fontSize: "9px" }}
zIndexRange={[72, 0]}
>
<div>{mob.activeCast.name}</div>
</Html>
</>
) : null}
<Html
center
position={[0, mob.activeCast ? (boss ? -0.6 : -0.52) : boss ? -0.3 : -0.24, 0.012]}
distanceFactor={18}
style={{ pointerEvents: "none" }}
zIndexRange={[70, 0]}
>
<TimedEffectStrip
targetId={instanceId}
kinds={["dot"]}
className="timed-effect-strip--world"
/>
<AuraStrip targetId={instanceId} className="aura-strip--world" />
</Html>
</group>
);
}
interface RoamingPackProps {
readonly definition: MobPackDefinition;
readonly entities: PopulationDefinitionMap;
readonly active: boolean;
readonly showLabels: boolean;
readonly useOriginalModels: boolean;
readonly allowedRuntimeIds: ReadonlySet<string> | 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<Array<Group | null>>([]);
const chasingRefs = useRef<boolean[]>(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 (
<group name={`mob-pack-${definition.id}`}>
{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 (
<group
key={member.id}
ref={(object) => {
memberRefs.current[index] = object;
}}
>
<ProxyMob
definition={entity}
instanceId={`${definition.id}:${member.id}`}
active={active}
moving
showLabel={showLabels && entity.kind === "boss"}
useOriginalModels={useOriginalModels}
/>
</group>
);
})}
</group>
);
}
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<Group>(null);
const positionRef = useRef<MutableMobVector3>([...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 (
<group ref={groupRef} name={`mob-spawn-${spawn.id}`} position={spawn.position} rotation={[0, spawn.yaw ?? 0, 0]}>
<ProxyMob
definition={entity}
instanceId={spawn.id}
active={active}
moving={(() => {
const phase = useCombatStore.getState().mobs[spawn.id]?.combatPhase;
return phase === "chasing" || phase === "returning";
})()}
showLabel={showLabels && entity.kind === "boss"}
useOriginalModels={useOriginalModels}
/>
</group>
);
}
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 (
<group name={`static-mob-cluster-${id}`}>
{spawns.map((spawn) => (
<StaticPopulationMember
key={spawn.id}
spawn={spawn}
entities={entities}
active={active}
showLabels={showLabels}
useOriginalModels={useOriginalModels}
/>
))}
</group>
);
}
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 (
<group name="mob-population">
{roamingPacks.map((pack) => (
<RoamingPack
key={pack.id}
definition={pack}
entities={entities}
active={active}
showLabels={showLabels}
useOriginalModels={useOriginalModels}
allowedRuntimeIds={allowedSet}
/>
))}
{staticClusters.map((cluster) => (
<StaticPopulationCluster
key={cluster.id}
id={cluster.id}
points={cluster.points}
spawns={cluster.spawns}
entities={entities}
active={active}
showLabels={showLabels}
useOriginalModels={useOriginalModels}
/>
))}
</group>
);
}