diff --git a/docs/plan.md b/docs/plan.md index 2c22dc2..8a95ea4 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -49,6 +49,7 @@ | 27 | v2 трек 6: рига/анимация («анимация = данные») — рига: кости (имя, родитель, pivot в связочном пространстве), детерминированная привязка вокселей к ближайшей кости; клипы: ключевые кадры {t, кость: rot/pos}, линейный семплинг поз, числовая валидация (монотонность ключей, непрерывность цикла первый=последний, контакт стопы); процедурная локомоция (walk-цикл из параметров); генератор манекена с ригой; рендер деформированных вокселей (instanced-облако); превью-лист кадров + мост (числовые снапшоты поз) + гейт | ✅ (2026-09-09). `animation/mat.ts` (чистая аффинная математика, порядок Эйлера X→Y→Z), `animation/rig.ts` (кости + pivot в связочном пространстве; привязка: у генератора — явная, для чужих — `bindNearest` по pivot; `poseVoxels` — жёсткая деформация на кость), `animation/clip.ts` (ключи {t, кость: rot/pos}, линейный семплинг с заворотом t для циклов, `validateClip` — границы/монотонность/замкнутость цикла, `footMinY` — контакт стопы), `animation/locomotion.ts` (walkClip: ноги в противофазе, руки против одноимённой ноги, корень приседает дважды и качается), `models/mannequin.ts` (5×11×3, 5 костей, явная привязка — Voronoi по pivot отнёс бы торс к рукам). Рендер: `addVoxelCloud` (InstancedMesh, ось-выровненные кубы, обновление позы без пересборки геометрии). Демка: `anim.html` + `anim.ts` — 8 кадров цикла (4×2), мост `poseStats(i)`/`clipCheck()`/`setModels`. Гейт `anim.mjs` 8 проверок: картинка (ячейки видны, соседние фазы различаются) + числа через мост (клип замкнут, противофазы, контакт стоп ≈0.5 в опоре / подъём в махе, приседание и качание корня). 91 юнит-тест. Уроки: **composition affine — перенос parent добавлялся дважды** (тест иерархии поймал: ребёнок уезжал на 2·t вместо t); **binding индексируется по сетке, posed-массив только из занятых вокселей** — сверять через `index` вокселя, не позицией в массиве; **Voronoi-привязка по pivot семантически неверна** для внутренних вокселей (торс у плеч ближе к кости руки) — генератор привязывает явно; соседние фазы 45° дают ~110 px разницы на мелком манекене (порог гейта 50, не 150); sheetInfo/gейт должен отдавать РЕАЛЬНЫЙ viewSize камеры (в turntable 30 vs 32 проходило только за счёт запаса эллипса — исправлено). | | 28 | v2 трек 7: физика/коллизии воксельного мира — AABB-тело (pos = центр низа, vel, size, grounded) против вокселей (занятый воксель = твёрдый), гравитация, ось-раздельное разрешение X→Z→Y с зажимом к граням вокселей, сабстепы против туннелирования; юнит-тесты (падение/остановка, стена, угол, сход с уступа, потолок, тонкая стена); демка: тело-манекен в сцене с walk-анимацией в движении, мост bodySpawn/bodyStep/bodyInfo + гейт (гравитация, стена, кадр меняется) | ✅ (2026-09-09). `physics/body.ts`: `SolidWorld` (структурный интерфейс — точечная выборка `get`, чистая математика без рендера), `makeBody`/`stepBody` (полунеявный Эйлер: скорость до позиции), `collides` (AABB по воксельным диапазонам, касание грани снаружи — не столкновение), ось-раздельные подшаги X→Z→Y с зажимом к грани вокселя и EPS-зазором (пол — ровно на грань, без ловли своей же опоры), сабстепы (смещение за подшаг < 0.9 вокселя) против туннелирования сквозь тонкие стены. 10 юнит-тестов: падение на грань, покой, сход с уступа, стена останавливает, туннелирование (50 вокс/с при dt=0.1), угол двух стен, потолок, число гравитации. Демка: тело-манекен (AABB 3×10×3 — узкий силуэт; махи ног визуально выходят за AABB, это норма) с walk-анимацией и поворотом по направлению движения; мост `bodySpawn(x,z,vx,vz)` (спавн в воздухе y=6)/`bodyStep(n)` (фиксированный dt=1/60 — детерминизм гейта)/`bodyInfo`/`bodyRemove`. Гейт `physics.mjs` 7 проверок: спавн в воздухе, падение на землю y=2, фаза walk-цикла растёт в движении, стена дома останавливает по грани (pos.x ≈ 44 − w/2 − eps), кадр изменился, манекен рисуется (без тела кадр возвращается). 101 юнит-тест. Уроки: **аргумент `VoxelWorld(n)` — это ВЫСОТА чанка, а не число чанков** (`set` вне y-границ молча возвращает false — тест-мир «потолка» не получил и тест падал загадочно); **`Vec3` в mat.ts — readonly**: мутирующее состояние физики (pos/vel) — обычные кортежи `[number,number,number]`, Vec3 только на входах; **спавн тестового тела проверять на пересечение AABB с миром** — тело 3×10×3 у плиты потолка цепляло её край и «мгновенно приземлялось» на y=6. | | 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, а камеру. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -583,6 +584,8 @@ Открытые вопросы v2 (копить здесь): ~~каркас ядра (ECS v1 или новый)~~ — решено (трек 8, строка 29): минимальный ECS v1-схемы (`EntityWorld`), GameLoop v1-схемы с ручным режимом, ввод как данные; ~~физика/коллизии в -воксельном мире~~ (трек 7), ~~тайлинг мира (чанки)~~ (трек 3). Остаётся: -производственный порядок следующих треков (диалоги/инвентарь/сохранение/ -аудио-перенос — по мере движения к воспроизведению демки v1 на v2). +воксельном мире~~ (трек 7), ~~тайлинг мира (чанки)~~ (трек 3), +~~диалоги как данные~~ (трек 9, строка 30: граф + рантайм семантики v1 + +валидатор + точки взаимодействия). Остаётся: производственный порядок +следующих треков (инвентарь/сохранение/сцены/аудио-перенос — по мере +движения к воспроизведению демки v1 на v2). diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 7115c28..08e1906 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -43,6 +43,7 @@ node apps/demo/tools/checks/render.mjs # скриншот-гейт рендера (Chromium, /tmp/v2_render_*.png) node apps/demo/tools/checks/physics.mjs # скриншот-гейт физики (/tmp/v2_physics_*.png) node apps/demo/tools/checks/hero.mjs # скриншот-гейт героя (/tmp/v2_hero_*.png) +node apps/demo/tools/checks/dialogue.mjs # скриншот-гейт диалогов (/tmp/v2_dialog_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -106,13 +107,33 @@ - `src/core/controls.ts` — ввод как данные: `ControlState` (движение в ЭКРАННЫХ осях) + `screenMoveToWorld(move, yaw)` — проекция экранных осей в мировую горизонталь на границе (урок №1), диагональ не быстрее; + - `src/dialogue/graph.ts` — диалоговые графы как данные (перенос v1): + узлы (speaker/text/choices/next/end) + условия (when/whenNot/whenVar/ + hasItem) + эффекты (setFlags/setVars/do[]), `evalConditions` (все + группы AND; не-число в gt/lt/ge/le — ложно), `validateDialogue` — + числовая валидация графа агентом (битые ссылки, тупики, выборы без + текста); `DialogueState` — интерфейс состояния (игра реализует поверх + своего стейта), `DialogueWorld` — предикаты мира (сумка); + - `src/dialogue/runner.ts` — `DialogueRunner`: рантайм семантики v1, + отделён от отрисовки (`DialogueView` — игра рисует сама). Узел с + текстом ЖДЁТ игрока: `advance()` → next или конец, `pick(i)` → + вариант; advance во время выбора игнорируется; узел без текста — + «действие»: эффекты и автопровал в next; MAX_STEPS против циклов; + эффекты `do[]` движок только эмитит (`onEffect`), исполнение — игра; + геттеры nodeId/node/choices/waitingForChoice/path/result — снапшоты + для моста; + - `src/interaction/interactables.ts` — точки взаимодействия как данные: + `Interactable` {id, pos, radius, label, dialogueId?}, чистые + `nearestInteractable` (ближайшая в радиусе, Y не учитывается) и + `interactablesInRadius` (список с дистанциями); - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; - `tools/png.mjs` — PNG-кодек (colorType 6 и 2 — скриншоты Chromium RGB). - `apps/demo` — демо-полигон: `src/main.ts` (мир 6×6 чанков 96×96 «пепельный - луг», деревья/валуны — из генераторов через `stampModel`, дом — ручной путь, - ремешинг по dirty-чанкам), `tools/checks/render.mjs` — скриншот-гейт: + луг», деревья/валуны — из генераторов через `stampModel`, дом и табличка — + ручной путь, ремешинг по dirty-чанкам), `tools/checks/render.mjs` — + скриншот-гейт: нет pageerror, меш собран, тени ползут (утро/вечер), апскейл кратен 2×2, многокрасочность, 36 чанков, постройка+мешинг < 2 с (замер ~0.5 с), `setVoxel` на шве меняет кадр, 12+ деревьев всех 3 вариантов, генератор @@ -121,9 +142,18 @@ /`bodySpawn(x,z,vx,vz)` (тело-манекен в воздухе y=6, Character со spawnY)/`bodyStep(n)` (n шагов физики с фиксированным dt=1/60)/`bodyInfo()` (pos/vel/grounded/size/walkT)/`bodyRemove()` /`setInput(x,z|null)` (виртуальный стик агента, экранные оси; null — - клавиатура)/`heroInfo()` (pos/vel/grounded/phase/yaw/camTarget)/`heroTeleport(x,z)`/`stepTicks(n)` (ручные шаги цикла)/`setManual(on)`/`setFollow(on)` (камера за героем; off — центр сцены). + клавиатура)/`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)`. Герой демки: WASD/стрелки → `screenMoveToWorld` с ISO_YAW, Character (скорость 4, stride 3.2), камера следит с шагом пиксельной сетки. + NPC: страж у дома (42,47) — Character на скорости 0 (поза покоя, один + путь с героем); табличка (30,70); точки взаимодействия — `INTERACTABLES` + (id/pos/radius/label/dialogueId), подсказка «E — …» в `#hint` (top), + диалог — панель `#dialog` (низ, кнопки вариантов). Диалоговые графы — + `GRAPHS` (страж: выборы → setFlags/setVars, условный узел ask2 по + переменной asked, тост-эффекты; табличка: один узел + тост), валидируются + `validateDialogue` при старте (`graphErrors` в мосту). - `apps/demo/tools/checks/physics.mjs` — скриншот-гейт физики (7 проверок): спавн в воздухе, падение на землю y=2 ровно на грань, фаза walk-цикла растёт в движении, стена дома останавливает по грани @@ -135,6 +165,14 @@ двигает (экранный (1,1) = +x мира), фаза ровно 1.25 цикла/с (скорость/stride), стена по грани, покой замирает, кадр меняется, камера следует (camTarget = герой), телепорт уезжает. +- `apps/demo/tools/checks/dialogue.mjs` — скриншот-гейт диалогов (15 + проверок): графы валидны, подсказка по дистанции (вдали нет / у стража + «E — Поговорить»), подсказка и панель рисуются (сравнение ЗОН кадра — + верх/низ с одной позиции камеры, `cropRows`), «E» открывает диалог, + advance при выборе игнорируется (семантика v1), эффекты выбора (флаг + admitted, тост), путь/результат, повторный заход через dialogueOpen, + переменная asked открывает условный узел ask2, второй граф таблички, + закрытие возвращает кадр. - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!) @@ -206,6 +244,16 @@ гейта — перед чистой проверкой кадра отпускать. - **cancelAnimationFrame нет в Node** (юнит-тесты GameLoop с инъекцией schedule): typeof-guard в `GameLoop.stop`. +- **HUD обновляется из тика — изменения состояния вне тика обязаны дёргать + обновление сами**: мост (`dialoguePick`/`interact`) и клик кнопки + меняют диалог вне `tick`, и подсказка «E» оставалась висеть при открытом + диалоге, пока не придёт следующий тик (в ручном режиме — никогда). + После любого мутации рантайма диалога звать `updateHint()`. +- **Скриншот-сравнение HUD — по зонам кадра с одной позиции камеры** + (`cropRows` верх/низ): сравнение целых кадров, снятых с разных позиций, + даёт diff всего луга (сотни тысяч пикселей) — проверяется камера, а не + HUD; а зона HUD локализует проверку (подсказка — верхние 60 строк, + панель диалога — нижняя половина). ## Рабочие привычки diff --git a/v2/apps/demo/index.html b/v2/apps/demo/index.html index 57ad407..d0a2385 100644 --- a/v2/apps/demo/index.html +++ b/v2/apps/demo/index.html @@ -13,10 +13,55 @@ image-rendering: pixelated; image-rendering: crisp-edges; } + /* HUD: подсказка взаимодействия у точки */ + #hint { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 14px; + background: rgba(23, 23, 29, 0.85); + color: #d99a32; + font: 16px/1.4 monospace; + border: 1px solid #44454e; + border-radius: 4px; + white-space: nowrap; + } + /* Диалог: панель внизу экрана, реплика + варианты (кнопки) */ + #dialog { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + width: 640px; + max-width: 92vw; + padding: 12px 16px; + background: rgba(23, 23, 29, 0.92); + color: #c9c9d2; + font: 16px/1.5 monospace; + border: 1px solid #44454e; + border-radius: 6px; + } + #dialog .speaker { color: #d99a32; font-weight: bold; } + #dialog .choices { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } + #dialog .choices button { + text-align: left; + padding: 4px 10px; + background: #23232c; + color: #c9c9d2; + border: 1px solid #44454e; + border-radius: 3px; + font: inherit; + cursor: pointer; + } + #dialog .choices button:hover { border-color: #d99a32; color: #fff; } + #dialog .continue { margin-top: 8px; color: #626c4c; font-size: 13px; } + + \ No newline at end of file diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 1b9fc4a..cb03e48 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -9,8 +9,9 @@ VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, ISO_YAW, treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, + DialogueRunner, validateDialogue, nearestInteractable, } from '@rpg/engine'; -import type { VoxelCloud } from '@rpg/engine'; +import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine'; import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex @@ -62,6 +63,9 @@ for (const [fx, fz] of [[38, 50], [46, 56], [56, 48], [36, 38]] as const) { world.set(fx, 2, fz, 9); // цветок — единственный тёплый цвет } + // табличка (ручной путь, как дом): столбик + доска + world.set(30, 2, 70, 4); + for (let x = 29; x <= 31; x++) world.set(x, 3, 70, 8); return { trees: treeSpots.length, boulders: boulderSpots.length, treeVariants }; } @@ -133,6 +137,147 @@ }); const heroCloud = renderer.addVoxelCloud(hero.visual()); +// ---- NPC: страж у дома (Character на скорости 0 — стоит в позе покоя) ---- +const npc = new Character(MAN_RIG, MAN, WALK, [42, 47], { speed: 0 }); +renderer.addVoxelCloud(npc.visual()); + +// ---- Взаимодействие и диалоги: граф — данные, рантайм — движок ---- +const hintEl = document.getElementById('hint') as HTMLDivElement; +const dialogEl = document.getElementById('dialog') as HTMLDivElement; + +/** Диалоговые графы демки: узлы с выборами, условиями и эффектами (JSON-путь). */ +const GRAPHS: Record = { + guard: { + start: 'greet', + nodes: { + greet: { + speaker: 'Страж', + text: 'Стой! Это ты рылся в золе у дома?', + choices: [ + { text: 'Да, я копал', next: 'dig', setFlags: ['admitted'] }, + { text: 'Кто ты такой?', next: 'who', setVars: { asked: 1 } }, + { text: 'Просто мимо иду', next: 'meh' }, + ], + }, + who: { + speaker: 'Страж', + text: 'Я страж пепла. Слежу, чтобы зола не проснулась.', + next: 'ask2', + }, + ask2: { + speaker: 'Страж', + text: 'Так что будешь делать?', + whenVar: { key: 'asked', op: 'eq', value: 1 }, + choices: [ + { text: 'Помогу с золой', next: 'dig', setFlags: ['admitted', 'helped'] }, + { text: 'Мне пора', next: 'meh' }, + ], + }, + dig: { + speaker: 'Страж', + text: 'Держи цветок. Пепел не любит пустых рук.', + do: [{ kind: 'toast', text: 'Получено: цветок пепла' }], + setFlags: ['flowerGiven'], + next: 'bye', + }, + meh: { speaker: 'Страж', text: 'Проходи. Но я всё вижу.', setFlags: ['suspicious'], next: 'bye' }, + bye: { speaker: 'Страж', text: 'Счастливо.', end: true }, + }, + }, + sign: { + start: 'read', + nodes: { + read: { + text: '«Пепельные луга. Глубже — только зола.»', + do: [{ kind: 'toast', text: 'Табличка прочитана' }], + end: true, + }, + }, + }, +}; +// графы — данные, валидатор ловит ошибки агентом до запуска +const graphErrors = Object.entries(GRAPHS).flatMap(([id, g]) => validateDialogue(g).map((e) => `${id}: ${e}`)); + +/** Точки взаимодействия: у кого доступно «E» (радиус в вокселях). */ +const INTERACTABLES: Interactable[] = [ + { id: 'guard', pos: [42, 2, 47], radius: 4, label: 'Поговорить', dialogueId: 'guard' }, + { id: 'sign', pos: [30, 2, 71], radius: 3, label: 'Прочитать', dialogueId: 'sign' }, +]; + +const flags = new Set(); +const vars = new Map(); +const dlgState: DialogueState = { + hasFlag: (f) => flags.has(f), + setFlag: (f) => flags.add(f), + clearFlag: (f) => flags.delete(f), + getVar: (k) => vars.get(k), + setVar: (k, v) => vars.set(k, v), +}; +const toasts: string[] = []; + +/** View поверх DOM-панели: реплика + кнопки вариантов. */ +function makeDialogueView(): DialogueView { + return { + show({ speaker, text, choices }) { + const sp = document.createElement('div'); + sp.className = 'speaker'; + sp.textContent = speaker ? `${speaker}:` : ''; + const tx = document.createElement('div'); + tx.textContent = text; + const box = document.createElement('div'); + box.className = 'choices'; + for (const c of choices) { + const btn = document.createElement('button'); + btn.textContent = c.text; + btn.addEventListener('click', () => { + runner.pick(c.index); + updateHint(); // выбор меняет state диалога вне тика + }); + box.append(btn); + } + const cont = document.createElement('div'); + cont.className = 'continue'; + cont.textContent = choices.length ? '' : 'E — дальше'; + dialogEl.replaceChildren(sp, tx, box, cont); + dialogEl.hidden = false; + }, + hide() { + dialogEl.hidden = true; + }, + }; +} + +const runner = new DialogueRunner(dlgState, makeDialogueView()); +runner.onEffect = (op) => { + if (op.kind === 'toast' && op.text) toasts.push(op.text); + if (op.kind === 'giveItem' && op.id) toasts.push(`Получено: ${op.id}`); +}; + +/** Ближайшая доступная точка (считается в тике, читается «E»/мостом). */ +let currentNear: Interactable | null = null; +let hintText: string | null = null; +function updateHint(): void { + const hint = runner.active || !currentNear ? null : `E — ${currentNear.label}`; + if (hint === hintText) return; + hintText = hint; + hintEl.hidden = hint === null; + if (hint !== null) hintEl.textContent = hint; +} + +/** «E»: открыть диалог ближайшей точки; в диалоге — «дальше». */ +function interactNearest(): void { + if (runner.active) { + if (!runner.waitingForChoice) runner.advance(); + updateHint(); + return; + } + const id = currentNear?.dialogueId; + if (currentNear && id && GRAPHS[id]) { + runner.start(GRAPHS[id]); + updateHint(); // подсказка скрыта, пока диалог активен + } +} + // Клавиатура: экранные оси (урок №1: проекцию делает screenMoveToWorld на границе) const heldKeys = new Set(); const KEYMAP: Record = { @@ -144,6 +289,10 @@ heldKeys.add(e.code); e.preventDefault(); } + if (e.code === 'KeyE') { + e.preventDefault(); + interactNearest(); + } }); window.addEventListener('keyup', (e) => heldKeys.delete(e.code)); function keyboardMove(): [number, number] { @@ -171,6 +320,8 @@ const b = hero.body.pos; renderer.setTarget([Math.round(b[0]), 2, Math.round(b[2])]); } + currentNear = nearestInteractable(INTERACTABLES, hero.body.pos); + updateHint(); } const loop = new GameLoop(60, { update: tick, render: () => renderer.render() }); @@ -286,6 +437,49 @@ followHero = on; if (!on) renderer.setTarget([S / 2, 2, S / 2]); }, + /** Нажать «E» агентом: открыть диалог ближайшей точки / «дальше». */ + interact(): void { + interactNearest(); + }, + /** Снапшот взаимодействия: ближайшая точка и текущая подсказка. */ + interactInfo() { + return { near: currentNear?.id ?? null, label: currentNear?.label ?? null, hint: hintText }; + }, + /** Открыть диалог по id напрямую (тестирование графов без ходьбы). */ + dialogueOpen(id: string): void { + const g = GRAPHS[id]; + if (g) { + runner.start(g); + updateHint(); + } + }, + /** Снапшот диалога: узел, выборы, путь, флаги/переменные, эффекты. */ + dialogueInfo() { + return { + active: runner.active, + nodeId: runner.nodeId, + text: runner.node?.text ?? null, + speaker: runner.node?.speaker ?? null, + choices: runner.choices, + waitingForChoice: runner.waitingForChoice, + path: runner.path, + flags: [...flags], + vars: Object.fromEntries(vars), + toasts: [...toasts], + result: runner.result, + graphErrors, + }; + }, + /** Выбрать вариант (index в показанном списке). */ + dialoguePick(index: number): void { + runner.pick(index); + updateHint(); + }, + /** «Дальше» без клавиши (в выбор игнорируется). */ + dialogueAdvance(): void { + if (!runner.waitingForChoice) runner.advance(); + updateHint(); + }, 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 new file mode 100644 index 0000000..c0287a9 --- /dev/null +++ b/v2/apps/demo/tools/checks/dialogue.mjs @@ -0,0 +1,152 @@ +/** + * Скриншот-гейт диалогов v2 (трек «диалоги + интерактивные точки»): + * подсказка взаимодействия у точки, открытие диалога по «E» (мост interact), + * выбор варианта с эффектами (флаги/переменные), закрытие, второй граф + * (табличка). Ручной режим цикла — детерминизм. Скриншоты: /tmp/v2_dialog_*.png. + * + * Запуск: node apps/demo/tools/checks/dialogue.mjs [--port 5303] + */ +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 ?? 5303); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'dialogue', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +// Геометрия: страж у дома (42,47) радиус 4; табличка (30,71) радиус 3. +const GUARD = { x: 42, z: 47 }; +const SIGN = { x: 30, z: 71 }; + +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); + const boot = await client.call('dialogueInfo'); + check('диалоговые графы валидны', boot.graphErrors.length === 0, boot.graphErrors); + + // Далеко от точек: подсказки нет + await client.call('heroTeleport', 40, 62); + await client.call('stepTicks', 2); + const far = await client.call('interactInfo'); + check('вдали от точек подсказки нет', far.near === null && far.hint === null, far); + + // Подошёл к стражу: ближайшая точка + подсказка «E — Поговорить» + await client.call('heroTeleport', GUARD.x, GUARD.z + 4); + await client.call('stepTicks', 2); + const near = await client.call('interactInfo'); + check('у стража: подсказка взаимодействия', + near.near === 'guard' && near.hint === 'E — Поговорить', near); + + // Скриншот с подсказкой — с той же позиции камеры, что и кадр диалога + const hintShot = await client.screenshot('/tmp/v2_dialog_hint.png'); + const hintImg = decodePng(readFileSync(hintShot)); + + // «E» у стража: диалог открыт, первый узел с тремя вариантами + await client.call('interact'); + const d1 = await client.call('dialogueInfo'); + check('«E» открывает диалог: узел greet, выбор ждёт', + d1.active === true && d1.nodeId === 'greet' && d1.waitingForChoice === true + && d1.choices.length === 3, d1); + const dialogShot = await client.screenshot('/tmp/v2_dialog_open.png'); + const dialogImg = decodePng(readFileSync(dialogShot)); + // Одна позиция камеры: подсказка живёт в верхних строках, панель — в нижних + const topDiff = countDiff(cropRows(hintImg, 0, 60), cropRows(dialogImg, 0, 60)); + check('подсказка рисуется на кадре (верх экрана)', topDiff > 100, { topDiff }); + const panelDiff = countDiff(cropRows(dialogImg, 270, 540), cropRows(hintImg, 270, 540)); + check('панель диалога рисуется на кадре (низ экрана)', panelDiff > 5000, { panelDiff }); + + // Выбор «Да, я копал» → флаг admitted, эффект toast, следующий узел + await client.call('dialoguePick', 0); + const d2 = await client.call('dialogueInfo'); + check('выбор применил эффекты: флаг admitted, тост цветка, узел dig', + d2.nodeId === 'dig' && d2.flags.includes('admitted') + && d2.toasts.includes('Получено: цветок пепла'), d2); + + // «Дальше» до конца: bye → закрытие, результат с путём + await client.call('dialogueAdvance'); + await client.call('dialogueAdvance'); + const d3 = await client.call('dialogueInfo'); + check('диалог дошёл до bye и закрылся', + d3.active === false && d3.result?.lastNodeId === 'bye' + && JSON.stringify(d3.result?.path) === JSON.stringify(['greet', 'dig', 'bye']), d3); + + // Повторный заход: advance во время выбора игнорируется (узел ждёт игрока) + await client.call('dialogueOpen', 'guard'); + await client.call('dialogueAdvance'); + const d4 = await client.call('dialogueInfo'); + check('advance при выборе варианта игнорируется', d4.nodeId === 'greet', d4); + + // Ветка «Кто ты такой?»: переменная asked открывает условный узел ask2 + await client.call('dialoguePick', 1); + await client.call('dialogueAdvance'); + const d5 = await client.call('dialogueInfo'); + check('переменная asked открыла условный узел ask2', + d5.nodeId === 'ask2' && d5.vars.asked === 1 && d5.choices.length === 2, d5); + await client.call('dialoguePick', 0); + await client.call('dialogueAdvance'); + await client.call('dialogueAdvance'); + const d6 = await client.call('dialogueInfo'); + check('ветка помощи записала оба флага и закрылась', + d6.active === false && d6.flags.includes('helped') && d6.flags.includes('admitted'), d6); + + // Табличка: второй граф, один узел, свой тост + await client.call('heroTeleport', SIGN.x, SIGN.z + 3); + await client.call('stepTicks', 2); + const nearSign = await client.call('interactInfo'); + check('у таблички: подсказка «E — Прочитать»', + nearSign.near === 'sign' && nearSign.hint === 'E — Прочитать', nearSign); + await client.call('interact'); + const d7 = await client.call('dialogueInfo'); + check('табличка открыла второй граф с тостом', + d7.nodeId === 'read' && d7.toasts.includes('Табличка прочитана'), d7); + await client.call('dialogueAdvance'); // end:true → закрытие + const d8 = await client.call('dialogueInfo'); + const closeShot = await client.screenshot('/tmp/v2_dialog_closed.png'); + const closeImg = decodePng(readFileSync(closeShot)); + const closeDiff = countDiff(closeImg.rgba, dialogImg.rgba); + check('диалог закрылся: панель скрыта, кадр вернулся', + d8.active === false && closeDiff > 5000, { active: d8.active, closeDiff }); + + 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); + +/** Строки y0..y1 кадра (RGBA) — сравнение зон HUD без шума сцены. */ +function cropRows(img, y0, y1) { + const stride = img.width * 4; + return img.rgba.subarray(y0 * stride, y1 * stride); +} + +/** Число различающихся 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/dialogue/__tests__/graph.test.ts b/v2/packages/engine/src/dialogue/__tests__/graph.test.ts new file mode 100644 index 0000000..099bd0d --- /dev/null +++ b/v2/packages/engine/src/dialogue/__tests__/graph.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { + evalConditions, hasConditions, hasText, validateDialogue, + type DialogueGraph, type DialogueState, type DialogueWorld, +} from '../graph'; + +/** Простое состояние-заглушка: флаги и переменные в Map. */ +function makeState(): DialogueState & { flags: Set; vars: Map } { + const flags = new Set(); + const vars = new Map(); + return { + flags, + vars, + hasFlag: (f) => flags.has(f), + setFlag: (f) => flags.add(f), + clearFlag: (f) => flags.delete(f), + getVar: (k) => vars.get(k), + setVar: (k, v) => vars.set(k, v), + }; +} + +const world: DialogueWorld = { hasItem: (id) => id === 'herb' }; + +describe('dialogue/graph — условия', () => { + it('hasConditions/hasText различают реплику, действие и условие', () => { + expect(hasText({ text: 'Привет' })).toBe(true); + expect(hasText({})).toBe(false); + expect(hasConditions({ when: ['a'] })).toBe(true); + expect(hasConditions({ whenVar: { key: 'x', op: 'gt', value: 1 } })).toBe(true); + expect(hasConditions({})).toBe(false); + }); + + it('when/whenNot — AND по списку флагов', () => { + const st = makeState(); + st.setFlag('met'); + expect(evalConditions({ when: ['met'] }, st, world)).toBe(true); + expect(evalConditions({ when: ['met', 'other'] }, st, world)).toBe(false); + expect(evalConditions({ whenNot: ['other'] }, st, world)).toBe(true); + expect(evalConditions({ whenNot: ['met'] }, st, world)).toBe(false); + }); + + it('whenVar: eq/ne работают с любыми значениями, gt/lt — только с числами', () => { + const st = makeState(); + st.setVar('gold', 5); + st.setVar('name', 'Ив'); + expect(evalConditions({ whenVar: { key: 'gold', op: 'eq', value: 5 } }, st, world)).toBe(true); + expect(evalConditions({ whenVar: { key: 'gold', op: 'ne', value: 5 } }, st, world)).toBe(false); + expect(evalConditions({ whenVar: { key: 'gold', op: 'gt', value: 4 } }, st, world)).toBe(true); + expect(evalConditions({ whenVar: { key: 'gold', op: 'le', value: 5 } }, st, world)).toBe(true); + expect(evalConditions({ whenVar: { key: 'gold', op: 'lt', value: 5 } }, st, world)).toBe(false); + expect(evalConditions({ whenVar: { key: 'name', op: 'gt', value: 1 } }, st, world)).toBe(false); + // нет такой переменной: eq false, ne true, сравнение ложно + expect(evalConditions({ whenVar: { key: 'nope', op: 'eq', value: 0 } }, st, world)).toBe(false); + expect(evalConditions({ whenVar: { key: 'nope', op: 'ne', value: 0 } }, st, world)).toBe(true); + expect(evalConditions({ whenVar: { key: 'nope', op: 'gt', value: 0 } }, st, world)).toBe(false); + }); + + it('hasItem требует DialogueWorld; все группы — AND', () => { + const st = makeState(); + expect(evalConditions({ hasItem: ['herb'] }, st, world)).toBe(true); + expect(evalConditions({ hasItem: ['herb', 'sword'] }, st, world)).toBe(false); + expect(evalConditions({ hasItem: ['herb'] }, st, undefined)).toBe(false); + expect(evalConditions({ when: ['met'], hasItem: ['herb'] }, st, world)).toBe(false); + }); +}); + +describe('dialogue/graph — валидация', () => { + it('чистый граф без ошибок', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'Привет', next: 'b' }, + b: { text: 'Пока', end: true }, + }, + }; + expect(validateDialogue(g)).toEqual([]); + }); + + it('ловит битые ссылки, тупики и выборы без текста', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'Привет', next: 'ghost' }, + ghostNode: { text: 'не ссылается никто' }, + dead: { do: [] }, + bad: { + text: 'Хм', + choices: [{ text: '', next: 'a' }, { text: 'Ок', next: 'nope' }], + }, + }, + }; + const errs = validateDialogue(g).join('\n'); + expect(errs).toContain('a: next → «ghost» не существует'); + expect(errs).toContain('dead: узел без текста, next, choices и end — тупик'); + expect(errs).toContain('bad: выбор без текста'); + expect(errs).toContain('bad: choice.next → «nope» не существует'); + }); + + it('узел-действие без текста с next — корректен', () => { + const g: DialogueGraph = { + start: 'act', + nodes: { act: { setFlags: ['met'], next: 'talk' }, talk: { text: 'Привет', end: true } }, + }; + expect(validateDialogue(g)).toEqual([]); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/dialogue/__tests__/runner.test.ts b/v2/packages/engine/src/dialogue/__tests__/runner.test.ts new file mode 100644 index 0000000..4ea7cca --- /dev/null +++ b/v2/packages/engine/src/dialogue/__tests__/runner.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { DialogueRunner, type DialogueView } from '../runner'; +import type { DialogueGraph, DialogueEffectOp, DialogueState } from '../graph'; + +/** Состояние-заглушка + собранные эффекты do[] для проверок. */ +function makeState(): DialogueState & { flags: Set; vars: Map } { + const flags = new Set(); + const vars = new Map(); + return { + flags, + vars, + hasFlag: (f) => flags.has(f), + setFlag: (f) => flags.add(f), + clearFlag: (f) => flags.delete(f), + getVar: (k) => vars.get(k), + setVar: (k, v) => vars.set(k, v), + }; +} + +/** View-заглушка: пишет показы реплик в журнал. */ +function makeView(): DialogueView & { shown: { speaker?: string; text: string; choices: string[] }[]; hidden: number } { + const v = { shown: [], hidden: 0 } as { shown: { speaker?: string; text: string; choices: string[] }[]; hidden: number }; + const view: DialogueView = { + show: (n) => v.shown.push({ speaker: n.speaker, text: n.text, choices: n.choices.map((c) => c.text) }), + hide: () => v.hidden++, + }; + return Object.assign(v, view) as never; +} + +describe('dialogue/runner — линейный проход', () => { + const graph: DialogueGraph = { + start: 'a', + nodes: { + a: { speaker: 'Страж', text: 'Стой, кто идёт?', next: 'b' }, + b: { text: 'Проходи.', next: 'c' }, + c: { end: true }, + }, + }; + + it('старт показывает первый узел; advance ходит по next; конец — hide', () => { + const st = makeState(); + const view = makeView(); + const r = new DialogueRunner(st, view); + r.start(graph); + expect(r.active).toBe(true); + expect(r.nodeId).toBe('a'); + expect(view.shown).toEqual([{ speaker: 'Страж', text: 'Стой, кто идёт?', choices: [] }]); + r.advance(); + expect(r.nodeId).toBe('b'); + r.advance(); // у c нет next → завершение + expect(r.active).toBe(false); + expect(view.hidden).toBe(1); + expect(r.result).toEqual({ lastNodeId: 'c', path: ['a', 'b', 'c'], picks: [] }); + }); + + it('advance на последнем узле ждёт игрока: второй advance уже не нужен, но безвреден', () => { + const r = new DialogueRunner(makeState(), makeView()); + r.start(graph); + r.advance(); + r.advance(); + r.advance(); // активен уже false — игнор + expect(r.active).toBe(false); + expect(r.result?.lastNodeId).toBe('c'); + }); + + it('действие-узел без текста проваливается сам, не показываясь', () => { + const g: DialogueGraph = { + start: 'act', + nodes: { act: { setFlags: ['met'], next: 't' }, t: { text: 'Привет', end: true } }, + }; + const st = makeState(); + const r = new DialogueRunner(st, makeView()); + r.start(g); + expect(r.nodeId).toBe('t'); + expect(st.hasFlag('met')).toBe(true); + expect(r.path).toEqual(['act', 't']); + }); +}); + +describe('dialogue/runner — выборы и условия', () => { + const graph: DialogueGraph = { + start: 'q', + nodes: { + q: { + text: 'Помочь?', + choices: [ + { text: 'Да', next: 'thanks', setFlags: ['helped'] }, + { text: 'Нет', setFlags: ['refused'] }, + ], + }, + thanks: { text: 'Спасибо!', end: true }, + }, + }; + + it('pick применяет эффекты выбора и идёт в next; выбор без next завершает', () => { + const st = makeState(); + const view = makeView(); + const r = new DialogueRunner(st, view); + r.start(graph); + expect(r.waitingForChoice).toBe(true); + expect(r.choices).toEqual([ + { index: 0, text: 'Да' }, + { index: 1, text: 'Нет' }, + ]); + r.pick(0); + expect(st.hasFlag('helped')).toBe(true); + expect(r.nodeId).toBe('thanks'); + expect(r.path).toEqual(['q', 'thanks']); + r.advance(); + expect(r.result?.picks).toEqual([{ nodeId: 'q', index: 0, text: 'Да' }]); + }); + + it('pick последнего варианта (без next) завершает диалог сразу', () => { + const st = makeState(); + const r = new DialogueRunner(st, makeView()); + r.start(graph); + r.pick(1); + expect(st.hasFlag('refused')).toBe(true); + expect(r.active).toBe(false); + expect(r.result?.picks).toEqual([{ nodeId: 'q', index: 1, text: 'Нет' }]); + }); + + it('выбор, скрытый условиями, не показывается; index — позиция в узле', () => { + const g: DialogueGraph = { + start: 'q', + nodes: { + q: { + text: 'Что?', + choices: [ + { text: 'Скрытый', when: ['secret'] }, + { text: 'Видимый' }, + ], + }, + }, + }; + const r = new DialogueRunner(makeState(), makeView()); + r.start(g); + expect(r.choices).toEqual([{ index: 1, text: 'Видимый' }]); + r.pick(0); + expect(r.active).toBe(false); + }); + + it('условия на узле: закрытый узел пропускается в next, без next — конец', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'Раз', next: 'locked' }, + locked: { when: ['secret'], text: 'Тайна', next: 'c' }, + c: { text: 'Три', end: true }, + }, + }; + const r = new DialogueRunner(makeState(), makeView()); + r.start(g); + expect(r.nodeId).toBe('a'); + r.advance(); // «locked» закрыт условием → прыгаем сразу в «c» + expect(r.nodeId).toBe('c'); + expect(r.path).toEqual(['a', 'c']); + r.advance(); + expect(r.result?.path).toEqual(['a', 'c']); + }); +}); + +describe('dialogue/runner — эффекты do[] и прочее', () => { + it('onEffect эмитится для узлов и выборов с координатами', () => { + const ops: DialogueEffectOp[] = []; + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { + text: 'Держи', + do: [{ kind: 'giveItem', id: 'herb', count: 2 }], + choices: [{ text: 'Ок', do: [{ kind: 'toast', text: 'Получено' }] }], + }, + }, + }; + const r = new DialogueRunner(makeState()); + r.onEffect = (op, at) => ops.push({ ...op }, { kind: 'custom', payload: { at: at.nodeId, choice: at.choice === undefined ? -1 : at.choice } }); + r.start(g); + expect(ops).toEqual([ + { kind: 'giveItem', id: 'herb', count: 2 }, + { kind: 'custom', payload: { at: 'a', choice: -1 } }, + ]); + r.pick(0); + expect(ops[2]).toEqual({ kind: 'toast', text: 'Получено' }); + expect(ops[3]).toEqual({ kind: 'custom', payload: { at: 'a', choice: 0 } }); + expect(r.active).toBe(false); + }); + + it('setVars применяются и видны условиям дальше по графу', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { text: 'Сколько?', next: 'b', setVars: { gold: 5 } }, + b: { + whenVar: { key: 'gold', op: 'ge', value: 5 }, + text: 'Богат!', + next: 'poor', + }, + poor: { text: 'Беден', end: true }, + }, + }; + const st = makeState(); + const r = new DialogueRunner(st, makeView()); + r.start(g); + expect(r.nodeId).toBe('a'); // a показан, setVars уже применены + expect(st.getVar('gold')).toBe(5); + r.advance(); // условие b (gold >= 5) истинно → показан b + expect(r.nodeId).toBe('b'); + }); + + it('abort завершает и записывает результат; start повторно сбрасывает', () => { + const r = new DialogueRunner(makeState(), makeView()); + const graph: DialogueGraph = { start: 'a', nodes: { a: { text: 'Хи', next: 'b' }, b: { text: 'Ха', end: true } } }; + r.start(graph); + r.advance(); + r.abort(); + expect(r.active).toBe(false); + expect(r.result?.lastNodeId).toBe('b'); + r.start(graph); + expect(r.path).toEqual(['a']); // стартовый узел уже показан + expect(r.result).toBeNull(); // прошлый прогон сброшен + expect(r.nodeId).toBe('a'); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/dialogue/graph.ts b/v2/packages/engine/src/dialogue/graph.ts new file mode 100644 index 0000000..1a63d0d --- /dev/null +++ b/v2/packages/engine/src/dialogue/graph.ts @@ -0,0 +1,162 @@ +/** + * Модель диалоговых графов (перенос архитектуры v1): граф — данные + * (JSON/TS): узлы с репликами, выборами, условиями и эффектами. + * Чистые предикаты над графом — без рантайма; рантайм — DialogueRunner, + * отрисовка — игра (view). Валидация числовая — агент правит графы текстом. + */ + +/** Условие по переменной состояния. */ +export interface VarCondition { + key: string; + op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'; + value: number | string; +} + +/** Действия, применяемые к состоянию при входе в узел/выбор. */ +export interface DialogueEffects { + setFlags?: string[]; + clearFlags?: string[]; + setVars?: Record; + /** + * Игровые эффекты как данные: движок только сообщает их (onEffect), + * исполнение — игра (EffectSink). Неизвестный kind — ошибка валидатора. + */ + do?: DialogueEffectOp[]; +} + +/** Одна игровая операция эффекта (смысл знает игра). */ +export interface DialogueEffectOp { + kind: 'giveItem' | 'takeItem' | 'sound' | 'toast' | 'custom'; + /** id предмета (give/take), звука (sound) или имя сюжетного эффекта (custom). */ + id?: string; + /** Количество (give/take), по умолчанию 1. */ + count?: number; + /** Текст всплывашки (toast). */ + text?: string; + /** Свободные параметры (custom). */ + payload?: Record; +} + +/** Итог пройденного диалога: где остановились, как дошли, что выбрали. */ +export interface DialogueResult { + lastNodeId: string | null; + path: string[]; + picks: { nodeId: string; index: number; text: string }[]; +} + +/** Предикаты мира, которых движок знать не может (сумка, репутация...). */ +export interface DialogueWorld { + hasItem(id: string): boolean; +} + +/** Условия показа узла/варианта (все перечисленные группы — AND). */ +export interface DialogueConditions { + when?: string[]; + whenNot?: string[]; + whenVar?: VarCondition; + whenVars?: VarCondition[]; + hasItem?: string[]; +} + +/** Интерфейс состояния для условий/эффектов (игра реализует поверх своего стейта). */ +export interface DialogueState { + hasFlag(flag: string): boolean; + setFlag(flag: string): void; + clearFlag(flag: string): void; + getVar(key: string): number | string | boolean | undefined; + setVar(key: string, value: number | string | boolean): void; +} + +export interface DialogueChoice extends DialogueEffects, DialogueConditions { + text: string; + /** Следующий узел (по умолчанию — конец диалога). */ + next?: string; + mood?: string; + /** Свободные метки для агента/инструментов (на геймплей не влияют). */ + tags?: string[]; +} + +export interface DialogueNode extends DialogueEffects, DialogueConditions { + speaker?: string; + /** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */ + text?: string; + choices?: DialogueChoice[]; + next?: string; + /** Явный конец диалога (для конечных узлов без choices/next). */ + end?: boolean; + mood?: string; + tags?: string[]; +} + +export interface DialogueGraph { + /** id стартового узла. */ + start: string; + nodes: Record; +} + +const CMP = { + eq: (a: unknown, b: unknown) => a === b, + ne: (a: unknown, b: unknown) => a !== b, + gt: (a: number, b: number) => a > b, + lt: (a: number, b: number) => a < b, + ge: (a: number, b: number) => a >= b, + le: (a: number, b: number) => a <= b, +} as const; + +/** Есть ли хоть одно условие (для различения «узла-действия» и реплики). */ +export function hasConditions(c: DialogueConditions): boolean { + return !!(c.when?.length || c.whenNot?.length || c.whenVar || c.whenVars?.length || c.hasItem?.length); +} + +/** Есть ли текст (inline или ключ). */ +export function hasText(n: { text?: string }): boolean { + return typeof n.text === 'string' && n.text.length > 0; +} + +function cmpVars(c: VarCondition, state: DialogueState): boolean { + const actual = state.getVar(c.key); + const value = c.value; + if (c.op === 'eq') return actual === value; + if (c.op === 'ne') return actual !== value; + if (typeof actual !== 'number' || typeof value !== 'number') return false; // нет/не число — сравнение ложно + const cmp = CMP[c.op]; + return cmp(actual, value); +} + +/** Все группы условий — AND; мир (сумка) — через DialogueWorld. */ +export function evalConditions(c: DialogueConditions, state: DialogueState, world?: DialogueWorld): boolean { + if (c.when && !c.when.every((f) => state.hasFlag(f))) return false; + if (c.whenNot && c.whenNot.some((f) => state.hasFlag(f))) return false; + if (c.whenVar && !cmpVars(c.whenVar, state)) return false; + if (c.whenVars && !c.whenVars.every((v) => cmpVars(v, state))) return false; + if (c.hasItem && !(world && c.hasItem.every((id) => world.hasItem(id)))) return false; + return true; +} + +/** Валидация графа: старт, ссылки next, структура узлов и выборов. */ +export function validateDialogue(g: DialogueGraph): string[] { + const errs: string[] = []; + const ids = Object.keys(g.nodes); + if (!g.nodes[g.start]) errs.push(`start: узел «${g.start}» не существует`); + for (const id of ids) { + const n = g.nodes[id]; + const refs: Array<[string | undefined, string]> = [ + [n.next, 'next'], ...(n.choices ?? []).map((c): [string | undefined, string] => [c.next, 'choice.next']), + ]; + for (const [ref, what] of refs) { + if (ref !== undefined && !g.nodes[ref]) errs.push(`${id}: ${what} → «${ref}» не существует`); + } + if (!hasText(n) && !n.next && !n.choices?.length && !n.end) { + errs.push(`${id}: узел без текста, next, choices и end — тупик-«действие»`); + } + for (const c of n.choices ?? []) { + if (!c.text) errs.push(`${id}: выбор без текста`); + for (const op of c.do ?? []) { + if (!(op.kind in { giveItem: 1, takeItem: 1, sound: 1, toast: 1, custom: 1 })) { + errs.push(`${id}: неизвестный kind «${String((op as { kind: string }).kind)}»`); + } + } + } + } + return errs; +} \ No newline at end of file diff --git a/v2/packages/engine/src/dialogue/runner.ts b/v2/packages/engine/src/dialogue/runner.ts new file mode 100644 index 0000000..633ee7c --- /dev/null +++ b/v2/packages/engine/src/dialogue/runner.ts @@ -0,0 +1,204 @@ +/** + * Рантайм обхода диалоговых графов (перенос схемы v1), отделённый от + * отрисовки: игра рисует реплику/варианты своим view и зовёт advance/pick. + * Узел с текстом ждёт игрока (advance → next, pick → вариант); узел без + * текста («действие») проваливается дальше сам. Состояние — DialogueState + * (флаги/переменные игры); игровые эффекты из do[] движок только эмитит + * (EffectSink), исполнение — игра. + */ +import { + evalConditions, hasText, + type DialogueGraph, type DialogueNode, type DialogueChoice, + type DialogueEffects, type DialogueResult, type DialogueState, type DialogueWorld, type DialogueEffectOp, +} from './graph'; + +/** Вид: игра рисует реплику и варианты своими средствами (DOM и т.п.). */ +export interface DialogueView { + /** Показать реплику; choices пуст, если выбора нет. */ + show(node: { speaker?: string; text: string; choices: { index: number; text: string }[] }): void; + /** Скрыть (диалог завершён). */ + hide(): void; +} + +const MAX_STEPS = 1000; // защита от цикла в графе без текста + +export class DialogueRunner { + /** Вызывается по завершении: граф + как он был пройден. */ + onFinish: ((graph: DialogueGraph, result: DialogueResult) => void) | null = null; + /** Игровые эффекты из do[]; at — где сработали: узел или выбор. */ + onEffect: ((op: DialogueEffectOp, at: { nodeId: string; choice?: number }) => void) | null = null; + + private graph: DialogueGraph | null = null; + private view: DialogueView | null = null; + private readonly state: DialogueState; + private readonly world?: DialogueWorld; + private shownChoices: { index: number; text: string }[] = []; + private currentId: string | null = null; + private pendingNext: string | undefined; + private awaitingChoice = false; + private walkedPath: string[] = []; + private madePicks: { nodeId: string; index: number; text: string }[] = []; + private walkedResult: DialogueResult | null = null; + + constructor(state: DialogueState, view?: DialogueView, world?: DialogueWorld) { + this.state = state; + this.view = view ?? null; + this.world = world; + } + + /** Подключить/заменить view (после пересоздания UI). */ + setView(view: DialogueView): void { + this.view = view; + } + + get active(): boolean { + return this.graph !== null; + } + + // --- снапшоты для агентного моста/отладки --- + + /** id текущего узла (или null — не активен/завершается). */ + get nodeId(): string | null { + return this.currentId; + } + + get node(): DialogueNode | null { + if (!this.graph || this.currentId === null) return null; + return this.graph.nodes[this.currentId] ?? null; + } + + /** Показанные варианты (index — позиция в узле графа). */ + get choices(): { index: number; text: string }[] { + return this.shownChoices; + } + + /** Ожидается ли выбор варианта. */ + get waitingForChoice(): boolean { + return this.awaitingChoice; + } + + /** Показанные узлы по порядку (текущий прогон). */ + get path(): string[] { + return this.walkedPath; + } + + /** Итог последнего завершённого диалога (null — не завершался). */ + get result(): DialogueResult | null { + return this.walkedResult; + } + + /** Запустить диалог. */ + start(graph: DialogueGraph): void { + this.graph = graph; + this.awaitingChoice = false; + this.pendingNext = undefined; + this.walkedPath = []; + this.madePicks = []; + this.walkedResult = null; + this.enterNode(graph.start); + } + + /** «Дальше»: во время выбора варианта игнорируется. */ + advance(): void { + if (!this.graph || this.awaitingChoice) return; + if (this.pendingNext === undefined) { + this.finish(); + return; + } + this.enterNode(this.pendingNext); + } + + /** Выбрать вариант ответа (индекс в показанном списке). */ + pick(index: number): void { + if (!this.graph || !this.awaitingChoice) return; + const shown = this.shownChoices[index]; + const choice = this.node?.choices?.[shown?.index ?? -1]; + if (!shown || !choice) return; + const atNode = this.currentId; + this.awaitingChoice = false; + this.shownChoices = []; + if (atNode !== null) this.madePicks.push({ nodeId: atNode, index: shown.index, text: shown.text }); + this.applyEffects(choice, atNode, shown.index); + if (choice.next === undefined) this.finish(); + else this.enterNode(choice.next); + } + + /** Завершить диалог досрочно (результат всё равно записывается). */ + abort(): void { + if (this.graph) this.finish(); + } + + /** Вход в узел: условия → пропуск, эффекты, показ или провал в next. */ + private enterNode(id: string): void { + if (!this.graph) return; + let cur = id; + for (let i = 0; i < MAX_STEPS; i++) { + const node = this.graph.nodes[cur]; + if (!node) { + this.finish(); // битая ссылка — валидатор ловит заранее + return; + } + if (!evalConditions(node, this.state, this.world)) { + if (node.next === undefined) { + this.finish(); // условный узел закрыт и обхода нет + return; + } + cur = node.next; + continue; + } + this.applyEffects(node, cur); + this.walkedPath.push(cur); + if (!hasText(node)) { + if (node.next === undefined) { + this.finish(); // узел-действие без next — конец + return; + } + cur = node.next; + continue; + } + this.show(node); + return; + } + throw new Error('Диалог: превышен MAX_STEPS — цикл в графе без текста'); + } + + /** Показ узла: выборы фильтруются условиями; index — позиция в узле. */ + private show(node: DialogueNode): void { + this.currentId = this.walkedPath[this.walkedPath.length - 1] ?? null; + this.pendingNext = node.next; + this.shownChoices = (node.choices ?? []) + .map((c: DialogueChoice, i: number) => ({ c, i })) + .filter(({ c }) => evalConditions(c, this.state, this.world)) + .map(({ c, i }) => ({ index: i, text: c.text })); + this.view?.show({ + speaker: node.speaker, + text: node.text ?? '', + choices: this.shownChoices, + }); + if (this.shownChoices.length > 0) this.awaitingChoice = true; + } + + private applyEffects(src: DialogueEffects, nodeId: string | null, choice?: number): void { + for (const f of src.setFlags ?? []) this.state.setFlag(f); + for (const f of src.clearFlags ?? []) this.state.clearFlag(f); + for (const [k, v] of Object.entries(src.setVars ?? {})) this.state.setVar(k, v); + for (const op of src.do ?? []) this.onEffect?.(op, { nodeId: nodeId ?? '', choice }); + } + + private finish(): void { + const graph = this.graph; + this.graph = null; + this.currentId = null; + this.awaitingChoice = false; + this.pendingNext = undefined; + this.shownChoices = []; + this.view?.hide(); + if (!graph) return; + this.walkedResult = { + lastNodeId: this.walkedPath[this.walkedPath.length - 1] ?? null, + path: [...this.walkedPath], + picks: [...this.madePicks], + }; + this.onFinish?.(graph, this.walkedResult); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 0973b48..5bb7f9d 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -53,4 +53,13 @@ export type { Entity, EcsSystem } from './core/ecs'; export { screenMoveToWorld } from './core/controls'; export type { ControlState } from './core/controls'; -export { ISO_YAW } from './render/voxelRenderer'; \ No newline at end of file +export { ISO_YAW } from './render/voxelRenderer'; +export { validateDialogue } from './dialogue/graph'; +export type { + DialogueGraph, DialogueNode, DialogueChoice, DialogueConditions, VarCondition, + DialogueEffects, DialogueEffectOp, DialogueResult, DialogueState, DialogueWorld, +} from './dialogue/graph'; +export { DialogueRunner } from './dialogue/runner'; +export type { DialogueView } from './dialogue/runner'; +export { nearestInteractable, interactablesInRadius } from './interaction/interactables'; +export type { Interactable, InteractableHit } from './interaction/interactables'; \ No newline at end of file diff --git a/v2/packages/engine/src/interaction/__tests__/interactables.test.ts b/v2/packages/engine/src/interaction/__tests__/interactables.test.ts new file mode 100644 index 0000000..500a38c --- /dev/null +++ b/v2/packages/engine/src/interaction/__tests__/interactables.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { nearestInteractable, interactablesInRadius, type Interactable } from '../interactables'; + +const points: Interactable[] = [ + { id: 'npc', pos: [10, 2, 10], radius: 4, label: 'Поговорить', dialogueId: 'guard' }, + { id: 'chest', pos: [20, 2, 20], radius: 2, label: 'Открыть' }, + { id: 'sign', pos: [40, 2, 10], radius: 3, label: 'Осмотреть' }, +]; + +describe('interaction/interactables', () => { + it('nearestInteractable: в радиусе — ближайшая, вне — null', () => { + expect(nearestInteractable(points, [12, 2, 11])?.id).toBe('npc'); + expect(nearestInteractable(points, [10, 2, 14])?.id).toBe('npc'); // ровно на границе + expect(nearestInteractable(points, [10, 2, 15])).toBeNull(); + expect(nearestInteractable(points, [21, 2, 21])?.id).toBe('chest'); + expect(nearestInteractable(points, [0, 0, 0])).toBeNull(); + }); + + it('Y не учитывается — только горизонталь x-z', () => { + expect(nearestInteractable(points, [10, 100, 12])?.id).toBe('npc'); + }); + + it('при пересечении радиусов берётся ближайшая, не первая в списке', () => { + const two: Interactable[] = [ + { id: 'far', pos: [10, 0, 10], radius: 10, label: 'Дальняя' }, + { id: 'near', pos: [11, 0, 10], radius: 10, label: 'Ближняя' }, + ]; + expect(nearestInteractable(two, [11, 0, 10])?.id).toBe('near'); // 1 < 3 + }); + + it('interactablesInRadius: сортировка по дистанции', () => { + const hits = interactablesInRadius(points, [11, 2, 10]); + expect(hits.map((h) => h.item.id)).toEqual(['npc']); + const all: Interactable[] = [ + ...points, + { id: 'well', pos: [14, 2, 10], radius: 10, label: 'Колодец' }, + ]; + const sorted = interactablesInRadius(all, [11, 2, 10]); + expect(sorted.map((h) => h.item.id)).toEqual(['npc', 'well']); // 1 < 3 + expect(sorted[0].dist).toBe(1); + expect(sorted[1].dist).toBe(3); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/interaction/interactables.ts b/v2/packages/engine/src/interaction/interactables.ts new file mode 100644 index 0000000..55b7c85 --- /dev/null +++ b/v2/packages/engine/src/interaction/interactables.ts @@ -0,0 +1,64 @@ +/** + * Точки взаимодействия (перенос идеи v1 как чистых данных): реестр + * «интерактивов» {id, pos, radius, label} + чистая функция поиска ближайшей + * в радиусе. Регистрация/удаление — игра; движок только считает ближайшую + * точку к герою и говорит, можно ли взаимодействовать. + */ + +/** Точка, около которой герою доступно взаимодействие (E). */ +export interface Interactable { + /** Идентификатор (стабильный, для моста и диалогов). */ + id: string; + /** Мировая позиция центра (x, y — не учитывается, z) — горизонтальная дистанция. */ + pos: readonly [number, number, number]; + /** Радиус доступности (в вокселях). */ + radius: number; + /** Подсказка для игрока/агента («Поговорить», «Осмотреть»). */ + label: string; + /** Граф диалога, если точка говорит (связка точка ↔ диалог — данные). */ + dialogueId?: string; +} + +/** Расстояние до точки и сама точка (для сортировки/подсказки «какая ближе»). */ +export interface InteractableHit { + item: T; + dist: number; +} + +/** Горизонтальное (x-z) расстояние от позиции до точки. */ +function horizontalDist( + pos: readonly [number, number, number], + it: { pos: readonly [number, number, number] }, +): number { + const dx = it.pos[0] - pos[0]; + const dz = it.pos[2] - pos[2]; + return Math.hypot(dx, dz); +} + +/** Ближайшая доступная точка (в радиусе) или null. Y не учитывается. */ +export function nearestInteractable( + registry: readonly T[], + pos: readonly [number, number, number], +): T | null { + let best: T | null = null; + let bestDist = Infinity; + for (const it of registry) { + const d = horizontalDist(pos, it); + if (d <= it.radius && d < bestDist) { + best = it; + bestDist = d; + } + } + return best; +} + +/** Все доступные точки с расстояниями, ближе — раньше (для отладки/моста). */ +export function interactablesInRadius( + registry: readonly T[], + pos: readonly [number, number, number], +): InteractableHit[] { + return registry + .map((item) => ({ item, dist: horizontalDist(pos, item) })) + .filter((h) => h.dist <= h.item.radius) + .sort((a, b) => a.dist - b.dist); +} \ No newline at end of file