Files
healer-man/src/game/enemyFearSummonMovement.ts
T
2026-10-06 22:25:34 -04:00

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;
}