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

+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);
});