Newer
Older
rpg / apps / game / src / scenes / MenuScene.ts
import {
    Container,
    MenuList,
    PixelText,
    type GameStateData,
    type Invariant,
    type JsonValue,
    type Scene,
    type SnapshotLayer
} from '@rpg/engine';
import type { Game } from '../Game';
import { locationOf } from '../data/locations';
import { LocationScene } from './LocationScene';
import { SaveSlotsScene } from './SaveSlotsScene';
import { SettingsScene } from './SettingsScene';

/**
 * Главное меню: название, «новая игра», «продолжить» (если есть сейв).
 * Список на движковом MenuList: мышь и клавиатура/геймпад.
 */
export class MenuScene implements Scene {
    private view = new Container();
    private menu: MenuList | null = null;

    constructor(private game: Game) {}

    enter(): 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: () => {
                    void this.game.audio.play('sfx/ui_click');
                    this.startNewGame();
                }
            },
            {
                label: 'Сейвы',
                onSelect: () => {
                    void this.game.audio.play('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: () => {
                    void this.game.audio.play('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: () => {
                    void this.game.audio.play('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.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 });
            void this.game.scenes.replace(new LocationScene(this.game, norm, locationOf(norm.location)), {
                duration: 0.4
            });
        } else {
            this.game.inventory.clear();
            void this.game.scenes.replace(new LocationScene(this.game, null, locationOf(undefined)), {
                duration: 0.4
            });
        }
    }

    update(_dt: number): void {
        if (!this.menu || this.game.scenes.transitioning) return;
        const input = this.game.engine.input;
        if (input.isActionJustPressed('up')) this.menu.moveCursor(-1);
        if (input.isActionJustPressed('down')) this.menu.moveCursor(1);
        if (input.isActionJustPressed('advance')) this.menu.activate();
    }

    render(): void {}

    exit(): void {
        this.view.destroy({ children: true });
    }
}

/**
 * Формат сейва: локация, позиция героя, сериализованное состояние прохождения
 * и сумка. У старых сейвов нет `version`/`items` — normalizeSave дорастает их.
 */
export const SAVE_VERSION = 2;

export interface SaveData {
    version: number;
    /** В какой локации герой (фолбэк 'meadows' для старых сейвов). */
    location: string;
    pos: { x: number; y: number };
    state: GameStateData;
    /** Содержимое сумки: itemId -> количество. */
    items: Record<string, number>;
    savedAt: number;
}

/** Дополнить сейв старого формата до текущего (без записи). */
export function normalizeSave(save: SaveData): SaveData {
    if (save.version === SAVE_VERSION) return save;
    // v1 (до сумки): location/pos/state/savedAt без version и items.
    return { ...save, version: SAVE_VERSION, items: save.items ?? {} };
}