294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
/**
|
|
* Deterministic, source-bounded fear destination planning for simulator actors.
|
|
*
|
|
* Movement adapters own the actor's real map projection, line of sight, and
|
|
* approved path query. A returned destination is a route goal, never a
|
|
* position to assign directly to an actor.
|
|
*/
|
|
|
|
export type EnemyFearPosition = readonly [x: number, y: number, z: number];
|
|
|
|
/** Constants read from e822d718's FleeingMovementGenerator.cpp. */
|
|
export const ENEMY_FEAR_SOURCE_RULES = Object.freeze({
|
|
minQuietDistanceYards: 28,
|
|
maxQuietDistanceYards: 43,
|
|
minPathLengthYards: 2,
|
|
maxPathLengthYards: 30,
|
|
nearDistanceScale: Object.freeze([0.4, 1.3] as const),
|
|
farDistanceScale: Object.freeze([0.4, 1.0] as const),
|
|
quietDistanceScale: Object.freeze([0.6, 1.2] as const),
|
|
nearAngleJitterRadians: Math.PI / 8,
|
|
farAngleJitterRadians: Math.PI / 4,
|
|
lineOfSightRetryMs: 200,
|
|
pathRetryMs: 100,
|
|
repathWaitMs: Object.freeze([800, 1_500] as const),
|
|
});
|
|
|
|
export interface EnemyFearMovementInput {
|
|
/** Stable identity of this independently controlled actor. */
|
|
readonly actorId: string;
|
|
/** Source aura caster identity; may be null if the aura has no source. */
|
|
readonly sourceActorId: string | null;
|
|
/** Aura application timestamp, used to seed this actor's deterministic choices. */
|
|
readonly fearAppliedAtMs: number;
|
|
/** Increment after each accepted destination or failed path attempt. */
|
|
readonly decisionIndex: number;
|
|
readonly nowMs: number;
|
|
readonly fearEndsAtMs: number;
|
|
readonly stunEndsAtMs: number | null;
|
|
readonly currentPosition: EnemyFearPosition;
|
|
/** Base actor speed in world units per second, before this source aura's bonus. */
|
|
readonly baseMovementSpeed: number;
|
|
/** Actor-local multiplier from the active source speed aura; use 1 when absent. */
|
|
readonly sourceSpeedMultiplier: number;
|
|
/** Conversion from source yards to the simulator's map units. */
|
|
readonly worldUnitsPerYard: number;
|
|
/** Read the live caster position at each repath; null selects the source fallback. */
|
|
readonly getSourcePosition: (sourceActorId: string) => EnemyFearPosition | null;
|
|
}
|
|
|
|
export interface EnemyFearMovementSpatialAdapter {
|
|
/**
|
|
* Return an actor-safe, nearby map destination, or null when this candidate
|
|
* cannot be projected without an excessive snap or floor change.
|
|
*/
|
|
readonly projectDestination: (
|
|
requested: EnemyFearPosition,
|
|
current: EnemyFearPosition,
|
|
) => EnemyFearPosition | null;
|
|
/** LOS from current actor position to the projected destination. */
|
|
readonly hasLineOfSight: (current: EnemyFearPosition, destination: EnemyFearPosition) => boolean;
|
|
/**
|
|
* Return the length of a complete actor-safe route, or null if no approved
|
|
* path exists. The adapter must reject shortcuts and blocked segments.
|
|
*/
|
|
readonly pathLength: (current: EnemyFearPosition, destination: EnemyFearPosition) => number | null;
|
|
}
|
|
|
|
export type EnemyFearMovementHoldReason =
|
|
| "fear-inactive"
|
|
| "stun"
|
|
| "invalid-input"
|
|
| "zero-speed"
|
|
| "map-rejected"
|
|
| "line-of-sight"
|
|
| "path-rejected"
|
|
| "path-too-short"
|
|
| "path-too-long";
|
|
|
|
export type EnemyFearMovementPlan =
|
|
| {
|
|
readonly kind: "hold";
|
|
readonly reason: EnemyFearMovementHoldReason;
|
|
/** Core retry delay for rejected goals; null when status state owns retry timing. */
|
|
readonly retryAfterMs: number | null;
|
|
}
|
|
| {
|
|
readonly kind: "move";
|
|
/** Safe route goal. Integrations must move along the approved actor path. */
|
|
readonly destination: EnemyFearPosition;
|
|
readonly requestedDestination: EnemyFearPosition;
|
|
readonly pathLength: number;
|
|
readonly movementSpeed: number;
|
|
readonly band: "near-source" | "quiet-range" | "far-source" | "source-lost";
|
|
/** Simulator repath time: estimated travel time plus a seeded source-range wait. */
|
|
readonly nextDecisionAtMs: number;
|
|
readonly seededWaitMs: number;
|
|
};
|
|
|
|
const TWO_PI = Math.PI * 2;
|
|
const COINCIDENT_SOURCE_DISTANCE_YARDS = 0.2;
|
|
|
|
function isFinitePosition(position: EnemyFearPosition): boolean {
|
|
return Number.isFinite(position[0])
|
|
&& Number.isFinite(position[1])
|
|
&& Number.isFinite(position[2]);
|
|
}
|
|
|
|
function clampUnit(value: number): number {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function mix32(value: number): number {
|
|
let mixed = value | 0;
|
|
mixed ^= mixed >>> 16;
|
|
mixed = Math.imul(mixed, 0x7feb352d);
|
|
mixed ^= mixed >>> 15;
|
|
mixed = Math.imul(mixed, 0x846ca68b);
|
|
mixed ^= mixed >>> 16;
|
|
return mixed >>> 0;
|
|
}
|
|
|
|
function movementSeed(input: EnemyFearMovementInput): number {
|
|
let seed = 0x811c9dc5;
|
|
const identity = `${input.actorId}\u0000${input.sourceActorId ?? "<lost-source>"}`;
|
|
for (let index = 0; index < identity.length; index += 1) {
|
|
seed = Math.imul(seed ^ identity.charCodeAt(index), 0x01000193);
|
|
}
|
|
seed = Math.imul(seed ^ (Math.trunc(input.fearAppliedAtMs) | 0), 0x01000193);
|
|
seed = Math.imul(seed ^ Math.trunc(input.fearAppliedAtMs / 0x1_0000_0000), 0x01000193);
|
|
seed = Math.imul(seed ^ (input.decisionIndex | 0), 0x01000193);
|
|
return mix32(seed);
|
|
}
|
|
|
|
function seededUnit(seed: number, channel: number): number {
|
|
return mix32(seed ^ Math.imul(channel + 1, 0x9e3779b9)) / 0x1_0000_0000;
|
|
}
|
|
|
|
function lerp(range: readonly [number, number], amount: number): number {
|
|
return range[0] + (range[1] - range[0]) * amount;
|
|
}
|
|
|
|
function planarAngle(from: EnemyFearPosition, to: EnemyFearPosition): number {
|
|
return Math.atan2(to[0] - from[0], to[2] - from[2]);
|
|
}
|
|
|
|
function distance3d(left: EnemyFearPosition, right: EnemyFearPosition): number {
|
|
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
|
|
}
|
|
|
|
function hold(reason: EnemyFearMovementHoldReason, retryAfterMs: number | null = null): EnemyFearMovementPlan {
|
|
return Object.freeze({ kind: "hold", reason, retryAfterMs });
|
|
}
|
|
|
|
/**
|
|
* Pick one deterministic flee goal using the historical server's distance
|
|
* bands and path limits. Random values are stable per actor, source aura, and
|
|
* decision index; they are simulator choices, not the core's global RNG.
|
|
*/
|
|
export function planEnemyFearMovement(
|
|
input: EnemyFearMovementInput,
|
|
spatial: EnemyFearMovementSpatialAdapter,
|
|
): EnemyFearMovementPlan {
|
|
if (input.stunEndsAtMs !== null && input.stunEndsAtMs > input.nowMs) return hold("stun");
|
|
if (input.fearEndsAtMs <= input.nowMs) return hold("fear-inactive");
|
|
|
|
if (!input.actorId
|
|
|| !Number.isFinite(input.nowMs)
|
|
|| !Number.isFinite(input.fearAppliedAtMs)
|
|
|| !Number.isInteger(input.decisionIndex)
|
|
|| input.decisionIndex < 0
|
|
|| !Number.isFinite(input.fearEndsAtMs)
|
|
|| (input.stunEndsAtMs !== null && !Number.isFinite(input.stunEndsAtMs))
|
|
|| !isFinitePosition(input.currentPosition)
|
|
|| !Number.isFinite(input.baseMovementSpeed)
|
|
|| !Number.isFinite(input.sourceSpeedMultiplier)
|
|
|| !Number.isFinite(input.worldUnitsPerYard)
|
|
|| input.baseMovementSpeed < 0
|
|
|| input.sourceSpeedMultiplier < 0
|
|
|| input.worldUnitsPerYard <= 0) return hold("invalid-input");
|
|
|
|
const movementSpeed = input.baseMovementSpeed * input.sourceSpeedMultiplier;
|
|
if (!Number.isFinite(movementSpeed) || movementSpeed <= 0) return hold("zero-speed");
|
|
|
|
const seed = movementSeed(input);
|
|
const sourcePosition = input.sourceActorId
|
|
? input.getSourcePosition(input.sourceActorId)
|
|
: null;
|
|
const sourceIsAvailable = Boolean(sourcePosition && isFinitePosition(sourcePosition));
|
|
const source = sourceIsAvailable ? sourcePosition! : null;
|
|
const sourceDistanceYards = source
|
|
? distance3d(input.currentPosition, source) / input.worldUnitsPerYard
|
|
: 0;
|
|
|
|
let band: Extract<EnemyFearMovementPlan, { kind: "move" }>["band"];
|
|
let requestedDistanceYards: number;
|
|
let angle: number;
|
|
if (!source) {
|
|
// Core fallback after its source GUID stops resolving: random angle, then
|
|
// the same near-source branch with casterDistance = 0.
|
|
band = "source-lost";
|
|
angle = seededUnit(seed, 0) * TWO_PI;
|
|
requestedDistanceYards = lerp(
|
|
ENEMY_FEAR_SOURCE_RULES.nearDistanceScale,
|
|
seededUnit(seed, 1),
|
|
) * ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards;
|
|
angle += (seededUnit(seed, 2) * 2 - 1) * ENEMY_FEAR_SOURCE_RULES.nearAngleJitterRadians;
|
|
} else if (sourceDistanceYards < ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards) {
|
|
band = "near-source";
|
|
const awayAngle = sourceDistanceYards > COINCIDENT_SOURCE_DISTANCE_YARDS
|
|
? planarAngle(source, input.currentPosition)
|
|
: seededUnit(seed, 0) * TWO_PI;
|
|
requestedDistanceYards = lerp(
|
|
ENEMY_FEAR_SOURCE_RULES.nearDistanceScale,
|
|
seededUnit(seed, 1),
|
|
) * (ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards - sourceDistanceYards);
|
|
angle = awayAngle + (seededUnit(seed, 2) * 2 - 1) * ENEMY_FEAR_SOURCE_RULES.nearAngleJitterRadians;
|
|
} else if (sourceDistanceYards > ENEMY_FEAR_SOURCE_RULES.maxQuietDistanceYards) {
|
|
band = "far-source";
|
|
const towardSource = planarAngle(input.currentPosition, source);
|
|
requestedDistanceYards = lerp(
|
|
ENEMY_FEAR_SOURCE_RULES.farDistanceScale,
|
|
seededUnit(seed, 1),
|
|
) * (ENEMY_FEAR_SOURCE_RULES.maxQuietDistanceYards - ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards);
|
|
angle = towardSource + (seededUnit(seed, 2) * 2 - 1) * ENEMY_FEAR_SOURCE_RULES.farAngleJitterRadians;
|
|
} else {
|
|
band = "quiet-range";
|
|
requestedDistanceYards = lerp(
|
|
ENEMY_FEAR_SOURCE_RULES.quietDistanceScale,
|
|
seededUnit(seed, 1),
|
|
) * (ENEMY_FEAR_SOURCE_RULES.maxQuietDistanceYards - ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards);
|
|
angle = seededUnit(seed, 2) * TWO_PI;
|
|
}
|
|
|
|
const distance = requestedDistanceYards * input.worldUnitsPerYard;
|
|
const requestedDestination: EnemyFearPosition = Object.freeze([
|
|
input.currentPosition[0] + Math.sin(angle) * distance,
|
|
input.currentPosition[1],
|
|
input.currentPosition[2] + Math.cos(angle) * distance,
|
|
]);
|
|
const destination = spatial.projectDestination(requestedDestination, input.currentPosition);
|
|
if (!destination || !isFinitePosition(destination)) return hold("map-rejected", ENEMY_FEAR_SOURCE_RULES.pathRetryMs);
|
|
if (!spatial.hasLineOfSight(input.currentPosition, destination)) {
|
|
return hold("line-of-sight", ENEMY_FEAR_SOURCE_RULES.lineOfSightRetryMs);
|
|
}
|
|
|
|
const pathLength = spatial.pathLength(input.currentPosition, destination);
|
|
if (pathLength === null || !Number.isFinite(pathLength) || pathLength < 0) {
|
|
return hold("path-rejected", ENEMY_FEAR_SOURCE_RULES.pathRetryMs);
|
|
}
|
|
const pathLengthYards = pathLength / input.worldUnitsPerYard;
|
|
if (pathLengthYards < ENEMY_FEAR_SOURCE_RULES.minPathLengthYards) {
|
|
return hold("path-too-short", ENEMY_FEAR_SOURCE_RULES.pathRetryMs);
|
|
}
|
|
if (pathLengthYards > ENEMY_FEAR_SOURCE_RULES.maxPathLengthYards) {
|
|
return hold("path-too-long", ENEMY_FEAR_SOURCE_RULES.pathRetryMs);
|
|
}
|
|
|
|
const seededWaitMs = Math.round(lerp(
|
|
ENEMY_FEAR_SOURCE_RULES.repathWaitMs,
|
|
seededUnit(seed, 3),
|
|
));
|
|
const estimatedTravelMs = pathLength / movementSpeed * 1_000;
|
|
return Object.freeze({
|
|
kind: "move",
|
|
destination: Object.freeze([...destination]) as EnemyFearPosition,
|
|
requestedDestination,
|
|
pathLength,
|
|
movementSpeed,
|
|
band,
|
|
nextDecisionAtMs: input.nowMs + estimatedTravelMs + seededWaitMs,
|
|
seededWaitMs,
|
|
});
|
|
}
|
|
|
|
/** Move a waypoint target toward a fear destination by a bounded planar step. */
|
|
export function stepEnemyFearTowardDestination(
|
|
current: EnemyFearPosition,
|
|
destination: EnemyFearPosition,
|
|
maximumStep: number,
|
|
): EnemyFearPosition {
|
|
if (!isFinitePosition(current) || !isFinitePosition(destination)) return Object.freeze([...current]);
|
|
const step = Number.isFinite(maximumStep) ? Math.max(0, maximumStep) : 0;
|
|
const dx = destination[0] - current[0];
|
|
const dz = destination[2] - current[2];
|
|
const distance = Math.hypot(dx, dz);
|
|
if (step <= 0 || distance <= 1e-9) return Object.freeze([...current]);
|
|
const scale = Math.min(1, step / distance);
|
|
return Object.freeze([
|
|
current[0] + dx * scale,
|
|
current[1] + (destination[1] - current[1]) * scale,
|
|
current[2] + dz * scale,
|
|
]);
|
|
}
|