diff --git a/docs/plan.md b/docs/plan.md index 672e50b..2dab901 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -51,6 +51,7 @@ | 29 | v2 трек 8: каркас ядра + герой — GameLoop (фиксированный шаг 60 Гц, ручной режим для моста/гейтов), минимальный ECS (решение открытого вопроса «каркас ядра»), ввод как данные (экранные оси, проекция в мир на границе), контроллер персонажа (тело физики + walk от скорости), камера следит; демка: герой на WASD и виртуальным стиком агента, мост setInput/heroInfo/heroTeleport/stepTicks/setManual/setFollow + гейт | ✅ (2026-09-09). `core/loop.ts` (GameLoop: аккумулятор, maxStepsPerFrame, `setManual`+`stepTick` — детерминизм гейтов; now/schedule инъектируются для Node-тестов), `core/ecs.ts` (`EntityWorld` — перенос проверенной минимальной ECS v1: сущность-число, компоненты по имени, query, системы), `core/controls.ts` (ControlState + `screenMoveToWorld` — экранные оси → мировая горизонталь по yaw камеры, диагональ не быстрее), `physics/character.ts` (Character: тело + фаза клипа ∝ путь/stride — стопы не скользят, покой замирает, поворот по движению; `spawnY` — спавн в воздухе для гейта физики; мост тела физики переехал на Character — один путь, урок №4). Рендер: `setTarget` (камера+солнце за целью) + `currentTarget`. Демка: герой на WASD/стрелках (клавиатура демки, `screenMoveToWorld` с ISO_YAW), камера следит с шагом пиксельной сетки (round), мост `setInput`/`heroInfo`/`heroTeleport`/`stepTicks`/`setManual`/`setFollow` (гейт физики держит камеру фиксированной). Гейт `hero.mjs` 9 проверок: герой на земле, ввод агента двигает (+x мира из (1,1) экранного), фаза ровно 1.25 цикла/с (скорость/stride), стена по грани (42.4999), покой замирает, кадр меняется, камера следует, телепорт уезжает. 115 юнит-тестов. Уроки: **фаза = путь/stride, не время** — при скорости 4 и stride 3.2 это 1.25 цикла/с (совпало с длительностью клипа 0.8 с намеренно: stride = speed·duration); **виртуальный стик агента живёт между вызовами моста** — перед чистой проверкой кадра отпускать setInput(null,null); **cancelAnimationFrame нет в Node** — typeof-guard в GameLoop.stop (юнит-тесты с инъекцией schedule). | | 30 | v2 трек 9: диалоги как данные + интерактивные точки — граф диалога (JSON/TS: узлы с репликами, выборами, условиями и эффектами), рантайм отделён от отрисовки, реестр точек взаимодействия; демка: NPC у дома, «E» открывает диалог, HTML-оверлей, 2 графа с эффектами + гейт | ✅ (2026-09-09). `dialogue/graph.ts` — перенос типов v1 (узел/выбор/условия/эффекты), `evalConditions` (когда/когдаНе/переменные/предметы — все группы AND), `validateDialogue` (битые ссылки, тупики, выборы без текста — числовая валидация для агента); состояние — интерфейс `DialogueState` (развязка от GameState v1), мир — `DialogueWorld` (сумка). `dialogue/runner.ts` — рантайм семантики v1: **узел с текстом ждёт игрока** (advance → next, pick → вариант; advance во время выбора игнорируется), **узел без текста — «действие»: применяет эффекты и проваливается в next сам**, MAX_STEPS против циклов; `onEffect` — эффекты `do[]` движок только эмитит, исполнение — игра; геттеры nodeId/node/choices/path/result — снапшоты для моста. `interaction/interactables.ts` — реестр точек {id, pos, radius, label, dialogueId} + чистые `nearestInteractable`/`interactablesInRadius` (Y не учитывается). Демка: страж у дома (Character на скорости 0 — поза покоя, один путь с героем), табличка; клавиша E + мост; HTML-оверлей (панель + кнопки вариантов); 2 графа: страж (выборы → setFlags/setVars, условный узел ask2 по переменной, тост) и табличка (один узел + тост); мост interact/interactInfo/dialogueOpen/dialogueInfo/dialoguePick/dialogueAdvance. Гейт `dialogue.mjs` 15 проверок: графы валидны, подсказка появляется/пропадает по дистанции, подсказка и панель рисуются (сравнение ЗОН кадра — верх/низ, одна позиция камеры), открытие по «E», advance при выборе игнорируется, эффекты флагов/переменных/тостов, путь и результат, закрытие возвращает кадр. 136 юнит-тестов. Уроки: **HUD-элементы обновляются только из тика — любое изменение состояния вне тика (мост, клик кнопки) обязано само дёргать обновление подсказки** (гейт поймал: диалог открыт, подсказка «E» осталась висеть); **скриншот-сравнение HUD делай по зонам кадра с одной позиции камеры** — сравнение целых кадров с разных позиций даёт diff всего луга (324000 px) и проверяет не HUD, а камеру. | | 31 | v2 трек 10: инвентарь — жанронезависимый контейнер предметов (перенос v1), эффекты диалогов giveItem/takeItem исполняются игрой, HUD сумки; демка: страж даёт/забирает цветок, выбор виден по hasItem + гейт | ✅ (2026-09-09). `inventory/inventory.ts` — перенос Inventory v1 1:1: счётчики по id (Map), лимиты слотов/стака обрезают и возвращают факт (модель не бросает), `onChange` с деталями изменения (add/remove/clear/load), serialize/load (мусор игнорируется, кламп под лимиты), `inventoryFromData` для сейвов. Демка: сумка героя (maxSlots 8), `DialogueWorld.hasItem` смотрит в неё (выбор «Отдать цветок» виден только с цветком), `onEffect` исполняет giveItem/takeItem (движок эмитит — исполнение игрой), HUD `#inv` в углу перерисовывается по событию контейнера (не из тика — надёжно), ITEM_NAMES для отображения. Гейт `inventory.mjs` 8 проверок: сумка пуста на старте, скрытый/видимый выбор по hasItem, giveItem наполнил (цветок ×1), HUD рисуется (зона угла кадра), takeItem забрал (флаг flowerGivenAway), HUD скрылся (diff 0). 147 юнит-тестов. Урок: **перерисовка HUD по событию контейнера, а не из тика** — Inventory.onChange синхронен, изменения из моста/кнопок применяются сразу. | +| 32 | v2 трек 11: сохранение — SaveManager (JSON-слоты, StorageLike инъекцией, мета в параллельном ключе), сериализация состояния демки (герой/флаги/переменные/сумка), мост saveTo/loadFrom/saveList/saveDelete + гейт | ✅ (2026-09-09). `save/saveManager.ts` — перенос SaveManager v1 1:1: `save(slot, data, meta?)`/`load` (битый JSON → null)/`slotMeta`/`list`/`delete`/`has`/`listSlots`; хранилище — `StorageLike` инъекцией (демка — window.localStorage, тесты — Map-заглушка), мета — в параллельном ключе `meta:` (старые сейвы читаемы). Демка: `DemoSave {version, heroPos, heroYaw, heroPhase, flags, vars, inv}` — `serializeSave`/`applySave` (версия формата проверяется; активный диалог прерывается; HUD-подсказка обновляется сразу — урок трека 9); мост `saveTo(slot, title)`/`loadFrom(slot)`/`saveList()`/`saveDelete(slot)`. Гейт `save.mjs` 9 проверок: слотов нет на старте, saveTo пишет слот+мету, состояние ломается (отдача цветка + уезд героя), loadFrom восстанавливает позицию/флаги (без flowerGivenAway)/сумку (цветок ×1), **кадр после загрузки попиксельно равен кадру сохранения (diff 0)**, saveDelete чистит. 154 юнит-теста. Уроки: **в ручном режиме кадр рендерится только в stepTicks** — после loadFrom-мостов нужен тик перед скриншотом (иначе скриншот захватывает протухший канвас с прежней камерой); **applySave меняет состояние вне тика — обновлять HUD сразу** (тот же урок, что у диалогов). | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -588,6 +589,8 @@ воксельном мире~~ (трек 7), ~~тайлинг мира (чанки)~~ (трек 3), ~~диалоги как данные~~ (трек 9, строка 30: граф + рантайм семантики v1 + валидатор + точки взаимодействия), ~~инвентарь~~ (трек 10, строка 31: -Inventory v1 1:1, эффекты giveItem/takeItem исполняются игрой). Остаётся: -производственный порядок следующих треков (сохранение/сцены/аудио-перенос -— по мере движения к воспроизведению демки v1 на v2). +Inventory v1 1:1, эффекты giveItem/takeItem исполняются игрой), +~~сохранение~~ (трек 11, строка 32: SaveManager v1 1:1, состояние демки +восстанавливается попиксельно). Остаётся: производственный порядок +следующих треков (сцены/переходы, бой, аудио-перенос — по мере движения +к воспроизведению демки v1 на v2). diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index a5c8850..440c534 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -45,6 +45,7 @@ node apps/demo/tools/checks/hero.mjs # скриншот-гейт героя (/tmp/v2_hero_*.png) node apps/demo/tools/checks/dialogue.mjs # скриншот-гейт диалогов (/tmp/v2_dialog_*.png) node apps/demo/tools/checks/inventory.mjs # скриншот-гейт инвентаря (/tmp/v2_inv_*.png) +node apps/demo/tools/checks/save.mjs # скриншот-гейт сохранений (/tmp/v2_save_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -132,6 +133,10 @@ возвращают факт (модель не бросает), `onChange` с деталями изменения, serialize/load (мусор игнорируется, кламп под лимиты), `inventoryFromData`; + - `src/save/saveManager.ts` — `SaveManager` (перенос v1 1:1): JSON-слоты + `save:`, мета в параллельном ключе `meta:` (старые сейвы + читаемы), `load` битого JSON → null; хранилище — `StorageLike` + инъекцией (localStorage в демке, Map в тестах); - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -151,7 +156,10 @@ клавиатура)/`heroInfo()` (pos/vel/grounded/phase/yaw/camTarget)/`heroTeleport(x,z)`/`stepTicks(n)` (ручные шаги цикла)/`setManual(on)`/`setFollow(on)` (камера за героем; off — центр сцены) /`interact()` («E» агентом)/`interactInfo()` (near/label/hint) /`dialogueOpen(id)`/`dialogueInfo()` (active/nodeId/text/choices/path/flags/vars/toasts/result/graphErrors)/`dialoguePick(i)`/`dialogueAdvance(i)` - /`invInfo()` (all/size/maxSlots/empty). + /`invInfo()` (all/size/maxSlots/empty) + /`saveTo(slot,title)`/`loadFrom(slot)`/`saveList()`/`saveDelete(slot)` + (сейв: герой/флаги/переменные/сумка, `DemoSave` version 1; applySave + прерывает активный диалог и обновляет HUD сразу). Герой демки: WASD/стрелки → `screenMoveToWorld` с ISO_YAW, Character (скорость 4, stride 3.2), камера следит с шагом пиксельной сетки. NPC: страж у дома (42,47) — Character на скорости 0 (поза покоя, один @@ -189,6 +197,11 @@ предмета и виден с ним (hasItem через DialogueWorld), giveItem диалога наполнил сумку (цветок ×1), HUD сумки рисуется в углу (зона кадра), takeItem забрал (флаг flowerGivenAway), HUD скрылся (diff 0). +- `apps/demo/tools/checks/save.mjs` — скриншот-гейт сохранений (9 + проверок): слотов нет на старте, saveTo пишет слот+мету, слом состояния + (отдача цветка + уезд героя), loadFrom восстанавливает позицию/флаги/ + сумку, кадр после загрузки попиксельно равен кадру сохранения (diff 0, + рендер — только в stepTicks ручного режима), saveDelete чистит слот. - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!) diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 72f4b94..b68a86f 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -9,9 +9,10 @@ VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, ISO_YAW, treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, - DialogueRunner, validateDialogue, nearestInteractable, Inventory, + DialogueRunner, validateDialogue, nearestInteractable, Inventory, SaveManager, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine'; +import type { InventoryData } from '@rpg/engine'; import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex @@ -366,6 +367,51 @@ const loop = new GameLoop(60, { update: tick, render: () => renderer.render() }); loop.start(); +// ---- Сохранения: слоты JSON с метой; сериализация состояния демки ---- +/** Сериализуемое состояние демки (формат стабильный — старые сейвы читаемы). */ +interface DemoSave { + version: 1; + heroPos: [number, number]; // x, z (ноги) + heroYaw: number; + heroPhase: number; + flags: string[]; + vars: Record; + inv: InventoryData; +} + +const saves = new SaveManager(window.localStorage); +const SAVE_VERSION = 1; + +function serializeSave(): DemoSave { + return { + version: SAVE_VERSION, + heroPos: [hero.body.pos[0], hero.body.pos[2]], + heroYaw: hero.yaw, + heroPhase: hero.phase, + flags: [...flags], + vars: Object.fromEntries(vars), + inv: inv.serialize(), + }; +} + +/** Применить сейв: герой, флаги/переменные, сумка; HUD обновить сразу. */ +function applySave(data: DemoSave): void { + if (data.version !== SAVE_VERSION) throw new Error(`Сейв версии ${data.version} не поддерживается`); + if (runner.active) runner.abort(); + hero.body.pos = [data.heroPos[0], 2, data.heroPos[1]]; + hero.body.vel = [0, 0, 0]; + hero.yaw = data.heroYaw; + hero.phase = data.heroPhase; + heroCloud.update(hero.visual()); + renderer.setTarget([Math.round(hero.body.pos[0]), 2, Math.round(hero.body.pos[2])]); + flags.clear(); + for (const f of data.flags) flags.add(f); + vars.clear(); + for (const [k, v] of Object.entries(data.vars)) vars.set(k, v); + inv.load(data.inv); + updateHint(); // состояние изменено вне тика — HUD-подсказку обновить сразу +} + // Агентный мост (DEV): солнце, статистика, правка мира атомарным ремешингом let sunK = 0.5; function stats(): { vertices: number; triangles: number; sun: number } { @@ -523,6 +569,26 @@ invInfo() { return { all: inv.all, size: inv.size, maxSlots: inv.slotLimit, empty: inv.empty }; }, + /** Сохранить состояние в слот (мета с заголовком и временем). */ + saveTo(slot: string, title?: string) { + const meta = { title: title ?? 'Демка v2', savedAt: Date.now(), version: SAVE_VERSION }; + saves.save(slot, serializeSave(), meta); + return { slot, meta, list: saves.list() }; + }, + /** Загрузить слот и применить (null — слота нет). */ + loadFrom(slot: string) { + const data = saves.load(slot); + if (data) applySave(data); + return { loaded: data !== null, heroPos: [hero.body.pos[0], hero.body.pos[2]] as const }; + }, + /** Список слотов с метой. */ + saveList() { + return saves.list(); + }, + /** Удалить слот. */ + saveDelete(slot: string): void { + saves.delete(slot); + }, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, diff --git a/v2/apps/demo/tools/checks/save.mjs b/v2/apps/demo/tools/checks/save.mjs new file mode 100644 index 0000000..958c7b6 --- /dev/null +++ b/v2/apps/demo/tools/checks/save.mjs @@ -0,0 +1,117 @@ +/** + * Скриншот-гейт сохранений v2 (трек «сохранение»): слоты JSON с метой + * (SaveManager, localStorage), сериализация состояния демки (герой, флаги/ + * переменные, сумка), загрузка восстанавливает состояние и кадр. + * Ручной режим цикла — детерминизм. Скриншоты: /tmp/v2_save_*.png. + * + * Запуск: node apps/demo/tools/checks/save.mjs [--port 5310] + */ +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { sleep, launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5310); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'save', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +// Точка съёмки «сохранённого состояния»: у дома (42,51), камера одна на кадры. +const SPOT = { x: 42, z: 51 }; + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + + const errs = client.errors(); + check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + + await client.call('setManual', true); + check('слотов на старте нет', (await client.call('saveList')).length === 0); + + // Готовим состояние: диалог дал цветок, герой у дома + await client.call('heroTeleport', 42, 51); + await client.call('stepTicks', 2); + await client.call('interact'); + await client.call('dialoguePick', 0); // «Да, я копал» → giveItem + await client.call('dialogueAdvance'); + await client.call('dialogueAdvance'); + await client.call('heroTeleport', SPOT.x, SPOT.z); + await client.call('stepTicks', 2); + const saved = await client.call('saveTo', 'gate', 'Гейт сейвов'); + check('saveTo пишет слот и мету', + saved.list.length === 1 && saved.list[0].slot === 'gate' + && saved.list[0].meta.title === 'Гейт сейвов' && saved.list[0].meta.version === 1, + saved.list); + const stateA = await client.call('dialogueInfo'); + const shotA = await client.screenshot('/tmp/v2_save_a.png'); + const imgA = decodePng(readFileSync(shotA)); + + // Ломаем состояние: отдаём цветок, уезжаем далеко + await client.call('interact'); + await client.call('dialoguePick', 3); // «Отдать цветок» → takeItem + await client.call('dialogueAdvance'); + await client.call('dialogueAdvance'); + await client.call('heroTeleport', 60, 12); + await client.call('stepTicks', 2); + const broken = await client.call('invInfo'); + const shotB = await client.screenshot('/tmp/v2_save_b.png'); + const imgB = decodePng(readFileSync(shotB)); + const abDiff = countDiff(imgA.rgba, imgB.rgba); + check('состояние изменилось: сумка пуста, кадр уехал', + broken.empty === true && abDiff > 5000, { empty: broken.empty, abDiff }); + const brokenFlags = (await client.call('dialogueInfo')).flags; + + // Загрузка: флаги/сумка/позиция восстановлены, кадр вернулся + const loaded = await client.call('loadFrom', 'gate'); + const restored = await client.call('dialogueInfo'); + const invRestored = await client.call('invInfo'); + const restoredFlags = restored.flags.filter((f) => !brokenFlags.includes(f) || stateA.flags.includes(f)); + check('loadFrom восстановил героя у дома', + loaded.loaded === true + && Math.abs(loaded.heroPos[0] - SPOT.x) < 0.01 + && Math.abs(loaded.heroPos[1] - SPOT.z) < 0.01, loaded); + check('loadFrom восстановил флаги (без flowerGivenAway)', + restoredFlags.includes('admitted') && restoredFlags.includes('flowerGiven') + && !restored.flags.includes('flowerGivenAway'), restored.flags); + check('loadFrom восстановил сумку: цветок ×1', + invRestored.all.length === 1 && invRestored.all[0].id === 'flower', invRestored); + await client.call('stepTicks', 2); // ручной режим: рендер только в тике + const shotBack = await client.screenshot('/tmp/v2_save_back.png'); + const imgBack = decodePng(readFileSync(shotBack)); + const backDiff = countDiff(imgA.rgba, imgBack.rgba); + check('кадр после загрузки = кадр на момент сохранения', backDiff < 100, { backDiff }); + + // Удаление слота + await client.call('saveDelete', 'gate'); + check('saveDelete чистит слот', (await client.call('saveList')).length === 0); + + report.ok = report.checks.every((c) => c.ok); + } finally { + await browser.close(); + } +} catch (e) { + check('прогон без исключений', false, String(e)); +} finally { + server.stop(); +} + +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); + +/** Число различающихся RGBA-пикселей двух кадров одного размера. */ +function countDiff(a, b) { + let n = 0; + for (let i = 0; i < a.length; i += 4) { + if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++; + } + return n; +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 1c9f3a9..d2d7128 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -64,4 +64,6 @@ export { nearestInteractable, interactablesInRadius } from './interaction/interactables'; export type { Interactable, InteractableHit } from './interaction/interactables'; export { Inventory, inventoryFromData } from './inventory/inventory'; -export type { InventoryOptions, InventoryChange, InventoryChangeKind, InventoryData } from './inventory/inventory'; \ No newline at end of file +export type { InventoryOptions, InventoryChange, InventoryChangeKind, InventoryData } from './inventory/inventory'; +export { SaveManager } from './save/saveManager'; +export type { SaveSlotMeta, StorageLike } from './save/saveManager'; \ No newline at end of file diff --git a/v2/packages/engine/src/save/__tests__/saveManager.test.ts b/v2/packages/engine/src/save/__tests__/saveManager.test.ts new file mode 100644 index 0000000..b4e40ea --- /dev/null +++ b/v2/packages/engine/src/save/__tests__/saveManager.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { SaveManager, type StorageLike } from '../saveManager'; + +/** Хранилище-заглушка (как localStorage, без DOM). */ +function makeStorage(): StorageLike & { map: Map } { + const map = new Map(); + return { + map, + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { return map.size; }, + }; +} + +describe('SaveManager', () => { + it('сохраняет и загружает по слотам', () => { + const sm = new SaveManager(makeStorage()); + expect(sm.has('a')).toBe(false); + sm.save('a', { pos: [1, 2, 3] }); + expect(sm.has('a')).toBe(true); + expect(sm.load('a')).toEqual({ pos: [1, 2, 3] }); + expect(sm.load('b')).toBeNull(); + }); + + it('битый JSON → null', () => { + const storage = makeStorage(); + const sm = new SaveManager(storage); + storage.setItem('save:x', '{oops'); + expect(sm.load('x')).toBeNull(); + }); + + it('listSlots и delete', () => { + const sm = new SaveManager(makeStorage()); + sm.save('a', 1); + sm.save('b', 2); + expect(sm.listSlots()).toEqual(['a', 'b']); + sm.delete('a'); + expect(sm.listSlots()).toEqual(['b']); + expect(sm.has('a')).toBe(false); + }); + + it('мета пишется в параллельный ключ и читается без сейва', () => { + const storage = makeStorage(); + const sm = new SaveManager(storage); + sm.save('slot', { hp: 5 }, { title: 'Луга', savedAt: 123, version: 1 }); + expect(storage.map.has('save:meta:slot')).toBe(true); + expect(sm.slotMeta('slot')).toEqual({ title: 'Луга', savedAt: 123, version: 1 }); + // мета-ключи не попадают в список слотов + expect(sm.listSlots()).toEqual(['slot']); + }); + + it('сейв без меты и битая мета → slotMeta null, сейв читается', () => { + const storage = makeStorage(); + const sm = new SaveManager(storage); + sm.save('old', 7); + expect(sm.slotMeta('old')).toBeNull(); + storage.setItem('save:meta:bad', '{oops'); + expect(sm.slotMeta('bad')).toBeNull(); + storage.setItem('save:bad', '3'); + expect(sm.load('bad')).toBe(3); + }); + + it('delete чистит и сейв, и мету; кастомный префикс', () => { + const sm = new SaveManager(makeStorage(), 'auto:'); + sm.save('s', 1, { title: 'Т' }); + expect(sm.listSlots()).toEqual(['s']); + sm.delete('s'); + expect(sm.listSlots()).toEqual([]); + expect(sm.slotMeta('s')).toBeNull(); + expect(sm.has('s')).toBe(false); + }); + + it('list возвращает слоты с метой', () => { + const sm = new SaveManager(makeStorage()); + sm.save('a', 1, { title: 'А' }); + sm.save('b', 2); + expect(sm.list()).toEqual([ + { slot: 'a', meta: { title: 'А' } }, + { slot: 'b', meta: null }, + ]); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/save/saveManager.ts b/v2/packages/engine/src/save/saveManager.ts new file mode 100644 index 0000000..83ebe4a --- /dev/null +++ b/v2/packages/engine/src/save/saveManager.ts @@ -0,0 +1,91 @@ +/** + * Сохранения в JSON-слотах (перенос v1). Хранилище инъекцией (localStorage + * или любой Storage-подобный объект) — чтобы работать и в тестах без + * браузера. Метаданные слота (заголовок, время) — в параллельном ключе + * `meta:`: формат самих сейвов не меняется, старые сейвы читаемы. + */ + +/** Метаданные слота — читаются без полной загрузки сейва (задаёт игра). */ +export interface SaveSlotMeta { + /** Человекочитаемый заголовок (движок не знает жанра). */ + title?: string; + /** Момент записи (Date.now()). */ + savedAt?: number; + /** Версия формата сейва. */ + version?: number; + /** Жанровые поля игры (область, уровень, часы игры…). */ + extras?: Record; +} + +export interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + key(index: number): string | null; + readonly length: number; +} + +export class SaveManager { + constructor( + private readonly storage: StorageLike, + private readonly prefix = 'save:', + ) {} + + save(slot: string, data: unknown, meta?: SaveSlotMeta): void { + this.storage.setItem(this.prefix + slot, JSON.stringify(data)); + if (meta) this.storage.setItem(this.metaKey(slot), JSON.stringify(meta)); + } + + load(slot: string): T | null { + const raw = this.storage.getItem(this.prefix + slot); + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; // битый JSON — слот как пустой + } + } + + /** Метаданные слота (null — мета не писалась, например у старых сейвов). */ + slotMeta(slot: string): SaveSlotMeta | null { + const raw = this.storage.getItem(this.metaKey(slot)); + if (raw === null) return null; + try { + return JSON.parse(raw) as SaveSlotMeta; + } catch { + return null; + } + } + + /** Слоты с метой (мета может отсутствовать — у сейвов старых версий). */ + list(): Array<{ slot: string; meta: SaveSlotMeta | null }> { + return this.listSlots().map((slot) => ({ slot, meta: this.slotMeta(slot) })); + } + + delete(slot: string): void { + this.storage.removeItem(this.prefix + slot); + this.storage.removeItem(this.metaKey(slot)); + } + + /** Есть ли сохранение в слоте. */ + has(slot: string): boolean { + return this.storage.getItem(this.prefix + slot) !== null; + } + + /** Список занятых слотов (без префикса; мета-ключи не попадают). */ + listSlots(): string[] { + const out: string[] = []; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i); + if (key && key.startsWith(this.prefix)) { + const slot = key.slice(this.prefix.length); + if (!slot.startsWith('meta:')) out.push(slot); + } + } + return out; + } + + private metaKey(slot: string): string { + return this.prefix + 'meta:' + slot; + } +} \ No newline at end of file