66 lines
3.8 KiB
TypeScript
66 lines
3.8 KiB
TypeScript
import type { ActiveAura } from "./combatAuras";
|
|
import { combatHasLineOfSight, combatNavigationGraph, combatSpatialActor, projectCombatGroundPoint } from "./combatSpatial";
|
|
import { createEnemyFearMovementRuntime, resolveEnemyFearActorAuras } from "./enemyFearMovementRuntime";
|
|
import { stepEnemyFearTowardDestination, type EnemyFearPosition } from "./enemyFearMovement";
|
|
import { enemyTriggeredShoutMovementSpeedMultiplier } from "./enemyTriggeredShouts";
|
|
import { findNearestPartyNavigationSurface, findPartyNavigationPath } from "./partyNavigation";
|
|
|
|
const runtime = createEnemyFearMovementRuntime();
|
|
const distance = (a: EnemyFearPosition, b: EnemyFearPosition) => Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
|
|
|
/** A fear-only position step; callers always suppress summon attacks and casts while it applies. */
|
|
export function sourceFearSummonPosition(input: {
|
|
readonly id: string;
|
|
readonly position: EnemyFearPosition;
|
|
readonly auras: readonly ActiveAura[];
|
|
readonly now: number;
|
|
readonly deltaSeconds: number;
|
|
readonly movementMultiplier: number;
|
|
}): EnemyFearPosition | null {
|
|
const control = resolveEnemyFearActorAuras(input.auras, input.id, input.now);
|
|
if (!control.fearAura) { runtime.clearActor(input.id); return null; }
|
|
// Existing generic control remains authoritative, even if its timer is shorter than source fear.
|
|
const otherMovementControl = input.auras.some((aura) => aura.targetId === input.id
|
|
&& (aura.expiresAt === null || aura.expiresAt > input.now)
|
|
&& aura.definition.id.startsWith("enemy-control:")
|
|
&& !aura.definition.id.startsWith("enemy-control:source-spell-")
|
|
&& !/:slow$|:interrupt$|:silence$/.test(aura.definition.id));
|
|
if (otherMovementControl) { runtime.clearActor(input.id); return input.position; }
|
|
const graph = combatNavigationGraph();
|
|
if (!graph) { runtime.clearActor(input.id); return input.position; }
|
|
const path = (current: EnemyFearPosition, goal: EnemyFearPosition) => findPartyNavigationPath(graph, current, goal, {
|
|
maxSnapDistance: 0.75,
|
|
routeSegmentAllowed: combatHasLineOfSight,
|
|
});
|
|
const plan = runtime.plan({
|
|
actorId: input.id, fearAura: control.fearAura, stunEndsAtMs: control.stunEndsAtMs,
|
|
nowMs: input.now, currentPosition: input.position, baseMovementSpeed: 4 * input.movementMultiplier,
|
|
sourceSpeedMultiplier: enemyTriggeredShoutMovementSpeedMultiplier(input.auras, input.id, input.now),
|
|
worldUnitsPerYard: 1,
|
|
getSourcePosition: (id) => combatSpatialActor(id)?.position ?? null,
|
|
}, {
|
|
projectDestination: (requested, current) => {
|
|
const projected = projectCombatGroundPoint(requested, distance(current, requested), current);
|
|
if (!projected || distance(requested, projected) > 1.25) return null;
|
|
return findNearestPartyNavigationSurface(graph, projected, 0.75)?.position ?? null;
|
|
},
|
|
hasLineOfSight: combatHasLineOfSight,
|
|
pathLength: (current, goal) => {
|
|
const route = path(current, goal);
|
|
return route ? route.reduce((length, point, index) => length + (index ? distance(route[index - 1], point) : 0), 0) : null;
|
|
},
|
|
});
|
|
if (plan.kind !== "move") return input.position;
|
|
const route = path(input.position, plan.destination);
|
|
if (!route) { runtime.clearActor(input.id); return input.position; }
|
|
const waypoint = route.find((point) => distance(input.position, point) > 0.05);
|
|
if (!waypoint) return input.position;
|
|
const maximumStep = plan.movementSpeed * Math.max(0, Math.min(1, input.deltaSeconds));
|
|
const requested = stepEnemyFearTowardDestination(input.position, waypoint, maximumStep);
|
|
const projected = projectCombatGroundPoint(requested, maximumStep, input.position);
|
|
if (!projected || distance(input.position, projected) > maximumStep + 0.05
|
|
|| !combatHasLineOfSight(input.position, projected)
|
|
|| !findNearestPartyNavigationSurface(graph, projected, 0.15)) return input.position;
|
|
return projected;
|
|
}
|