diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index 17a26d4..5ef46a7 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -1,16 +1,25 @@ import { playSfx } from '../data/sfxSpecs'; -import { Container, Panel, PixelText, type Scene } from '@rpg/engine'; +import { MenuSceneBase, Panel, PixelText } from '@rpg/engine'; import { Game } from '../Game'; import { inventoryItems, questLog } from '../data/quests'; /** * Сумка и журнал квестов (панель поверх локации, push/pop). * Содержимое собирается из флагов/варов GameState при каждом открытии. + * Список-просмотр без курсора: закрытие — Esc/инвентарь (через каркас). */ -export class InventoryScene implements Scene { - private view = new Container(); +export class InventoryScene extends MenuSceneBase { + 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'] } + ); + } - constructor(private game: Game, private onBack: () => void) { + protected build(): void { const panel = new Panel({ width: 280, height: 180 }); panel.position.set((Game.VIRTUAL_W - 280) / 2, (Game.VIRTUAL_H - 180) / 2); @@ -51,20 +60,17 @@ this.game.renderer.uiRoot.addChild(this.view); } - enter(): void {} - - exit(): void { - this.view.destroy({ children: true }); + /** Esc или клавиша инвентаря — назад в локацию. */ + protected onCancel(): void { + this.close(); } - render(): void {} + protected onAction(action: string): void { + if (action === 'inventory') this.close(); + } - update(_dt: number): void { - if (this.game.scenes.transitioning) return; - const input = this.game.engine.input; - if (input.isActionJustPressed('menu') || input.isActionJustPressed('inventory')) { - playSfx(this.game.audio, 'sfx/ui_click'); - this.onBack(); - } + private close(): void { + playSfx(this.game.audio, 'sfx/ui_click'); + this.onBack(); } } \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index bab9715..ff964b3 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -1169,15 +1169,19 @@ private saveAndExit(): void { const pos = this.player.currentTile(); this.game.state.setVar(VARS.hp, this.playerCombat.hp); - this.game.saves.save('autosave', { - version: SAVE_VERSION, - area: this.area.id, - pos, - state: this.game.state.serialize(), - items: this.game.inventory.serialize().items, - returnTo: this.returnTo ?? null, - savedAt: Date.now() - } satisfies SaveData); + this.game.saves.save( + 'autosave', + { + version: SAVE_VERSION, + area: this.area.id, + pos, + state: this.game.state.serialize(), + items: this.game.inventory.serialize().items, + returnTo: this.returnTo ?? null, + savedAt: Date.now() + } satisfies SaveData, + { title: 'Автосохранение', savedAt: Date.now(), version: SAVE_VERSION, extras: { area: this.area.id } } + ); void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); } } diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index df84595..bdcf909 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -1,11 +1,10 @@ import { playSfx } from '../data/sfxSpecs'; import { - Container, MenuList, + MenuSceneBase, PixelText, type Invariant, type JsonValue, - type Scene, type SnapshotLayer } from '@rpg/engine'; import type { Game } from '../Game'; @@ -21,13 +20,15 @@ * Главное меню: название, «новая игра», «продолжить» (если есть сейв). * Список на движковом MenuList: мышь и клавиатура/геймпад. */ -export class MenuScene implements Scene { - private view = new Container(); - private menu: MenuList | null = null; +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' } + ); + } - constructor(private game: Game) {} - - enter(): void { + protected build(): void { const title = new PixelText({ text: 'ПЕПЕЛЬНЫЕ ЛУГА', size: 28, @@ -149,18 +150,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 }); - } } \ No newline at end of file diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts index fb39f75..4e54918 100644 --- a/apps/game/src/scenes/SaveSlotsScene.ts +++ b/apps/game/src/scenes/SaveSlotsScene.ts @@ -1,21 +1,27 @@ import { playSfx } from '../data/sfxSpecs'; -import { Container, MenuList, Panel, PixelText, type Scene } from '@rpg/engine'; +import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; import { Game } from '../Game'; +import { SAVE_SLOTS, canDelete, slotTitle } from '../data/saves'; import type { SaveData } from './saveData'; /** * Меню сейвов: autosave (только загрузка) + 3 именованных слота - * (загрузка/удаление). Панель поверх меню (push/pop). + * (загрузка/удаление). Панель поверх меню (push/pop). Подписи — из меты + * слота; у старых сейвов без меты — полная загрузка (как раньше). */ -export class SaveSlotsScene implements Scene { - private view = new Container(); - private menu: MenuList; - +export class SaveSlotsScene extends MenuSceneBase { constructor( private game: Game, private onPick: (save: SaveData) => void, private onBack: () => void ) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['attack'] } + ); + } + + protected build(): void { const panel = new Panel({ width: 240, height: 150 }); panel.position.set((Game.VIRTUAL_W - 240) / 2, (Game.VIRTUAL_H - 150) / 2); @@ -33,69 +39,77 @@ this.rebuild(); } - enter(): void {} - - exit(): void { - this.view.destroy({ children: true }); + /** Delete на выбранном именованном слоте — стереть. */ + protected onAction(action: string, index: number): void { + if (action === 'attack') this.deleteAt(index); } - render(): void {} - - update(_dt: number): void { - if (this.game.scenes.transitioning) return; - const input = this.game.engine.input; - if (input.isActionJustPressed('menu')) { - this.back(); - return; - } - if (input.isActionJustPressed('up')) this.menu.moveCursor(-1); - if (input.isActionJustPressed('down')) this.menu.moveCursor(1); - if (input.isActionJustPressed('advance')) this.menu.activate(); - // Delete на выбранном именованном слоте — стереть - if (input.isActionJustPressed('attack')) this.deleteAt(this.menu.index); + protected onCancel(): void { + this.back(); } - /** Пересобрать список слотов из SaveManager (listSlots + load). */ + /** Пересобрать список слотов из SaveManager. */ private rebuild(): void { - const items: { label: string; onSelect: () => void }[] = []; + const items: { label: string; disabled?: boolean; onSelect?: () => void }[] = []; const slots = this.game.saves.listSlots(); - for (const slot of ['autosave', 'slot1', 'slot2', 'slot3']) { - const exists = slots.includes(slot); - if (!exists) { - items.push({ label: `${this.slotTitle(slot)} — пусто —`, onSelect: () => {} }); + for (const slot of SAVE_SLOTS) { + const meta = this.game.saves.slotMeta(slot); + const save = meta + ? null + : slots.includes(slot) + ? this.game.saves.load(slot)! + : null; + if (!meta && !save) { + items.push({ label: `${slotTitle(slot)} — пусто —`, disabled: true }); continue; } - const save = this.game.saves.load(slot)!; - const areaId = save.area ?? (save as { location?: string }).location; - const loc = areaId === 'ponds' ? 'пруды' : areaId === 'zvenets' ? 'Звенец' : 'луга'; - const date = new Date(save.savedAt); - const when = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + const label = meta + ? this.metaLabel(slot, meta) + : this.saveLabel(slot, save!); items.push({ - label: `${this.slotTitle(slot)} ${loc} ${when}`, + label, onSelect: () => { playSfx(this.game.audio, 'sfx/ui_click'); - this.onPick(save); + const picked = save ?? this.game.saves.load(slot)!; + this.onPick(picked); } }); } items.push({ label: 'Назад', onSelect: () => this.back() }); - this.menu.setItems(items); + this.menu!.setItems(items); + } + + /** Подпись из меты: локация из extras, время из savedAt. */ + private metaLabel(slot: string, meta: { savedAt?: number; extras?: Record }): string { + const area = typeof meta.extras?.area === 'string' ? meta.extras.area : undefined; + const when = meta.savedAt ? this.formatTime(meta.savedAt) : '--:--'; + return `${slotTitle(slot)} ${this.areaName(area)} ${when}`; + } + + /** Подпись из полного сейва (старые сейвы без меты). */ + private saveLabel(slot: string, save: SaveData): string { + const areaId = save.area ?? (save as { location?: string }).location; + return `${slotTitle(slot)} ${this.areaName(areaId)} ${this.formatTime(save.savedAt)}`; + } + + private formatTime(ts: number): string { + const date = new Date(ts); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + } + + private areaName(areaId: string | undefined): string { + return areaId === 'ponds' ? 'пруды' : areaId === 'zvenets' ? 'Звенец' : 'луга'; } /** Удалить сейв в выбранной строке (именованные слоты; autosave защищён). */ private deleteAt(index: number): void { - const slot = ['autosave', 'slot1', 'slot2', 'slot3'][index]; - if (!slot || slot === 'autosave' || !this.game.saves.listSlots().includes(slot)) return; + const slot = SAVE_SLOTS[index]; + if (!slot || !canDelete(slot) || !this.game.saves.listSlots().includes(slot)) return; this.game.saves.delete(slot); playSfx(this.game.audio, 'sfx/ui_click'); this.rebuild(); } - private slotTitle(slot: string): string { - if (slot === 'autosave') return 'Авто'; - return slot.replace('slot', 'Слот '); - } - private back(): void { playSfx(this.game.audio, 'sfx/ui_click'); this.onBack(); diff --git a/apps/game/src/scenes/SettingsScene.ts b/apps/game/src/scenes/SettingsScene.ts index f7a35aa..512f9bb 100644 --- a/apps/game/src/scenes/SettingsScene.ts +++ b/apps/game/src/scenes/SettingsScene.ts @@ -1,27 +1,24 @@ import { playSfx } from '../data/sfxSpecs'; -import { - Container, - MenuList, - Panel, - PixelText, - type Scene, - type SettingsData -} from '@rpg/engine'; +import { MenuList, MenuSceneBase, Panel, PixelText, type SettingsData } from '@rpg/engine'; import { Game } from '../Game'; /** * Настройки: громкости шин аудио. Панель поверх вызывающей сцены (push/pop), * изменения сразу применяются через settings.onChange -> AudioManager. + * Громкости рисуются текстовыми барами, left/right/Enter — подстройка. */ -export class SettingsScene implements Scene { - private view = new Container(); - private menu: MenuList; - - /** Куда вернуться (закрыть панель). */ +export class SettingsScene extends MenuSceneBase { constructor( private game: Game, private onBack: () => void ) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', left: 'left', right: 'right' } + ); + } + + protected build(): void { const panel = new Panel({ width: 220, height: 130 }); panel.position.set((Game.VIRTUAL_W - 220) / 2, (Game.VIRTUAL_H - 130) / 2); @@ -39,33 +36,23 @@ this.rebuild(); } - enter(): void {} - - exit(): void { - this.view.destroy({ children: true }); + /** Enter — на 10% вверх. */ + protected onConfirm(index: number): void { + this.adjust(index, 0.1); } - render(): void {} + /** Влево/вправо — подстройка громкости выбранной строки. */ + protected onAdjust(delta: number, index: number): void { + this.adjust(index, delta * 0.1); + } - update(_dt: number): void { - if (this.game.scenes.transitioning) return; - const input = this.game.engine.input; - if (input.isActionJustPressed('menu')) { - this.back(); - return; - } - if (!this.menu) return; - if (input.isActionJustPressed('up')) this.menu.moveCursor(-1); - if (input.isActionJustPressed('down')) this.menu.moveCursor(1); - // влево/вправо меняют громкость выбранной строки, Enter — на 10% вверх - const step = input.isActionJustPressed('advance') ? 0.1 : 0; - const dir = input.isActionJustPressed('right') ? 0.1 : input.isActionJustPressed('left') ? -0.1 : step; - if (dir !== 0) this.adjust(this.menu.index, dir); + protected onCancel(): void { + this.back(); } private rebuild(): void { const s = this.game.settings.data; - this.menu.setItems([ + this.menu!.setItems([ { label: this.volumeLabel('Общая', s.master), onSelect: () => this.adjust(0, 0.1) }, { label: this.volumeLabel('Амбиент', s.ambience), onSelect: () => this.adjust(1, 0.1) }, { label: this.volumeLabel('Звуки', s.sfx), onSelect: () => this.adjust(2, 0.1) }, diff --git a/packages/engine/src/ui/MenuList.ts b/packages/engine/src/ui/MenuList.ts index ba509e5..eb80ede 100644 --- a/packages/engine/src/ui/MenuList.ts +++ b/packages/engine/src/ui/MenuList.ts @@ -33,7 +33,7 @@ this.listCursor = new ListCursor(0); } - /** Пересобрать список пунктов (старые кнопки уничтожаются). */ + /** Пересобрать список пунктов (старые кнопки уничтожаются); фокус сохраняется. */ setItems(items: MenuItem[]): void { for (const b of this.buttons) b.destroy({ children: true }); this.buttons = []; @@ -52,8 +52,10 @@ this.buttons.push(btn); y += this.options.height + (this.options.gap ?? 2); } + const keep = Math.min(this.listCursor.index, Math.max(0, items.length - 1)); this.listCursor = new ListCursor(items.length, (i) => !items[i]?.disabled); - if (items[0]?.disabled) this.listCursor.move(1); // старт на выбираемом + this.listCursor.index = keep; + if (items[keep]?.disabled) this.listCursor.move(1); // старт на выбираемом this.applyFocus(); }