Files
healer-man/src/scene/PartyPopulation.tsx
T
2026-09-06 14:38:59 -04:00

1495 lines
61 KiB
TypeScript

import { Html, useGLTF } from "@react-three/drei";
import { resolveContentUrl } from "../content/contentManager";
import { useFrame } from "@react-three/fiber";
import { useRapier } from "@react-three/rapier";
import {
Component,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ErrorInfo,
type ReactNode,
} from "react";
import { BufferGeometry, Quaternion, type Group, type Mesh } from "three";
import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog";
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { useCombatStore } from "../game/combatStore";
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { derivePartySpawnSlots } from "../game/manastormAdminConfig";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { DungeonBossObjective } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import {
appendPartyBreadcrumb,
createPartyBreadcrumbCursor,
createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
type PartyBreadcrumbCursor,
type PartyMovementProfile,
} from "../game/partyMovement";
import {
buildPartyNavigationGraph,
findNearestPartyNavigationSurface,
type PartyNavigationGraph,
} from "../game/partyNavigation";
import {
MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
partyObjectivesForNavigation,
shouldLoadPartyNavigation,
} from "../game/partyNavigationPolicy";
import {
emptyPartyRouteState,
nextPartyRouteRecoveryWaypoint,
nextPartyRouteWaypoint,
partyRouteNeedsPlanning,
planPartyRoute,
type PartyRouteState,
} from "../game/partyPathing";
import { partyCombatRangeBand } from "../game/partyRuntime";
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
import {
clearPartyRuntimeAttackCorridor,
getPartyRuntimePosition,
registerPartyRuntimePosition,
updatePartyRuntimeAttackCorridor,
updatePartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
type MutablePartyWorldPosition,
type PartyWorldPosition,
} from "../game/partyRuntimeRegistry";
import { usePartyStore } from "../game/partyStore";
import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore";
import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
const BREADCRUMB_REACHED_DISTANCE = 0.04;
const PARTY_COLLISION_RADIUS = 0.34;
const PARTY_COLLISION_HALF_HEIGHT = 0.48;
const PARTY_COLLISION_CENTER_HEIGHT = PARTY_COLLISION_RADIUS + PARTY_COLLISION_HALF_HEIGHT + 0.04;
const PARTY_WALKABLE_STEP_HEIGHT = 0.32;
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 6;
const PARTY_FLOOR_SAMPLE_SPACING = 0.6;
const PARTY_FLOOR_RAY_HEIGHT = 1.25;
const PARTY_FLOOR_RAY_DEPTH = 2;
const PARTY_MAX_FLOOR_DELTA = 0.55;
const PARTY_MAX_GROUND_CORRECTION = 0.85;
const EMPTY_NAVIGATION_LINKS = [] as const;
const PARTY_PLAYER_TRAILING_DISTANCE: readonly number[] = [1.45, 2.6, 3.75, 4.9];
const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6];
const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45];
interface PartyAvatarMetrics {
readonly labelY: number;
readonly ringRadius: number;
readonly hitRadius: number;
readonly hitLength: number;
readonly hitCenterY: number;
}
interface PartyStuckProgress {
readonly position: MutablePartyWorldPosition;
lastProgressAt: number;
}
interface PartyCombatAnchor {
readonly targetId: string;
readonly bearing: number;
}
type PartyAvatarMetricKey = `${RaceId}:${GenderId}`;
/** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */
const PARTY_AVATAR_METRICS: Readonly<Partial<Record<PartyAvatarMetricKey, PartyAvatarMetrics>>> = {
"human:male": { labelY: 2.38, ringRadius: 0.72, hitRadius: 0.5, hitLength: 1.13, hitCenterY: 1.06 },
"human:female": { labelY: 2.19, ringRadius: 0.44, hitRadius: 0.42, hitLength: 1.1, hitCenterY: 0.97 },
"orc:male": { labelY: 2.84, ringRadius: 0.94, hitRadius: 0.67, hitLength: 1.25, hitCenterY: 1.29 },
"orc:female": { labelY: 2.46, ringRadius: 0.63, hitRadius: 0.43, hitLength: 1.36, hitCenterY: 1.1 },
"dwarf:male": { labelY: 1.92, ringRadius: 0.69, hitRadius: 0.47, hitLength: 0.73, hitCenterY: 0.83 },
"dwarf:female": { labelY: 1.79, ringRadius: 0.55, hitRadius: 0.42, hitLength: 0.7, hitCenterY: 0.77 },
"night-elf:male": { labelY: 2.81, ringRadius: 0.71, hitRadius: 0.49, hitLength: 1.6, hitCenterY: 1.28 },
"night-elf:female": { labelY: 2.54, ringRadius: 0.51, hitRadius: 0.42, hitLength: 1.45, hitCenterY: 1.14 },
"undead:male": { labelY: 2.61, ringRadius: 0.59, hitRadius: 0.42, hitLength: 1.52, hitCenterY: 1.18 },
"undead:female": { labelY: 2.41, ringRadius: 0.5, hitRadius: 0.42, hitLength: 1.32, hitCenterY: 1.08 },
"tauren:male": { labelY: 2.58, ringRadius: 0.94, hitRadius: 0.67, hitLength: 1.04, hitCenterY: 1.14 },
"tauren:female": { labelY: 2.48, ringRadius: 0.8, hitRadius: 0.56, hitLength: 1.1, hitCenterY: 1.11 },
"gnome:male": { labelY: 1.57, ringRadius: 0.5, hitRadius: 0.42, hitLength: 0.49, hitCenterY: 0.66 },
"gnome:female": { labelY: 1.42, ringRadius: 0.41, hitRadius: 0.42, hitLength: 0.34, hitCenterY: 0.59 },
"troll:male": { labelY: 3.44, ringRadius: 0.82, hitRadius: 0.58, hitLength: 2.04, hitCenterY: 1.6 },
"troll:female": { labelY: 2.93, ringRadius: 0.6, hitRadius: 0.42, hitLength: 1.84, hitCenterY: 1.34 },
"blood-elf:male": { labelY: 2.49, ringRadius: 0.6, hitRadius: 0.42, hitLength: 1.4, hitCenterY: 1.12 },
"blood-elf:female": { labelY: 2.28, ringRadius: 0.47, hitRadius: 0.42, hitLength: 1.19, hitCenterY: 1.02 },
"draenei:male": { labelY: 2.96, ringRadius: 0.85, hitRadius: 0.6, hitLength: 1.5, hitCenterY: 1.35 },
"draenei:female": { labelY: 2.72, ringRadius: 0.54, hitRadius: 0.42, hitLength: 1.63, hitCenterY: 1.23 },
};
const DEFAULT_PARTY_AVATAR_METRICS: PartyAvatarMetrics = {
labelY: 2.55,
ringRadius: 0.68,
hitRadius: 0.5,
hitLength: 1.35,
hitCenterY: 1.15,
};
const partyLabelStyle: CSSProperties = {
color: "#eef6e6",
fontFamily: "system-ui, sans-serif",
fontSize: "10px",
fontWeight: 700,
lineHeight: 1.1,
pointerEvents: "none",
textAlign: "center",
textShadow: "0 1px 2px #000, 0 0 5px #000",
userSelect: "none",
whiteSpace: "nowrap",
};
interface NavigationAssetBoundaryProps {
readonly children: ReactNode;
readonly onFailure: () => void;
}
interface NavigationAssetBoundaryState {
readonly failed: boolean;
}
/** A navigation failure removes only the optional route helper, never the Canvas. */
class NavigationAssetBoundary extends Component<NavigationAssetBoundaryProps, NavigationAssetBoundaryState> {
state: NavigationAssetBoundaryState = { failed: false };
static getDerivedStateFromError(): NavigationAssetBoundaryState {
return { failed: true };
}
componentDidCatch(error: unknown, info: ErrorInfo): void {
this.props.onFailure();
console.warn("Party navigation asset could not be prepared; companions will follow breadcrumbs.", error, info);
}
render(): ReactNode {
return this.state.failed ? null : this.props.children;
}
}
function navigationGeometry(scene: Group): BufferGeometry {
scene.updateMatrixWorld(true);
let sourceMesh: Mesh | null = null;
let largestVertexCount = 0;
scene.traverse((node) => {
const mesh = node as Mesh;
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
if (count > largestVertexCount) {
sourceMesh = mesh;
largestVertexCount = count;
}
});
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone();
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry;
}
function PartyNavigationLoader({
url,
offMeshLinks,
onReady,
}: {
readonly url: string;
readonly offMeshLinks: ReturnType<typeof requireDungeonDefinition>["navigationLinks"];
readonly onReady: (graph: PartyNavigationGraph) => void;
}) {
const gltf = useGLTF(resolveContentUrl(url));
const graph = useMemo(() => {
const geometry = navigationGeometry(gltf.scene);
try {
return buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks,
});
} finally {
geometry.dispose();
}
}, [gltf.scene, offMeshLinks]);
useEffect(() => onReady(graph), [graph, onReady]);
return null;
}
function planarDistance(left: PartyWorldPosition, right: PartyWorldPosition): number {
return Math.hypot(right[0] - left[0], right[2] - left[2]);
}
function fullDistance(left: PartyWorldPosition, right: PartyWorldPosition): number {
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
}
function setEntrancePosition(
index: number,
anchor: PartyWorldPosition,
forwardX: number,
forwardZ: number,
out: MutablePartyWorldPosition,
): void {
const forwardDistance = PARTY_ENTRANCE_FORWARD_DISTANCE[index] ?? 0.6;
// The dungeon entrance ends immediately behind the player. Spawn the squad
// in a compact line on the walkable side, with the tank furthest forward.
partyFormationPosition(anchor, forwardX, forwardZ, 0, -forwardDistance, out);
}
function initialMemberPosition(index: number): MutablePartyWorldPosition {
const game = useGameStore.getState();
if (game.gameMode === "manastorm") return [...game.playerPosition];
const out: MutablePartyWorldPosition = [0, 0, 0];
setEntrancePosition(
index,
game.playerPosition,
Math.sin(game.cameraYaw),
Math.cos(game.cameraYaw),
out,
);
return out;
}
function PartyActors({
active,
simulate,
navigationGraph,
navigationFailed,
objectives,
stackAtEntrance,
}: {
readonly active: boolean;
readonly simulate: boolean;
readonly navigationGraph: PartyNavigationGraph | null;
readonly navigationFailed: boolean;
readonly objectives: readonly DungeonBossObjective[];
readonly stackAtEntrance: boolean;
}) {
const members = usePartyStore((state) => state.members);
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
const equipmentItemsByOwner = useMemo(
() => new Map(
members.map((member) => [
member.id,
resolvedEquipmentItems(equipment, inventory, {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
}),
]),
),
[equipment, inventory, members],
);
const resetRevision = useGameStore((state) => state.resetRevision);
const { rapier, world } = useRapier();
const navigationCapsule = useMemo(
// Trim the lower leg band while keeping the capsule top at normal head
// height. This supplies step clearance without demanding extra ceiling.
() => new rapier.Capsule(
PARTY_COLLISION_HALF_HEIGHT - PARTY_WALKABLE_STEP_HEIGHT * 0.5,
PARTY_COLLISION_RADIUS,
),
[rapier],
);
const navigationRotation = useMemo(() => new Quaternion(), []);
const presentationY = useCallback((position: PartyWorldPosition): number => {
const ray = new rapier.Ray({
x: position[0],
y: position[1] + PARTY_FLOOR_RAY_HEIGHT,
z: position[2],
}, { x: 0, y: -1, z: 0 });
const floor = world.castRay(
ray,
PARTY_FLOOR_RAY_DEPTH,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
);
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
}, [rapier, world]);
const directSegmentAllowed = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
const planarDistance = Math.hypot(dx, dz);
// Collision and navigation surfaces commonly disagree by a few centimeters.
// Treat a same-column height correction as grounding, not a blocked move.
if (planarDistance <= 0.04) {
return Math.abs(dy) <= PARTY_WALKABLE_STEP_HEIGHT;
}
const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
const obstacle = world.castShape(
{
x: start[0],
// Raise only the trimmed lower band over walkable relief. The floor
// samples below still reject real ledges and excessive elevation.
y: start[1] + PARTY_COLLISION_CENTER_HEIGHT + PARTY_WALKABLE_STEP_HEIGHT * 0.5,
z: start[2],
},
navigationRotation,
{ x: dx / distance, y: dy / distance, z: dz / distance },
navigationCapsule,
0.025,
distance,
false,
queryFlags,
);
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
// A clear chest-height cast is insufficient across a ledge. Require the
// collision floor to remain close to the planned segment at short samples.
const sampleCount = Math.max(1, Math.ceil(distance / PARTY_FLOOR_SAMPLE_SPACING));
for (let sample = 0; sample <= sampleCount; sample += 1) {
const progress = sample / sampleCount;
const expectedY = start[1] + dy * progress;
const ray = new rapier.Ray({
x: start[0] + dx * progress,
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
z: start[2] + dz * progress,
}, { x: 0, y: -1, z: 0 });
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, queryFlags);
if (!floor) return false;
const floorY = ray.origin.y - floor.timeOfImpact;
if (Math.abs(floorY - expectedY) > PARTY_MAX_FLOOR_DELTA) return false;
}
return true;
}, [navigationCapsule, navigationRotation, rapier, world]);
const hasStaticLineOfSight = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
const routeSurfaceSegmentAllowed = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= 0.04) return true;
const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
// Portal-centered paths already stay inside walkable triangles. One
// body-center ray catches a true wall/overhang without treating the floor
// or a tessellation edge as missing agent-radius clearance. Failed
// triangles are excluded and A* searches another corridor.
const ray = new rapier.Ray({
x: start[0],
y: start[1] + 0.9,
z: start[2],
}, { x: dx / distance, y: dy / distance, z: dz / distance });
const hit = world.castRay(ray, distance, true, queryFlags);
return !hit || hit.timeOfImpact >= distance - 0.04;
}, [rapier, world]);
const actorRefs = useRef<Array<Group | null>>([]);
const bodyRefs = useRef<Array<Group | null>>([]);
const positionsRef = useRef(new Map<string, MutablePartyWorldPosition>());
const movingSignalsRef = useRef(new Map<string, { current: boolean }>());
const animationEventSignalsRef = useRef(new Map<string, { current: CharacterCombatAnimationEvent | null }>());
const animationRevisionRef = useRef(0);
const lastNextActionAtRef = useRef(new Map<string, number>());
const lastHealthRef = useRef(new Map<string, number>());
const routeRef = useRef<PartyRouteState>(emptyPartyRouteState());
const combatRoutesRef = useRef(new Map<string, PartyRouteState>());
const navigationGraphRef = useRef(navigationGraph);
navigationGraphRef.current = navigationGraph;
const playerTrailRef = useRef(createPartyBreadcrumbTrail());
const leaderPlayerCursorRef = useRef(createPartyBreadcrumbCursor());
const leaderTrailRef = useRef(createPartyBreadcrumbTrail());
const trailLeaderIdRef = useRef<string | null>(null);
const lastActiveTargetIdRef = useRef<string | null>(null);
const followerCursorsRef = useRef(new Map<string, PartyBreadcrumbCursor>());
const lastLeaderWaypointRef = useRef<MutablePartyWorldPosition | null>(null);
const lastCommandRef = useRef(usePartyStore.getState().command);
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
const memberIdKey = members.map((member) => member.id).join("|");
useEffect(() => registerCombatSpatialProvider({
actor: (actorId) => {
const gameState = useGameStore.getState();
if (actorId === PLAYER_AGGRO_ID) {
return { id: actorId, position: gameState.playerPosition, yaw: gameState.cameraYaw };
}
const memberIndex = usePartyStore.getState().members.findIndex((member) => member.id === actorId);
const memberPosition = positionsRef.current.get(actorId);
if (memberPosition) {
return {
id: actorId,
position: memberPosition,
yaw: actorRefs.current[memberIndex]?.rotation.y ?? gameState.cameraYaw,
};
}
const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === actorId);
if (summon) return { id: actorId, position: summon.position, yaw: gameState.cameraYaw };
const mobPosition = getMobPosition(actorId);
if (!mobPosition) return null;
const mob = useCombatStore.getState().mobs[actorId];
const targetPosition = mob?.targetActorId === PLAYER_AGGRO_ID
? gameState.playerPosition
: mob?.targetActorId
? positionsRef.current.get(mob.targetActorId)
?? useCombatStore.getState().summons.find((candidate) => candidate.id === mob.targetActorId)?.position
: null;
const yaw = targetPosition
? Math.atan2(targetPosition[0] - mobPosition[0], targetPosition[2] - mobPosition[2])
: 0;
return { id: actorId, position: mobPosition, yaw };
},
hasLineOfSight: hasStaticLineOfSight,
projectGroundPoint: (requested, maximumDistance, requestedOrigin) => {
const origin = requestedOrigin ?? useGameStore.getState().playerPosition;
const dx = requested[0] - origin[0];
const dz = requested[2] - origin[2];
const planar = Math.hypot(dx, dz);
const scale = planar > maximumDistance && planar > 0 ? maximumDistance / planar : 1;
const x = origin[0] + dx * scale;
const z = origin[2] + dz * scale;
const ray = new rapier.Ray({ x, y: requested[1] + 8, z }, { x: 0, y: -1, z: 0 });
const hit = world.castRay(
ray,
20,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
);
return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null;
},
}), [hasStaticLineOfSight, memberIdKey, rapier, world]);
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState();
party.members.forEach((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
// The player's exact point is the only location guaranteed to be on the
// same side of a shortcut, doorway, or ledge. The breadcrumb system
// spreads the party back into a trail once the player moves.
position[0] = player[0];
position[1] = player[1];
position[2] = player[2];
positionsRef.current.set(member.id, position);
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
const movingSignal = movingSignalsRef.current.get(member.id);
if (movingSignal) movingSignal.current = false;
const body = bodyRefs.current[index];
if (body) body.position.y = 0;
stuckProgressRef.current.set(member.id, {
position: [position[0], position[1], position[2]],
lastProgressAt: now,
});
});
const firstLiving = party.members.find((member) => member.health > 0);
resetPartyBreadcrumbTrail(playerTrailRef.current, player);
leaderPlayerCursorRef.current.breadcrumbId = null;
resetPartyBreadcrumbTrail(leaderTrailRef.current, player);
trailLeaderIdRef.current = firstLiving?.id ?? null;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
lastActiveTargetIdRef.current = party.activeMobId;
lastLeaderWaypointRef.current = null;
clearPartyRuntimeAttackCorridor();
party.setRouteBlocked(false);
}, [presentationY]);
useEffect(() => {
const game = useGameStore.getState();
const activeIds = new Set(members.map((member) => member.id));
for (const id of animationEventSignalsRef.current.keys()) {
if (!activeIds.has(id)) animationEventSignalsRef.current.delete(id);
}
for (const id of lastNextActionAtRef.current.keys()) {
if (!activeIds.has(id)) lastNextActionAtRef.current.delete(id);
}
for (const id of lastHealthRef.current.keys()) {
if (!activeIds.has(id)) lastHealthRef.current.delete(id);
}
for (const id of combatRoutesRef.current.keys()) {
if (!activeIds.has(id)) combatRoutesRef.current.delete(id);
}
for (const id of stuckProgressRef.current.keys()) {
if (!activeIds.has(id)) stuckProgressRef.current.delete(id);
}
for (const id of directFollowMovingRef.current.keys()) {
if (!activeIds.has(id)) directFollowMovingRef.current.delete(id);
}
for (const id of combatAnchorsRef.current.keys()) {
if (!activeIds.has(id)) combatAnchorsRef.current.delete(id);
}
for (const key of movementProfilesRef.current.keys()) {
if (![...activeIds].some((id) => key.startsWith(`${id}|`))) {
movementProfilesRef.current.delete(key);
}
}
const cleanups = members.map((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
positionsRef.current.set(member.id, position);
if (!lastNextActionAtRef.current.has(member.id)) {
lastNextActionAtRef.current.set(member.id, member.nextActionAt);
}
if (!lastHealthRef.current.has(member.id)) lastHealthRef.current.set(member.id, member.health);
actorRefs.current[index]?.position.set(position[0], position[1], position[2]);
const runtimeCleanup = registerPartyRuntimePosition(member.id, position);
const actor = actorRefs.current[index];
const anchorCleanup = actor ? registerCombatEffectAnchor(member.id, actor) : undefined;
return () => {
runtimeCleanup();
anchorCleanup?.();
};
});
// Make the initial facing agree with the player instead of flashing at yaw zero.
for (const actor of actorRefs.current) {
if (actor) actor.rotation.y = game.cameraYaw;
}
return () => {
cleanups.forEach((cleanup) => cleanup());
for (const id of followerCursorsRef.current.keys()) {
if (!activeIds.has(id)) followerCursorsRef.current.delete(id);
}
};
// IDs, not low-frequency health/status commits, own registry lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [memberIdKey]);
useEffect(() => {
const game = useGameStore.getState();
const spawnSlots = derivePartySpawnSlots(game.activeSpawn, members.length + 1);
members.forEach((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
position[0] = slot.footPosition[0];
position[1] = slot.footPosition[1];
position[2] = slot.footPosition[2];
const navigationSpawn = navigationGraphRef.current
? findNearestPartyNavigationSurface(navigationGraphRef.current, position, 6)
: null;
if (navigationSpawn) {
position[0] = navigationSpawn.position[0];
position[1] = navigationSpawn.position[1];
position[2] = navigationSpawn.position[2];
}
positionsRef.current.set(member.id, position);
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
actorRefs.current[index]?.position.set(position[0], position[1], position[2]);
});
const firstLiving = members.find((member) => member.health > 0);
const leaderPosition = firstLiving ? positionsRef.current.get(firstLiving.id) : null;
resetPartyBreadcrumbTrail(playerTrailRef.current, game.playerPosition);
leaderPlayerCursorRef.current.breadcrumbId = null;
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? game.playerPosition);
trailLeaderIdRef.current = firstLiving?.id ?? null;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
lastActiveTargetIdRef.current = null;
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
stuckProgressRef.current.clear();
}, [memberIdKey, resetRevision, stackAtEntrance]);
useEffect(() => {
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
if (!navigationGraph) return;
const partyMembers = usePartyStore.getState().members;
partyMembers.forEach((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
const navigationSpawn = findNearestPartyNavigationSurface(navigationGraph, position, 6);
if (!navigationSpawn) return;
position[0] = navigationSpawn.position[0];
position[1] = navigationSpawn.position[1];
position[2] = navigationSpawn.position[2];
positionsRef.current.set(member.id, position);
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
actorRefs.current[index]?.position.set(position[0], position[1], position[2]);
});
const firstLiving = partyMembers.find((member) => member.health > 0);
const leaderPosition = firstLiving ? positionsRef.current.get(firstLiving.id) : null;
if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
followerCursorsRef.current.clear();
stuckProgressRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
}, [navigationGraph]);
// Time spent in a pause/menu must not count toward the stuck watchdog.
useEffect(() => {
stuckProgressRef.current.clear();
}, [active]);
useEffect(() => clearPartyRuntimeAttackCorridor, []);
useFrame(({ clock }, delta) => {
if (!active) return;
const party = usePartyStore.getState();
if (!party.members.length) return;
if (!simulate) {
party.members.forEach((member, index) => {
const synchronized = getPartyRuntimePosition(member.id);
if (!synchronized) return;
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
position[0] = synchronized[0];
position[1] = synchronized[1];
position[2] = synchronized[2];
positionsRef.current.set(member.id, position);
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
const movingSignal = movingSignalsRef.current.get(member.id);
if (movingSignal) movingSignal.current = false;
});
return;
}
const game = useGameStore.getState();
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000;
const playerTrail = playerTrailRef.current.points;
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
let playerForwardX = Math.sin(game.cameraYaw);
let playerForwardZ = Math.cos(game.cameraYaw);
if (latestPlayerTrailPoint && previousPlayerTrailPoint) {
const trailX = latestPlayerTrailPoint.position[0] - previousPlayerTrailPoint.position[0];
const trailZ = latestPlayerTrailPoint.position[2] - previousPlayerTrailPoint.position[2];
if (Math.hypot(trailX, trailZ) > 0.05) {
playerForwardX = trailX;
playerForwardZ = trailZ;
}
}
if (lastRecallRevisionRef.current !== party.recallRevision) {
lastRecallRevisionRef.current = party.recallRevision;
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
if (lastCommandRef.current !== party.command) {
lastCommandRef.current = party.command;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
const livingLeader = party.members.find((member) => member.health > 0);
const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null;
resetPartyBreadcrumbTrail(
leaderTrailRef.current,
livingLeaderPosition ?? playerNavigation,
);
lastLeaderWaypointRef.current = null;
}
const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null;
const leaderIndex = party.members.findIndex((member) => member.health > 0);
const safeDelta = Math.min(delta, 0.05);
const desired: MutablePartyWorldPosition = [0, 0, 0];
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
const livingIndices = party.members
.map((member, index) => member.health > 0 ? index : -1)
.filter((index) => index >= 0);
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
const key = `${memberId}|${livingRank}`;
let profile = movementProfilesRef.current.get(key);
if (!profile) {
profile = partyMovementProfile(memberId, livingRank);
movementProfilesRef.current.set(key, profile);
}
return profile;
};
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
const peers: PartyWorldPosition[] = [];
for (const [id, position] of positionsRef.current) {
if (id !== memberId && livingIds.has(id)) peers.push(position);
}
return peers;
};
let leaderHasWaypoint = false;
let leaderMovementExpected = false;
let leaderFollowingPlayer = false;
let leaderPlayerTrailBlocked = false;
let forceLeaderBreadcrumb = false;
const leaderMember = leaderIndex >= 0 ? party.members[leaderIndex] : null;
const leaderPosition = leaderMember ? positionsRef.current.get(leaderMember.id) : null;
const leaderTargetId = leaderMember
? party.memberTargetIds[leaderMember.id] ?? party.activeMobId
: party.activeMobId;
const targetPosition = leaderTargetId ? getMobPosition(leaderTargetId) : null;
const activeTargetId = targetPosition ? leaderTargetId : null;
if (lastActiveTargetIdRef.current !== activeTargetId) {
lastActiveTargetIdRef.current = activeTargetId;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
followerCursorsRef.current.clear();
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? playerNavigation);
lastLeaderWaypointRef.current = null;
}
if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) {
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
trailLeaderIdRef.current = leaderMember.id;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
combatAnchorsRef.current.clear();
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
}
if (leaderMember && leaderPosition && party.command !== "stop") {
const leader = leaderMember;
let goalKey: string;
let hasDestination = true;
if (targetPosition && leaderTargetId) {
const profile = movementProfileFor(leader.id, leaderIndex);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(leader),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(leader.id);
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
combatAnchor = {
targetId: leaderTargetId,
bearing: Math.atan2(
leaderPosition[0] - targetPosition[0],
leaderPosition[2] - targetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(leader.id, combatAnchor);
}
const peers = peerPositionsFor(leader.id);
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
leaderPosition,
targetPosition,
hasStaticLineOfSight(leaderPosition, targetPosition),
rangeBand,
combatAnchor.bearing,
crowded,
leaderDestination,
);
if (movementIntent === "hold") {
hasDestination = false;
goalKey = `leader:${leader.id}|mob:${leaderTargetId}|in-range`;
} else {
goalKey = `leader:${leader.id}|mob:${leaderTargetId}|${movementIntent}`;
}
} else if (party.command === "attack" && objective) {
// Imported source spawns can sit on decorative or discarded Recast
// islands. Route to the nearest retained walkable surface and let the
// normal aggro/combat path take over when the encounter engages.
const objectiveApproach = navigationGraph
? findNearestPartyNavigationSurface(
navigationGraph,
objective.position,
PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE,
)?.position
: null;
const objectiveDestination = objectiveApproach ?? objective.position;
leaderDestination[0] = objectiveDestination[0];
leaderDestination[1] = objectiveDestination[1];
leaderDestination[2] = objectiveDestination[2];
goalKey = `leader:${leader.id}|objective:${objective.id}`;
} else {
leaderFollowingPlayer = true;
leaderDestination[0] = playerNavigation[0];
leaderDestination[1] = playerNavigation[1];
leaderDestination[2] = playerNavigation[2];
goalKey = `leader:${leader.id}|player`;
}
leaderMovementExpected = hasDestination
&& fullDistance(leaderPosition, leaderDestination) > 0.18;
if (leaderFollowingPlayer) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const cursor = leaderPlayerCursorRef.current;
const profile = movementProfileFor(leader.id, leaderIndex);
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
const personalizedTrailingDistance = Math.max(
0.9,
baseTrailingDistance + profile.followDistanceJitter,
);
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
baseTrailingDistance,
profile,
routeNow,
leaderDestination,
);
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
leaderHasWaypoint = partyShouldMoveToComfortTarget(
leaderPosition,
leaderDestination,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(leader.id, leaderHasWaypoint);
synchronizePartyBreadcrumbCursor(
playerTrailRef.current,
cursor,
personalizedTrailingDistance,
);
if (leaderHasWaypoint) {
desired[0] = leaderDestination[0];
desired[1] = leaderDestination[1];
desired[2] = leaderDestination[2];
}
leaderMovementExpected = leaderHasWaypoint;
} else {
directFollowMovingRef.current.set(leader.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
playerTrailRef.current,
cursor,
leaderPosition,
personalizedTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
leaderHasWaypoint
&& requiresValidatedRejoin
&& !directSegmentAllowed(leaderPosition, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack;
}
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
if (
routeRef.current.goalKey !== goalKey
|| routeRef.current.blocked
|| routeRef.current.waypoints.length > 0
) routeRef.current = { ...emptyPartyRouteState(), goalKey };
} else {
if (partyRouteNeedsPlanning(
routeRef.current,
goalKey,
leaderPosition,
leaderDestination,
routeNow,
)) {
routeRef.current = planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
leaderDestination,
routeNow,
{
maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed,
},
);
}
leaderHasWaypoint = nextPartyRouteWaypoint(routeRef.current, leaderPosition, desired);
if (!leaderHasWaypoint && routeRef.current.blocked) {
leaderHasWaypoint = nextPartyRouteRecoveryWaypoint(
routeRef.current,
leaderPosition,
desired,
directSegmentAllowed,
);
}
if (
!leaderHasWaypoint
&& targetPosition
&& routeRef.current.blocked
&& directSegmentAllowed(leaderPosition, leaderDestination)
) {
desired[0] = leaderDestination[0];
desired[1] = leaderDestination[1];
desired[2] = leaderDestination[2];
leaderHasWaypoint = true;
}
}
}
if (leaderHasWaypoint) {
const previousWaypoint = lastLeaderWaypointRef.current;
if (!previousWaypoint || fullDistance(previousWaypoint, desired) > 0.05) {
forceLeaderBreadcrumb = true;
lastLeaderWaypointRef.current = [desired[0], desired[1], desired[2]];
}
} else {
lastLeaderWaypointRef.current = null;
}
if (forceLeaderBreadcrumb && leaderPosition) {
appendPartyBreadcrumb(leaderTrailRef.current, leaderPosition, 0.01);
}
if (
party.command === "attack"
&& objective
&& !targetPosition
&& leaderPosition
&& !routeRef.current.blocked
&& routeRef.current.waypoints.length > routeRef.current.cursor
) {
updatePartyRuntimeAttackCorridor(
leaderPosition,
routeRef.current.waypoints,
routeRef.current.cursor,
);
} else {
clearPartyRuntimeAttackCorridor();
}
let frameRouteBlocked = Boolean(
leaderIndex >= 0
&& party.command !== "stop"
&& !targetPosition
&& leaderMovementExpected
&& (
leaderFollowingPlayer
? leaderPlayerTrailBlocked
: navigationFailed || Boolean(navigationGraph && routeRef.current.blocked)
),
);
let autoRecallRequested = false;
for (let index = 0; index < party.members.length; index += 1) {
const member = party.members[index];
const actor = actorRefs.current[index];
const body = bodyRefs.current[index];
const current = positionsRef.current.get(member.id);
const previousNextActionAt = lastNextActionAtRef.current.get(member.id)
?? member.nextActionAt;
if (member.nextActionAt > 0 && member.nextActionAt !== previousNextActionAt) {
const signal = animationEventSignalsRef.current.get(member.id);
animationRevisionRef.current += 1;
if (signal) signal.current = { revision: animationRevisionRef.current, kind: "attack" };
}
lastNextActionAtRef.current.set(member.id, member.nextActionAt);
const previousHealth = lastHealthRef.current.get(member.id) ?? member.health;
if (previousHealth > 0 && member.health <= 0) {
const signal = animationEventSignalsRef.current.get(member.id);
animationRevisionRef.current += 1;
if (signal) signal.current = { revision: animationRevisionRef.current, kind: "death" };
} else if (previousHealth <= 0 && member.health > 0) {
const signal = animationEventSignalsRef.current.get(member.id);
animationRevisionRef.current += 1;
if (signal) signal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" };
}
lastHealthRef.current.set(member.id, member.health);
if (!actor || !current) continue;
const memberTargetId = party.memberTargetIds[member.id] ?? party.activeMobId;
const memberTargetPosition = memberTargetId ? getMobPosition(memberTargetId) : null;
const canSeeActiveTarget = Boolean(
memberTargetId
&& memberTargetPosition
&& hasStaticLineOfSight(current, memberTargetPosition),
);
updatePartyRuntimeTargetVisibility(member.id, memberTargetId, canSeeActiveTarget);
let shouldMove = false;
let hasDesired = false;
let movementExpected = false;
let faceX = Math.sin(actor.rotation.y);
let faceZ = Math.cos(actor.rotation.y);
if (member.health <= 0) {
const signal = movingSignalsRef.current.get(member.id);
if (signal) signal.current = false;
} else if (party.command === "stop") {
// Holding is represented by retaining the current world-space tuple.
} else if (index === leaderIndex) {
hasDesired = leaderHasWaypoint;
movementExpected = leaderMovementExpected;
if (memberTargetPosition) {
faceX = memberTargetPosition[0] - current[0];
faceZ = memberTargetPosition[2] - current[2];
} else if (hasDesired) {
faceX = desired[0] - current[0];
faceZ = desired[2] - current[2];
}
} else {
if (memberTargetPosition && memberTargetId) {
const profile = movementProfileFor(member.id, index);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(member),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(member.id);
if (!combatAnchor || combatAnchor.targetId !== memberTargetId) {
combatAnchor = {
targetId: memberTargetId,
bearing: Math.atan2(
current[0] - memberTargetPosition[0],
current[2] - memberTargetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(member.id, combatAnchor);
}
const peers = peerPositionsFor(member.id);
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
current,
memberTargetPosition,
canSeeActiveTarget,
rangeBand,
combatAnchor.bearing,
crowded,
desired,
);
movementExpected = movementIntent !== "hold";
if (movementIntent === "hold") {
combatRoutesRef.current.delete(member.id);
} else {
const goalKey = `member:${member.id}|mob:${memberTargetId}|${movementIntent}`;
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
if (partyRouteNeedsPlanning(
route,
goalKey,
current,
desired,
routeNow,
{
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
},
)) {
route = planPartyRoute(
navigationGraph,
goalKey,
current,
desired,
routeNow,
{
maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed,
},
);
combatRoutesRef.current.set(member.id, route);
}
hasDesired = nextPartyRouteWaypoint(route, current, desired);
const routePrefixRecovery = !hasDesired && route.blocked
? nextPartyRouteRecoveryWaypoint(
route,
current,
desired,
directSegmentAllowed,
)
: false;
if (routePrefixRecovery) hasDesired = true;
const directCombatFallback = !hasDesired
&& route.blocked
&& directSegmentAllowed(current, desired);
if (directCombatFallback) {
hasDesired = true;
}
}
} else {
combatRoutesRef.current.delete(member.id);
let cursor = followerCursorsRef.current.get(member.id);
if (!cursor) {
cursor = createPartyBreadcrumbCursor();
followerCursorsRef.current.set(member.id, cursor);
}
const livingRank = Math.max(1, livingIndices.indexOf(index));
const profile = movementProfileFor(member.id, index);
const playerTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[livingRank]
?? 1.45 + livingRank * 1.15;
const leaderTrailingDistance = Math.max(
0.9,
(PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15)
+ profile.followDistanceJitter,
);
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
playerTrailingDistance,
profile,
routeNow,
naturalFollowTarget,
);
if (directSegmentAllowed(current, naturalFollowTarget)) {
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
hasDesired = partyShouldMoveToComfortTarget(
current,
naturalFollowTarget,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(member.id, hasDesired);
synchronizePartyBreadcrumbCursor(
leaderTrailRef.current,
cursor,
leaderTrailingDistance,
);
if (hasDesired) {
desired[0] = naturalFollowTarget[0];
desired[1] = naturalFollowTarget[1];
desired[2] = naturalFollowTarget[2];
}
movementExpected = hasDesired;
} else {
directFollowMovingRef.current.set(member.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
hasDesired = nextPartyBreadcrumbFollowWaypoint(
leaderTrailRef.current,
cursor,
current,
leaderTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
hasDesired
&& requiresValidatedRejoin
&& !directSegmentAllowed(current, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
}
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack;
}
}
if (memberTargetPosition) {
faceX = memberTargetPosition[0] - current[0];
faceZ = memberTargetPosition[2] - current[2];
} else if (hasDesired) {
faceX = desired[0] - current[0];
faceZ = desired[2] - current[2];
}
}
const movementProfile = movementProfileFor(member.id, index);
if (hasDesired) {
const peers = peerPositionsFor(member.id);
if (
partySeparationGoal(
current,
desired,
peers,
movementProfile.swayPhase,
separatedDesired,
)
&& directSegmentAllowed(current, separatedDesired)
) {
desired[0] = separatedDesired[0];
desired[1] = separatedDesired[1];
desired[2] = separatedDesired[2];
}
}
const currentPlanarDistance = planarDistance(current, playerNavigation);
if (member.health > 0 && party.command !== "stop" && hasDesired) {
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
* movementProfile.speedMultiplier;
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
// Route progress, not radial player distance, is authoritative. A
// valid path may need to move away briefly around a U-bend.
current[0] = nextPosition[0];
current[1] = nextPosition[1];
current[2] = nextPosition[2];
shouldMove = moved > 0.002;
}
if (index === leaderIndex && member.health > 0) {
appendPartyBreadcrumb(leaderTrailRef.current, current);
}
const signal = movingSignalsRef.current.get(member.id);
if (signal) signal.current = shouldMove;
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
// Navigation height remains authoritative for route progress. Collision
// height is presentation-only; mutating the route position here can pin
// an actor forever on a vertically offset first waypoint.
actor.position.set(
current[0],
presentationY(current),
current[2],
);
let stuckProgress = stuckProgressRef.current.get(member.id);
if (!stuckProgress) {
stuckProgress = {
position: [current[0], current[1], current[2]],
lastProgressAt: routeNow,
};
stuckProgressRef.current.set(member.id, stuckProgress);
}
if (member.health <= 0 || party.command === "stop" || !movementExpected) {
stuckProgress.position[0] = current[0];
stuckProgress.position[1] = current[1];
stuckProgress.position[2] = current[2];
stuckProgress.lastProgressAt = routeNow;
} else if (partyMadeStuckProgress(stuckProgress.position, current)) {
stuckProgress.position[0] = current[0];
stuckProgress.position[1] = current[1];
stuckProgress.position[2] = current[2];
stuckProgress.lastProgressAt = routeNow;
} else if (partyShouldRecallForStuck(
current,
playerNavigation,
movementExpected,
routeNow,
stuckProgress.lastProgressAt,
)) {
autoRecallRequested = true;
}
if (Math.hypot(faceX, faceZ) > 0.001) {
const targetYaw = Math.atan2(faceX, faceZ);
const yawDelta = Math.atan2(
Math.sin(targetYaw - actor.rotation.y),
Math.cos(targetYaw - actor.rotation.y),
);
actor.rotation.y += yawDelta * (1 - Math.exp(-12 * safeDelta));
}
if (body) {
const locomotionPhase = clock.elapsedTime * 9 + index * 0.85;
body.position.y = shouldMove ? Math.abs(Math.sin(locomotionPhase)) * 0.055 : 0;
body.rotation.z += (0 - body.rotation.z) * (1 - Math.exp(-10 * safeDelta));
body.rotation.x = 0;
}
}
if (autoRecallRequested) {
party.recallParty();
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
party.setRouteBlocked(frameRouteBlocked);
});
if (!members.length) return null;
return (
<group name="party-population">
{members.map((member, index) => {
const characterClass = classById(member.classId);
const race = raceById(member.raceId);
const avatarMetrics = PARTY_AVATAR_METRICS[`${member.raceId}:${member.gender}`]
?? DEFAULT_PARTY_AVATAR_METRICS;
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
let movingSignal = movingSignalsRef.current.get(member.id);
if (!movingSignal) {
movingSignal = { current: false };
movingSignalsRef.current.set(member.id, movingSignal);
}
let animationEventSignal = animationEventSignalsRef.current.get(member.id);
if (!animationEventSignal) {
animationEventSignal = { current: null };
animationEventSignalsRef.current.set(member.id, animationEventSignal);
}
positionsRef.current.set(member.id, position);
return (
<group
key={member.id}
ref={(object) => { actorRefs.current[index] = object; }}
name={`party-member-${member.id}`}
position={position}
scale={member.role === "tank" ? 1.04 : 0.96}
onClick={(event) => {
event.stopPropagation();
event.nativeEvent.stopPropagation();
if (event.button !== 0 || !active || member.health <= 0) return;
useCombatStore.getState().clearTarget();
usePartyStore.getState().selectMember(member.id);
}}
>
<mesh position={[0, avatarMetrics.hitCenterY, 0]}>
<capsuleGeometry args={[avatarMetrics.hitRadius, avatarMetrics.hitLength, 6, 10]} />
<meshBasicMaterial
transparent
opacity={0}
colorWrite={false}
depthWrite={false}
/>
</mesh>
<group ref={(object) => { bodyRefs.current[index] = object; }}>
<CharacterModel
identity={member}
equipment={equipmentItemsByOwner.get(member.id)}
active={active}
movingRef={movingSignal}
animationEventRef={animationEventSignal}
fallback={<ProceduralAvatar accent={characterClass.color} />}
/>
</group>
{selectedMemberId === member.id && member.health > 0 ? (
<mesh position={[0, 0.035, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[avatarMetrics.ringRadius, 0.055, 6, 28]} />
<meshBasicMaterial color="#f3dc82" transparent opacity={0.92} depthWrite={false} />
</mesh>
) : null}
{active ? (
<Html center position={[0, avatarMetrics.labelY, 0]} zIndexRange={[5, 0]} style={{
...partyLabelStyle,
opacity: member.health > 0 ? 1 : 0.55,
}}>
<div>{member.name}<br /><small>{race.name} · {member.specialization}</small></div>
</Html>
) : null}
</group>
);
})}
</group>
);
}
export function PartyPopulation({
active = true,
simulate = true,
}: {
readonly active?: boolean;
readonly simulate?: boolean;
}) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const wailingPhase = useWailingEncounterStore((state) => state.phase);
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const dungeon = requireDungeonDefinition(activeDungeonId);
const manastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
const objectives: readonly DungeonBossObjective[] = gameMode === "manastorm" && manastormEncounter
? [{
id: manastormEncounter.bossRuntimeId,
name: manastormEncounter.bossName,
position: manastormEncounter.bossPosition,
}]
// Generated packages preserve complete objective metadata, but their
// fragmented Recast islands do not claim autonomous route fidelity. Until
// reviewed off-mesh links make the pack green, the player leads and the
// party follows/engages locally instead of displaying a false blocked route.
: dungeon.validation.status === "green"
? activeDungeonId === "wailing-caverns"
&& wailingPhase !== "mutanus"
&& wailingPhase !== "complete"
? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly)
: dungeon.objectiveOrder.filter((objective) => !objective.optional)
: [];
const assets = useMemo(
() => manastormPackage
? resolveManastormStageAssets(manastormPackage)
: resolveDungeonAssets(dungeon),
[dungeon, manastormPackage, sessionRevision],
);
const navigationEnabled = Boolean(assets.navigation)
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage);
const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks;
const [navigationState, setNavigationState] = useState<{
readonly url: string;
readonly graph: PartyNavigationGraph;
} | null>(null);
const [failedNavigationUrl, setFailedNavigationUrl] = useState<string | null>(null);
const navigationGraph = navigationEnabled && navigationState?.url === assets.navigation
? navigationState.graph
: null;
const navigationFailed = Boolean(
assets.navigation && failedNavigationUrl === assets.navigation,
);
const acceptNavigationGraph = useCallback((graph: PartyNavigationGraph) => {
if (!assets.navigation) return;
setFailedNavigationUrl((current) => current === assets.navigation ? null : current);
setNavigationState((current) => (
current?.url === assets.navigation && current.graph === graph
? current
: { url: assets.navigation!, graph }
));
}, [assets.navigation]);
const rejectNavigationGraph = useCallback(() => {
if (assets.navigation) setFailedNavigationUrl(assets.navigation);
}, [assets.navigation]);
useEffect(() => {
setNavigationState((current) => (
navigationEnabled && current?.url === assets.navigation ? current : null
));
}, [assets.navigation, navigationEnabled]);
return (
<>
<PartyActors
key={activeDungeonId}
active={active}
simulate={simulate}
navigationGraph={navigationGraph}
navigationFailed={navigationFailed || !navigationEnabled}
objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)}
stackAtEntrance={gameMode === "manastorm" || dungeon.validation.status !== "green"}
/>
{navigationEnabled && assets.navigation ? (
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>
<Suspense fallback={null}>
<PartyNavigationLoader
url={assets.navigation}
offMeshLinks={navigationLinks}
onReady={acceptNavigationGraph}
/>
</Suspense>
</NavigationAssetBoundary>
) : null}
</>
);
}