import {
Container,
MenuList,
PixelText,
type GameStateData,
type Invariant,
type JsonValue,
type Scene,
type SnapshotLayer
} from '@rpg/engine';
import type { Game } from '../Game';
import { areaOf, type AreaId } 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, areaOf(norm.area)), {
duration: 0.4
});
} else {
this.game.inventory.clear();
void this.game.scenes.replace(new LocationScene(this.game, null, areaOf(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 = 3;
/** Откуда герой вошёл в область (для переходов kind 'return'). */
export interface ReturnToData {
area: AreaId;
entry: { x: number; y: number };
}
export interface SaveData {
version: number;
/** В какой области герой (фолбэк 'meadows' для старых сейвов). */
area: string;
pos: { x: number; y: number };
state: GameStateData;
/** Содержимое сумки: itemId -> количество. */
items: Record<string, number>;
/** Откуда вошли в область (v3; null/undefined — вход не через переход). */
returnTo?: ReturnToData | null;
savedAt: number;
}
/** Дополнить сейв старого формата до текущего (без записи). */
export function normalizeSave(save: SaveData): SaveData {
if (save.version === SAVE_VERSION) return save;
// v1/v2: area зовётся location, returnTo ещё нет.
const legacy = save as SaveData & { location?: string };
return {
...save,
version: SAVE_VERSION,
area: save.area ?? legacy.location ?? 'meadows',
returnTo: save.returnTo ?? null,
items: save.items ?? {}
};
}