1462 lines
52 KiB
TypeScript
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>
|
|
);
|
|
}
|