509 lines
16 KiB
TypeScript
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;
|
|
}
|