Release Healer Man 0.1.36

This commit is contained in:
phenom committed 2026-09-12 19:16:01 -04:00
1 parent 40d9f346d8
commit 03a4e3f51c
22 files changed
+1389 -153

No files matched your search

+43 -34
View File
@@ -27,7 +27,7 @@ import {
sampleMobAnimation,
} from "../game/mobAnimation";
import type { MobAnimationState } from "../game/mobAnimation";
import { useCombatStore } from "../game/combatStore";
import { useCombatStore, type MobCombatState } from "../game/combatStore";
import { PRIMARY_MOUSE_BUTTON } from "../game/inputManager";
import { TimedEffectStrip } from "../ui/TimedEffectStrip";
import { AuraStrip } from "../ui/AuraStrip";
@@ -76,9 +76,10 @@ import {
resolveMobAnimationInterval,
} from "./mobAnimationCadence";
import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { onlineSessionOwnsWorldSimulation, onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
import { useMobNavigation } from "./mobNavigation";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -119,8 +120,32 @@ function mobTargetPosition(
playerPosition: MobVector3,
): MobVector3 {
if (!targetActorId || targetActorId === PLAYER_AGGRO_ID) return playerPosition;
const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === targetActorId);
if (summon) return summon.position;
return getPartyRuntimePosition(targetActorId) ?? playerPosition;
}
function navigateMob(
navigator: ReturnType<typeof useMobNavigation>, mob: MobCombatState,
position: MutableMobVector3, target: MobVector3, home: MobVector3,
maximumStep: number, active: boolean, nowMs: number, controlled = false,
): void {
const game = useGameStore.getState();
const hostileRanges = mob.attacks.filter((attack) => attack.target === "primary"
|| attack.target === "random-party" || attack.target === "nearby-party").map((attack) => attack.range);
const result = navigator.step({
id: mob.id, targetId: mob.targetActorId, position, target, home,
phase: mob.combatPhase, health: mob.health, engaged: mob.engaged,
active: active && onlineSessionOwnsWorldSimulation() && !game.paused && !game.mapOpen && !game.companionOpen,
casting: mob.activeCast !== null, controlled,
attackRange: hostileRanges.length ? Math.max(...hostileRanges) : mob.attackRange,
leashRange: mob.leashRange, maximumStep, nowMs,
});
if (result === "reset" || result === "home") {
useCombatStore.getState().resetMobForNavigation(mob.id,
result === "home" || Math.hypot(position[0] - home[0], position[1] - home[1], position[2] - home[2]) <= 0.85, nowMs);
}
}
const PROXIMITY_CHECK_INTERVAL = 0.3;
const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25;
let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>();
@@ -771,23 +796,6 @@ function isPlayerOrPartyNear(points: readonly MobVector3[], maxDistance: number)
});
}
function stepToward(
current: MutableMobVector3,
target: MobVector3,
maximumStep: number,
): number {
const dx = target[0] - current[0];
const dy = target[1] - current[1];
const dz = target[2] - current[2];
const distance = Math.hypot(dx, dy, dz);
if (distance <= 0.001) return 0;
const step = Math.min(Math.max(0, maximumStep), distance);
current[0] += dx / distance * step;
current[1] += dy / distance * step;
current[2] += dz / distance * step;
return distance;
}
/**
* Activates content when the player or an autonomous party member approaches.
* The stores are read
@@ -1159,6 +1167,7 @@ function RoamingPack({
useOriginalModels,
allowedRuntimeIds,
}: RoamingPackProps) {
const navigator = useMobNavigation();
const path = useMemo(() => createLoopedPath(definition.waypoints), [definition.waypoints]);
const distanceRef = useRef(definition.startDistance ?? 0);
const { scratchPositions, scratchForwards, chasePositions } = useMemo(() => {
@@ -1277,7 +1286,7 @@ function RoamingPack({
const maximumStep = Math.min(delta, 0.05)
* mob!.moveSpeed
* Math.max(0.1, 1 - slow);
if (!movementLocked && feared) {
if (active && !movementLocked && feared) {
stepFearedMobAway(
chasePosition,
targetPosition,
@@ -1286,19 +1295,16 @@ function RoamingPack({
mob!.leashRange,
chasePosition,
);
} else if (!movementLocked && mob!.combatPhase === "chasing") {
stepToward(
chasePosition,
targetPosition,
maximumStep,
);
}
navigateMob(navigator, mob!, chasePosition, targetPosition,
mob!.homePosition ?? patrolPosition, maximumStep, active, now, movementLocked || feared);
position = chasePosition;
const facingSign = feared && !movementLocked ? -1 : 1;
forward[0] = (targetPosition[0] - position[0]) * facingSign;
forward[2] = (targetPosition[2] - position[2]) * facingSign;
} else if (mob?.combatPhase === "returning") {
stepToward(chasePosition, patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
navigateMob(navigator, mob, chasePosition, targetPosition,
mob.homePosition ?? patrolPosition, Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
position = chasePosition;
} else {
chasePosition[0] = patrolPosition[0];
@@ -1360,6 +1366,7 @@ interface StaticPopulationProps {
}
function StaticPopulationMember({ spawn, entities, active, showLabels, useOriginalModels }: StaticPopulationProps) {
const navigator = useMobNavigation();
const entity = entities[spawn.entityId];
const groupRef = useRef<Group>(null);
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
@@ -1398,7 +1405,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) {
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
targetPosition,
@@ -1407,11 +1414,12 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
mob.leashRange,
position,
);
} else if (!movementLocked && mob.combatPhase === "chasing") {
stepToward(position, targetPosition, maximumStep);
}
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
maximumStep, active, now, movementLocked || feared);
} else if (mob?.combatPhase === "returning") {
stepToward(position, spawn.position, Math.min(delta, 0.05) * mob.moveSpeed * 1.25);
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? spawn.position,
Math.min(delta, 0.05) * mob.moveSpeed * 1.25, active, now);
} else {
position[0] = spawn.position[0];
position[1] = spawn.position[1];
@@ -1500,6 +1508,7 @@ function SummonedPopulationMember({
readonly active: boolean;
readonly useOriginalModels: boolean;
}) {
const navigator = useMobNavigation();
const groupRef = useRef<Group>(null);
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
const positionRef = useRef<MutableMobVector3>([...initial]);
@@ -1515,7 +1524,7 @@ function SummonedPopulationMember({
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) {
if (active && !movementLocked && feared) {
stepFearedMobAway(
position,
targetPosition,
@@ -1524,9 +1533,9 @@ function SummonedPopulationMember({
mob.leashRange,
position,
);
} else if (!movementLocked && mob.combatPhase === "chasing") {
stepToward(position, targetPosition, maximumStep);
}
navigateMob(navigator, mob, position, targetPosition, mob.homePosition ?? initial,
maximumStep, active, now, movementLocked || feared);
const facingSign = feared && !movementLocked ? -1 : 1;
const yaw = Math.atan2(
(targetPosition[0] - position[0]) * facingSign,
+85 -41
View File
@@ -33,12 +33,10 @@ import {
createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
@@ -47,6 +45,15 @@ import {
type PartyBreadcrumbCursor,
type PartyMovementProfile,
} from "../game/partyMovement";
import {
applyPartyStuckRecoveryLanding,
choosePartyStuckRecoveryPosition,
createPartyStuckRecoveryState,
markPartyStuckRecovered,
resetPartyStuckRecoveryProgress,
updatePartyStuckRecovery,
type PartyStuckRecoveryState,
} from "../game/partyStuckRecovery";
import {
findNearestPartyNavigationSurface,
type PartyNavigationGraph,
@@ -87,6 +94,7 @@ import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import {
createPartyDirectSegmentValidator,
createPartyRouteSegmentValidator,
createPartyRecoveryPointValidator,
PARTY_FLOOR_RAY_DEPTH,
PARTY_FLOOR_RAY_HEIGHT,
} from "./partyTraversal";
@@ -112,11 +120,6 @@ interface PartyAvatarMetrics {
readonly hitCenterY: number;
}
interface PartyStuckProgress {
readonly position: MutablePartyWorldPosition;
lastProgressAt: number;
}
interface PartyCombatAnchor {
readonly targetId: string;
readonly bearing: number;
@@ -317,6 +320,10 @@ function PartyActors({
() => createPartyRouteSegmentValidator(rapier, world),
[rapier, world],
);
const recoveryPointAllowed = useMemo(
() => createPartyRecoveryPointValidator(rapier, world, navigationGraph),
[rapier, world, navigationGraph],
);
// Authored-link graphs use their eroded Recast node edges directly. Their
// boundary vertices are already agent-radius safe, while a center ray can
// falsely read the stair floor as a wall across a short vertical seam.
@@ -344,7 +351,7 @@ function PartyActors({
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 stuckProgressRef = useRef(new Map<string, PartyStuckRecoveryState>());
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
@@ -381,6 +388,7 @@ function PartyActors({
return { id: actorId, position: mobPosition, yaw };
},
hasLineOfSight: hasStaticLineOfSight,
navigationGraph: () => navigationGraphRef.current,
projectGroundPoint: (requested, maximumDistance, requestedOrigin) => {
const origin = requestedOrigin ?? useGameStore.getState().playerPosition;
const dx = requested[0] - origin[0];
@@ -425,10 +433,9 @@ function PartyActors({
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 progress = stuckProgressRef.current.get(member.id) ?? createPartyStuckRecoveryState(position, now);
markPartyStuckRecovered(progress, position, now);
stuckProgressRef.current.set(member.id, progress);
});
const firstLiving = party.members.find((member) => member.health > 0);
resetPartyBreadcrumbTrail(playerTrailRef.current, player);
@@ -569,7 +576,7 @@ function PartyActors({
// Time spent in a pause/menu must not count toward the stuck watchdog.
useEffect(() => {
stuckProgressRef.current.clear();
for (const state of stuckProgressRef.current.values()) resetPartyStuckRecoveryProgress(state);
}, [active]);
useEffect(() => clearPartyRuntimeAttackCorridor, []);
@@ -597,6 +604,7 @@ function PartyActors({
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000;
const combatNow = Date.now();
const playerTrail = playerTrailRef.current.points;
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
@@ -889,6 +897,13 @@ function PartyActors({
localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
destinationReached: targetPosition ? (surface) => {
const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember),
movementProfileFor(leaderMember.id, leaderIndex));
const distance = fullDistance(surface, targetPosition);
return distance >= band.minimum && distance <= band.maximum
&& hasStaticLineOfSight(surface, targetPosition);
} : undefined,
},
);
}
@@ -953,7 +968,6 @@ function PartyActors({
: navigationFailed || Boolean(navigationGraph && routeRef.current.blocked)
),
);
let autoRecallRequested = false;
for (let index = 0; index < party.members.length; index += 1) {
const member = party.members[index];
@@ -1000,6 +1014,8 @@ function PartyActors({
if (signal) signal.current = false;
} else if (party.command === "stop") {
// Holding is represented by retaining the current world-space tuple.
} else if (member.activeCast || member.controlledUntil > combatNow) {
// Casting and crowd control are intentional immobility, not stuck navigation.
} else if (index === leaderIndex) {
hasDesired = leaderHasWaypoint;
movementExpected = leaderMovementExpected;
@@ -1069,6 +1085,11 @@ function PartyActors({
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
destinationReached: (surface) => {
const distance = fullDistance(surface, memberTargetPosition);
return distance >= rangeBand.minimum && distance <= rangeBand.maximum
&& hasStaticLineOfSight(surface, memberTargetPosition);
},
},
);
combatRoutesRef.current.set(member.id, route);
@@ -1263,30 +1284,59 @@ function PartyActors({
let stuckProgress = stuckProgressRef.current.get(member.id);
if (!stuckProgress) {
stuckProgress = {
position: [current[0], current[1], current[2]],
lastProgressAt: routeNow,
};
stuckProgress = createPartyStuckRecoveryState(current, 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,
const recoveryGoal = memberTargetPosition ? `mob:${memberTargetId}`
: index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`;
const recovery = updatePartyStuckRecovery(stuckProgress, {
position: current,
waypoint: hasDesired ? desired : null,
goalKey: `${party.command}|${recoveryGoal}`,
movementExpected,
routeNow,
stuckProgress.lastProgressAt,
)) {
autoRecallRequested = true;
health: member.health,
paused: !active || game.paused || game.mapOpen || game.companionOpen,
stopped: party.command === "stop",
casting: member.activeCast !== null,
controlled: member.controlledUntil > combatNow,
nowMs: routeNow,
});
if (recovery !== "none") {
// Invalidate only this actor's route. Keep its watchdog running across
// replans so an endlessly blocked connector cannot restart the timer.
combatRoutesRef.current.delete(member.id);
followerCursorsRef.current.delete(member.id);
directFollowMovingRef.current.delete(member.id);
if (index === leaderIndex) {
routeRef.current = emptyPartyRouteState();
leaderPlayerCursorRef.current.breadcrumbId = null;
lastLeaderWaypointRef.current = null;
}
if (recovery === "recover") {
const landing = choosePartyStuckRecoveryPosition({
current,
player: playerNavigation,
groupAnchor: index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation,
trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position),
validate: recoveryPointAllowed,
});
if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) {
updatePartyRuntimePosition(member.id, current[0], current[1], current[2]);
actor.position.set(current[0], presentationY(current), current[2]);
const movingSignal = movingSignalsRef.current.get(member.id);
if (movingSignal) movingSignal.current = false;
shouldMove = false;
combatAnchorsRef.current.delete(member.id);
updatePartyRuntimeTargetVisibility(member.id, memberTargetId, Boolean(memberTargetPosition
&& hasStaticLineOfSight(current, memberTargetPosition)));
if (index === leaderIndex) {
// Do not record a teleport as a straight, walkable breadcrumb segment.
resetPartyBreadcrumbTrail(leaderTrailRef.current, current);
followerCursorsRef.current.clear();
clearPartyRuntimeAttackCorridor();
}
}
}
}
if (Math.hypot(faceX, faceZ) > 0.001) {
@@ -1305,12 +1355,6 @@ function PartyActors({
body.rotation.x = 0;
}
}
if (autoRecallRequested) {
party.recallParty();
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
party.setRouteBlocked(frameRouteBlocked);
});
+32
View File
@@ -0,0 +1,32 @@
import { useRapier } from "@react-three/rapier";
import { useEffect, useMemo } from "react";
import { combatNavigationGraph } from "../game/combatSpatial";
import { createMobPursuit } from "../game/mobPursuit";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
import { createPartyDirectSegmentValidator, createPartyRecoveryPointValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
/** Uses terrain only; actors remain non-blocking and the graph is loaded only once. */
export function useMobNavigation() {
const { rapier, world } = useRapier();
const navigator = useMemo(() => {
let graph = combatNavigationGraph();
let landing = createPartyRecoveryPointValidator(rapier, world, graph);
return createMobPursuit({
graph: combatNavigationGraph,
hasLineOfSight: (from, to) => hasDungeonLineOfSight(rapier, world, from, to),
directAllowed: createPartyDirectSegmentValidator(rapier, world),
routeAllowed: createPartyRouteSegmentValidator(rapier, world),
validateLanding: (point, anchor) => {
const currentGraph = combatNavigationGraph();
if (graph !== currentGraph) {
graph = currentGraph;
landing = createPartyRecoveryPointValidator(rapier, world, graph);
}
return landing(point, anchor);
},
});
}, [rapier, world]);
useEffect(() => registerDungeonRuntimeReset(navigator.clear), [navigator]);
return navigator;
}
+73
View File
@@ -0,0 +1,73 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { BufferAttribute, BufferGeometry } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import { buildPartyNavigationGraph } from "../game/partyNavigation";
import { ACTOR_COLLISION_GROUPS } from "./actorCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { createPartyRecoveryPointValidator } from "./partyTraversal";
beforeAll(async () => { await RAPIER.init(); });
function floorWorld() {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 5).setTranslation(0, -0.1, 0)
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
return world;
}
function floorGraph() {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
-4, 0.05, -4, 4, 0.05, -4, -4, 0.05, 4, 4, 0.05, 4,
]), 3));
geometry.setIndex([0, 2, 1, 1, 2, 3]);
const graph = buildPartyNavigationGraph(geometry);
geometry.dispose();
return graph;
}
describe("safe recovery terrain", () => {
it("accepts walkable, unobstructed landings connected to the group", () => {
const world = floorWorld();
world.step();
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).not.toBeNull();
world.free();
});
it("rejects a landing inside a wall and a route cut off by a wall", () => {
const world = floorWorld();
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 2, 5).setTranslation(0, 2, 0)
.setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
world.step();
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
expect(allowed([0, 0.05, 0], [-2, 0.05, 0])).toBeNull();
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
expect(allowed([-2, 0.05, 0], [-3, 0.05, 0])).not.toBeNull();
world.free();
});
it("rejects low ceilings, missing floors, and points on the wrong vertical level", () => {
const world = floorWorld();
world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.1, 0.5).setTranslation(-2, 1.4, 0)
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.step();
const allowed = createPartyRecoveryPointValidator(RAPIER, world, floorGraph());
expect(allowed([-2, 0.05, 0], [2, 0.05, 0])).toBeNull();
expect(allowed([0, 5, 0], [2, 0.05, 0])).toBeNull();
const noFloor = new RAPIER.World({ x: 0, y: 0, z: 0 });
noFloor.step();
expect(createPartyRecoveryPointValidator(RAPIER, noFloor, floorGraph())([0, 0.05, 0], [0, 0.05, 0])).toBeNull();
noFloor.free();
world.free();
});
it("allows a grounded player fallback without a navigation asset and ignores actor occupancy", () => {
const world = floorWorld();
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 0.84, 0));
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
world.step();
expect(createPartyRecoveryPointValidator(RAPIER, world, null)([0, 0, 0], [0, 0, 0])).toEqual([0, 0, 0]);
world.free();
});
});
+38
View File
@@ -2,6 +2,8 @@ import type RAPIER from "@dimforge/rapier3d-compat";
import type { World } from "@dimforge/rapier3d-compat";
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import { findNearestPartyNavigationSurface, type PartyNavigationGraph } from "../game/partyNavigation";
import { planPartyRoute } from "../game/partyPathing";
const COLLISION_RADIUS = 0.34;
const COLLISION_HALF_HEIGHT = 0.48;
@@ -72,3 +74,39 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W
return !hit || hit.timeOfImpact >= distance - 0.04;
};
}
/** Recovery landings must have real walkable ground, free body space, and a route back to the group. */
export function createPartyRecoveryPointValidator(rapier: typeof RAPIER, world: World, graph: PartyNavigationGraph | null) {
const directAllowed = createPartyDirectSegmentValidator(rapier, world);
const routeAllowed = createPartyRouteSegmentValidator(rapier, world);
const capsule = new rapier.Capsule(COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5, COLLISION_RADIUS);
const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
const project = (point: PartyWorldPosition): PartyWorldPosition | null => {
const surface = graph ? findNearestPartyNavigationSurface(graph, point, 0.6)?.position : point;
if (!surface || Math.abs(surface[1] - point[1]) > 0.55) return null;
if (graph && Math.hypot(surface[0] - point[0], surface[1] - point[1], surface[2] - point[2]) > 0.04
&& !directAllowed(point, surface)) return null;
const ray = new rapier.Ray({ x: surface[0], y: surface[1] + PARTY_FLOOR_RAY_HEIGHT, z: surface[2] }, { x: 0, y: -1, z: 0 });
const floor = world.castRayAndGetNormal(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
if (!floor || floor.normal.y < 0.65 || Math.abs(ray.origin.y - floor.timeOfImpact - surface[1]) > MAX_FLOOR_DELTA) return null;
const obstruction = world.intersectionWithShape(
{ x: surface[0], y: surface[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: surface[2] },
{ x: 0, y: 0, z: 0, w: 1 }, capsule, flags, ACTOR_TERRAIN_QUERY_GROUPS,
);
return obstruction ? null : surface;
};
return (point: PartyWorldPosition, anchor: PartyWorldPosition): PartyWorldPosition | null => {
const position = project(point);
const destination = project(anchor);
if (!position || !destination) return null;
if (!graph) return directAllowed(position, destination) ? position : null;
const route = planPartyRoute(graph, "stuck-recovery-validation", position, destination, 0, {
maxOriginSnapDistance: 0.1,
maxDestinationSnapDistance: 0.1,
localFinishDistance: 0.2,
directSegmentAllowed: directAllowed,
routeSegmentAllowed: graph.triangles?.length ? routeAllowed : undefined,
});
return route.blocked ? null : position;
};
}