Files
healer-man/src/game/mobAi.ts
T
2026-08-14 15:56:39 -04:00

382 lines
13 KiB
TypeScript

import type { MobStatusKind } from "./abilityCatalog";
export type MobAiPhase = "idle" | "chasing" | "attacking" | "returning" | "dead";
export type MobAiPosition = readonly [x: number, y: number, z: number];
export type MutableMobAiPosition = [x: number, y: number, z: number];
/** Structural subset of combatStore's MobStatus, kept free of store dependencies. */
export interface MobAiStatus {
readonly kind: MobStatusKind;
readonly endsAt: number;
readonly magnitude?: number;
}
/**
* Low-frequency combat tuning. Positions stay in the runtime registry; this
* profile contains only serializable rules consumed by the store and renderer.
*/
export interface MobCombatProfile {
readonly chaseSpeed: number;
readonly returnSpeed: number;
readonly attackRange: number;
readonly attackHeightTolerance: number;
readonly leashRange: number;
readonly returnTolerance: number;
readonly swingIntervalMs: number;
readonly initialSwingDelayMs: number;
readonly attackDamage: number;
}
export interface MobCombatProfileOptions {
readonly boss?: boolean;
readonly level?: number;
readonly overrides?: Partial<MobCombatProfile>;
}
export const DEFAULT_MOB_COMBAT_PROFILE: MobCombatProfile = Object.freeze({
chaseSpeed: 3.25,
returnSpeed: 4.25,
attackRange: 4.5,
attackHeightTolerance: 4,
leashRange: 35,
returnTolerance: 0.3,
swingIntervalMs: 1_600,
initialSwingDelayMs: 1_600,
attackDamage: 6,
});
export const DEFAULT_BOSS_COMBAT_PROFILE: MobCombatProfile = Object.freeze({
chaseSpeed: 2.9,
returnSpeed: 3.8,
attackRange: 5.5,
attackHeightTolerance: 8,
leashRange: 45,
returnTolerance: 0.45,
swingIntervalMs: 1_600,
initialSwingDelayMs: 1_600,
attackDamage: 14,
});
const EMPTY_STATUSES: readonly MobAiStatus[] = Object.freeze([]);
const POSITION_EPSILON = 1e-6;
function finiteAtLeast(value: number | undefined, fallback: number, minimum: number): number {
return Number.isFinite(value) ? Math.max(minimum, value!) : fallback;
}
function finitePosition(position: MobAiPosition): boolean {
return Number.isFinite(position[0]) && Number.isFinite(position[1]) && Number.isFinite(position[2]);
}
function finiteDistance(value: number): number {
return Number.isFinite(value) && value >= 0 ? value : Number.POSITIVE_INFINITY;
}
/** Creates level-scaled defaults and clamps malformed per-creature overrides. */
export function createMobCombatProfile(options: MobCombatProfileOptions = {}): MobCombatProfile {
const boss = options.boss ?? false;
const level = Number.isFinite(options.level) ? Math.max(1, Math.trunc(options.level!)) : 1;
const base = boss ? DEFAULT_BOSS_COMBAT_PROFILE : DEFAULT_MOB_COMBAT_PROFILE;
const overrides = options.overrides ?? {};
const scaledDamage = (boss ? 12 : 4) + level * 2;
return {
chaseSpeed: finiteAtLeast(overrides.chaseSpeed, base.chaseSpeed, 0),
returnSpeed: finiteAtLeast(overrides.returnSpeed, base.returnSpeed, 0),
attackRange: finiteAtLeast(overrides.attackRange, base.attackRange, 0),
attackHeightTolerance: finiteAtLeast(
overrides.attackHeightTolerance,
base.attackHeightTolerance,
0,
),
leashRange: finiteAtLeast(overrides.leashRange, base.leashRange, 0),
returnTolerance: finiteAtLeast(overrides.returnTolerance, base.returnTolerance, 0),
swingIntervalMs: finiteAtLeast(overrides.swingIntervalMs, base.swingIntervalMs, 1),
initialSwingDelayMs: finiteAtLeast(
overrides.initialSwingDelayMs,
base.initialSwingDelayMs,
0,
),
attackDamage: finiteAtLeast(overrides.attackDamage, scaledDamage, 0),
};
}
/** Returns Infinity for malformed positions so invalid data cannot authorize a hit. */
export function planarDistanceSquared(from: MobAiPosition, to: MobAiPosition): number {
if (!finitePosition(from) || !finitePosition(to)) return Number.POSITIVE_INFINITY;
const dx = to[0] - from[0];
const dz = to[2] - from[2];
return dx * dx + dz * dz;
}
export function distanceSquared(from: MobAiPosition, to: MobAiPosition): number {
if (!finitePosition(from) || !finitePosition(to)) return Number.POSITIVE_INFINITY;
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const dz = to[2] - from[2];
return dx * dx + dy * dy + dz * dz;
}
export function isMobInAttackRange(
mobPosition: MobAiPosition,
targetPosition: MobAiPosition,
profile: MobCombatProfile,
): boolean {
const range = finiteAtLeast(profile.attackRange, 0, 0);
const heightTolerance = finiteAtLeast(profile.attackHeightTolerance, 0, 0);
return planarDistanceSquared(mobPosition, targetPosition) <= range * range
&& Math.abs(targetPosition[1] - mobPosition[1]) <= heightTolerance;
}
export function isMobOutsideLeash(
mobPosition: MobAiPosition,
targetPosition: MobAiPosition,
homePosition: MobAiPosition,
profile: MobCombatProfile,
): boolean {
const leash = finiteAtLeast(profile.leashRange, 0, 0);
const leashSquared = leash * leash;
return planarDistanceSquared(homePosition, mobPosition) > leashSquared
|| planarDistanceSquared(homePosition, targetPosition) > leashSquared;
}
export interface MobAiTransitionInput {
readonly phase: MobAiPhase;
readonly dead: boolean;
readonly engaged: boolean;
readonly playerAlive: boolean;
readonly position: MobAiPosition;
readonly homePosition: MobAiPosition;
readonly targetPosition: MobAiPosition | null;
readonly profile: MobCombatProfile;
readonly lineOfSight?: boolean;
}
export interface MobAiTransitionResult {
readonly phase: MobAiPhase;
/** Store should discard threat/forced-target state when true. */
readonly clearThreat: boolean;
/** Returning actor reached home and may restore health/statuses. */
readonly restoreAtHome: boolean;
/** Any pending attack wind-up should be discarded when true. */
readonly cancelSwing: boolean;
}
/**
* Pure orthogonal-state transition. Crowd control is evaluated separately so
* stun/root/fear do not explode the phase count or discard engagement state.
*/
export function resolveMobAiTransition(input: MobAiTransitionInput): MobAiTransitionResult {
let phase: MobAiPhase;
let restoreAtHome = false;
if (input.dead) {
phase = "dead";
} else if (input.phase === "dead") {
// A dead -> alive transition is an encounter reset, never an implicit re-pull.
phase = "idle";
} else if (input.phase === "returning") {
const homeDistance = Math.sqrt(distanceSquared(input.position, input.homePosition));
if (homeDistance <= input.profile.returnTolerance) {
phase = "idle";
restoreAtHome = true;
} else {
phase = "returning";
}
} else if (!input.engaged) {
phase = "idle";
} else if (
!input.playerAlive
|| !input.targetPosition
|| isMobOutsideLeash(input.position, input.targetPosition, input.homePosition, input.profile)
) {
phase = "returning";
} else if (
(input.lineOfSight ?? true)
&& isMobInAttackRange(input.position, input.targetPosition, input.profile)
) {
phase = "attacking";
} else {
phase = "chasing";
}
return {
phase,
clearThreat: phase === "idle" || phase === "returning" || phase === "dead",
restoreAtHome,
cancelSwing: phase !== "attacking",
};
}
export function hasActiveMobStatus(
statuses: readonly MobAiStatus[],
kind: MobStatusKind,
now: number,
): boolean {
const safeNow = Number.isFinite(now) ? now : 0;
return statuses.some((status) => status.kind === kind
&& Number.isFinite(status.endsAt)
&& status.endsAt > safeNow);
}
function strongestActiveMagnitude(
statuses: readonly MobAiStatus[],
kind: MobStatusKind,
now: number,
defaultMagnitude: number,
): number {
const safeNow = Number.isFinite(now) ? now : 0;
let strongest = 0;
for (const status of statuses) {
if (status.kind !== kind || !Number.isFinite(status.endsAt) || status.endsAt <= safeNow) continue;
const magnitude = Number.isFinite(status.magnitude) ? status.magnitude! : defaultMagnitude;
strongest = Math.max(strongest, magnitude);
}
return strongest;
}
const MOVEMENT_LOCKING_STATUSES: readonly MobStatusKind[] = [
"stun", "root", "fear", "sleep", "knockdown", "freeze", "charm", "confuse",
];
const ATTACK_LOCKING_STATUSES: readonly MobStatusKind[] = [
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
];
/** Immobilizing control suppresses directed movement; the strongest slow wins. */
export function mobMovementSpeedMultiplier(
statuses: readonly MobAiStatus[],
now: number,
): number {
if (MOVEMENT_LOCKING_STATUSES.some((kind) => hasActiveMobStatus(statuses, kind, now))) return 0;
const slow = Math.min(0.95, Math.max(0, strongestActiveMagnitude(statuses, "slow", now, 0.5)));
return 1 - slow;
}
/** A 20% attack-speed slow yields a 1 / 0.8 = 1.25 interval multiplier. */
export function mobSwingIntervalMultiplier(
statuses: readonly MobAiStatus[],
now: number,
): number {
const reduction = Math.min(
0.9,
Math.max(0, strongestActiveMagnitude(statuses, "attack-speed", now, 0.2)),
);
return 1 / (1 - reduction);
}
export function canMobMove(
phase: MobAiPhase,
dead: boolean,
statuses: readonly MobAiStatus[] = EMPTY_STATUSES,
now = 0,
): boolean {
return !dead
&& (phase === "chasing" || phase === "returning")
&& mobMovementSpeedMultiplier(statuses, now) > 0;
}
export function mobMovementSpeed(
profile: MobCombatProfile,
phase: MobAiPhase,
statuses: readonly MobAiStatus[] = EMPTY_STATUSES,
now = 0,
): number {
if (!canMobMove(phase, false, statuses, now)) return 0;
const base = phase === "returning" ? profile.returnSpeed : profile.chaseSpeed;
return finiteAtLeast(base, 0, 0) * mobMovementSpeedMultiplier(statuses, now);
}
export interface MobSwingEligibilityInput {
readonly phase: MobAiPhase;
readonly dead: boolean;
readonly engaged: boolean;
readonly playerAlive: boolean;
readonly targetAvailable: boolean;
/** Planar distance; callers can use sqrt(planarDistanceSquared(...)). */
readonly distanceToTarget: number;
readonly verticalDistance?: number;
readonly lineOfSight?: boolean;
readonly now: number;
readonly nextSwingAt: number;
readonly profile: MobCombatProfile;
readonly statuses?: readonly MobAiStatus[];
}
export function canMobSwing(input: MobSwingEligibilityInput): boolean {
const statuses = input.statuses ?? EMPTY_STATUSES;
const distance = finiteDistance(input.distanceToTarget);
const verticalDistance = finiteDistance(Math.abs(input.verticalDistance ?? 0));
return input.phase === "attacking"
&& !input.dead
&& input.engaged
&& input.playerAlive
&& input.targetAvailable
&& (input.lineOfSight ?? true)
&& Number.isFinite(input.now)
&& Number.isFinite(input.nextSwingAt)
&& input.now >= input.nextSwingAt
&& distance <= input.profile.attackRange
&& verticalDistance <= input.profile.attackHeightTolerance
&& !ATTACK_LOCKING_STATUSES.some((kind) => hasActiveMobStatus(statuses, kind, input.now));
}
/** First hit is deliberately telegraphed instead of landing on the aggro frame. */
export function firstMobSwingAt(now: number, profile: MobCombatProfile): number {
const safeNow = Number.isFinite(now) ? now : 0;
return safeNow + finiteAtLeast(profile.initialSwingDelayMs, 0, 0);
}
/** Schedules from `now` so a throttled tab cannot unleash catch-up attack bursts. */
export function nextMobSwingAt(
now: number,
profile: MobCombatProfile,
statuses: readonly MobAiStatus[] = EMPTY_STATUSES,
): number {
const safeNow = Number.isFinite(now) ? now : 0;
const interval = finiteAtLeast(profile.swingIntervalMs, 1, 1)
* mobSwingIntervalMultiplier(statuses, safeNow);
return safeNow + Math.max(1, Math.round(interval));
}
/** Allocation-free, no-overshoot 3D steering for the R3F motion adapter. */
export function moveMobToward(
current: MobAiPosition,
target: MobAiPosition,
maximumDistance: number,
out: MutableMobAiPosition,
): boolean {
if (!finitePosition(current) || !finitePosition(target)) {
out[0] = Number.isFinite(current[0]) ? current[0] : 0;
out[1] = Number.isFinite(current[1]) ? current[1] : 0;
out[2] = Number.isFinite(current[2]) ? current[2] : 0;
return false;
}
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 (distance <= POSITION_EPSILON) {
out[0] = target[0];
out[1] = target[1];
out[2] = target[2];
return true;
}
const step = Number.isFinite(maximumDistance) ? Math.max(0, maximumDistance) : 0;
if (step >= distance) {
out[0] = target[0];
out[1] = target[1];
out[2] = target[2];
return true;
}
const alpha = step / distance;
out[0] = current[0] + dx * alpha;
out[1] = current[1] + dy * alpha;
out[2] = current[2] + dz * alpha;
return false;
}