import { StateMachine, type Vec2 } from '@rpg/engine';
import { Cooldown } from '@rpg/engine';
import type { EnemyKindDef } from '../../data/enemies';
/**
* Чистый ИИ «пепельного сгустка» поверх движкового StateMachine.
* Сенсоры входят (дистанция/направление до героя), намерения выходят
* (куда идти, когда бить/плевать). Без Pixi — тестируется в Vitest.
*
* Состояния: dormant (спит в пепле) -> rise (поднимается) -> chase -> windup ->
* attack (удар/плевок) -> recover -> chase; sleeping (от низкого резонанса), dead.
*/
export type EnemyIntent =
| { type: 'idle' }
| { type: 'move'; dir: Vec2 }
| { type: 'strike' }
| { type: 'shoot'; dir: Vec2 };
export interface EnemySenses {
/** Дистанция до героя, мировых юнитов (1 юнит = 1 тайл). */
dist: number;
/** Нормализованное направление к герою. */
dirToPlayer: Vec2;
}
export class EnemyBrain {
readonly sm = new StateMachine();
private attackCd: Cooldown;
private sleepTimer = 0;
private hurtTimer = 0;
constructor(readonly kind: EnemyKindDef) {
this.attackCd = new Cooldown(kind.attackCooldown);
this.sm.add('dormant', {});
this.sm.add('rise', {});
this.sm.add('chase', {});
this.sm.add('windup', {});
this.sm.add('attack', {});
this.sm.add('recover', {});
this.sm.add('hurt', {});
this.sm.add('sleeping', {});
this.sm.add('dead', {});
// Обычные переходы
this.sm.transition('dormant', 'wake', 'rise');
this.sm.transition('rise', 'done', 'chase');
this.sm.transition('chase', 'inRange', 'windup');
this.sm.transition('chase', 'exhausted', 'sleeping');
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('dormant');
}
/** Громкий звук рядом — проснуться. */
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);
// Таймеры поверх автомата
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 'chase': {
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 };
}
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' };
}
}
}
function negate(v: Vec2): Vec2 {
return { x: -v.x, y: -v.y };
}