Newer
Older
rpg / apps / game / src / systems / combat / EnemyBrain.ts
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 };
}