Newer
Older
rpg / apps / game / src / systems / fauna / FaunaSystem.ts
import {
    Container,
    Sprite,
    SpriteAnimator,
    StateMachine,
    Texture,
    moveTowardsW,
    worldDist,
    worldToScreen,
    gridOf,
    type EventBus,
    type Grid,
    type IsoDepthLayer,
    type SceneRegistry,
    type TileMapData,
    type Vec2
} from '@rpg/engine';

/**
 * Пейзажная фауна (витрина StateMachine вне боя): безгласный олень.
 * Стоит, медленно бродит по соседним тайлам; на громкий звон рядом
 * подходит к источнику и стоит у колокольчиков. Не боевой.
 */

/** Радиус слуха на звон (мировые юниты). */
const HEAR_RADIUS = 7;
/** Скорость брожения / подхода (юниты/сек). */
const WANDER_SPEED = 0.35;
const APPROACH_SPEED = 0.7;
/** Радиус тела оленя (юниты) — для телесных коллизий. */
const FAUNA_RADIUS = 0.4;

interface FaunaEntity {
    /** Позиция (ноги) в мировых юнитах. */
    pos: Vec2;
    machine: StateMachine;
    /** Куда идём (если идём). */
    dest: Vec2 | null;
    view: Container;
    sprite: Sprite;
    animator: SpriteAnimator;
    /** Пауза перед следующим шагом (idle). */
    pause: number;
}

export class FaunaSystem {
    private entities: FaunaEntity[] = [];
    private rng: () => number;
    private off: () => void;
    /** Проходимость карты (blocked-иды + пропы) — один адаптер на систему. */
    private grid: Grid;

    constructor(
        private actors: IsoDepthLayer,
        events: EventBus,
        data: TileMapData,
        spawns: Vec2[],
        /** Реестр сцены (опция): олени регистрируются для «кто рядом». */
        private registry?: SceneRegistry,
        seed = 20260908
    ) {
        this.grid = gridOf(data);
        // Детерминированный ГПСЧ — брожение одинаково между запусками.
        let s = seed;
        this.rng = () => {
            s = (s + 0x6d2b79f5) | 0;
            let t = Math.imul(s ^ (s >>> 15), 1 | s);
            t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
        };
        for (const p of spawns) this.spawn(p);
        // Громкий звон рядом — олень подходит к источнику и стоит у колокольчиков.
        this.off = events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => {
            for (const e of this.entities) {
                if (worldDist(e.pos, origin) <= HEAR_RADIUS) {
                    e.dest = { ...origin };
                    e.machine.change('approach');
                    // Олень, услышав звон, вскрикивает — звук вешает AudioSystem.
                    events.emit('fauna:startle', { pos: { ...e.pos } });
                }
            }
        });
    }

    /** Привязать кадры (вызывается сценой после конструктора). */
    setFrames(frames: [Texture, Texture]): void {
        for (const e of this.entities) {
            e.animator.setClipFrames('walk', frames);
            e.animator.showStep(0);
        }
    }

    /** Заспавнить оленя в точке (юниты). */
    private spawn(pos: Vec2): void {
        const view = new Container();
        const sprite = new Sprite();
        sprite.anchor.set(0.5, 1);
        view.addChild(sprite);
        const sp = worldToScreen(pos.x, pos.y);
        view.position.set(sp.x, sp.y);
        this.actors.add(view, Math.floor(pos.x), Math.floor(pos.y));

        const e: FaunaEntity = {
            pos: { ...pos },
            machine: new StateMachine(),
            dest: null,
            view,
            sprite,
            animator: new SpriteAnimator(sprite, { walk: { frames: [sprite.texture, sprite.texture], fps: 3 } }),
            pause: 0
        };

        // Стоит: через паузу делает шаг к соседнему тайлу.
        e.machine.add('idle', {
            update: () => {
                if (e.machine.time >= e.pause) this.startWander(e);
            }
        });
        // Бродит: медленно к случайной соседней точке.
        e.machine.add('wander', {
            update: (dt) => this.stepTo(e, dt, WANDER_SPEED, 'idle')
        });
        // Подходит на звон: быстрее, к источнику звука.
        e.machine.add('approach', {
            update: (dt) => this.stepTo(e, dt, APPROACH_SPEED, 'idle')
        });
        e.machine.change('idle');
        e.pause = 1.5 + this.rng() * 3;
        this.entities.push(e);
        this.registry?.add({
            id: `fauna:${this.entities.length - 1}`,
            kind: 'fauna',
            pos: e.pos,
            radius: FAUNA_RADIUS
        });
    }

    /** Начать шаг к случайной соседней проходимой точке. */
    private startWander(e: FaunaEntity): void {
        const dirs = [
            [1, 0],
            [-1, 0],
            [0, 1],
            [0, -1]
        ];
        const options = dirs
            .map(([dx, dy]) => ({ x: e.pos.x + dx, y: e.pos.y + dy }))
            .filter((p) => this.walkable(p));
        if (options.length === 0) {
            e.pause = 2;
            return;
        }
        e.dest = options[Math.floor(this.rng() * options.length)]!;
        e.pause = 1.5 + this.rng() * 3;
        e.machine.change('wander');
    }

    /** Движение к dest; по приходе — переход в idle. */
    private stepTo(e: FaunaEntity, dt: number, speed: number, back: string): void {
        if (!e.dest) {
            e.machine.change(back);
            return;
        }
        e.pos = moveTowardsW(e.pos, e.dest, speed * dt);
        // Прибытие с eps: расталкивание тел могло сместить существо.
        if (Math.abs(e.pos.x - e.dest.x) < 1e-4 && Math.abs(e.pos.y - e.dest.y) < 1e-4) {
            e.pos = { x: e.dest.x, y: e.dest.y }; // снап — тайл без дрейфа
            e.dest = null;
            e.machine.change(back);
        }
    }

    private walkable(p: Vec2): boolean {
        const tx = Math.floor(p.x);
        const ty = Math.floor(p.y);
        return this.grid.isWalkable(tx, ty);
    }

    update(dt: number): void {
        for (const [i, e] of this.entities.entries()) {
            e.machine.update(dt);
            // Покадровая анимация: на ходу play, в покое — стоп-кадр 0.
            if (e.dest) {
                e.animator.resume();
                e.animator.play('walk');
            } else {
                e.animator.showStep(0);
            }
            e.animator.update(dt);
            this.registry?.move(`fauna:${i}`, e.pos);
            const sp = worldToScreen(e.pos.x, e.pos.y);
            e.view.position.set(sp.x, sp.y);
            this.actors.setDepth(e.view, Math.floor(e.pos.x), Math.floor(e.pos.y));
        }
    }

    exit(): void {
        this.off?.();
        for (const e of this.entities) {
            this.actors.removeChild(e.view);
            e.view.destroy({ children: true });
        }
        this.entities = [];
    }
}