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>> = { "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 { 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["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>([]); const bodyRefs = useRef>([]); const positionsRef = useRef(new Map()); const movingSignalsRef = useRef(new Map()); const animationEventSignalsRef = useRef(new Map()); const animationRevisionRef = useRef(0); const lastNextActionAtRef = useRef(new Map()); const lastHealthRef = useRef(new Map()); const routeRef = useRef(emptyPartyRouteState()); const combatRoutesRef = useRef(new Map()); const navigationGraphRef = useRef(navigationGraph); navigationGraphRef.current = navigationGraph; const playerTrailRef = useRef(createPartyBreadcrumbTrail()); const leaderPlayerCursorRef = useRef(createPartyBreadcrumbCursor()); const leaderTrailRef = useRef(createPartyBreadcrumbTrail()); const trailLeaderIdRef = useRef(null); const lastActiveTargetIdRef = useRef(null); const followerCursorsRef = useRef(new Map()); const lastLeaderWaypointRef = useRef(null); const lastCommandRef = useRef(usePartyStore.getState().command); const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision); const stuckProgressRef = useRef(new Map()); const movementProfilesRef = useRef(new Map()); const directFollowMovingRef = useRef(new Map()); const combatAnchorsRef = useRef(new Map()); 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 ( {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 ( { 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); }} > { bodyRefs.current[index] = object; }}> } /> {selectedMemberId === member.id && member.health > 0 ? ( ) : null} {active ? ( 0 ? 1 : 0.55, }}>
{member.name}
{race.name} ยท {member.specialization}
) : null}
); })}
); } 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(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 ( <> {navigationEnabled && assets.navigation ? ( ) : null} ); }