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; }
+ +