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