Newer
Older
rpg / apps / game / src / systems / InteractableViews.ts
import { Container, FxLayer, Graphics, PixelText, Sprite, SpriteAnimator, SpriteMotion, type AssetLoader, type Updatable } from '@rpg/engine';
import type { InteractableDef } from '../data/interactables';
import type { NpcDef } from '../data/npcs';

/**
 * Вьюхи NPC и интерактивных объектов: фабрики спрайтов/плейсхолдеров с
 * подписями + растворение использованных объектов. Сцена только вызывает
 * фабрики и держит слои глубины.
 */

/** Вьюха интерактивного объекта + её части (тело — для кликов, подпись — близость). */
export interface InteractableView {
    def: InteractableDef;
    view: Container;
    body: Graphics | Sprite;
    label: PixelText;
}

export interface InteractableViewDeps {
    assets: AssetLoader;
    /** Реестр тикающихся (engine.fx): пульс мота и idle-анимация NPC. */
    addFx(u: Updatable): void;
    /** Слой мировых эффектов: растворение использованной вьюхи. */
    fx: FxLayer;
}

/** Экранный центр ромба тайла (worldToScreen ∘ tileToWorld). */
export type TileCenter = (tx: number, ty: number) => { x: number; y: number };

export class InteractableViews {
    /** Созданные вьюхи интерактивов (подписи, клики по телу, растворение). */
    readonly items: InteractableView[] = [];

    constructor(private deps: InteractableViewDeps) {}

    /** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */
    createInteractableView(def: InteractableDef, center: TileCenter): InteractableView {
        const view = new Container();
        const p = center(def.tile.x, def.tile.y);
        view.position.set(p.x, p.y);
        let g: Graphics | Sprite;
        if (def.view?.tex) {
            // Настоящий арт из def.view: якорь низом в центр ромба (или задан).
            const sprite = new Sprite(this.deps.assets.texture(def.view.tex));
            sprite.anchor.set(0.5, def.view.anchorY ?? 1);
            g = sprite;
        } else {
            // Плейсхолдер-ящик по роду (пока нет арта конкретного объекта).
            g = new Graphics();
            switch (def.kind) {
                case 'signpost': // столбик с доской
                    g.rect(-1, -10, 2, 10).fill(0x4a3b2a);
                    g.rect(-6, -14, 12, 5).fill(0x5c4a33);
                    g.rect(-6, -14, 12, 1).fill(0x8a6d3a);
                    break;
                case 'lever': // рычаг: основание и наклонная рукоять
                    g.rect(-3, -2, 6, 2).fill(0x4a3b2a);
                    g.rect(0, -8, 2, 7).fill(0x8a6d3a);
                    g.rect(-2, -10, 4, 2).fill(0x8a6d3a);
                    break;
                case 'pickup': // пепельная насыпь, из которой теплится мот
                    g.rect(-6, -2, 12, 3).fill(0x3a3a3a);
                    g.rect(-4, -4, 8, 2).fill(0x4a4a4a);
                    g.rect(-2, -5, 4, 1).fill(0x5a5a5a);
                    break;
                default: // контейнер/предмет осмотра: ящик с бронзовой кромкой
                    g.rect(-5, -12, 10, 12).fill(0x4a3b2a);
                    g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо
            }
        }
        view.addChild(g);
        // У мота — тёплая искра поверх насыпи: заметный пульс видно издалека.
        if (def.kind === 'pickup') {
            const spark = new Graphics();
            spark.rect(-1, -1, 3, 3).fill(0xf0d878);
            spark.position.set(0, -5);
            view.addChild(spark);
            this.deps.addFx(
                new SpriteMotion(spark, { pulse: { min: 0.3, max: 1, period: 1.1 } }, {
                    seed: def.tile.x * 31 + def.tile.y
                })
            );
        }
        const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a });
        label.anchor.set(0.5, 1);
        label.position.set(0, -16);
        label.visible = false;
        view.addChild(label);
        const made = { def, view, body: g, label };
        this.items.push(made);
        return made;
    }

    /**
     * Одноразовый объект поднят (sink.onConsumed): вьюха растворяется, из
     * тайла вылетают пеплинки — состояние мира меняется на глазах.
     */
    consume(def: InteractableDef, center: TileCenter, burst: (s: { x: number; y: number }) => void): void {
        const iv = this.items.find((v) => v.def.id === def.id);
        if (!iv) return;
        this.items.splice(this.items.indexOf(iv), 1);
        const w = center(def.tile.x, def.tile.y);
        burst(w);
        // Вьюха тает и удаляется сама (пресет; removeChild+destroy внутри).
        this.deps.fx.fade(iv.view, { duration: 0.5 });
    }

    /** NPC: спрайт с idle-дыханием кадрами атласа + маркер «с ним можно говорить». */
    createNpcView(def: NpcDef, center: TileCenter): { view: Container; body: Sprite } {
        const view = new Container();
        const p = center(def.tile.x, def.tile.y);
        view.position.set(p.x, p.y);

        const sprite = new Sprite();
        sprite.anchor.set(0.5, 1); // ноги в центре ромба
        // idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC).
        const idle = this.deps.assets.animation(
            def.atlas ?? 'chars/npcs_sheet.json',
            `${def.sprite}_idle`
        );
        sprite.texture = idle[0]!;
        const animator = new SpriteAnimator(
            sprite,
            { idle: { frames: idle, fps: 1.2 } },
            'idle',
            { randomPhase: def.tile.y * 64 + def.tile.x }
        );
        this.deps.addFx(animator);
        view.addChild(sprite);

        // Маркер «с ним можно говорить»: восклицательный штрих над головой
        // (высота — от реального кадра атласа, не от захардкоженной).
        const frameH = idle[0]!.height;
        const marker = new Graphics();
        marker.rect(-1, -(frameH + 3), 2, 4).fill(0xf0d878);
        marker.rect(-1, -(frameH - 3), 2, 2).fill(0xf0d878);
        view.addChild(marker);
        return { view, body: sprite };
    }
}