diff --git a/apps/game/src/data/__tests__/saves.test.ts b/apps/game/src/data/__tests__/saves.test.ts new file mode 100644 index 0000000..7be8159 --- /dev/null +++ b/apps/game/src/data/__tests__/saves.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { SAVE_SLOTS, canDelete, slotTitle } from '../saves'; + +describe('слоты сейвов', () => { + it('autosave первый и неудаляемый вручную', () => { + expect(SAVE_SLOTS[0]).toBe('autosave'); + expect(canDelete('autosave')).toBe(false); + expect(canDelete('slot1')).toBe(true); + }); + + it('подписи слотов человекочитаемы', () => { + expect(slotTitle('autosave')).toBe('Автосохранение'); + expect(slotTitle('slot2')).toBe('Слот 2'); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 9c15ad6..67b00dc 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -42,8 +42,8 @@ name: string; /** Тайлы низины (свежий пласт пепла). */ tiles: { x: number; y: number }[]; - /** Без чего нельзя дышать здесь (по умолчанию — полотно-маска). */ - requiresItem?: ItemId; + /** Без чего нельзя дышать здесь. */ + requiresItem: ItemId; } /** Локальный амбиент-слой: громкость из дистанции до ближайшего тайла/точки. */ diff --git a/apps/game/src/data/saves.ts b/apps/game/src/data/saves.ts new file mode 100644 index 0000000..60005bf --- /dev/null +++ b/apps/game/src/data/saves.ts @@ -0,0 +1,16 @@ +/** + * Слоты сохранений и их подписи: конфиг вместо хардкода в сцене. + * autosave — служебный слот автосейва (Esc в локации), только загрузка. + */ + +export const SAVE_SLOTS = ['autosave', 'slot1', 'slot2', 'slot3'] as const; + +/** Человекочитаемый заголовок слота (для экрана сейвов). */ +export function slotTitle(slot: string): string { + return slot === 'autosave' ? 'Автосохранение' : slot.replace('slot', 'Слот '); +} + +/** Автосейв перезаписывается игрой — удалять его вручную нельзя. */ +export function canDelete(slot: string): boolean { + return slot !== 'autosave'; +} \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index b8044a1..bab9715 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -31,7 +31,8 @@ type Vec2 } from '@rpg/engine'; import { Game } from '../Game'; -import { MenuScene, SAVE_VERSION, type SaveData } from './MenuScene'; +import { MenuScene } from './MenuScene'; +import { SAVE_VERSION, type SaveData } from './saveData'; import { InventoryScene } from './InventoryScene'; import { TILES, DECOR, TALL_OBJECTS } from '../data/map'; import { playSfx, ROOM_TONES } from '../data/sfxSpecs'; @@ -623,7 +624,7 @@ (this.area.hazards ?? []).find((h) => h.tiles.some((t) => t.x === tile.x && t.y === tile.y) ) ?? null; - const masked = hazard !== null && this.game.inventory.has(hazard.requiresItem ?? 'cloth'); + const masked = hazard !== null && this.game.inventory.has(hazard.requiresItem); this.player.speedMul = hazard !== null && !masked ? 0.5 : 1; // Вход в низину: предупреждение один раз за вход. diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index 94ecfa1..df84595 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -3,17 +3,19 @@ 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 { areaOf } from '../data/locations'; import { LocationScene } from './LocationScene'; import { SaveSlotsScene } from './SaveSlotsScene'; import { SettingsScene } from './SettingsScene'; +import { normalizeSave, type SaveData } from './saveData'; + +export { SAVE_VERSION, normalizeSave, type ReturnToData, type SaveData } from './saveData'; /** * Главное меню: название, «новая игра», «продолжить» (если есть сейв). @@ -161,43 +163,4 @@ 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; - /** Откуда вошли в область (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 ?? {} - }; } \ No newline at end of file diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts index 3cd1738..fb39f75 100644 --- a/apps/game/src/scenes/SaveSlotsScene.ts +++ b/apps/game/src/scenes/SaveSlotsScene.ts @@ -1,7 +1,7 @@ import { playSfx } from '../data/sfxSpecs'; import { Container, MenuList, Panel, PixelText, type Scene } from '@rpg/engine'; import { Game } from '../Game'; -import type { SaveData } from './MenuScene'; +import type { SaveData } from './saveData'; /** * Меню сейвов: autosave (только загрузка) + 3 именованных слота diff --git a/apps/game/src/scenes/saveData.ts b/apps/game/src/scenes/saveData.ts new file mode 100644 index 0000000..ba663fa --- /dev/null +++ b/apps/game/src/scenes/saveData.ts @@ -0,0 +1,41 @@ +import type { GameStateData } from '@rpg/engine'; +import type { AreaId } from '../data/locations'; + +/** + * Формат сейва: область, позиция героя, сериализованное состояние прохождения + * и сумка. У старых сейвов нет `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; + /** Откуда вошли в область (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 ?? {} + }; +} \ No newline at end of file