382 lines
13 KiB
TypeScript
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;
|
|
}
|