Newer
Older
rpg / apps / game / src / systems / combat / CombatViews.ts
import {
    Container,
    Graphics,
    IsoDepthLayer,
    Sprite,
    SpriteAnimator,
    Texture,
    worldToTile,
    worldToScreen,
    type Entity,
    type FxLayer,
    type Vec2
} from '@rpg/engine';
import type { CombatWorld } from './CombatWorld';
import type { EnemyKindId } from '../../data/enemies';

/**
 * Пиксельные вьюхи боя: спрайты сгустков, снаряды, вспышки урона, растворение
 * трупов, частицы ударов. Данные живут в CombatWorld (ECS) — здесь только отображение.
 */

interface EnemyView {
    root: Container;
    sprite: Sprite;
    animator: SpriteAnimator;
    /** Растворение запущено (труп тает через FxFade, не вручную). */
    dissolving?: boolean;
}

export class CombatViews {
    private views = new Map<Entity, EnemyView>();
    private projectileViews = new Map<Entity, Graphics>();

    constructor(
        private combat: CombatWorld,
        private actors: IsoDepthLayer,
        private enemyTextures: Map<EnemyKindId, [Texture, Texture]>,
        private fxRoot: Container,
        /** Слой эффектов (FxLayer сцены): тик и зачистка — сами. */
        private fx: FxLayer
    ) {}

    /** Спавн-вспышка пепла (взрыв частиц). */
    hitBurst(pos: Vec2): void {
        this.fx.burst(10, { x: pos.x, y: pos.y - 6 }, {
            color: 0x52525c,
            rate: 0,
            lifetime: [0.2, 0.45],
            radialSpeed: [30, 70],
            size: 2,
            drag: 2.5,
            scaleOverLife: 'shrink',
            seed: (pos.x * 31 + pos.y) | 0
        }, this.fxRoot);
    }

    /** Смерть: заметный выброс пепла. */
    deathBurst(pos: Vec2): void {
        this.fx.burst(16, { x: pos.x, y: pos.y - 8 }, {
            colors: [0x6a6a74, 0x8a8a96],
            rate: 0,
            lifetime: [0.3, 0.6],
            radialSpeed: [40, 90],
            size: 2,
            drag: 2,
            spin: [-4, 4],
            scaleOverLife: 'shrink',
            seed: (pos.x * 17 + pos.y) | 0
        }, this.fxRoot);
    }

    /** Кольцо резонанса — расходящаяся окружность (пресет, тикается слоем). */
    resonanceRing(pos: Vec2): void {
        this.fx.ring(
            { color: 0xd99a32, from: 8, to: 96, duration: 0.4, width: 1 },
            { x: pos.x, y: pos.y - 4 },
            { parent: this.fxRoot }
        );
    }

    /** Синхронизация вьюх с ECS-миром. Вызывать каждый тик после combat.update. */
    sync(dt: number): void {
        const map = this.combat.deps.map;
        const tileOf = (p: Vec2): { x: number; y: number } =>
            worldToTile(p.x, p.y, map.data.width, map.data.height) ?? { x: 0, y: 0 };

        // Спавн новых
        for (const [e, en] of this.combat.enemies) {
            if (!this.views.has(e)) {
                const frames = this.enemyTextures.get(en.kind.id)!;
                const root = new Container();
                const sprite = new Sprite(frames[0]);
                sprite.anchor.set(0.5, 1);
                root.addChild(sprite);
                const sp = worldToScreen(en.pos.x, en.pos.y);
                root.position.set(sp.x, sp.y);
                const t = tileOf(en.pos);
                this.actors.add(root, t.x, t.y);
                this.views.set(e, {
                    root,
                    sprite,
                    animator: new SpriteAnimator(sprite, { walk: { frames, fps: 4 } }, 'walk')
                });
            }
        }

        // Обновление/смерть
        for (const [e, en] of this.combat.enemies) {
            const view = this.views.get(e)!;
            const sp = worldToScreen(en.pos.x, en.pos.y);
            view.root.position.set(sp.x, sp.y);
            const t = tileOf(en.pos);
            this.actors.setDepth(view.root, t.x, t.y);

            if (en.brain.dead) {
                // Растворение трупа: один fade на переход alive→dead (keep —
                // вьюху уничтожает сцена по !isAlive, без двойного destroy).
                if (!view.dissolving) {
                    view.dissolving = true;
                    this.fx.fade(view.root, { duration: 0.5, keep: true });
                }
                view.sprite.tint = 0x555560;
                continue;
            }

            // Мигание неактивных: спящий сгусток почти невидим в пепле
            view.root.alpha = en.brain.asleep ? 0.45 : 1;

            // Покадровая анимация (4 Гц) — только когда активен; спящим pause.
            if (en.brain.asleep) view.animator.pause();
            else {
                view.animator.resume();
                view.animator.play('walk');
            }
            view.animator.update(dt);
        }

        // Удаление уничтоженных сущностей
        for (const [e, view] of this.views) {
            if (!this.combat.world.isAlive(e)) {
                this.actors.removeChild(view.root);
                view.root.destroy({ children: true });
                this.views.delete(e);
            }
        }

        // Снаряды
        for (const [e, pr] of this.combat.projectiles) {
            let pv = this.projectileViews.get(e);
            if (!pv) {
                pv = new Graphics();
                pv.circle(0, 0, 2).fill(0x86868f);
                this.fxRoot.addChild(pv);
                this.projectileViews.set(e, pv);
            }
            const sp = worldToScreen(pr.pos.x, pr.pos.y);
            pv.position.set(sp.x, sp.y);
        }
        for (const [e, pv] of this.projectileViews) {
            if (!this.combat.world.isAlive(e)) {
                pv.destroy();
                this.projectileViews.delete(e);
            }
        }
    }

    /** Вспышка конкретного врага (вызывает CombatWorld при попадании). */
    flashEnemy(e: Entity): void {
        const view = this.views.get(e);
        if (view) this.fx.flash(view.sprite, 0xf2b45a, 0.15);
    }

    exit(): void {
        for (const [, view] of this.views) {
            this.actors.removeChild(view.root);
            view.root.destroy({ children: true });
        }
        this.views.clear();
        for (const [, pv] of this.projectileViews) pv.destroy();
        this.projectileViews.clear();
    }
}