diff --git a/docs/plan.md b/docs/plan.md index 2dab901..cecffe2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -52,6 +52,7 @@ | 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 сразу** (тот же урок, что у диалогов). | +| 33 | v2 трек 12: сцены/переходы — стек SceneManager (меню поверх игры), фейд-переходы out→swap→in, параллельные переходы глотаются; демка: DOM-меню (старт/продолжить), автосейв при уходе в меню, Esc + гейт | ✅ (2026-09-09). `core/scene.ts` — перенос схемы v1 без привязки к бэкенду: `Scene` {enter/exit/update/render}, стек push/pop/replace, переход {duration} фазами **затухание → смена сцены → проявление** (каждая фаза длится duration — полный переход 2×duration); `FadeOverlay {setAlpha}` инъекцией (демка — DOM-слой `#fade`, тесты — заглушка); **параллельные переходы глотаются** (pending-проверка в begin), **enter новой сцены не ожидается** (void), update тикает текущую сцену и во время перехода, render рисует весь стек (нижние сцены видны). Демка: `gameScene` (tick+рендер) внизу стека, `menuScene` (DOM-панель) сверху — мир живёт под меню с самого старта; Esc/мост `gotoMenu` — из игры автосохранение в слот `auto` + push меню, из меню — возврат; «Продолжить» грузит `auto` (кнопка disabled без сейва); мост sceneInfo/gotoMenu/startGame. Гейт `scenes.mjs` 11 проверок: старт в меню без перехода, startGame — переход идёт (сцена ещё меню), **фейд в конце фазы out — кадр чёрный**, переход завершён (меню скрыто), кадры меню/игры различаются, gotoMenu пишет автосейв, возврат в меню, «Продолжить» вернул героя на место сохранения, параллельный переход глотается, повторный переход работает. 164 юнит-теста. Уроки: **CSS `display:flex` перебивает атрибут `hidden`** — нужен `#menu[hidden]{display:none}` (гейт save поймал: меню «скрыто», но видно на каждом кадре, испортив попиксельное сравнение); **полный переход — это out+in (2×duration = 48 тиков при 0.4 с), а не 24** — в ручном режиме гейты обязаны пройти все 48 тиков, иначе фейд висит частично затемняя каждый кадр (ловится только попиксельным сравнением); **все гейты демки теперь входят в игру через startGame(false)+60 тиков** — демка стартует в меню, старые гейты снимали кадры поверх панели меню. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -591,6 +592,7 @@ валидатор + точки взаимодействия), ~~инвентарь~~ (трек 10, строка 31: Inventory v1 1:1, эффекты giveItem/takeItem исполняются игрой), ~~сохранение~~ (трек 11, строка 32: SaveManager v1 1:1, состояние демки -восстанавливается попиксельно). Остаётся: производственный порядок -следующих треков (сцены/переходы, бой, аудио-перенос — по мере движения -к воспроизведению демки v1 на v2). +восстанавливается попиксельно), ~~сцены/переходы~~ (трек 12, строка 33: +SceneManager — стек + фейд, параллельные переходы глотаются). Остаётся: +производственный порядок следующих треков (бой, аудио-перенос — по мере +движения к воспроизведению демки v1 на v2). diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 440c534..7c048b4 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -46,6 +46,7 @@ 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) +node apps/demo/tools/checks/scenes.mjs # скриншот-гейт сцен/меню (/tmp/v2_scenes_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -137,6 +138,13 @@ `save:`, мета в параллельном ключе `meta:` (старые сейвы читаемы), `load` битого JSON → null; хранилище — `StorageLike` инъекцией (localStorage в демке, Map в тестах); + - `src/core/scene.ts` — `SceneManager` (перенос схемы v1, без привязки + к бэкенду): стек сцен push/pop/replace; переход {duration} фазами + затухание → смена → проявление (каждая фаза длится duration — полный + переход 2×duration); `FadeOverlay {setAlpha}` инъекцией (демка — + DOM-слой, тесты — заглушка); параллельные переходы глотаются + (pending-проверка), enter новой сцены не ожидается (void); update + тикает текущую сцену и во время перехода, render рисует весь стек; - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -159,7 +167,14 @@ /`invInfo()` (all/size/maxSlots/empty) /`saveTo(slot,title)`/`loadFrom(slot)`/`saveList()`/`saveDelete(slot)` (сейв: герой/флаги/переменные/сумка, `DemoSave` version 1; applySave - прерывает активный диалог и обновляет HUD сразу). + прерывает активный диалог и обновляет HUD сразу) + /`sceneInfo()` (current/transitioning/autoSaved)/`gotoMenu()` + (игра → меню с автосейвом в слот `auto`, меню → игра)/`startGame(load)` + («Войти в мир»/«Продолжить»). + Сцены демки: стек SceneManager — `gameScene` (tick+рендер) внизу, + `menuScene` (DOM-панель `#menu`, мир виден под ней) сверху; демка + стартует в меню; переход — DOM-слой `#fade` (0.4 с, out+in = 0.8 с); + Esc — gotoMenu; «Продолжить» disabled без автосейва. Герой демки: WASD/стрелки → `screenMoveToWorld` с ISO_YAW, Character (скорость 4, stride 3.2), камера следит с шагом пиксельной сетки. NPC: страж у дома (42,47) — Character на скорости 0 (поза покоя, один @@ -202,6 +217,14 @@ (отдача цветка + уезд героя), loadFrom восстанавливает позицию/флаги/ сумку, кадр после загрузки попиксельно равен кадру сохранения (diff 0, рендер — только в stepTicks ручного режима), saveDelete чистит слот. +- `apps/demo/tools/checks/scenes.mjs` — скриншот-гейт сцен (11 проверок): + старт в меню без перехода и без автосейва, startGame — переход идёт + (сцена ещё меню), фейд в конце фазы out — кадр чёрный, переход завершён + (меню скрыто), кадры меню/игры различаются, gotoMenu пишет автосейв + «auto», возврат в меню, «Продолжить» вернул героя на место сохранения, + параллельный переход глотается (startGame во время перехода игнорируется), + повторный переход работает. Ручной режим: полный переход = 48 тиков + (0.4 с × 2 фазы), гейты входят в игру через startGame(false)+60 тиков. - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!) @@ -283,6 +306,17 @@ даёт diff всего луга (сотни тысяч пикселей) — проверяется камера, а не HUD; а зона HUD локализует проверку (подсказка — верхние 60 строк, панель диалога — нижняя половина). +- **CSS `display:flex` на элементе перебивает атрибут `hidden`**: `#menu` + «скрывался» (hidden=true), но панель оставалась на каждом кадре — нужен + отдельный `#menu[hidden]{display:none}`. Ловится попиксельным + сравнением гейта (меню затемняло/перекрывало кадры). +- **Полный фейд-переход — это out+in, 2×duration**: при 0.4 с это 48 тиков + по 1/60, а не 24. Гейты в ручном режиме обязаны пройти все 48 тиков + после startGame/gotoMenu, иначе фейд замерает на частичной прозрачности + и затемняет каждый кадр (backDiff сотни тысяч при «рабочем» сейве). +- **Демка стартует в меню (стек сцен)**: любой новый гейт обязан сначала + войти в игру (`startGame(false)` + 60 тиков), иначе stepTicks тикает + только меню, а скриншоты снимаются поверх панели меню. ## Рабочие привычки diff --git a/v2/apps/demo/index.html b/v2/apps/demo/index.html index 0814aad..608df55 100644 --- a/v2/apps/demo/index.html +++ b/v2/apps/demo/index.html @@ -43,6 +43,43 @@ #inv .title { color: #626c4c; } #inv .item { color: #d99a32; } + /* Фейд перехода: чёрный слой поверх всего, прозрачность двигает движок */ + #fade { + position: fixed; + inset: 0; + background: #000; + opacity: 0; + pointer-events: none; + } + /* Меню: панель в центре поверх мира (стек сцен) */ + #menu { + position: fixed; + left: 50%; + top: 40%; + transform: translate(-50%, -50%); + padding: 20px 28px; + background: rgba(23, 23, 29, 0.92); + border: 1px solid #44454e; + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 280px; + } + #menu[hidden] { display: none; } /* display:flex перебивает атрибут hidden */ + #menu .title { color: #d99a32; font-weight: bold; font-size: 20px; margin-bottom: 6px; } + #menu button { + padding: 8px 14px; + background: #23232c; + color: #c9c9d2; + border: 1px solid #44454e; + border-radius: 3px; + font: 16px monospace; + cursor: pointer; + } + #menu button:hover:not(:disabled) { border-color: #d99a32; color: #fff; } + #menu button:disabled { opacity: 0.4; cursor: default; } + /* Диалог: панель внизу экрана, реплика + варианты (кнопки) */ #dialog { position: fixed; @@ -76,6 +113,12 @@ + +
diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index b68a86f..1228aba 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -10,9 +10,10 @@ treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, DialogueRunner, validateDialogue, nearestInteractable, Inventory, SaveManager, + SceneManager, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine'; -import type { InventoryData } from '@rpg/engine'; +import type { InventoryData, Scene, FadeOverlay } from '@rpg/engine'; import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex @@ -333,6 +334,10 @@ e.preventDefault(); interactNearest(); } + if (e.code === 'Escape') { + e.preventDefault(); + gotoMenu(); // hoisted-функция (объявлена ниже, в секции сцен) + } }); window.addEventListener('keyup', (e) => heldKeys.delete(e.code)); function keyboardMove(): [number, number] { @@ -364,9 +369,6 @@ updateHint(); } -const loop = new GameLoop(60, { update: tick, render: () => renderer.render() }); -loop.start(); - // ---- Сохранения: слоты JSON с метой; сериализация состояния демки ---- /** Сериализуемое состояние демки (формат стабильный — старые сейвы читаемы). */ interface DemoSave { @@ -412,6 +414,85 @@ updateHint(); // состояние изменено вне тика — HUD-подсказку обновить сразу } +/** Записать слот (мета с заголовком и временем); возвращает список слотов. */ +function saveSlot(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() }; +} + +/** Загрузить и применить слот (false — слота нет/битый). */ +function loadSlot(slot: string): boolean { + const data = saves.load(slot); + if (data) applySave(data); + return data !== null; +} + +// ---- Сцены: игра + меню (DOM); стек SceneManager, фейд — DOM-слой ---- +const fadeEl = document.getElementById('fade') as HTMLDivElement; +const menuEl = document.getElementById('menu') as HTMLDivElement; +const menuStartBtn = document.getElementById('menu-start') as HTMLButtonElement; +const menuContBtn = document.getElementById('menu-continue') as HTMLButtonElement; + +const fadeOverlay: FadeOverlay = { + setAlpha(alpha: number): void { + fadeEl.style.opacity = String(alpha); + }, +}; + +const menuScene: Scene = { + name: 'menu', + enter(): void { + menuContBtn.disabled = !saves.has('auto'); // «Продолжить» есть только с автосейвом + menuEl.hidden = false; + }, + exit(): void { + menuEl.hidden = true; + }, + update(): void {}, // DOM-меню не тикает + render(): void {}, // мир под меню рисует игровая сцена +}; + +const gameScene: Scene = { + name: 'game', + enter(): void {}, // мир собран при старте модуля + exit(): void {}, + update(dt: number): void { + tick(dt); + }, + render(): void { + renderer.render(); + }, +}; + +const sm = new SceneManager(fadeOverlay); +sm.push(gameScene); // мир живёт под меню сразу +sm.push(menuScene); + +/** Esc/мост: автосохранение и меню поверх игры; в меню — возврат в игру. */ +function gotoMenu(): void { + if (sm.transitioning) return; + if (sm.current === gameScene) { + saveSlot('auto', 'Автосохранение'); + sm.push(menuScene, { duration: 0.4 }); + } else if (sm.current === menuScene) { + sm.pop({ duration: 0.4 }); + } +} + +/** Действие меню: «Войти в мир» / «Продолжить» (load=true — грузит auto). */ +function menuAction(load: boolean): void { + if (sm.transitioning || sm.current !== menuScene) return; + if (load && saves.has('auto')) loadSlot('auto'); + sm.pop({ duration: 0.4 }); +} + +menuStartBtn.addEventListener('click', () => menuAction(false)); +menuContBtn.addEventListener('click', () => menuAction(true)); + +const loop = new GameLoop(60, { update: (dt) => sm.update(dt), render: () => sm.render() }); +loop.start(); + // Агентный мост (DEV): солнце, статистика, правка мира атомарным ремешингом let sunK = 0.5; function stats(): { vertices: number; triangles: number; sun: number } { @@ -571,15 +652,12 @@ }, /** Сохранить состояние в слот (мета с заголовком и временем). */ 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() }; + return saveSlot(slot, title); }, /** Загрузить слот и применить (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 }; + const loaded = loadSlot(slot); + return { loaded, heroPos: [hero.body.pos[0], hero.body.pos[2]] as const }; }, /** Список слотов с метой. */ saveList() { @@ -589,6 +667,22 @@ saveDelete(slot: string): void { saves.delete(slot); }, + /** Снапшот сцен: текущая, идёт ли переход, есть ли автосейв. */ + sceneInfo() { + return { + current: sm.current?.name ?? null, + transitioning: sm.transitioning, + autoSaved: saves.has('auto'), + }; + }, + /** Esc агентом: игра → меню (с автосохранением), меню → игра. */ + gotoMenu(): void { + gotoMenu(); + }, + /** Действие меню агентом: startGame(false) — «Войти», (true) — «Продолжить». */ + startGame(load: boolean): void { + menuAction(load); + }, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, diff --git a/v2/apps/demo/tools/checks/dialogue.mjs b/v2/apps/demo/tools/checks/dialogue.mjs index e110bf5..ca6c45b 100644 --- a/v2/apps/demo/tools/checks/dialogue.mjs +++ b/v2/apps/demo/tools/checks/dialogue.mjs @@ -37,6 +37,10 @@ // Детерминизм + графы валидны (валидатор — часть ядра) await client.call('setManual', true); + + // Демка стартует в меню (стек сцен) — входим в игру и проходим переход (0.4 с) + await client.call('startGame', false); + await client.call('stepTicks', 60); const boot = await client.call('dialogueInfo'); check('диалоговые графы валидны', boot.graphErrors.length === 0, boot.graphErrors); diff --git a/v2/apps/demo/tools/checks/hero.mjs b/v2/apps/demo/tools/checks/hero.mjs index e904189..579619a 100644 --- a/v2/apps/demo/tools/checks/hero.mjs +++ b/v2/apps/demo/tools/checks/hero.mjs @@ -38,6 +38,10 @@ // Детерминизм: цикл в ручной режим, шаги только через stepTicks await client.call('setManual', true); + + // Демка стартует в меню (стек сцен) — входим в игру и проходим переход (0.4 с) + await client.call('startGame', false); + await client.call('stepTicks', 60); const idle = await client.call('heroInfo'); check('герой на сцене, стоит на земле', idle && idle.grounded === true && Math.abs(idle.pos[1] - GROUND_Y) < 0.02, idle); @@ -76,7 +80,7 @@ // Скриншоты: герой у стены (кадр «стоит») vs идёт (кадр меняется) await client.call('heroTeleport', 38, 46); await client.call('setInput', 1, 1); - await client.call('stepTicks', 30); + await client.call('stepTicks', 60); const goShot = await client.screenshot('/tmp/v2_hero_go.png'); await client.call('stepTicks', 210); const stopShot = await client.screenshot('/tmp/v2_hero_stop.png'); @@ -88,7 +92,7 @@ // Камера следит: телепорт далеко — кадр уезжает за героем await client.call('heroTeleport', 60, 12); - await client.call('stepTicks', 30); + await client.call('stepTicks', 60); const farInfo = await client.call('heroInfo'); const far = Math.abs(farInfo.camTarget[0] - 60) + Math.abs(farInfo.camTarget[2] - 12); check('камера следит: цель камеры = герой', far < 1.5, farInfo.camTarget); diff --git a/v2/apps/demo/tools/checks/inventory.mjs b/v2/apps/demo/tools/checks/inventory.mjs index b42863f..fdc8887 100644 --- a/v2/apps/demo/tools/checks/inventory.mjs +++ b/v2/apps/demo/tools/checks/inventory.mjs @@ -36,6 +36,10 @@ check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); await client.call('setManual', true); + + // Демка стартует в меню (стек сцен) — входим в игру и проходим переход (0.4 с) + await client.call('startGame', false); + await client.call('stepTicks', 60); await client.call('heroTeleport', GUARD.x, GUARD.z); await client.call('stepTicks', 2); diff --git a/v2/apps/demo/tools/checks/physics.mjs b/v2/apps/demo/tools/checks/physics.mjs index c371309..1a76e29 100644 --- a/v2/apps/demo/tools/checks/physics.mjs +++ b/v2/apps/demo/tools/checks/physics.mjs @@ -37,6 +37,10 @@ const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + // Демка стартует в меню (стек сцен) — гейту нужен мир без панели меню + await client.call('startGame', false); + await sleep(700); // переход 0.4 с + запас + // Камера фиксированная: слежение за героем off (герой проверяет hero.mjs) await client.call('setFollow', false); diff --git a/v2/apps/demo/tools/checks/render.mjs b/v2/apps/demo/tools/checks/render.mjs index de5ba24..0705494 100644 --- a/v2/apps/demo/tools/checks/render.mjs +++ b/v2/apps/demo/tools/checks/render.mjs @@ -35,6 +35,10 @@ const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + // Демка стартует в меню (стек сцен) — гейту нужен мир без панели меню + await client.call('startGame', false); + await sleep(700); // переход 0.4 с + запас + const stats = await client.call('stats'); check('меш собран', !!stats && stats.triangles > 1000 && stats.vertices > 1000, stats); diff --git a/v2/apps/demo/tools/checks/save.mjs b/v2/apps/demo/tools/checks/save.mjs index 958c7b6..9cbe9e9 100644 --- a/v2/apps/demo/tools/checks/save.mjs +++ b/v2/apps/demo/tools/checks/save.mjs @@ -35,6 +35,9 @@ check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); await client.call('setManual', true); + // Демка стартует в меню (стек сцен) — входим в игру и проходим переход (0.4 с) + await client.call('startGame', false); + await client.call('stepTicks', 60); check('слотов на старте нет', (await client.call('saveList')).length === 0); // Готовим состояние: диалог дал цветок, герой у дома diff --git a/v2/apps/demo/tools/checks/scenes.mjs b/v2/apps/demo/tools/checks/scenes.mjs new file mode 100644 index 0000000..018c9ff --- /dev/null +++ b/v2/apps/demo/tools/checks/scenes.mjs @@ -0,0 +1,139 @@ +/** + * Скриншот-гейт сцен v2 (трек «сцены»): стек SceneManager (меню поверх игры), + * фейд-переходы out→swap→in (DOM-слой #fade), автосохранение при уходе в + * меню, «Продолжить» восстанавливает состояние, параллельные переходы + * глотаются. Ручной режим цикла — переход продвигается stepTicks. + * Скриншоты: /tmp/v2_scenes_*.png. + * + * Запуск: node apps/demo/tools/checks/scenes.mjs [--port 5310] + */ +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { 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: 'scenes', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +/** Тиков на полный переход 0.4+0.4 с (out+in = 48 по 1/60) + запас. */ +const PASS = 60; + +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); + + // Старт: меню сверху, игры без перехода + let si = await client.call('sceneInfo'); + check('на старте текущая сцена — меню, без перехода, автосейва нет', + si.current === 'menu' && si.transitioning === false && si.autoSaved === false, si); + + const shotMenu = await client.screenshot('/tmp/v2_scenes_menu.png'); + const imgMenu = decodePng(readFileSync(shotMenu)); + + // «Войти в мир»: переход начинается затуханием — сцена ещё меню + await client.call('startGame', false); + si = await client.call('sceneInfo'); + check('startGame: переход идёт, сцена пока меню (фаза out)', + si.transitioning === true && si.current === 'menu', si); + + // Конец фазы out: фейд накрыл экран чёрным (24 тика = 0.4 с) + await client.call('stepTicks', 24); + const shotFade = await client.screenshot('/tmp/v2_scenes_fade.png'); + const imgFade = decodePng(readFileSync(shotFade)); + check('фейд в середине перехода — кадр чёрный', isBlack(imgFade.rgba)); + + // Переход завершён: игра сверху, меню скрыто, кадр другой + await client.call('stepTicks', PASS); + si = await client.call('sceneInfo'); + check('переход завершён: сцена игра, перехода нет', + si.current === 'game' && si.transitioning === false, si); + const shotGame = await client.screenshot('/tmp/v2_scenes_game.png'); + const imgGame = decodePng(readFileSync(shotGame)); + const mgDiff = countDiff(imgMenu.rgba, imgGame.rgba); + check('кадры меню и игры различаются', mgDiff > 5000, { mgDiff }); + + // Esc в меню: автосохранение + возврат с переходом + const beforePos = (await client.call('heroInfo')).pos; + await client.call('gotoMenu'); + const list = await client.call('saveList'); + check('gotoMenu записал автосейв «auto»', + list.length === 1 && list[0].slot === 'auto' + && list[0].meta.title === 'Автосохранение', list); + await client.call('stepTicks', PASS); + si = await client.call('sceneInfo'); + check('возврат в меню завершён', si.current === 'menu' && si.transitioning === false, si); + + // Ломаем состояние: герой уезжает — «Продолжить» должен вернуть + await client.call('heroTeleport', 60, 12); + await client.call('stepTicks', 2); + + await client.call('startGame', true); + await client.call('stepTicks', PASS); + si = await client.call('sceneInfo'); + const pos = (await client.call('heroInfo')).pos; + check('«Продолжить» загрузил автосейв: герой на месте сохранения', + si.current === 'game' + && Math.abs(pos[0] - beforePos[0]) < 0.01 + && Math.abs(pos[2] - beforePos[2]) < 0.01, { current: si.current, pos }); + + // Параллельный переход глотается: startGame во время перехода игнорируется + await client.call('gotoMenu'); // push-переход 0.4 с + await client.call('startGame', false); // должен быть проглочен + await client.call('stepTicks', PASS); + si = await client.call('sceneInfo'); + check('параллельный переход глотается: меню осталось сверху', + si.current === 'menu', si); + + // Второй переход (меню → игра) проходит нормально + await client.call('gotoMenu'); // теперь это возврат в игру + await client.call('stepTicks', PASS); + si = await client.call('sceneInfo'); + check('повторный переход меню → игра работает', si.current === 'game', si); + + 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; +} + +/** Кадр почти целиком чёрный (фейд накрыл экран). */ +function isBlack(rgba) { + let bright = 0; + let n = 0; + for (let i = 0; i < rgba.length; i += 4) { + n++; + if (rgba[i] + rgba[i + 1] + rgba[i + 2] > 30) bright++; + } + return bright / n < 0.01; +} \ No newline at end of file diff --git a/v2/packages/engine/src/core/__tests__/scene.test.ts b/v2/packages/engine/src/core/__tests__/scene.test.ts new file mode 100644 index 0000000..c8c70e9 --- /dev/null +++ b/v2/packages/engine/src/core/__tests__/scene.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SceneManager, type Scene } from '../scene'; + +/** Сцена-болванка с журналом вызовов (для проверки порядка enter/exit/update). */ +function makeScene(name: string) { + const events: string[] = []; + const scene: Scene = { + name, + enter: () => void events.push(`${name}:enter`), + exit: () => void events.push(`${name}:exit`), + update: () => void events.push(`${name}:update`), + render: () => void events.push(`${name}:render`), + }; + return { scene, events }; +} + +describe('SceneManager', () => { + it('push без перехода: enter сразу, сцена в стеке и тикает', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + sm.push(a.scene); + expect(sm.current).toBe(a.scene); + sm.update(0.016); + expect(a.events).toContain('a:enter'); + expect(a.events).toContain('a:update'); + }); + + it('pop без перехода: exit верхней сцены, стек пуст', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + sm.push(a.scene); + sm.pop(); + expect(a.events).toContain('a:exit'); + expect(sm.current).toBeUndefined(); + }); + + it('replace без перехода: exit старой, enter новой синхронно', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + sm.push(a.scene); + sm.replace(b.scene); + expect(sm.current).toBe(b.scene); + expect(a.events).toContain('a:exit'); + expect(b.events).toContain('b:enter'); + }); + + it('replace с переходом: out → swap (exit/enter) → in, затем transitioning=false', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + sm.replace(a.scene); + a.events.length = 0; + + sm.replace(b.scene, { duration: 0.2 }); + expect(sm.transitioning).toBe(true); + + sm.update(0.1); // половина out — сцены ещё не менялись + expect(sm.current).toBe(a.scene); + expect(sm.transitioning).toBe(true); + + sm.update(0.1); // out завершён — swap: exit(a), enter(b) + expect(a.events).toContain('a:exit'); + expect(b.events).toContain('b:enter'); + expect(sm.current).toBe(b.scene); + + sm.update(0.1); + sm.update(0.1); // in завершён + expect(sm.transitioning).toBe(false); + }); + + it('push с переходом: новая сцена входит в середине (за чёрным экраном)', () => { + const sm = new SceneManager(); + const b = makeScene('b'); + sm.push(makeScene('a').scene); + + sm.push(b.scene, { duration: 0.2 }); + sm.update(0.2); // out завершён + expect(sm.current).toBe(b.scene); + expect(b.events).toContain('b:enter'); + }); + + it('параллельный переход глотается: новая сцена не входит', () => { + const sm = new SceneManager(); + const b = makeScene('b'); + const c = makeScene('c'); + sm.push(makeScene('a').scene); + + sm.replace(b.scene, { duration: 0.2 }); + sm.replace(c.scene, { duration: 0.2 }); // глотается + sm.update(0.2); + sm.update(0.2); + sm.update(0.2); + + expect(sm.current).toBe(b.scene); + expect(c.events).toEqual([]); + }); + + it('update во время перехода тикает текущую (старую) сцену', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + sm.push(a.scene); + a.events.length = 0; + + sm.replace(makeScene('b').scene, { duration: 0.2 }); + sm.update(0.05); + expect(a.events).toContain('a:update'); + }); + + it('render рисует весь стек (нижние сцены видны под верхними)', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + sm.push(a.scene); + sm.push(b.scene); + + sm.render(); + expect(a.events).toContain('a:render'); + expect(b.events).toContain('b:render'); + }); + + it('enter может быть асинхронным — менеджер его не ждёт', () => { + const sm = new SceneManager(); + let resolved = false; + const scene: Scene = { + enter: () => new Promise((res) => setTimeout(() => { resolved = true; res(); }, 5)), + exit: () => {}, + update: () => {}, + render: () => {}, + }; + const spy = vi.spyOn(scene, 'enter'); + sm.push(scene); + expect(spy).toHaveBeenCalled(); + return new Promise((r) => setTimeout(() => { + expect(resolved).toBe(true); + r(); + }, 10)); + }); + + it('фейд: оверлей 0→1 на out, 1→0 на in, после перехода 0', () => { + const alphas: number[] = []; + const sm = new SceneManager({ setAlpha: (a) => alphas.push(a) }); + sm.replace(makeScene('a').scene, { duration: 0.2 }); + sm.update(0.1); + sm.update(0.1); // out завершён, alpha 1 + sm.update(0.1); + sm.update(0.1); // in завершён, alpha 0 + expect(alphas[0]).toBeGreaterThan(0); + expect(alphas[Math.floor(alphas.length / 2) - 1]).toBe(1); + expect(alphas[alphas.length - 1]).toBe(0); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/core/scene.ts b/v2/packages/engine/src/core/scene.ts new file mode 100644 index 0000000..507af66 --- /dev/null +++ b/v2/packages/engine/src/core/scene.ts @@ -0,0 +1,148 @@ +/** + * Сцены (перенос схемы v1, без привязки к бэкенду): сцена — экран игры + * (меню, локация, бой); менеджер держит стек, чаще всего достаточно + * push/pop/replace. Затемнение при смене — инъекция FadeOverlay (в демке + * это DOM-слой; в тестах — заглушка): ядро о бэкенде не знает (урок №3). + * Параллельные переходы глотаются; enter новой сцены не ожидается — + * проявление идёт параллельно с её инициализацией. + */ + +/** Сцена — экран игры. update — фиксированный шаг dt в секундах. */ +export interface Scene { + /** Имя для моста/отладки (необязательно). */ + name?: string; + /** Вызывается один раз при входе (может быть асинхронным). */ + enter(): void | Promise; + exit(): void | Promise; + update(dt: number): void; + render(): void; +} + +/** Затемнение экрана при смене сцены (цвет/стиль — на игре). */ +export interface FadeOverlay { + /** Прозрачность 0..1 (0 — не рисовать). */ + setAlpha(alpha: number): void; +} + +/** Параметры перехода: длительность затухание+проявление, секунды. */ +export interface SceneTransition { + duration?: number; +} + +type TransitionKind = 'push' | 'pop' | 'replace'; + +interface PendingTransition { + kind: TransitionKind; + scene?: Scene; + duration: number; + phase: 'out' | 'in'; + /** Прошедшее время внутри фазы. */ + t: number; +} + +const DEFAULT_DURATION = 0.5; + +export class SceneManager { + private stack: Scene[] = []; + private pending: PendingTransition | null = null; + + constructor(private readonly overlay?: FadeOverlay) {} + + /** Положить сцену на стек (с переходом — по завершении затухания). */ + push(scene: Scene, transition?: SceneTransition): void { + if (transition) { + this.begin('push', scene, transition); + return; + } + this.stack.push(scene); + void scene.enter(); + } + + /** Снять верхнюю сцену. */ + pop(transition?: SceneTransition): void { + if (transition) { + this.begin('pop', undefined, transition); + return; + } + this.stack.pop()?.exit(); + } + + /** Заменить верхнюю сцену (например меню → локация). */ + replace(scene: Scene, transition?: SceneTransition): void { + if (transition) { + this.begin('replace', scene, transition); + return; + } + this.stack.pop()?.exit(); + this.stack.push(scene); + void scene.enter(); + } + + get current(): Scene | undefined { + return this.stack[this.stack.length - 1]; + } + + /** Идёт ли сейчас переход (для блокировки ввода). */ + get transitioning(): boolean { + return this.pending !== null; + } + + update(dt: number): void { + if (this.pending) this.stepTransition(dt); + this.current?.update(dt); + } + + /** Рисует весь стек: нижние сцены видны под верхними. */ + render(): void { + for (const scene of this.stack) scene.render(); + } + + private begin(kind: TransitionKind, scene: Scene | undefined, tr: SceneTransition): void { + if (this.pending) return; // параллельные переходы запрещены + this.pending = { + kind, + scene, + duration: tr.duration && tr.duration > 0 ? tr.duration : DEFAULT_DURATION, + phase: 'out', + t: 0, + }; + } + + /** Фазы: затухание → смена сцены → проявление. */ + private stepTransition(dt: number): void { + const p = this.pending!; + p.t += dt; + if (p.phase === 'out') { + const a = Math.min(p.t / p.duration, 1); + this.overlay?.setAlpha(a); + if (a >= 1) { + this.performSwap(p); + p.phase = 'in'; + p.t = 0; + } + return; + } + const a = Math.max(1 - p.t / p.duration, 0); + this.overlay?.setAlpha(a); + if (p.t >= p.duration) { + this.pending = null; + this.overlay?.setAlpha(0); + } + } + + private performSwap(p: PendingTransition): void { + switch (p.kind) { + case 'push': + if (p.scene) this.stack.push(p.scene); + break; + case 'pop': + this.stack.pop()?.exit(); + break; + case 'replace': + this.stack.pop()?.exit(); + if (p.scene) this.stack.push(p.scene); + break; + } + void this.stack[this.stack.length - 1]?.enter(); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index d2d7128..c328723 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -53,6 +53,8 @@ export type { Entity, EcsSystem } from './core/ecs'; export { screenMoveToWorld } from './core/controls'; export type { ControlState } from './core/controls'; +export { SceneManager } from './core/scene'; +export type { Scene, FadeOverlay, SceneTransition } from './core/scene'; export { ISO_YAW } from './render/voxelRenderer'; export { validateDialogue } from './dialogue/graph'; export type {