Newer
Older
rpg / apps / game / src / scenes / MenuScene.ts
import { playSfx } from '../data/sfxSpecs';
import {
    MenuList,
    MenuSceneBase,
    PixelText,
    type Invariant,
    type JsonValue,
    type SnapshotLayer
} from '@rpg/engine';
import type { Game } from '../Game';
import { areaOf } from '../data/locations';
import { LocationScene } from './LocationScene';
import { SaveSlotsScene } from './SaveSlotsScene';
import { SettingsScene } from './SettingsScene';
import { normalizeSave, type SaveData } from './saveData';
import { START_MINUTES } from '../systems/Clock';

export { SAVE_VERSION, normalizeSave, type ReturnToData, type SaveData } from './saveData';

/**
 * Главное меню: название, «новая игра», «продолжить» (если есть сейв).
 * Список на движковом MenuList: мышь и клавиатура/геймпад.
 */
export class MenuScene extends MenuSceneBase {
    constructor(private game: Game) {
        super(
            { input: game.engine.input, inputBlocked: () => game.scenes.transitioning },
            { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu' }
        );
    }

    protected build(): void {
        const title = new PixelText({
            text: 'ПЕПЕЛЬНЫЕ ЛУГА',
            size: 28,
            color: 0xd8c79a
        });
        title.anchor.set(0.5);
        title.position.set(240, 66);
        this.view.addChild(title);

        const subtitle = new PixelText({
            text: 'пепел всё ещё дышит',
            size: 11,
            color: 0x8a8a9a
        });
        subtitle.anchor.set(0.5);
        subtitle.position.set(240, 94);
        this.view.addChild(subtitle);

        const items = [
            {
                label: 'Новая игра',
                // Звук не блокирует навигацию: в suspended-контексте play может не дойти.
                onSelect: () => {
                    playSfx(this.game.audio, 'sfx/ui_click');
                    this.startNewGame();
                }
            },
            {
                label: 'Сейвы',
                onSelect: () => {
                    playSfx(this.game.audio, 'sfx/ui_click');
                    void this.game.scenes.push(
                        new SaveSlotsScene(
                            this.game,
                            (save) => void this.game.scenes.pop().then(() => this.start(save)),
                            () => void this.game.scenes.pop()
                        )
                    );
                }
            },
            {
                label: 'Настройки',
                onSelect: () => {
                    playSfx(this.game.audio, 'sfx/ui_click');
                    void this.game.scenes.push(
                        new SettingsScene(this.game, () => void this.game.scenes.pop())
                    );
                }
            }
        ];
        if (this.game.saves.has('autosave')) {
            items.unshift({
                label: 'Продолжить',
                onSelect: () => {
                    playSfx(this.game.audio, 'sfx/ui_click');
                    const save = this.game.saves.load<SaveData>('autosave');
                    this.start(save);
                }
            });
        }

        this.menu = new MenuList({ width: 120, height: 16, gap: 3 });
        this.menu.setItems(items);
        this.menu.position.set(180, 130);
        this.view.addChild(this.menu);

        const hint = new PixelText({
            text: 'клик по тайлу — идти · клик по NPC — говорить\nEsc в игре — меню с сохранением',
            size: 9,
            color: 0x666677,
            align: 'center',
            lineHeight: 13,
            wordWrapWidth: 400
        });
        hint.anchor.set(0.5);
        hint.position.set(240, 234);
        this.view.addChild(hint);

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

    /** Новая игра: сброс прогресса, сумки и часов, старт на лугах. */
    private startNewGame(): void {
        this.game.state.reset();
        this.game.inventory.clear();
        this.game.clock.reset();
        this.start(null);
    }

    // --- агентный мост: сцена отдаёт снапшот и принимает команды ---

    agentSnapshot(): SnapshotLayer {
        return { scene: 'menu' };
    }

    agentInvariants(): Invariant[] {
        return [];
    }

    agentCommand(name: string): JsonValue {
        if (name === 'menu:newGame') {
            this.startNewGame();
            return true;
        }
        return null;
    }

    private start(save: SaveData | null): void {
        if (save) {
            const norm = normalizeSave(save);
            this.game.inventory.load({ items: norm.items });
            this.game.clock.set(norm.clock?.minutes ?? START_MINUTES);
            void this.game.scenes.replace(
                new LocationScene(this.game, norm, areaOf(norm.area), undefined, norm.returnTo ?? undefined),
                { duration: 0.4 }
            );
        } else {
            this.game.inventory.clear();
            void this.game.scenes.replace(new LocationScene(this.game, null, areaOf(undefined)), {
                duration: 0.4
            });
        }
    }
}