Files
healer-man/src/game/partyMovement.ts
T
2026-09-12 16:02:15 -04:00

509 lines
16 KiB
TypeScript

import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
const EPSILON = 1e-6;
export interface PartyBreadcrumb {
readonly id: number;
readonly distance: number;
readonly position: PartyWorldPosition;
}
/** Mutable, allocation-light trail recorded by a collision-safe leader. */
export interface PartyBreadcrumbTrail {
readonly points: PartyBreadcrumb[];
nextId: number;
}
/** Absolute breadcrumb ID avoids invalid cursors when old trail points are pruned. */
export interface PartyBreadcrumbCursor {
breadcrumbId: number | null;
}
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export interface PartyMovementProfile {
readonly followDistanceJitter: number;
readonly lateralOffset: number;
readonly comfortSlack: number;
readonly speedMultiplier: number;
readonly swayAmplitude: number;
readonly swayPeriodMs: number;
readonly swayPhase: number;
readonly combatAngleOffset: number;
readonly combatRangeBias: number;
}
export interface PartyCombatRangeLike {
readonly minimum: number;
readonly preferred: number;
readonly maximum: number;
}
function stableHash(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hashUnit(value: string): number {
return stableHash(value) / 0xffff_ffff;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
/** Stable per-character preferences keep motion reproducible without looking cloned. */
export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile {
const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0));
const prefix = `${memberId}|${rank}`;
const lateralMagnitude = rank === 0
? hashUnit(`${prefix}|leader-lateral`) * 0.3
: 0.35 + hashUnit(`${prefix}|lateral`) * 0.3;
const lateralSign = rank === 0
? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1
: rank % 2 === 1 ? -1 : 1;
const angleSlots = [0, -35, 35, -50, 50];
const angleDegrees = clamp(
(angleSlots[rank % angleSlots.length] ?? 0)
+ (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10,
-50,
50,
);
return {
followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7,
lateralOffset: lateralSign * lateralMagnitude,
comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5,
speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12,
swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1,
swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000,
swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2,
combatAngleOffset: angleDegrees * Math.PI / 180,
combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1,
};
}
// Allow companions to operate across a full boss arena before the stuck
// watchdog treats their separation from the player as a reason to recall.
export const PARTY_STUCK_RECALL_DISTANCE = 80;
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
export const PARTY_STUCK_PROGRESS_DISTANCE = 0.45;
/** Tests whether an expected move has made enough planar progress to reset the stuck timer. */
export function partyMadeStuckProgress(
previous: PartyWorldPosition,
current: PartyWorldPosition,
minimumDistance = PARTY_STUCK_PROGRESS_DISTANCE,
): boolean {
const minimum = Math.max(0, Number.isFinite(minimumDistance) ? minimumDistance : 0);
const distance = Math.hypot(current[0] - previous[0], current[2] - previous[2]);
return Number.isFinite(distance) && distance >= minimum;
}
/**
* A distant ally is stuck only while it should be moving and its progress
* timer has expired. Intentional ranged/melee holds never enter this state.
*/
export function partyShouldRecallForStuck(
current: PartyWorldPosition,
player: PartyWorldPosition,
movementExpected: boolean,
nowMs: number,
lastProgressAtMs: number,
recallDistance = PARTY_STUCK_RECALL_DISTANCE,
stuckDelayMs = PARTY_STUCK_RECALL_DELAY_MS,
): boolean {
if (!movementExpected || !Number.isFinite(nowMs) || !Number.isFinite(lastProgressAtMs)) return false;
const distance = Math.hypot(current[0] - player[0], current[2] - player[2]);
const minimumDistance = Math.max(0, Number.isFinite(recallDistance) ? recallDistance : 0);
const delay = Math.max(0, Number.isFinite(stuckDelayMs) ? stuckDelayMs : 0);
return Number.isFinite(distance)
&& distance >= minimumDistance
&& nowMs - lastProgressAtMs >= delay;
}
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
return { points: [], nextId: 0 };
}
export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor {
return { breadcrumbId: null };
}
export function synchronizePartyBreadcrumbCursor(
trail: PartyBreadcrumbTrail,
cursor: PartyBreadcrumbCursor,
trailingDistance: number,
): void {
const latest = trail.points[trail.points.length - 1];
if (!latest) {
cursor.breadcrumbId = null;
return;
}
const maximumDistance = latest.distance - Math.max(0, trailingDistance);
let target = trail.points[0];
for (const point of trail.points) {
if (point.distance > maximumDistance + EPSILON) break;
target = point;
}
cursor.breadcrumbId = target.id;
}
export function resetPartyBreadcrumbTrail(
trail: PartyBreadcrumbTrail,
position?: PartyWorldPosition,
): void {
trail.points.length = 0;
trail.nextId = 0;
if (position) appendPartyBreadcrumb(trail, position, 0, Number.POSITIVE_INFINITY);
}
function finitePosition(position: PartyWorldPosition): boolean {
return Number.isFinite(position[0]) && Number.isFinite(position[1]) && Number.isFinite(position[2]);
}
/** Records movement without allowing an old breadcrumb list to grow forever. */
export function appendPartyBreadcrumb(
trail: PartyBreadcrumbTrail,
position: PartyWorldPosition,
minimumSpacing = 0.45,
maximumTrailDistance = 96,
): boolean {
if (!finitePosition(position)) return false;
const spacing = Math.max(0, Number.isFinite(minimumSpacing) ? minimumSpacing : 0);
const maximumDistance = Number.isFinite(maximumTrailDistance)
? Math.max(spacing, maximumTrailDistance)
: Number.POSITIVE_INFINITY;
const previous = trail.points[trail.points.length - 1];
const travelled = previous ? Math.hypot(
position[0] - previous.position[0],
position[1] - previous.position[1],
position[2] - previous.position[2],
) : 0;
if (previous && travelled < spacing) return false;
const point: PartyBreadcrumb = {
id: trail.nextId,
distance: (previous?.distance ?? 0) + travelled,
position: [position[0], position[1], position[2]],
};
trail.nextId += 1;
trail.points.push(point);
while (
trail.points.length > 2
&& point.distance - trail.points[1].distance > maximumDistance
) trail.points.shift();
return true;
}
function breadcrumbDistanceSquared(point: PartyWorldPosition, current: PartyWorldPosition): number {
const dx = point[0] - current[0];
const dy = point[1] - current[1];
const dz = point[2] - current[2];
return dx * dx + dy * dy + dz * dz;
}
/**
* Advances only one recorded breadcrumb at a time. A lagging follower therefore
* walks around every bend the leader used instead of drawing a diagonal shortcut.
*/
export function nextPartyBreadcrumbWaypoint(
trail: PartyBreadcrumbTrail,
cursor: PartyBreadcrumbCursor,
current: PartyWorldPosition,
trailingDistance: number,
reachedDistance: number,
out: MutablePartyWorldPosition,
): boolean {
if (!trail.points.length) return false;
const trailing = Math.max(0, Number.isFinite(trailingDistance) ? trailingDistance : 0);
const reached = Math.max(0, Number.isFinite(reachedDistance) ? reachedDistance : 0);
const last = trail.points[trail.points.length - 1];
const maximumDistance = last.distance - trailing;
let maximumIndex = -1;
for (let index = 0; index < trail.points.length; index += 1) {
if (trail.points[index].distance > maximumDistance + EPSILON) break;
maximumIndex = index;
}
if (maximumIndex < 0) return false;
let cursorIndex = cursor.breadcrumbId === null
? -1
: trail.points.findIndex((point) => point.id === cursor.breadcrumbId);
// A spatially-nearest recovery can jump to the far side of a hairpin. Rejoin
// monotonically from the oldest retained point; the scene validates that
// one rejoin connector against collision before allowing movement.
if (cursorIndex < 0 || cursorIndex > maximumIndex) cursorIndex = 0;
// Advance exactly one required point. Breadcrumbs are deliberately close
// together, so consuming every point within a radius can skip an L-corner
// and turn an approved path into a diagonal shortcut.
if (
cursorIndex < maximumIndex
&& breadcrumbDistanceSquared(trail.points[cursorIndex].position, current) <= reached * reached
) cursorIndex += 1;
const target = trail.points[cursorIndex];
cursor.breadcrumbId = target.id;
out[0] = target.position[0];
out[1] = target.position[1];
out[2] = target.position[2];
return true;
}
/**
* Follows at the requested trail distance, but still permits a distant actor
* to rejoin a newly reset or not-yet-long-enough trail. The caller remains
* responsible for validating the initial connector against world collision.
*/
export function nextPartyBreadcrumbFollowWaypoint(
trail: PartyBreadcrumbTrail,
cursor: PartyBreadcrumbCursor,
current: PartyWorldPosition,
trailingDistance: number,
reachedDistance: number,
out: MutablePartyWorldPosition,
catchUpSlack = 0.75,
): boolean {
if (nextPartyBreadcrumbWaypoint(
trail,
cursor,
current,
trailingDistance,
reachedDistance,
out,
)) return true;
const latest = trail.points[trail.points.length - 1];
if (!latest) return false;
const trailing = Math.max(0, Number.isFinite(trailingDistance) ? trailingDistance : 0);
const slack = Math.max(0, Number.isFinite(catchUpSlack) ? catchUpSlack : 0);
const distance = Math.hypot(
latest.position[0] - current[0],
latest.position[2] - current[2],
);
if (!Number.isFinite(distance) || distance <= trailing + slack) return false;
return nextPartyBreadcrumbWaypoint(
trail,
cursor,
current,
0,
reachedDistance,
out,
);
}
export function stepPartyPosition(
current: PartyWorldPosition,
target: PartyWorldPosition,
maximumStep: number,
out: MutablePartyWorldPosition,
): 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 (!Number.isFinite(distance) || distance <= EPSILON) {
out[0] = Number.isFinite(target[0]) ? target[0] : current[0];
out[1] = Number.isFinite(target[1]) ? target[1] : current[1];
out[2] = Number.isFinite(target[2]) ? target[2] : current[2];
return 0;
}
const step = Math.min(distance, Math.max(0, Number.isFinite(maximumStep) ? maximumStep : 0));
out[0] = current[0] + dx / distance * step;
out[1] = current[1] + dy / distance * step;
out[2] = current[2] + dz / distance * step;
return step;
}
/**
* Resolves a render-only foot height without feeding collision-floor offsets
* back into navigation progress.
*/
export function partyPresentationY(
navigationY: number,
sampledFloorY: number | null,
maximumCorrection: number,
): number {
if (!Number.isFinite(navigationY)) return 0;
if (!Number.isFinite(sampledFloorY)) return navigationY;
const correction = Number.isFinite(maximumCorrection)
? Math.max(0, maximumCorrection)
: 0;
return Math.abs(sampledFloorY! - navigationY) <= correction
? sampledFloorY!
: navigationY;
}
export function partyFormationPosition(
anchor: PartyWorldPosition,
forwardX: number,
forwardZ: number,
lateral: number,
trailing: number,
out: MutablePartyWorldPosition,
): void {
const length = Math.hypot(forwardX, forwardZ);
const normalizedForwardX = length > EPSILON ? forwardX / length : 0;
const normalizedForwardZ = length > EPSILON ? forwardZ / length : 1;
const rightX = normalizedForwardZ;
const rightZ = -normalizedForwardX;
out[0] = anchor[0] + rightX * lateral - normalizedForwardX * trailing;
out[1] = anchor[1];
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
}
export function partyNaturalFollowPosition(
anchor: PartyWorldPosition,
forwardX: number,
forwardZ: number,
baseTrailingDistance: number,
profile: PartyMovementProfile,
nowMs: number,
out: MutablePartyWorldPosition,
): void {
const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter);
const safeNow = Number.isFinite(nowMs) ? nowMs : 0;
const sway = Math.sin(
safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase,
) * profile.swayAmplitude;
partyFormationPosition(
anchor,
forwardX,
forwardZ,
profile.lateralOffset + sway,
trailing,
out,
);
}
/** Start outside a personal comfort radius, then finish close enough to avoid jitter. */
export function partyShouldMoveToComfortTarget(
current: PartyWorldPosition,
target: PartyWorldPosition,
wasMoving: boolean,
comfortSlack: number,
): boolean {
const distance = Math.hypot(
target[0] - current[0],
target[1] - current[1],
target[2] - current[2],
);
if (!Number.isFinite(distance)) return false;
const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35);
return distance > (wasMoving ? 0.18 : startDistance);
}
export function personalizedPartyCombatRangeBand(
base: PartyCombatRangeLike,
profile: PartyMovementProfile,
): PartyCombatRangeLike {
if (base.minimum > 0) {
const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11);
const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1);
return {
minimum,
preferred: clamp(
base.preferred + profile.combatRangeBias * 1.25,
minimum + 0.75,
maximum - 0.5,
),
maximum,
};
}
const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2);
return {
minimum: 0,
preferred: clamp(
base.preferred + profile.combatRangeBias * 0.3,
2.35,
maximum - 0.12,
),
maximum,
};
}
/**
* Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band.
*/
export function partyCombatMovementGoal(
current: PartyWorldPosition,
target: PartyWorldPosition,
targetVisible: boolean,
minimumRange: number,
preferredRange: number,
maximumRange: number,
out: MutablePartyWorldPosition,
fallbackAwayX = 0,
fallbackAwayZ = 1,
): PartyCombatMovementIntent {
const minimum = Math.max(0, Number.isFinite(minimumRange) ? minimumRange : 0);
const maximum = Math.max(minimum, Number.isFinite(maximumRange) ? maximumRange : minimum);
const preferred = Math.max(
minimum,
Math.min(maximum, Number.isFinite(preferredRange) ? preferredRange : maximum),
);
const dx = current[0] - target[0];
const dy = current[1] - target[1];
const dz = current[2] - target[2];
const distance = Math.hypot(dx, dy, dz);
if (!targetVisible || !Number.isFinite(distance) || distance > maximum) {
out[0] = target[0];
out[1] = target[1];
out[2] = target[2];
return "approach";
}
if (minimum <= 0 || distance >= minimum) return "hold";
let awayX = dx;
let awayZ = dz;
let planarLength = Math.hypot(awayX, awayZ);
if (planarLength <= EPSILON) {
awayX = fallbackAwayX;
awayZ = fallbackAwayZ;
planarLength = Math.hypot(awayX, awayZ);
}
if (planarLength <= EPSILON) {
awayX = 0;
awayZ = 1;
planarLength = 1;
}
out[0] = target[0] + awayX / planarLength * preferred;
out[1] = current[1];
out[2] = target[2] + awayZ / planarLength * preferred;
return "retreat";
}
export function partyPersonalizedCombatMovementGoal(
current: PartyWorldPosition,
target: PartyWorldPosition,
targetVisible: boolean,
rangeBand: PartyCombatRangeLike,
preferredBearing: number,
out: MutablePartyWorldPosition,
): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal(
current,
target,
targetVisible,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
out,
Math.sin(preferredBearing),
Math.cos(preferredBearing),
);
if (!targetVisible || intent === "hold") return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent;
}