import { StateMachine, Cooldown, type Vec2 } from '@rpg/engine';
import type { EnemyKindDef } from '../../data/enemies';
/**
* Чистый ИИ «пепельного сгустка» поверх движкового StateMachine.
* Сенсоры входят (дистанция/направление до героя, видимость, шум, патруль),
* намерения выходят (куда идти, когда бить/плевать). Без Pixi — тестируется
* в Vitest.
*
* Состояния: спящий враг (dormant, старт без патруля) -> rise -> chase ->
* windup -> attack -> recover -> chase; патрульный враг: patrol <-> wary ->
* chase; отступление: chase -> flee (низкое hp) / -> return (потерял героя) ->
* patrol | dormant; sleeping (от низкого резонанса), dead.
*
* Шум слышен бодрому врагу от уровня NOISE_HEAR_LEVEL (в радиусе hearRadius —
* фильтрует CombatWorld); спящих будит только уровень >= 0.7 (гулкий удар).
*/
export type EnemyIntent =
| { type: 'idle' }
| { type: 'move'; dir: Vec2; run?: boolean }
| { type: 'strike' }
| { type: 'shoot'; dir: Vec2 };
export interface EnemySenses {
/** Дистанция до героя, мировых юнитов (1 юнит = 1 тайл). */
dist: number;
/** Нормализованное направление к герою. */
dirToPlayer: Vec2;
/** Позиция врага (для хождения по патрульным точкам и к дому). */
pos?: Vec2;
/** Герой в прямой видимости (LOS; по умолчанию true — старые сенсоры). */
canSee?: boolean;
/** Последний шум в радиусе слуха (уже отфильтрован по hearRadius). */
noise?: { origin: Vec2; level: number } | null;
/** Точки патруля, мировые юниты (пусто — враг без патруля). */
patrolPoints?: Vec2[];
/** Точка спавна — «дом», к которому враг отступает, юниты. */
home?: Vec2;
/** Доля hp (0..1; по умолчанию 1). */
hpFraction?: number;
}
/** Шум от этого уровня слышит бодрый враг (спящих будит CombatWorld от 0.7). */
export const NOISE_HEAR_LEVEL = 0.35;
/** Шум от этого уровня будит спящих (гулкий удар; см. CombatWorld.noise). */
export const NOISE_WAKE_LEVEL = 0.7;
/** Легальные состояния автомата (инвариант enemy-state-legal). */
export const ENEMY_STATES = [
'dormant',
'rise',
'patrol',
'wary',
'chase',
'windup',
'attack',
'recover',
'hurt',
'flee',
'return',
'sleeping',
'dead'
] as const;
/** Дальность, на которой считается «дошёл до точки» (юниты). */
const ARRIVE_DIST = 0.35;
export class EnemyBrain {
readonly sm = new StateMachine();
private attackCd: Cooldown;
private sleepTimer = 0;
private hurtTimer = 0;
/** Секунд без прямой видимости в погоне. */
private lostTimer = 0;
/** Таймер настороженности и точка, к которой идём проверять. */
private waryTimer = 0;
private waryTarget: Vec2 | null = null;
/** Патруль: индекс точки (ping-pong) и пауза на точке, сек. */
private patrolIndex = 0;
private patrolDir = 1;
private patrolPause = 0;
constructor(
readonly kind: EnemyKindDef,
private patrol?: { points: Vec2[]; pauseSec: number }
) {
this.attackCd = new Cooldown(kind.attackCooldown);
for (const s of ENEMY_STATES) this.sm.add(s, {});
// Обычные переходы
this.sm.transition('dormant', 'wake', 'rise');
this.sm.transition('rise', 'done', 'chase');
// Патруль: увидел — погоня, услышал шум — насторожился.
this.sm.transition('patrol', 'see', 'chase');
this.sm.transition('patrol', 'noise', 'wary');
this.sm.transition('wary', 'see', 'chase');
this.sm.transition('wary', 'lost', 'return');
this.sm.transition('chase', 'inRange', 'windup');
this.sm.transition('chase', 'exhausted', 'sleeping');
this.sm.transition('chase', 'lost', 'return');
this.sm.transition('chase', 'flee', 'flee');
this.sm.transition('flee', 'calm', 'wary');
this.sm.transition('return', 'home', this.patrol ? 'patrol' : 'dormant');
this.sm.transition('return', 'see', 'chase');
this.sm.transition('windup', 'strike', 'attack');
this.sm.transition('attack', 'done', 'recover');
this.sm.transition('recover', 'done', 'chase');
this.sm.transition('hurt', 'done', 'chase');
this.sm.transition('sleeping', 'wake', 'rise');
// Из любого состояния: урон и смерть (кроме dead — повторный вход игнорируется)
this.sm.transitionAny('hurt', 'hurt');
this.sm.transitionAny('die', 'dead');
this.sm.change(this.patrol ? 'patrol' : 'dormant');
}
/** Гулкий звук рядом — проснуться (только для спящих; уровень фильтрует CombatWorld). */
hearLoud(): void {
if (this.sm.current === 'dormant' || this.sm.current === 'sleeping') {
this.sm.handleEvent('wake');
}
}
/** Низкий резонанс: усыпить на seconds (мёртвых не трогает). */
putToSleep(seconds: number): void {
if (this.sm.current === 'dead') return;
this.sm.change('sleeping');
this.sleepTimer = seconds;
}
/** Получил урон: короткий стан (не выбивает из dead). */
hurt(): void {
if (this.sm.current === 'dead') return;
this.hurtTimer = 0.25;
this.sm.handleEvent('hurt');
}
/** Умер. Возвращает true, если умер именно сейчас. */
die(): boolean {
if (this.sm.current === 'dead') return false;
this.sm.handleEvent('die');
return true;
}
get state(): string | null {
return this.sm.current;
}
get asleep(): boolean {
return this.sm.current === 'sleeping' || this.sm.current === 'dormant';
}
get dead(): boolean {
return this.sm.current === 'dead';
}
/** Тик ИИ: сенсоры внутрь, намерение наружу. */
update(dt: number, senses: EnemySenses): EnemyIntent {
this.sm.update(dt);
this.attackCd.update(dt);
const canSee = senses.canSee ?? true;
const aggro = this.kind.aggroRange ?? 4;
// Таймеры поверх автомата
if (this.hurtTimer > 0) {
this.hurtTimer -= dt;
if (this.hurtTimer <= 0) this.sm.handleEvent('done');
return { type: 'idle' };
}
if (this.sm.current === 'sleeping') {
this.sleepTimer -= dt;
if (this.sleepTimer <= 0) this.sm.handleEvent('wake');
return { type: 'idle' };
}
if (this.sm.current === 'rise') {
// Подъём из пепла: 0.6 с, потом в погоню
if (this.sm.time >= 0.6) this.sm.handleEvent('done');
return { type: 'idle' };
}
switch (this.sm.current) {
case 'patrol':
return this.updatePatrol(dt, senses, canSee, aggro);
case 'wary':
return this.updateWary(dt, senses, canSee, aggro);
case 'chase':
return this.updateChase(dt, senses, canSee, aggro);
case 'flee':
return this.updateFlee(senses);
case 'return':
return this.updateReturn(senses, canSee, aggro);
case 'windup': {
if (this.sm.time >= this.kind.windup) {
this.sm.handleEvent('strike');
this.attackCd.triggerForce();
return this.kind.keepDistance !== undefined
? { type: 'shoot', dir: senses.dirToPlayer }
: { type: 'strike' };
}
return { type: 'idle' };
}
case 'attack': {
if (this.sm.time >= Math.max(this.kind.recover, 0.3)) {
this.sm.handleEvent('done');
}
return { type: 'idle' };
}
case 'recover': {
if (this.sm.time >= this.kind.recover) this.sm.handleEvent('done');
return { type: 'idle' };
}
default:
return { type: 'idle' };
}
}
/** Патруль: ход по точкам туда-обратно; герой/шум вырывают из круга. */
private updatePatrol(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent {
const seen = this.checkSee(senses, canSee, aggro);
if (seen) return { type: 'idle' };
const noise = this.checkNoise(senses);
if (noise) return { type: 'idle' };
const points = this.patrol?.points ?? senses.patrolPoints ?? [];
if (points.length === 0) return { type: 'idle' };
if (this.patrolPause > 0) {
this.patrolPause -= dt;
return { type: 'idle' };
}
const move = this.dirToward(senses.pos, points[this.patrolIndex]!);
if (move === null) {
// Точка достигнута: пауза и разворот на следующую (ping-pong).
this.patrolPause = this.patrol?.pauseSec ?? 0;
this.advancePatrolIndex(points.length);
}
return move ? { type: 'move', dir: move } : { type: 'idle' };
}
/** Настороженность: идём к источнику шума, осматриваемся warySec — и домой. */
private updateWary(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent {
const seen = this.checkSee(senses, canSee, aggro);
if (seen) return { type: 'idle' };
const noise = this.checkNoise(senses);
if (noise) return { type: 'idle' }; // свежий шум обновляет цель и таймер
this.waryTimer -= dt;
if (this.waryTimer <= 0) {
this.sm.handleEvent('lost');
return { type: 'idle' };
}
const move = this.waryTarget ? this.dirToward(senses.pos, this.waryTarget) : null;
return move ? { type: 'move', dir: move } : { type: 'idle' };
}
/** Погоня: потерял героя дольше deaggroSec — домой; мало hp — прочь. */
private updateChase(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent {
if (canSee) {
this.lostTimer = 0;
} else {
this.lostTimer += dt;
if (this.lostTimer >= (this.kind.deaggroSec ?? 2.5)) {
this.sm.handleEvent('lost');
return { type: 'idle' };
}
}
const fleeAt = this.kind.fleeBelowHpFraction ?? 0;
if (fleeAt > 0 && (senses.hpFraction ?? 1) <= fleeAt) {
this.sm.handleEvent('flee');
return { type: 'idle' };
}
if (senses.dist > aggro * 1.5) {
// Убежал слишком далеко даже с LOS — не гоняемся по всей карте.
this.sm.handleEvent('lost');
return { type: 'idle' };
}
const inRange = senses.dist <= this.kind.attackRange;
if (inRange && this.kind.keepDistance === undefined && this.attackCd.ready) {
// контактный (ползун): без замаха
if (this.kind.windup === 0) {
this.attackCd.triggerForce();
return { type: 'strike' };
}
this.sm.handleEvent('inRange');
return { type: 'idle' };
}
// Плевун держит дистанцию
if (this.kind.keepDistance !== undefined) {
if (senses.dist < this.kind.keepDistance - 0.4) {
return { type: 'move', dir: negate(senses.dirToPlayer) };
}
if (senses.dist <= this.kind.attackRange && this.attackCd.ready) {
this.sm.handleEvent('inRange');
return { type: 'idle' };
}
if (senses.dist > this.kind.keepDistance + 0.4) {
return { type: 'move', dir: senses.dirToPlayer };
}
return { type: 'idle' };
}
return { type: 'move', dir: senses.dirToPlayer };
}
/** Отступление: прочь от героя бегом, пока он не отстал на fleeRange. */
private updateFlee(senses: EnemySenses): EnemyIntent {
if (senses.dist >= (this.kind.fleeRange ?? 4)) {
// Оторвался: стоит и осматривается warySec, потом домой.
this.waryTarget = null;
this.waryTimer = this.kind.warySec ?? 2.5;
this.sm.handleEvent('calm');
return { type: 'idle' };
}
return { type: 'move', dir: negate(senses.dirToPlayer), run: true };
}
/** Возврат к дому; на месте — снова в патруль или обратно в пепел. */
private updateReturn(senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent {
const seen = this.checkSee(senses, canSee, aggro);
if (seen) return { type: 'idle' };
const noise = this.checkNoise(senses);
if (noise) return { type: 'idle' };
if (senses.home) {
const move = this.dirToward(senses.pos, senses.home);
if (move === null) {
this.lostTimer = 0;
this.sm.handleEvent('home');
} else {
return { type: 'move', dir: move };
}
}
return { type: 'idle' };
}
/** Переход «увидел героя» (LOS + aggroRange); true — событие отправлено. */
private checkSee(senses: EnemySenses, canSee: boolean, aggro: number): boolean {
if (canSee && senses.dist <= aggro) {
this.lostTimer = 0;
this.sm.handleEvent('see');
return true;
}
return false;
}
/** Переход «услышал шум» (уровень >= порога); true — событие отправлено. */
private checkNoise(senses: EnemySenses): boolean {
const noise = senses.noise;
if (noise && noise.level >= NOISE_HEAR_LEVEL) {
this.waryTarget = { ...noise.origin };
this.waryTimer = this.kind.warySec ?? 2.5;
this.sm.handleEvent('noise');
return true;
}
return false;
}
/** Направление к точке; null — точка достигнута (в пределах ARRIVE_DIST). */
private dirToward(from: Vec2 | undefined, target: Vec2): Vec2 | null {
if (!from) return null;
const dx = target.x - from.x;
const dy = target.y - from.y;
const d = Math.hypot(dx, dy);
if (d < ARRIVE_DIST) return null;
return { x: dx / d, y: dy / d };
}
private advancePatrolIndex(count: number): void {
if (count < 2) return;
if (this.patrolIndex + this.patrolDir >= count || this.patrolIndex + this.patrolDir < 0) {
this.patrolDir = -this.patrolDir;
}
this.patrolIndex += this.patrolDir;
}
}
function negate(v: Vec2): Vec2 {
return { x: -v.x, y: -v.y };
}