Newer
Older
rpg / apps / game / src / scenes / InventoryScene.ts
import { playSfx } from '../data/sfxSpecs';
import { Container, MenuList, MenuSceneBase, Panel, PixelText, Sprite } from '@rpg/engine';
import { Game } from '../Game';
import { questLog } from '../data/quests';
import { itemName, useItemLine, type ItemId } from '../data/items';
import { MapScene } from './MapScene';

/**
 * Сумка и журнал квестов (панель поверх локации, push/pop).
 * Содержимое собирается из флагов/варов GameState при каждом открытии.
 * Список сумки с курсором: Enter — применить предмет (часы показывают время,
 * время в сумке заморожено — локация не тикается). Журнал — текст без фокуса.
 */

/** Иконка предмета 8×8 (арт-библия §4): ui/icon_<id>.png. */
const ITEM_ICONS: Record<ItemId, string> = {
    clock: 'ui/icon_clock',
    cloth: 'ui/icon_cloth',
    bellflower: 'ui/icon_bellflower',
    salt: 'ui/icon_salt',
    water_flask: 'ui/icon_flask',
    mote: 'ui/icon_mote',
    map_scroll: 'ui/icon_map'
};
export class InventoryScene extends MenuSceneBase {
    /** Иконки строк сумки (синхронизируются с окном скролла). */
    private icons: Container | null = null;

    constructor(
        private game: Game,
        private onBack: () => void
    ) {
        super(
            { input: game.engine.input, inputBlocked: () => game.scenes.transitioning },
            { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['inventory'] }
        );
    }

    override update(dt: number): void {
        super.update(dt);
        this.syncIcons();
    }

    /** Иконки следуют за окном скролла: видимость и строка — как у кнопок. */
    private syncIcons(): void {
        if (!this.icons || !this.menu) return;
        const win = this.menu.window ?? { first: 0, last: this.icons.children.length };
        this.icons.children.forEach((icon, i) => {
            icon.visible = i >= win.first && i < win.last;
            icon.position.y = 7 + (i - win.first) * 15;
        });
    }

    protected build(): void {
        const panel = new Panel({ width: 300, height: 222 });
        panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2);

        const title = new PixelText({ text: 'ЗВОНАРЬ', size: 14, color: 0xd8c79a });
        title.anchor.set(0.5);
        title.position.set(150, 14);
        panel.addChild(title);

        const addLine = (text: string, color: number, x: number, y: number): void => {
            const t = new PixelText({ text, size: 10, color });
            t.position.set(x, y);
            panel.addChild(t);
        };

        // Статус-строка: подсказка до первого применения, результат — после.
        const result = new PixelText({ text: 'Enter — применить, Esc — назад', size: 9, color: 0xf2d49a });
        result.position.set(16, 28);
        panel.addChild(result);

        // Сумка: список с курсором и скроллом (5 строк), Enter — применить.
        addLine('Сумка', 0x999988, 16, 40);
        const slots = this.game.inventory.all;
        if (slots.length > 0) {
            this.menu = new MenuList({ width: 260, height: 14, gap: 1, size: 10, visibleRows: 5 });
            this.menu.position.set(20, 52);
            this.menu.setItems(
                slots.map((slot) => ({
                    label: slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id),
                    value: '', // метка прижата влево — освобождаем место под иконку
                    onSelect: () => this.useItem(slot.id, result)
                }))
            );
            panel.addChild(this.menu);
            // Иконки 8×8 слева от строк: тикаются с окном скролла (syncIcons).
            this.icons = new Container();
            this.icons.position.set(20, 52);
            slots.forEach((slot, i) => {
                const icon = new Sprite(this.game.assets.texture(ITEM_ICONS[slot.id as ItemId]));
                icon.anchor.set(0.5, 0.5);
                icon.position.set(-6, 7 + i * 15);
                this.icons!.addChild(icon);
            });
            panel.addChild(this.icons);
        } else {
            addLine('— пусто —', 0x777788, 24, 54);
        }

        // Журнал квестов — текущие задачи (незавершённые достигнутые стадии),
        // без фокуса; перенос по ширине панели, шаг — по фактической высоте.
        addLine('Журнал', 0x999988, 16, 132);
        let y = 146;
        let shown = 0;
        for (const entry of questLog(this.game.state)) {
            if (entry.done) continue;
            const line = new PixelText({
                text: `· ${entry.text}`,
                size: 10,
                color: 0xaaaaaa,
                wordWrapWidth: 264
            });
            line.position.set(24, y);
            panel.addChild(line);
            y += Math.ceil(line.height) + 3;
            shown++;
        }
        if (shown === 0) addLine('— текущих задач нет —', 0x777788, 24, 146);

        this.view.addChild(panel);
        this.game.renderer.uiRoot.addChild(this.view);
    }

    /** Применить предмет: юза́бельные дают строку-результат, остальные молчат. */
    private useItem(id: string, result: PixelText): void {
        // Карта окрестностей — не строка, а оверлей поверх сумки.
        if (id === 'map_scroll') {
            playSfx(this.game.audio, 'sfx/ui_click');
            void this.game.scenes.push(
                new MapScene(this.game, () => void this.game.scenes.pop()),
                { duration: 0.2 }
            );
            return;
        }
        const line = useItemLine(id, { label: this.game.clock.label });
        if (line === null) return;
        playSfx(this.game.audio, 'sfx/ui_click');
        result.text = line;
    }

    /** Esc или клавиша инвентаря — назад в локацию. */
    protected onCancel(): void {
        this.close();
    }

    protected onAction(action: string): void {
        if (action === 'inventory') this.close();
    }

    private close(): void {
        playSfx(this.game.audio, 'sfx/ui_click');
        this.onBack();
    }
}