Release Healer Man 0.1.33

This commit is contained in:
phenom committed 2026-09-12 14:25:23 -04:00
1 parent 6dcb1034e5
commit 05b25c4341
20 files changed
+573 -51

No files matched your search

+156 -25
View File
@@ -92,7 +92,8 @@ 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_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
const PARTY_FLOOR_SAMPLE_SPACING = 0.6;
const PARTY_FLOOR_RAY_HEIGHT = 1.25;
const PARTY_FLOOR_RAY_DEPTH = 2;
@@ -394,6 +395,12 @@ function PartyActors({
const hit = world.castRay(ray, distance, true, queryFlags);
return !hit || hit.timeOfImpact >= distance - 0.04;
}, [rapier, world]);
// Authored-link graphs use their eroded Recast node edges directly. Their
// boundary vertices are already agent-radius safe, while a center ray can
// falsely read the stair floor as a wall across a short vertical seam.
const navigationRouteSegmentAllowed = navigationGraph?.triangles?.length
? routeSurfaceSegmentAllowed
: undefined;
const actorRefs = useRef<Array<Group | null>>([]);
const bodyRefs = useRef<Array<Group | null>>([]);
const positionsRef = useRef(new Map<string, MutablePartyWorldPosition>());
@@ -472,14 +479,22 @@ function PartyActors({
}), [hasStaticLineOfSight, memberIdKey, rapier, world]);
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState();
const navigationRecall = navigationGraphRef.current
? findNearestPartyNavigationSurface(
navigationGraphRef.current,
player,
PARTY_NAVIGATION_SNAP_DISTANCE,
)
: null;
const recallPosition = navigationRecall?.position ?? player;
party.members.forEach((member, index) => {
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];
// Attach a recalled squad to the reviewed surface just as initial
// spawns do. An exact visual-floor point can sit between Recast stair
// vertices and leave the first route connector falsely blocked.
position[0] = recallPosition[0];
position[1] = recallPosition[1];
position[2] = recallPosition[2];
positionsRef.current.set(member.id, position);
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
@@ -495,7 +510,7 @@ function PartyActors({
const firstLiving = party.members.find((member) => member.health > 0);
resetPartyBreadcrumbTrail(playerTrailRef.current, player);
leaderPlayerCursorRef.current.breadcrumbId = null;
resetPartyBreadcrumbTrail(leaderTrailRef.current, player);
resetPartyBreadcrumbTrail(leaderTrailRef.current, recallPosition);
trailLeaderIdRef.current = firstLiving?.id ?? null;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
@@ -756,12 +771,14 @@ function PartyActors({
const leader = leaderMember;
let goalKey: string;
let hasDestination = true;
let leaderLocalFinishDistance = 3;
if (targetPosition && leaderTargetId) {
const profile = movementProfileFor(leader.id, leaderIndex);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(leader),
profile,
);
leaderLocalFinishDistance = Math.max(leaderLocalFinishDistance, rangeBand.maximum);
let combatAnchor = combatAnchorsRef.current.get(leader.id);
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
combatAnchor = {
@@ -817,7 +834,6 @@ function PartyActors({
&& 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];
@@ -835,6 +851,7 @@ function PartyActors({
leaderDestination,
);
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
leaderHasWaypoint = partyShouldMoveToComfortTarget(
leaderPosition,
@@ -877,9 +894,63 @@ function PartyActors({
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
}
if (!leaderHasWaypoint && navigationGraph) {
const navigationFollowDestination = findNearestPartyNavigationSurface(
navigationGraph,
leaderDestination,
PARTY_NAVIGATION_SNAP_DISTANCE,
)?.position ?? leaderDestination;
if (partyRouteNeedsPlanning(
routeRef.current,
goalKey,
leaderPosition,
navigationFollowDestination,
routeNow,
{
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
},
)) {
routeRef.current = planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
navigationFollowDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
}
leaderHasWaypoint = nextPartyRouteWaypoint(
routeRef.current,
leaderPosition,
desired,
);
if (!leaderHasWaypoint && routeRef.current.blocked) {
leaderHasWaypoint = nextPartyRouteRecoveryWaypoint(
routeRef.current,
leaderPosition,
desired,
directSegmentAllowed,
);
}
leaderPlayerTrailBlocked = !leaderHasWaypoint && routeRef.current.blocked;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack;
|| (
(!navigationGraph || leaderPlayerTrailBlocked)
&& planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack
);
}
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
if (
@@ -902,11 +973,11 @@ function PartyActors({
leaderDestination,
routeNow,
{
maxOriginSnapDistance: 3,
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
}
@@ -1082,14 +1153,14 @@ function PartyActors({
desired,
routeNow,
{
maxOriginSnapDistance: 3,
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
localFinishDistance: Math.max(3, rangeBand.maximum),
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
combatRoutesRef.current.set(member.id, route);
@@ -1112,7 +1183,6 @@ function PartyActors({
}
}
} else {
combatRoutesRef.current.delete(member.id);
let cursor = followerCursorsRef.current.get(member.id);
if (!cursor) {
cursor = createPartyBreadcrumbCursor();
@@ -1137,6 +1207,7 @@ function PartyActors({
naturalFollowTarget,
);
if (directSegmentAllowed(current, naturalFollowTarget)) {
combatRoutesRef.current.delete(member.id);
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
hasDesired = partyShouldMoveToComfortTarget(
current,
@@ -1158,6 +1229,7 @@ function PartyActors({
movementExpected = hasDesired;
} else {
directFollowMovingRef.current.set(member.id, false);
let followerTrailBlocked = false;
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
@@ -1177,11 +1249,68 @@ function PartyActors({
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
followerTrailBlocked = true;
}
if (!hasDesired && navigationGraph) {
const goalKey = `member:${member.id}|leader-follow`;
const navigationFollowDestination = findNearestPartyNavigationSurface(
navigationGraph,
naturalFollowTarget,
PARTY_NAVIGATION_SNAP_DISTANCE,
)?.position ?? naturalFollowTarget;
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
if (partyRouteNeedsPlanning(
route,
goalKey,
current,
navigationFollowDestination,
routeNow,
{
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
},
)) {
route = planPartyRoute(
navigationGraph,
goalKey,
current,
navigationFollowDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
combatRoutesRef.current.set(member.id, route);
}
hasDesired = nextPartyRouteWaypoint(route, current, desired);
const routePrefixRecovery = !hasDesired && route.blocked
? nextPartyRouteRecoveryWaypoint(
route,
current,
desired,
directSegmentAllowed,
)
: false;
if (routePrefixRecovery) hasDesired = true;
followerTrailBlocked = !hasDesired && route.blocked;
} else if (hasDesired) {
combatRoutesRef.current.delete(member.id);
}
if (followerTrailBlocked) frameRouteBlocked = true;
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack;
|| (
(!navigationGraph || followerTrailBlocked)
&& planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack
);
}
}
if (memberTargetPosition) {
@@ -1382,6 +1511,8 @@ export function PartyPopulation({
const wailingPhase = useWailingEncounterStore((state) => state.phase);
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const dungeon = requireDungeonDefinition(activeDungeonId);
const navigationReviewed = dungeon.validation.status === "green"
|| dungeon.validation.navigation === "green";
const manastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
@@ -1393,9 +1524,9 @@ export function PartyPopulation({
}]
// 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"
// reviewed links make navigation green, the player leads and the party
// follows/engages locally instead of displaying a false blocked route.
: navigationReviewed
? activeDungeonId === "wailing-caverns"
&& wailingPhase !== "mutanus"
&& wailingPhase !== "complete"
@@ -1450,7 +1581,7 @@ export function PartyPopulation({
navigationGraph={navigationGraph}
navigationFailed={navigationFailed || !navigationEnabled}
objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)}
stackAtEntrance={gameMode === "manastorm" || dungeon.validation.status !== "green"}
stackAtEntrance={gameMode === "manastorm" || !navigationReviewed}
/>
{navigationEnabled && assets.navigation ? (
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>