1674 lines
71 KiB
TypeScript
1674 lines
71 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 { type Group } from "three";
|
|
import { classById, isRomClassId, 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 {
|
|
characterCombatAnimationForPresentation,
|
|
type CharacterCombatAnimationEvent,
|
|
} from "../game/combatAnimation";
|
|
import { subscribeCombatPresentation } from "../game/combatPresentation";
|
|
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
|
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
|
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
|
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
|
|
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
|
import {
|
|
enemyControlMovementMultiplier,
|
|
enemyControlPreventsMovement,
|
|
} from "../game/enemyAttacks";
|
|
import {
|
|
appendPartyBreadcrumb,
|
|
createPartyBreadcrumbCursor,
|
|
createPartyBreadcrumbTrail,
|
|
nextPartyBreadcrumbFollowWaypoint,
|
|
partyFormationPosition,
|
|
partyMovementProfile,
|
|
partyNaturalFollowPosition,
|
|
partyPersonalizedCombatMovementGoal,
|
|
partyPresentationY,
|
|
partyShouldMoveToComfortTarget,
|
|
personalizedPartyCombatRangeBand,
|
|
resetPartyBreadcrumbTrail,
|
|
stepPartyPosition,
|
|
synchronizePartyBreadcrumbCursor,
|
|
type PartyBreadcrumbCursor,
|
|
type PartyMovementProfile,
|
|
} from "../game/partyMovement";
|
|
import {
|
|
applyPartyStuckRecoveryLanding,
|
|
choosePartyStuckRecoveryPosition,
|
|
createPartyStuckRecoveryState,
|
|
markPartyStuckRecovered,
|
|
resetPartyStuckRecoveryProgress,
|
|
updatePartyStuckRecovery,
|
|
type PartyStuckRecoveryState,
|
|
} from "../game/partyStuckRecovery";
|
|
import {
|
|
findNearestPartyNavigationSurface,
|
|
type PartyNavigationGraph,
|
|
} from "../game/partyNavigation";
|
|
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
|
|
import {
|
|
partyObjectivesForNavigation,
|
|
shouldLoadPartyNavigation,
|
|
} from "../game/partyNavigationPolicy";
|
|
import {
|
|
emptyPartyRouteState,
|
|
nextPartyRouteRecoveryWaypoint,
|
|
nextPartyRouteWaypoint,
|
|
partyRouteNeedsPlanning,
|
|
planPartyRoute,
|
|
type PartyRouteState,
|
|
} from "../game/partyPathing";
|
|
import { partyCombatRangeBand } from "../game/partyRuntime";
|
|
import { partyRejoinMovement } from "../game/partyRejoin";
|
|
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
|
|
import {
|
|
clearPartyRuntimeAttackCorridor,
|
|
consumePartyRuntimeRejoin,
|
|
finishPartyRuntimeRejoin,
|
|
getPartyRuntimePosition,
|
|
partyRuntimeIsRejoining,
|
|
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";
|
|
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
|
import {
|
|
createPartyDirectSegmentValidator,
|
|
createPartyRouteSegmentValidator,
|
|
createPartyRecoveryPointValidator,
|
|
PARTY_FLOOR_RAY_DEPTH,
|
|
PARTY_FLOOR_RAY_HEIGHT,
|
|
} from "./partyTraversal";
|
|
import {
|
|
activeDungeonRouteTarget,
|
|
routeForActiveDungeon,
|
|
type ActiveDungeonRouteTarget,
|
|
} from "../game/dungeonRouteRuntime";
|
|
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
|
|
import { useDungeonRouteStore } from "../game/dungeonRouteStore";
|
|
|
|
const PARTY_MOVE_SPEED = 5.25;
|
|
const PARTY_CATCH_UP_SPEED = 7.5;
|
|
const BREADCRUMB_REACHED_DISTANCE = 0.04;
|
|
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
|
|
const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
|
|
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 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 PartyNavigationLoader({
|
|
url,
|
|
offMeshLinks,
|
|
ramps,
|
|
onReady,
|
|
}: {
|
|
readonly url: string;
|
|
readonly offMeshLinks: ReturnType<typeof requireDungeonDefinition>["navigationLinks"];
|
|
readonly ramps: readonly DungeonTraversalRamp[];
|
|
readonly onReady: (graph: PartyNavigationGraph) => void;
|
|
}) {
|
|
const gltf = useGLTF(resolveContentUrl(url));
|
|
const graph = useMemo(
|
|
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks, ramps),
|
|
[gltf.scene, offMeshLinks, ramps, url],
|
|
);
|
|
|
|
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 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,
|
|
ACTOR_TERRAIN_QUERY_GROUPS,
|
|
);
|
|
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
|
|
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
|
|
}, [rapier, world]);
|
|
const directSegmentAllowed = useMemo(
|
|
() => createPartyDirectSegmentValidator(rapier, world),
|
|
[rapier, world],
|
|
);
|
|
const hasStaticLineOfSight = useCallback((
|
|
start: PartyWorldPosition,
|
|
end: PartyWorldPosition,
|
|
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
|
|
const routeSurfaceSegmentAllowed = useMemo(
|
|
() => createPartyRouteSegmentValidator(rapier, world),
|
|
[rapier, world],
|
|
);
|
|
const recoveryPointAllowed = useMemo(
|
|
() => createPartyRecoveryPointValidator(rapier, world, navigationGraph),
|
|
[rapier, world, navigationGraph],
|
|
);
|
|
// Authored-link graphs use their eroded Recast node edges directly. Their
|
|
// boundary vertices are already agent-radius safe, while a center ray can
|
|
// falsely read the stair floor as a wall across a short vertical seam.
|
|
const navigationRouteSegmentAllowed = navigationGraph?.triangles?.length
|
|
? routeSurfaceSegmentAllowed
|
|
: undefined;
|
|
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 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, PartyStuckRecoveryState>());
|
|
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
|
|
const directFollowMovingRef = useRef(new Map<string, boolean>());
|
|
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
|
|
const lastSimulationElapsedRef = useRef(0);
|
|
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,
|
|
navigationGraph: () => navigationGraphRef.current,
|
|
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,
|
|
ACTOR_TERRAIN_QUERY_GROUPS,
|
|
);
|
|
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();
|
|
const navigationRecall = navigationGraphRef.current
|
|
? findNearestPartyNavigationSurface(
|
|
navigationGraphRef.current,
|
|
player,
|
|
PARTY_NAVIGATION_SNAP_DISTANCE,
|
|
)
|
|
: null;
|
|
const recallPosition = navigationRecall?.position ?? player;
|
|
party.members.forEach((member, index) => {
|
|
finishPartyRuntimeRejoin(member.id);
|
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
|
// Attach a recalled squad to the reviewed surface just as initial
|
|
// spawns do. An exact visual-floor point can sit between Recast stair
|
|
// vertices and leave the first route connector falsely blocked.
|
|
position[0] = recallPosition[0];
|
|
position[1] = recallPosition[1];
|
|
position[2] = recallPosition[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;
|
|
const progress = stuckProgressRef.current.get(member.id) ?? createPartyStuckRecoveryState(position, now);
|
|
markPartyStuckRecovered(progress, position, now);
|
|
stuckProgressRef.current.set(member.id, progress);
|
|
});
|
|
const firstLiving = party.members.find((member) => member.health > 0);
|
|
resetPartyBreadcrumbTrail(playerTrailRef.current, player);
|
|
leaderPlayerCursorRef.current.breadcrumbId = null;
|
|
resetPartyBreadcrumbTrail(leaderTrailRef.current, recallPosition);
|
|
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 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 (!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(() => subscribeCombatPresentation((message) => {
|
|
if (message.kind === "reset") {
|
|
for (const signal of animationEventSignalsRef.current.values()) {
|
|
animationRevisionRef.current += 1;
|
|
signal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" };
|
|
}
|
|
return;
|
|
}
|
|
const signal = animationEventSignalsRef.current.get(message.event.sourceActorId);
|
|
if (!signal) return;
|
|
const revision = animationRevisionRef.current + 1;
|
|
const animationEvent = characterCombatAnimationForPresentation(message.event, revision);
|
|
if (!animationEvent) return;
|
|
animationRevisionRef.current = revision;
|
|
signal.current = animationEvent;
|
|
}), []);
|
|
|
|
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];
|
|
finishPartyRuntimeRejoin(member.id);
|
|
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(() => {
|
|
for (const state of stuckProgressRef.current.values()) resetPartyStuckRecoveryProgress(state);
|
|
}, [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 simulation = useDungeonSimulationStore.getState();
|
|
const playerNavigation = game.playerNavigationPosition;
|
|
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
|
const routeNow = simulation.sessionMode === "spectate-live"
|
|
? simulation.elapsedMs
|
|
: clock.elapsedTime * 1_000;
|
|
const combatNow = simulation.sessionMode === "spectate-live"
|
|
? simulation.startedAt + simulation.elapsedMs
|
|
: Date.now();
|
|
for (const member of party.members) {
|
|
if (member.health <= 0) {
|
|
finishPartyRuntimeRejoin(member.id);
|
|
continue;
|
|
}
|
|
const checkpoint = consumePartyRuntimeRejoin(member.id);
|
|
if (!checkpoint) continue;
|
|
const landing = navigationGraph
|
|
? findNearestPartyNavigationSurface(navigationGraph, checkpoint, 6)?.position ?? checkpoint : checkpoint;
|
|
const current = positionsRef.current.get(member.id) ?? [...landing];
|
|
current[0] = landing[0]; current[1] = landing[1]; current[2] = landing[2];
|
|
positionsRef.current.set(member.id, current);
|
|
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
|
|
combatRoutesRef.current.delete(member.id);
|
|
combatAnchorsRef.current.delete(member.id);
|
|
followerCursorsRef.current.delete(member.id);
|
|
directFollowMovingRef.current.delete(member.id);
|
|
stuckProgressRef.current.delete(member.id);
|
|
updatePartyRuntimeTargetVisibility(member.id, null, false);
|
|
const animationSignal = animationEventSignalsRef.current.get(member.id);
|
|
animationRevisionRef.current += 1;
|
|
if (animationSignal) animationSignal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" };
|
|
}
|
|
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 && !partyRuntimeIsRejoining(member.id));
|
|
const simulationDelta = simulation.sessionMode === "spectate-live"
|
|
? Math.max(0, (simulation.elapsedMs - lastSimulationElapsedRef.current) / 1_000)
|
|
: delta;
|
|
lastSimulationElapsedRef.current = simulation.elapsedMs;
|
|
const safeDelta = simulation.sessionMode === "spectate-live"
|
|
? Math.min(simulationDelta, 0.5)
|
|
: 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 livingIndices = party.members
|
|
.map((member, index) => member.health > 0 ? index : -1)
|
|
.filter((index) => index >= 0);
|
|
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;
|
|
};
|
|
let leaderHasWaypoint = false;
|
|
let leaderMovementExpected = false;
|
|
let leaderFollowingPlayer = false;
|
|
let leaderPlayerTrailBlocked = false;
|
|
let forceLeaderBreadcrumb = false;
|
|
let authoredRouteTarget: ActiveDungeonRouteTarget | null = null;
|
|
|
|
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;
|
|
let leaderLocalFinishDistance = 3;
|
|
if (targetPosition && leaderTargetId) {
|
|
const profile = movementProfileFor(leader.id, leaderIndex);
|
|
const rangeBand = personalizedPartyCombatRangeBand(
|
|
partyCombatRangeBand(leader),
|
|
profile,
|
|
);
|
|
leaderLocalFinishDistance = Math.max(leaderLocalFinishDistance, rangeBand.maximum);
|
|
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 movementIntent = partyPersonalizedCombatMovementGoal(
|
|
leaderPosition,
|
|
targetPosition,
|
|
hasStaticLineOfSight(leaderPosition, targetPosition),
|
|
rangeBand,
|
|
combatAnchor.bearing,
|
|
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.
|
|
authoredRouteTarget = activeDungeonRouteTarget(
|
|
game.activeDungeonId,
|
|
objective.id,
|
|
leaderPosition,
|
|
leader.id,
|
|
combatNow,
|
|
);
|
|
const objectiveApproach = authoredRouteTarget?.target ?? (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 = authoredRouteTarget
|
|
? `leader:${leader.id}|route:${authoredRouteTarget.route.hash}|${authoredRouteTarget.leg.id}|${authoredRouteTarget.checkpointIndex}`
|
|
: `leader:${leader.id}|objective:${objective.id}`;
|
|
if (authoredRouteTarget) leaderLocalFinishDistance = 0.3;
|
|
} 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) {
|
|
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)) {
|
|
routeRef.current = { ...emptyPartyRouteState(), goalKey };
|
|
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;
|
|
}
|
|
if (!leaderHasWaypoint && navigationGraph) {
|
|
const navigationFollowDestination = findNearestPartyNavigationSurface(
|
|
navigationGraph,
|
|
leaderDestination,
|
|
PARTY_NAVIGATION_SNAP_DISTANCE,
|
|
)?.position ?? leaderDestination;
|
|
if (partyRouteNeedsPlanning(
|
|
routeRef.current,
|
|
goalKey,
|
|
leaderPosition,
|
|
navigationFollowDestination,
|
|
routeNow,
|
|
{
|
|
replanDistance: 1.25,
|
|
replanIntervalMs: 350,
|
|
blockedRetryMs: 800,
|
|
},
|
|
)) {
|
|
routeRef.current = planPartyRoute(
|
|
navigationGraph,
|
|
goalKey,
|
|
leaderPosition,
|
|
navigationFollowDestination,
|
|
routeNow,
|
|
{
|
|
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
|
maxDestinationSnapDistance: 5,
|
|
localFinishDistance: 3,
|
|
replanDistance: 1.25,
|
|
replanIntervalMs: 350,
|
|
blockedRetryMs: 800,
|
|
directSegmentAllowed,
|
|
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
|
},
|
|
);
|
|
}
|
|
leaderHasWaypoint = nextPartyRouteWaypoint(
|
|
routeRef.current,
|
|
leaderPosition,
|
|
desired,
|
|
);
|
|
if (!leaderHasWaypoint && routeRef.current.blocked) {
|
|
leaderHasWaypoint = nextPartyRouteRecoveryWaypoint(
|
|
routeRef.current,
|
|
leaderPosition,
|
|
desired,
|
|
directSegmentAllowed,
|
|
);
|
|
}
|
|
leaderPlayerTrailBlocked = !leaderHasWaypoint && routeRef.current.blocked;
|
|
}
|
|
leaderMovementExpected = leaderHasWaypoint
|
|
|| (
|
|
(!navigationGraph || leaderPlayerTrailBlocked)
|
|
&& 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 = authoredRouteTarget
|
|
&& !navigationGraph
|
|
&& directSegmentAllowed(leaderPosition, leaderDestination)
|
|
? {
|
|
goalKey,
|
|
destination: [leaderDestination[0], leaderDestination[1], leaderDestination[2]],
|
|
waypoints: [[leaderDestination[0], leaderDestination[1], leaderDestination[2]]],
|
|
cursor: 0,
|
|
plannedAt: routeNow,
|
|
blocked: false,
|
|
}
|
|
: planPartyRoute(
|
|
navigationGraph,
|
|
goalKey,
|
|
leaderPosition,
|
|
leaderDestination,
|
|
routeNow,
|
|
{
|
|
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
|
maxDestinationSnapDistance: 5,
|
|
localFinishDistance: leaderLocalFinishDistance,
|
|
directSegmentAllowed,
|
|
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
|
destinationReached: targetPosition ? (surface) => {
|
|
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
|
|
movementProfileFor(leaderMember.id, leaderIndex));
|
|
const distance = fullDistance(surface, targetPosition);
|
|
return distance >= band.minimum && distance <= band.maximum
|
|
&& hasStaticLineOfSight(surface, targetPosition);
|
|
} : undefined,
|
|
},
|
|
);
|
|
}
|
|
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
|
|
&& authoredRouteTarget
|
|
&& routeRef.current.blocked
|
|
&& simulation.sessionMode === "play"
|
|
&& directSegmentAllowed(leaderPosition, playerNavigation)
|
|
) {
|
|
desired[0] = playerNavigation[0];
|
|
desired[1] = playerNavigation[1];
|
|
desired[2] = playerNavigation[2];
|
|
leaderHasWaypoint = true;
|
|
leaderFollowingPlayer = 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
|
|
&& (Boolean(authoredRouteTarget) || routeRef.current.waypoints.length > routeRef.current.cursor)
|
|
) {
|
|
updatePartyRuntimeAttackCorridor(
|
|
leaderPosition,
|
|
authoredRouteTarget?.remaining ?? routeRef.current.waypoints,
|
|
authoredRouteTarget ? 0 : routeRef.current.cursor,
|
|
);
|
|
} else {
|
|
clearPartyRuntimeAttackCorridor();
|
|
}
|
|
let frameRouteBlocked = Boolean(
|
|
leaderIndex >= 0
|
|
&& party.command !== "stop"
|
|
&& !targetPosition
|
|
&& leaderMovementExpected
|
|
&& (
|
|
leaderFollowingPlayer
|
|
? leaderPlayerTrailBlocked
|
|
: navigationFailed || Boolean((navigationGraph || authoredRouteTarget) && routeRef.current.blocked)
|
|
),
|
|
);
|
|
|
|
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 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 rejoining = partyRuntimeIsRejoining(member.id);
|
|
const memberTargetId = rejoining ? null : 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);
|
|
const activeControlMechanic = member.controlledUntil > combatNow
|
|
? member.controlMechanic
|
|
: null;
|
|
|
|
if (member.health <= 0) {
|
|
const signal = movingSignalsRef.current.get(member.id);
|
|
if (signal) signal.current = false;
|
|
} else if (member.activeCast || enemyControlPreventsMovement(activeControlMechanic)) {
|
|
// Casting and crowd control are intentional immobility, not stuck navigation.
|
|
} else if (rejoining) {
|
|
const intent = partyRejoinMovement(member.id, current, playerNavigation, routeNow,
|
|
navigationGraph, combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState(),
|
|
{ directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed });
|
|
combatRoutesRef.current.set(member.id, intent.route);
|
|
if (intent.rejoined) {
|
|
finishPartyRuntimeRejoin(member.id);
|
|
combatRoutesRef.current.delete(member.id);
|
|
followerCursorsRef.current.delete(member.id);
|
|
} else {
|
|
movementExpected = true;
|
|
frameRouteBlocked ||= intent.blocked;
|
|
if (intent.waypoint) {
|
|
desired[0] = intent.waypoint[0]; desired[1] = intent.waypoint[1]; desired[2] = intent.waypoint[2];
|
|
hasDesired = true;
|
|
faceX = desired[0] - current[0]; faceZ = desired[2] - current[2];
|
|
}
|
|
}
|
|
} 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 movementIntent = partyPersonalizedCombatMovementGoal(
|
|
current,
|
|
memberTargetPosition,
|
|
canSeeActiveTarget,
|
|
rangeBand,
|
|
combatAnchor.bearing,
|
|
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: PARTY_NAVIGATION_SNAP_DISTANCE,
|
|
maxDestinationSnapDistance: 5,
|
|
localFinishDistance: Math.max(3, rangeBand.maximum),
|
|
replanDistance: 1.25,
|
|
replanIntervalMs: 350,
|
|
blockedRetryMs: 800,
|
|
directSegmentAllowed,
|
|
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
|
destinationReached: (surface) => {
|
|
const distance = fullDistance(surface, memberTargetPosition);
|
|
return distance >= rangeBand.minimum && distance <= rangeBand.maximum
|
|
&& hasStaticLineOfSight(surface, memberTargetPosition);
|
|
},
|
|
},
|
|
);
|
|
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 {
|
|
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)) {
|
|
combatRoutesRef.current.delete(member.id);
|
|
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);
|
|
let followerTrailBlocked = 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;
|
|
followerTrailBlocked = true;
|
|
}
|
|
if (!hasDesired && navigationGraph) {
|
|
const goalKey = `member:${member.id}|leader-follow`;
|
|
const navigationFollowDestination = findNearestPartyNavigationSurface(
|
|
navigationGraph,
|
|
naturalFollowTarget,
|
|
PARTY_NAVIGATION_SNAP_DISTANCE,
|
|
)?.position ?? naturalFollowTarget;
|
|
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
|
|
if (partyRouteNeedsPlanning(
|
|
route,
|
|
goalKey,
|
|
current,
|
|
navigationFollowDestination,
|
|
routeNow,
|
|
{
|
|
replanDistance: 1.25,
|
|
replanIntervalMs: 350,
|
|
blockedRetryMs: 800,
|
|
},
|
|
)) {
|
|
route = planPartyRoute(
|
|
navigationGraph,
|
|
goalKey,
|
|
current,
|
|
navigationFollowDestination,
|
|
routeNow,
|
|
{
|
|
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
|
maxDestinationSnapDistance: 5,
|
|
localFinishDistance: 3,
|
|
replanDistance: 1.25,
|
|
replanIntervalMs: 350,
|
|
blockedRetryMs: 800,
|
|
directSegmentAllowed,
|
|
routeSegmentAllowed: navigationRouteSegmentAllowed,
|
|
},
|
|
);
|
|
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;
|
|
followerTrailBlocked = !hasDesired && route.blocked;
|
|
} else if (hasDesired) {
|
|
combatRoutesRef.current.delete(member.id);
|
|
}
|
|
if (followerTrailBlocked) frameRouteBlocked = true;
|
|
movementExpected = hasDesired
|
|
|| (
|
|
(!navigationGraph || followerTrailBlocked)
|
|
&& 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);
|
|
|
|
const currentPlanarDistance = planarDistance(current, playerNavigation);
|
|
if (member.health > 0 && (party.command !== "stop" || rejoining) && hasDesired) {
|
|
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
|
|
* movementProfile.speedMultiplier
|
|
* enemyControlMovementMultiplier(activeControlMechanic);
|
|
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 = createPartyStuckRecoveryState(current, routeNow);
|
|
stuckProgressRef.current.set(member.id, stuckProgress);
|
|
}
|
|
const recoveryGoal = rejoining ? "resurrection-rejoin:player" : memberTargetPosition ? `mob:${memberTargetId}`
|
|
: index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`;
|
|
const recovery = updatePartyStuckRecovery(stuckProgress, {
|
|
position: current,
|
|
waypoint: hasDesired ? desired : null,
|
|
goalKey: `${party.command}|${recoveryGoal}`,
|
|
movementExpected,
|
|
health: member.health,
|
|
paused: !active || game.paused || game.mapOpen || game.companionOpen,
|
|
stopped: party.command === "stop" && !rejoining,
|
|
casting: member.activeCast !== null,
|
|
controlled: enemyControlPreventsMovement(activeControlMechanic),
|
|
nowMs: routeNow,
|
|
});
|
|
if (recovery !== "none") {
|
|
// Invalidate only this actor's route. Keep its watchdog running across
|
|
// replans so an endlessly blocked connector cannot restart the timer.
|
|
combatRoutesRef.current.delete(member.id);
|
|
followerCursorsRef.current.delete(member.id);
|
|
directFollowMovingRef.current.delete(member.id);
|
|
if (index === leaderIndex) {
|
|
routeRef.current = emptyPartyRouteState();
|
|
leaderPlayerCursorRef.current.breadcrumbId = null;
|
|
lastLeaderWaypointRef.current = null;
|
|
}
|
|
if (recovery === "recover") {
|
|
const landing = choosePartyStuckRecoveryPosition({
|
|
current,
|
|
player: playerNavigation,
|
|
groupAnchor: rejoining || index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation,
|
|
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
|
|
validate: recoveryPointAllowed,
|
|
});
|
|
if (landing && simulation.sessionMode === "spectate-live") {
|
|
simulation.addFinding({
|
|
id: `recovery:${member.id}:${simulation.elapsedMs}`,
|
|
atMs: simulation.elapsedMs,
|
|
severity: "critical",
|
|
code: "position-recovery",
|
|
message: `${member.name} required a position-only recovery teleport.`,
|
|
position: [current[0], current[1], current[2]],
|
|
actorId: member.id,
|
|
objectiveId: simulation.objectiveId ?? undefined,
|
|
checkpointIndex: simulation.checkpointIndex,
|
|
});
|
|
simulation.addRecovery();
|
|
}
|
|
if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) {
|
|
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
|
|
actor.position.set(current[0], presentationY(current), current[2]);
|
|
const movingSignal = movingSignalsRef.current.get(member.id);
|
|
if (movingSignal) movingSignal.current = false;
|
|
shouldMove = false;
|
|
combatAnchorsRef.current.delete(member.id);
|
|
updatePartyRuntimeTargetVisibility(member.id, memberTargetId, Boolean(memberTargetPosition
|
|
&& hasStaticLineOfSight(current, memberTargetPosition)));
|
|
if (index === leaderIndex) {
|
|
// Do not record a teleport as a straight, walkable breadcrumb segment.
|
|
resetPartyBreadcrumbTrail(leaderTrailRef.current, current);
|
|
followerCursorsRef.current.clear();
|
|
clearPartyRuntimeAttackCorridor();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
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 subtitle = isRomClassId(member.classId)
|
|
? `${characterClass.name}${member.secondaryClassId ? `/${classById(member.secondaryClassId).name}` : ""}`
|
|
: `${race.name} · ${member.specialization}`;
|
|
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>{subtitle}</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 simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
|
|
useDungeonRouteStore((state) => state.approvals);
|
|
const dungeon = requireDungeonDefinition(activeDungeonId);
|
|
const navigationReviewed = dungeon.validation.status === "green"
|
|
|| dungeon.validation.navigation === "green";
|
|
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 links make navigation green, the player leads and the party
|
|
// follows/engages locally instead of displaying a false blocked route.
|
|
: simulationSessionMode === "spectate-live" || Boolean(routeForActiveDungeon(activeDungeonId))
|
|
? dungeon.objectiveOrder.filter((objective) => !objective.optional)
|
|
: navigationReviewed
|
|
? 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 traversalRamps = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.traversalRamps ?? EMPTY_NAVIGATION_LINKS;
|
|
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" || !navigationReviewed}
|
|
/>
|
|
{navigationEnabled && assets.navigation ? (
|
|
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>
|
|
<Suspense fallback={null}>
|
|
<PartyNavigationLoader
|
|
url={assets.navigation}
|
|
offMeshLinks={navigationLinks}
|
|
ramps={traversalRamps}
|
|
onReady={acceptNavigationGraph}
|
|
/>
|
|
</Suspense>
|
|
</NavigationAssetBoundary>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|