# План развития и актуальные задачи

Живой документ планирования: сюда сходятся планы на развитие, актуальные
задачи и проработка крупных направлений. Срез-статус архитектуры —
`docs/demo.md` (дорожная карта там — историческая, актуальный план здесь).

Дата ревизии: 2026-09-09.

**Статус проекта (2026-09-06): техно-демка движка.** Основные силы — движок
(`v1/packages/engine`); игра — витрина и полигон проверки API. Приоритет решений:
что двигает движок (API, тулзы, доки, границы), то и делаем; игрового контента
ровно столько, сколько нужно демонстрации.

---

## Актуальные задачи

| # | Задача | Статус |
|---|--------|--------|
| 1 | Срез «меньше ошибок»: реестры ID, pre-commit, разрезание LocationScene, фикстуры, fail-fast waitFor | ✅ сделано (c379f20, 16b59d8, 39c6349) |
| 2 | AI-генерация текстур и спрайтов (работа B, ниже) | B0+B1 ✅ (PoC); B3 проба ✅ (SD-атласы, 6.6 мин/вид); **B4 ✅ (2026-09-09, RTX 3090: интерактивы/снаряд/иконки в игре, ~1.3 с/вид)**; B2 отложено — RD API или ручной арт |
| 3 | AI-генерация аудио (работа A, ниже) | A0 ✅ (2026-09-09, AudioLDM2 на GPU, апрув пользователя; в игру — ponds, ponds_night, zvenets, tower_hum); дальше A1 |
| 4 | Акт 2: новые локации/сюжет по `docs/world.md` | очередь после акта 1+AI-ассетов |
| 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | ✅ в основном сделано (2026-09-09, B4): интерактивы, снаряд плевка и иконки сумки — арт в игре; плейсхолдерный Graphics остался только у редких разовых объектов, догонять по мере надобности |
| 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | ✅ сделано (2026-09-09): C0–C2 см. работу C; додержано в игре — большой дом 2×2 в Звенце (первый проп на карте), минимапа красит footprint пропа (`renderMinimap`), чек `checks/big_house.mjs` (коллизия, approach, глубина севера/юга) |
| 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) |
| 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1–E4 сделаны (2026-09-07) |
| 9 | Инфраструктура аудио + музыкальный слой (работы F/G, ниже) | ✅ сделаны (2026-09-07) |
| 10 | Освещение: ambient multiply + аддитивные источники (движок L1, игра L2, контент L3) | ✅ сделано (2026-09-07); точки расширения: day/night через `setAmbient`-лерп, динамические источники `addLight/removeLight`, импульсы переосвещения — позже |
| 10b | Освещение II: импульсы (`pulseLight`/`pulseAmbient`), день/ночь, виньетка (`setVignette`, низкий hp/накат), тёмные ауры зон (`upsertDarkSpot`), гало: 4 ступени + дизер-кольцо | ✅ сделано (2026-09-07): движок L1 + игра L2 (хуки боя, `scene:damagePlayer`) + контент L3 (ночь лугов/Звенца/прудов, свет колокольчиков, окна) |
| 10c | Время суток: `GameClock` (1 с = 1 игровая минута, старт 8:00), кривая `dayNightFactor` в движке, `GameLighting` по депу `timeHours` (`lerpAmbient`-микс, `whenNight`-источники, лампа по фактору), сейв `clock` (v4), миграция state v3 (−`evening`), мост `s.time`/`scene:setTime`/инвариант `time-bounded`, сценарий `checks/time.mjs` | ✅ сделано (2026-09-07); дальше — предмет «часы» в инвентаре |
| 11 | Катсцены v2: шаг `until` (ожидание события, timeout), tween `applyEase`, шаг `moveView` (движение вьюх/актёров), `skip()` раннера, диалог и катсцена в одном тике, актёры-ходоки (`CutsceneActors`) | ✅ сделано (2026-09-07): f398d83, 7478d90, К3 |
| 12 | UI: скролл списков (`ListScroll` + `MenuList.visibleRows`, hover→курсор, value-слот кнопки), инвентарь/настройки на скролле | ✅ сделано (2026-09-07): движок + eb1596b (игра) |
| 13 | Зум камеры: `Camera.setZoom/zoomTo`, шаг `cameraZoom`, интеграция в игре (клики/culling/свет/мост), debug-клавиша Z, чек `zoom.mjs` | ✅ сделано (2026-09-07): 878355c, 8730614 |
| 14 | Акт 2 (контент): карты Ржавая роща/Старый разъезд, Клинт + размещения NPC по флагу, квест резонатора, Старая Машина и финальная катсцена | ✅ сделано (2026-09-08): A1 fa38ee7 (карты/переходы); A2 58a8164 (Клинт, `NPC_PLACEMENTS`); A3 6068646 (квест резонатора, чек `resonator.mjs`); A4 ccc3ab3 (правильный звон, чек `final.mjs`, docs) |
| 15 | Уход от кликов: фикс радиуса взаимодействия (евклидова линейка вместо проекционной), ходьба WASD/стик/джойстик, взаимодействие кнопкой действия E/A в радиусе, подпись `[E]`, удаление клик-роутинга (`InteractRouting.ts`), мост `scene:walk`/`scene:approach` + переписывание чеков и смоуков | ✅ сделано (2026-09-08): C1 fde88b6, C2 50cfc15, C3, C4; тач-кнопки действий — позже (джойстик даёт только движение) |
| 16 | Переделка по фидбеку после игры: свет заново («тьма с прорезями»), ветер лугов, квестовая ясность (HUD-трекер, тосты, направления, журнал), баги UI (флип side-анимации, подложка диалога, переносы шрифтов) | ✅ сделано (2026-09-08): L1 920d0b0 (движок: RT-карта силы тьмы, erase-прорези, dirty-bake, day fast-path), L2 93491b7 (солнечный день, `duskColor` золотого часа, тюнинг радиусов/арт), L3 64e5ce8 (ветер: воздушная полоса 250–1800 Гц вместо «прибоя»), L4 64efcf5 (`questTracker` в HUD, тост «Тон (N/3)», направления в квестах/диалогах, журнал активных задач, фикс вталкивания врагов в стены), L5 8490bf9 (флип side-анимации, панель диалога сразу полной высоты, wordWrap имён/кнопок/тостов) |
| 17 | Второй раунд фидбека: баг мотов/сейвы/маркер NPC, свет +25% и фонарь, замах+след боя, деревья (варианты+качание+пепел), фасады домов, пепельная трава и читаемые зоны наката, амбиент день/ночь, миникарта (движок + предмет + оверлей) | ✅ сделано (2026-09-08): B1 (моты/автосейв на переходах и закрытии вкладки/маркер), B2 (лампа 2.0/1.0, спрайт фонаря, центр внизу), B3 (сектор замаха + дуга-след, `conePath` в движке), B4 7f14fb0 (3 вида крон × 2 кадра качания, осыпание пепла), B5 7ae42cf (3 фасада домов, явная расстановка в Звенце), B6 1a906e0 (трава сквозь пепел, плотнее летающий пепел, тёплый DARK_SPOT_TINT), B7 19ce175 (ночные лупы областей + ashflow, кроссфейд по `dayNightFactor`, positions-слои зон), B8 ef8d9b7+8516872 (`renderMinimap` в движке, предмет `map_scroll` у Ирвина, оверлей `MapScene`) |
| 18 | Третий раунд фидбека: свет в центре персонажа, пепел с заметной травой, изометрические объёмные дома (5 видов), валун и куст, карты внешних локаций ×2, карта мира «Низина Звенца» (композит областей), амбиент лугов заново (тихий «дыхательный» ветер + события) | ✅ сделано (2026-09-08): C1 25d2875 (свет лампы в центре тела героя), C2 6c3e23b (стебли травы в пепле заметнее, ярче, пучками), C3 9883a24 (изо-дома: две стены + двускатная крыша, 5 видов), C4 a8622f5 (валун BOULDER и куст BUSH — новые тайлы), C5 1dada11 (все внешние карты ×2, пересчёт координат контента/тулз), C6 b86b458 (карта мира — композит миниатюр областей с дорогами и подписями), C7 0dacab3 (новая фактура ветра: мягкий низкий шум с порывами, шуршание травы, скрипы и далёкий звон, тише) |
| 19 | Герой за tall-объектами: деревья/дома-тайлы/башня/валуны/кусты рисуются в `objectsLayer` карты — целиком под слоем `actors`, герой всегда поверх (пропы с footprint уже сортируются правильно через `actors.addRect`) | ✅ сделано (2026-09-09): движок — `IsometricTileMap.setTallDepthLayer(layer\|null)` переносит tall-вьюхи клеток в общий `IsoDepthLayer` с глубиной `tx+ty` (кулинг/`setTile` работают по ссылке, `null` возвращает в карту); игра — `LocationScene` вызывает `setTallDepthLayer(this.actors)` + `actors.addRect` для пропов; тесты движка (2 новых), сценарий `hero_depth.mjs` (численные zIndex-assert + скриншоты) |
| 20 | Пост-эффекты движка: переменный блюр (в точке с радиусом / на объекте / на всю сцену), хроматическая аберрация, марево («горячий воздух») | ✅ сделано (2026-09-09): движок — `render/postfx/` (`postSim.ts` чистая математика + 7 тестов, `PostFx.ts` — один кастомный Pixi-фильтр на `worldRoot`, API `set*/pulse*` по образцу Lighting, `blurObject` на вьюхе, `resolution: 'inherit'`); игра — блюр-волна резонанса, аберрация урона, авто-марево у Старой Машины/очага, мост `scene:postfx` (blur/aberration/shimmer/clear), сценарий `checks/postfx.mjs` (команды + скриншоты в пике) |
| 20b | Тени без 3D: блоб-тени актёров + направленные тени tall-объектов по солнцу | ✅ сделано (2026-09-09): движок — `render/shadowSim.ts` (чистая математика солнца `sunShadow`/`blobPlacement`, 8 тестов) + `render/shadows.ts` (ступенчатая текстура блоба, раскладка); игра — `GameShadows` (ground-слой между картой и актёрами, блобы первого ребёнком вьюхи, чистка уничтоженных), сценарий `checks/shadows.mjs` (утро/полдень/вечер/ночь, скриншоты). Позже по желанию: контактный AO у подножий |
| 21 | Перепаковка репо под две версии движка: v1 (черновик + демо) → `v1/` целиком, корень — диспетчер команд (`--prefix v1`); фиксы тихих поломок v1 (тени «сидят на базе», RT-тьма выводится на экран); скелет v2: `v2/` workspace, `packages/engine` (`@rpg/engine` 2.0.0-alpha, three.js), маниалы CLAUDE.md двух уровней | ✅ сделано (2026-09-09): см. «Уроки черновика» и «Направление v2» ниже; верификация — check:fast + agent:check из корня зелёные |
| 22 | Спайк v2: воксельный рендер — чистая математика вокселей (сетка, culled-меш, классический AO 0..3), `VoxelRenderer` на three.js (вершинные цвета, изо-ортокамера, dirLight+shadowMap), low-res 480×270 + nearest-апскейл, `apps/demo` со сценкой, скриншот-гейт через агента с первого дня (день/вечер, тени ползут) | ✅ сделано (2026-09-09): движок — `voxel/grid.ts` + `voxel/mesher.ts` (12 тестов) + `render/voxelRenderer.ts`; тулзы — `tools/agent-lib.mjs` (каркас v2) + `png.mjs` (RGB-скриншоты Chromium); демка — сценка в палитре арт-библии, мост `__agent.setSun/stats`; гейт `apps/demo/tools/checks/render.mjs` зелёный (тени ползут: 76k пикселей разницы утро/вечер, апскейл 2×2 без нарушений, 67 цветов). Грабля спайка: минус в Y изо-направления = камера под землёй — снапшоты зелёные, увидел только скриншотом (урок №2 подтверждён в первый же день) |
| 23 | v2 трек 2: чанковый мир — `Chunk` (16×H×16), `VoxelWorld` (словарь чанков, get/set через границы, dirty-трекинг), `meshRegion` (мешер области по функции доступа — шов чанков бесшовный, AO через границы), демка на мире 3×3 чанков, мост `setVoxel`, гейт: воксель поставлен → кадр изменился, AO на шве не рвётся | ✅ сделано (2026-09-09): движок — `world/chunk.ts` + `world/world.ts` (правка кромки помечает соседа) + `world/worldMesher.ts`; мешер обобщён до `meshRegion` (meshVoxels — обёртка); тест шва — меш 4 чанков ≡ меш одной сеткой (поз+AO попиксельно), 20 тестов v2. Демка — мир 6×6 чанков (96×96) с ремешингом dirty, мост `setVoxel`/`chunkStats`/`perf` (правка атомарна с ремешингом). Гейт 9/9: постройка 36 чанков + мешинг ~0.4–0.5 с (лимит 2 с), столбик на шве x=48 меняет кадр (~1000 пикселей). Урок счёта: интерфейсная грань двух вокселей — два квада (12−2=10, не 11); правка занятого вокселя лицом не меняет счётчики (тест-столбик дважды «исчезал»: вне кадра и внутри дома) |
| 24 | v2 трек 3: генераторы воксельных моделей — формат внутреннего JSON (size + dense base64, валидация агентом; .vox — позже), процедурные генераторы (деревья 3 варианта, валуны 3 варианта, детерминизм по сиду), штамповка в мир с клиппингом, демка на генераторах, гейт: варианты различаются, штампы на месте | ✅ (2026-09-09). `math/rng.ts` (mulberry32), `models/format.ts` (encode/decode/validate), `models/generators.ts` (деревья: круглая/колонна/ель; валуны: union сфер + свет на верхних гранях), `models/stamp.ts` (клиппинг half-open). Демка: 12 деревьев + 3 валуна из генераторов, ручные tree/boulder удалены (урок №4). Гейт 12 проверок: + модельная статистика, + детерминизм через мост (`stampTree` дважды → тот же счётчик, вариант 1 ≠ вариант 0). Грабля: `VoxelGrid.count()` — метод, а не геттер (тесты дважды ловили). |
| 25 | v2 трек 4: turntable-листы — визуальный гейт генераторов: страница со всеми вариантами моделей (деревья/валуны) × 4 поворота, скриншот в /tmp, проверки: ячейки непустые, варианты попарно различаются; основа для апрува сгенерированного контента агентом | ✅ (2026-09-09). `apps/demo/turntable.html` + `src/turntable.ts`: 6 моделей × 4 ракурса (0/45/90/135° — диагонали дают другой силуэт) на подставках 8×8×2, ракурс — `mesh.rotation.y` вокруг центра (geometry.translate −sx/2). Гейт `tools/checks/turntable.mjs`: центры ячеек считаются той же проекцией, что камера (зеркало lookAt-базиса), референсный кадр без моделей через `setModels(false)`, выборка — эллипс 48×42 в ромбе подставки; 5/5 зелёных, minDiff 570/490 при пороге 150. `launchBrowser({viewport})` в agent-lib (страница 720px выше дефолтного вьюпорта). Уроки: дробные координаты VoxelGrid.set молчу no-op (Uint8Array[4.5]); скриншот = вьюпорт браузера, канвас без центрирования; малые боксы у края ромба дают мало пикселей — выборка эллипсом. |
| 26 | v2 трек 5: .vox импорт/экспорт (MagicaVoxel) — parseVox/exportVox (чанки MAIN/SIZE/XYZI/RGBA, PACK>1 — ошибка), оси vox z-up → движок y-up, слоты модели → подряд 1..N при экспорте; тесты roundtrip; мост демки `stampVoxB64` + гейт: штамп .vox меняет кадр | ✅ (2026-09-09). `models/vox.ts`: полный обход чанков (неизвестные пропускаются с детьми, обрыв файла и обрыв в заголовке — ошибки), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей. Обнаружили «слоты модели ≠ слоты сцены» → `models/paletteMap.ts`: `remapSlots` (точный hex или ближайший RGB, детерминированно). Демка: мост `stampVoxB64(b64,x,z)` (парсинг → перекладка на палитру сцены → штамп + ремешинг). Гейт 13 проверок: `.vox`-фикстура (собрана exportVox-ом, base64 вкладывается в скрипт) штампует 12 вокселей и меняет кадр. Уроки: **Node не импортирует `@rpg/engine`** (TS-исходники с extensionless-импортами) — фиксстуры для гейтов генерятся tsx-ом и вкладываются в скрипт готовыми; точка штампа для проверки «кадр изменился» обязана быть в кадре камеры (штамп вне вью дал placed=12 при voxDiff=0); заголовок чанка — 12 байт (id+contentSize+childrenSize), обрыв в нём ловить границей в `id()`, иначе голый RangeError из DataView. |
| 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, а камеру. |
| 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:<slot>` (старые сейвы читаемы). Демка: `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 тиков** — демка стартует в меню, старые гейты снимали кадры поверх панели меню. |
| 34 | v2 трек 13: бой — перенос боевой системы v1 на воксельный движок: PlayerCombat (конус «гулкий звон» будит пепел / заряженный резонанс усыпляет), EnemyBrain (стейты dormant/rise/chase/hurt/flee/sleeping, слух по уровням шума, LOS с кэшем), CombatWorld (спавн, снаряды плевуна, трупы, толкучка-сепарация); демка: сгустки-кучки на лугу, атака на Space, HUD сердец, мост combatSpawn/combatClear/combatInfo/combatStats/attackInput + гейт | ✅ (2026-09-09). `combat/` движка: `shapes.ts`/`los.ts`/`stats.ts`/`enemies.ts`/`playerCombat.ts`/`brain.ts` (StateMachine + Cooldown) + `world.ts` — CombatWorld с хуками {onEnemyHurt/onEnemyKilled/onEnemyAwake/onAttack/onSpit} вместо EventBus v1; deps {getPlayerPos, damagePlayer, solid, opaque} — мир инъекцией предикатами. 16 юнит-тестов (217 всего). **Масштаб: 1 боевой юнит = 3 вокселя** — боевые числа v1 в юнитах (герой ≈ 1), конвертация только на границе демки (позиции /3, отброс ×3), CombatWorld живёт в чистом юнит-пространстве. Демка: спавны (патруль (8,10)→(10,12), спящие плевун (19,20) и тяжёлый (22,13)), кучки — приплюснутые эллипсоиды из вокселей (спящий — нижний ряд, «блин»; hurt — вспышка 0.12 с), шум шагов 0.35, HUD `#hp` сердечками (скрывается вне игровой сцены — isBlack-проверка scenes-гейта сканирует весь кадр). Гейт `combat.mjs` 14 проверок: 3 сгустка/2 спят, кучка рисуется (diff ~1100), звон будит без урона (rise), погоня до равновесия 0.55, конус 3→1→труп→исчез, смерть героя 4,3,2,1,5 + респаун (40,60), плевун 5→4 + HUD-зона, резонанс усыпляет обоих. Уроки: **dormant глух к близости героя** — спящий вплотную не просыпается (только шум ≥ 0.7 или урон), гейт будит звоном издали; **равновесие сепарации = сумма радиусов (0.55) > attackRange ползуна (0.4)** — ползун «липнет», но стоящего героя не бьёт (v1-faithful; контактные удары в гейте — телепортом в упор, тяжёлый 0.65 ≤ 0.8 бьёт); **толкучка вталкивает врага в strike-радиус** — два сгустка с разных сторон дают удар без атаки, спавнить с одной стороны; **combatClear обязан чистить и снаряды** — снаряд плевуна летит после удаления врагов и «призраком» наносит урон; **боевые точки проверять прогоном** — у старта героя (40,60) пусто, у (14,84) препятствие (равновесие погони ровно 0.55 — маркер открытости). |
| 35 | v2 трек 14: аудио — перенос аудио-подсистемы v1 на воксельный движок: AudioManager (4 шины, спек-синтез sfx без WAV-пайплайна), Voices/Loops/BufferCache/SoundSpec/Music, synth-примитивы в src движка; демка: позиционный звук боя (затухание + изо-панорама), шаги с вариациями, DEV-шпион + гейт | ✅ (2026-09-09). `audio/` движка: `types.ts` (шины/хендлы/опции), `decode.ts` (BufferCache: in-flight дедуп декода), `Voices.ts` (полифония 24, вор самого тихого, restart по ключу, панорама в полёте), `Loops.ts` (слоты музыки/амбиента с кроссфейдом, независимые), `SoundSpec.ts`/`Music.ts` (спек-синтез: hit/chime/scrape/hum/tone + треки bpm/beats/tracks), `synth.ts` (примитивы v1 tools/synth.mjs в TS — рантайм и тулзы делят одни формулы), `AudioManager.ts` (фасад: prepare() синхронно заводит контекст без resume, unlock, playSpec кэширует буфер по ключу, шпион onPlayed). 44 юнит-теста (261 всего, 31 файл), тесты v1 перенесены 1:1 (фейковый граф AudioContext). Демка: `src/audio.ts` — спеки боя/урона/шагов (формулы v1 sfxSpecs), `DemoAudio` (playAt: (1−d/radius)^1.5 + панорама ∝ изо-(dx−dz)·16/240, дедуп 0.08 с; StepVariants: 3 варианта шага, сид = база + i·10), обвязка в main.ts — хуки боя (cone→bell_hit, resonance→bell_low, spit, awake→ash_hiss, kill→ash_die, playerHit→hurt), шаг на границе половины walk-цикла, unlock на первом жесте, prepare() на старте (буферы вариантов компилируются сразу); мост `audioUnlock`/`audioInfo` (кольцевой буфер 24 записей фактических запусков). Гейт `audio.mjs` 8 проверок: тишина на старте (амбиента нет, герой не двигался), шаги играют, атака→bell_hit, плевок→spit (позиционный, в радиусе слышимости 12), резонанс→bell_low, лог ограничен 24. Уроки: **кулдаун конуса 0.45 с не сбрасывается телепортом/очисткой** — атака-пробуждение через ~9 тиков после атаки-звонка не выходит (release глотает), между боевыми секциями гейта ждать ≥30 тиков; **звук в headless работает без жестов** (agent-lib уже передаёт `--autoplay-policy=no-user-gesture-required`), prepare() даёт компиляцию буферов до первого resume. |

Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся
единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт
пользы демо и добавляет второй способ рассинхрона изометрии.

Приоритет между работами A и B: **B (изображения) выше** — визуальный стиль
блокирует восприятие акта 2; аудио сейчас закрывается процедурным генератором
(`tools/audio/gen.mjs`) приемлемо.

---

## Ограничения (читать первым)

- **Машина разработки (обновлено 2026-09-09): RTX 3090 24 ГБ VRAM (CUDA 13.3),
  12 ядер CPU, 62 ГБ RAM.** Прежняя запись про Intel HD 620 / 4 ядра / 15 ГБ
  относилась к старому ПК и устарела вместе с B3. GPU-инференс — основной путь
  (замер A0: 10 с аудио за ~15 с; SD-листы тоже ускоряются кратно). Квантизация
  и компактные модели больше не обязательны, ограничение — VRAM 24 ГБ и диск.
- **Стиль игры жёсткий**: палитра 32 цветов (`tools/pixelart/palette.mjs`),
  размеры спрайтов по `docs/art-style.md`, pixel-perfect. Любая AI-генерация —
  только как **сырьё** с обязательной пост-обработкой: даунскейл nearest,
  квантизация в палитру, проверка чек-листом арт-библии. AI-вывод не попадает
  в `assets/` напрямую.
- **Формат аудио фиксирован**: моно 22050 Гц, 16-бит WAV
  (`tools/audio/gen.mjs`). AI-вывод приводится к нему (ffmpeg: sample rate,
  моно, нормализация, обрезка тишины).
- **Лицензии весов**: для игры важен коммерчески-допустимый статус. Отмечено в
  таблицах; некоммерческие веса (CC BY-NC) — только для экспериментов/PoC.
- **Детерминизм**: у всего пайплайна фиксированный seed в конфиге генерации —
  регенерация не должна молча менять ассеты (аналогично `npm run maps:fresh`).

---

## Работа B — AI-генерация текстур и спрайтов

### Классы потребностей

1. **Тайлы** (трава, пепел, вода…) — процедурный генератор уже даёт приемлемый
   результат; AI ценен для сложных поверхностей (бутеи, руины, интерьеры).
2. **Спрайты объектов** (предметы, декор, интерактивы) — главная точка роста:
   сейчас Graphics-плейсхолдеры.
3. **Спрайты персонажей + атласы** (герой, NPC, враги, кадры анимаций) — самое
   сложное: консистентность кадров, фиксированные размеры листа
   (`hero_sheet.png` — контракт арт-библии).

### Кандидаты

| Модель | Размер | Железо | Нативная палитра | Лицензия | Комментарий |
|--------|--------|--------|------------------|----------|-------------|
| **PXG-Tiny** (HF: Tarul/pxg-tiny) | 483K | CPU, NumPy, 1.8 МБ | да (прямые индексы) | MIT + CC0 | 16×16, 38 классов. Идеален по железу, но узкий домен |
| **PixelGPT-24×24** (github.com/unstonio/pixelgpt-24x24) | 68M | CPU, ~22 с | да, 5 цветов/спрайт, кастомная палитра на запрос | **нет (не указана)** | 24×24; отказов нет, домен тянет частично (см. B1). Веса в репо; датасет 20K — CC BY 4.0 |
| **Pixel Forge** (cochranblock) | 1.1–17M | CPU (Rust) | да, 7 встроенных палитр | Unlicense | 108 классов объектов; появилась фича 4-directional sprite sheet relight — кандидат пробы B3 (нужен Rust-тулчейн) |
| **SD_PixelArt_SpriteSheet_Generator** (Onodofthenorth) | ~1B (SD 1.5) | CPU 6.6 мин/вид fp32 (25 шагов — 11 мин; q8 отвергнуто: медленнее и шумит) | нет (пост-обработка) | Apache-2.0 | 4 ракурса по токенам (FSS/RSS/BSS/LSS); кадры — позы стоя, не walk-цикл. Проба B3 ✅ — рабочий путь для разовых атласов |
| **Retro Diffusion SD1.5** (LostMedia, архив) | ~1B | CPU минуты/картинку | нет (пост-обработка) | Public Domain | классика пиксель-диффузии; для офлайн-пакета |
| **Retro Diffusion API (RD Plus/Tile/Animation)** | облако | — | да | платный API | лучший результат для тайлов/анимаций; фолбэк, если локальное не тянет |
| **LPC-style (PIXEL-T2I и др.)** | research | GPU | нет | research | перспективное направление для атласов 4 ракурса × walk/slash |

Вывод по классам (после B0+B1): инвентарь/декор — **PXG-Tiny локально** (её
классы); простые объекты 24×24 — PixelGPT (но лицензии нет — только PoC);
критичные интерактивы и тайлы сложных поверхностей — **RD API (платный) или
ручной арт**; дообучение под игру не делаем. Персонажи-атласы — кандидаты
пробы: SD_PixelArt_SpriteSheet_Generator (Apache-2.0, CPU-минуты) и Pixel
Forge relight (нужен Rust); фолбэк — платный RD API или ручной арт. Отдельный
путь для полноразмерных атласов с боевыми кадрами — LoRA к Qwen-Image-Edit
(Apache-2.0), но база ~20B: только разовая генерация на Kaggle/Colab GPU,
результат — в наш пайплайн пост-обработки.

### Пайплайн интеграции (проект решения)

```
tools/aiart/gen.mjs        # вызов модели (seed, промпт из манифеста)
  → tools/aiart/post.mjs   # даунскейл nearest → квантизация palette.mjs → дедуп
  → tools/aiart/manifest.json  # список ассетов: промпт, seed, размер, статус
  → v1/apps/game/assets/…     # только после ручного апрува (review-каталог)
```

Принципы: манифест-драйвен (как `maps/`); апрув-гейт (человек смотрит обзорный
лист перед попаданием в игру); ревизия в git; проверка стиля — чек-лист
`docs/art-style.md` + скриншот-смоук.

### Инструменты, которых не хватает (по итогам B0–B3)

1. **`sheet2atlas` — сборщик атласа из AI-листов** (главная дыра). SD-лист 512²
   → кроп фигур (bbox, отсев фона) → нормализация в слоты 16×24/32×48 →
   раскладка в сетку `hero_sheet.png` + `hero_sheet.json` (контракт арт-библии).
   ✅ **Сделан (2026-09-06)**: `tools/aiart/atlas.mjs` (gen/build/promote) +
   `atlas-manifest.json` + `sd_sheet.py`; движковая база — `findFigures`/
   `resizeRect`/`buildAtlas` в `@rpg/engine/tools`. Палитра — параметр манифеста
   (`game` или `palettes/<name>.json` на 64/128/256 цветов). Фон листов модели
   неоднородный + сплошная «земля» под фигурами — автодетект фигур не срабатывает,
   в манифесте работают crop-оверрайды (раскладка 4 фигуры в ряд стабильна).
2. **Палитровый ремапер (recolor)**: перекраска готового квантованного спрайта
   маппингом цветов палитры. Две потребности: сгладить «плавающие» промпт-цвета
   SD (приводить плащ к каноническому) и дешёвые цветовые вариации NPC из одного
   базового спрайта. ✅ **Сделан (2026-09-06)**: `tools/aiart/recolor.mjs` (CLI
   по ключам палитры/hex) + поле `remap` в манифесте атласов — применяется в
   build после квантизации (плащ звонаря приведён к C0/C1).
3. **Линтер арта (авто-чеклист арт-библии)**: скрипт-гейт перед попаданием в
   `assets/` — размер в слоте, все цвета из палитры, детект «мыла» (градиентные
   пары соседних пикселей — чужая плотность пикселя). ✅ **Сделан
   (2026-09-06)**: `tools/aiart/lint-art.mjs` (`npm run art:lint` по всем
   assets, негативы проверены); в `agent:check` не заведён — по мере надобности.
4. **Отложено**: клиент RD API (tile/animation) — только если решимся платить
   (B2, кадры walk-цикла); генерация/склейка walk-цикла из поз — пока ручной
   арт, инструмента не видно.

### Этапы

- **B0 (PoC, ~1 вечер)**: запустить PXG-Tiny/Pixel Forge локально на CPU;
  сгенерировать 10–20 объектов, прогнать пост-обработку, сравнить с текущими
  плейсхолдерами. Критерий: «хотя бы 30% годных без ручной правки».
  ✅ **Выполнен (2026-09-06)**: PXG-Tiny на CPU (~6 с/спрайт), 20 спрайтов,
  40% годных, стиль совпал с игрой на 100%. Пайплайн: `tools/aiart/`
  (`post.mjs` — кроп/квантизация в палитру + обзорный лист; `manifest.json`
  — вердикты). Pixel Forge отложен (нет Rust-тулчейна). Ограничение:
  критичные для игры ассеты (колодец, столб, рычаг, колокольчик, моты)
  вне 38 классов модели — для них дообучение / RD API / ручной арт.
- **B1 (домен, вторая модель)**: PixelGPT-24×24 на доменных интерактивах
  (промпты + обусловливание палитрой игры из 5 цветов).
  ✅ **Выполнен (2026-09-06)**: 18 спрайтов, 44% годных, **отказов нет** —
  доменные объекты (колокол, дверь, камин, столб, моты) модель рисует, где
  PXG-Tiny отказывала. Ограничения: даунскейл 24→16 губит деталь (колодец
  good на 24×24), фон часто залит, ровно 5 цветов, ~22 с/спрайт CPU,
  **лицензии на код/веса нет — в игру нельзя**. Манифест: `manifest-b1.json`.
- **B2**: тайлы сложных поверхностей (img-условность от существующих тайлов) —
  локальным моделям недоступно, кандидат RD API; отложено.
- **B3 (проба, 2026-09-06)**: SD_PixelArt_SpriteSheet_Generator на CPU
  (venv-pxgpt, fp32). Токен-ракурс работает: 4 фигуры читаются; но кадры —
  вариации позы стоя (не walk-цикл), промпт-цвета плавают, кадры ~120 px
  (даунскейл до 16×24 некратный). Тайминги: 25 шагов — 11 мин, **15 шагов —
  6.6 мин** (взято за рабочий режим). Квантование q8_0 (stable-diffusion.cpp,
  GGUF) отвергнуто: 34 мин (ggml-ядра int8 без VNNI медленнее fp32-oneDNN) и
  VAE в q8 даёт шум — `probe-b3/sd_q8_fss_42_broken.png`. Вердикт: путь
  рабочий для разовых атласов (4 вида ≈ 30 мин/персонажа), но кадры-позы
  придётся дорабатывать руками; полный прогон — при потребности акта 2.
  Пробы: `v1/apps/game/tools/aiart/probe-b3/`. Фолбэк — RD API или ручной арт.
- **B3-доп (ускорители, 2026-09-06)**: альтернативные пути разгона проверены и
  **отвергнуты**, остаёмся на fp32-CPU (diffusers, 4 потока):
  - **iGPU HD 620 (Vulkan/ggml)** — не готовился к прогону осознанно: стек
    Vulkan в системе есть (Mesa ANV Gen9.5, glslc), но HD 620 — 24 EU и общая
    с CPU память ~30 ГБ/с, т.е. на порядок слабее CPU в матмулах; ggml-Vulkan
    на Gen9.5 обычно даёт 10–25 мин на SD1.5 512²×20 — сравнимо или хуже.
    OpenCL мёртв: intel-compute-runtime 26.x — только Tiger Lake+ (Gen9.5 —
    legacy-ветки). Оставлено как есть, без бенчмарка.
  - **Квантование q8_0/q4** — отвергнуто ранее: на 7-м поколении без VNNI
    ggml-ядра int8 медленнее fp32-oneDNN (34 мин против 11), VAE в q8 шумит.
  - **Меньше шагов** — единственный рабочий рычаг: 25→15 шагов даёт 11→6.6 мин
    без видимой деградации кадра; ниже 15 качество токен-ракурса падает.
- **B4 (задача 5, 2026-09-09, новая машина с RTX 3090)**: замена плейсхолдеров
  интерактивов, снаряда и иконок сумки на арт. GPU меняет экономику: ~1.3 с/вид
  fp16 (SD1.5, 20 шагов) против 6.6 мин CPU — сиды бесплатны, кандидатов можно
  генерировать пачками.
  - **Модель**: Retro Diffusion (HF `LostMedia/RetroDiffusion`, Public Domain
    Mark 1.0, SD1.5-пиксель-финтюн) — одиночные объекты по промпту ведёт хорошо;
    B3-модель листов (SD_PixelArt_SpriteSheet_Generator) для объектов непригодна
    (всегда рисует 4 фигуры на кирпичном фоне). Sheet-модели не использовать.
  - **Пайплайн**: `tools/aiart/gen_b4.py` (GPU-партия, реестр LIST, CLI
    `id:seed:…`) → `tools/aiart/post_b4.mjs` (кроп фигуры → meanRect-даунскейл
    усреднением ячеек с фоновой маской → квантизация в палитру → обзорный лист).
    Фоновая маска — BFS-наращивание от краёв (фон RD — градиент, угловой пиксель
    его не описывает; допуск шага 40, у границы объекта скачок).
  - **В игру**: 7 интерактивов AI (столб, мот, верёвка колокола, прилавок, очаг,
    записка, плевок-снаряд) + 7 иконок сумки 8×8 (`ui/icon_*`) через механизм
    `InteractableDef.view.tex` (якорь низ-центр по тайлу, вместо Graphics-
    плейсхолдера) и `Sprite` в `InventoryScene` (строки с `value: ''` — метка
    прижата влево, иконки слева синхронны окну скролла). Старая Машина — AI.
  - **Рукодельный фолбэк сработал**: tone_tree/resonator (32×40+) у RD на тёплом
    градиентном фоне и с шумной деталью — даунскейл даёт кашу при любом кейнге
    (8+16 новых сидов перепробовано). Нарисованы кодом в `gen.mjs`
    (`toneTreeTile`/`resonatorTile`) — стиль единый с остальными тайлами.
    Вывод: AI — для мелких/средних объектов с чистым силуэтом; крупные — код.
  - Кандидаты: `tools/aiart/review-b4/` (raw+processed, 160+), в git не идут.

---

## Работа C — крупные объекты (footprint + большие спрайты)

Акт 2 захочет здания и крупные деревья — объекты больше 1×1 тайла. Сегодня
движок умеет только `TallSpec` (1 тайл footprint + произвольная высота) и
ручной `blocked`. Большой спрайт, якоренный в один тайл, сортируется глубиной
по точке — герой сбоку от дома перекрывается неверно.

### Что нужно

1. **Движок: footprint w×h** — коллизия по всему прямоугольнику, земля под ним,
   спрайт якорем низ-центр footprint'а; глубина по прямоугольнику
   (`(x+w-1)+(y+h-1)`, `IsoDepthLayer.addRect`), а не по точке якоря.
2. **Арт-библия: слоты крупных спрайтов** — фиксированные размеры с якорями
   (дом 3×3, большое здание 4×4…), чтобы любой источник рисовал в готовый слот.
3. **Арт: три пути** — крупный объект кодом в `gen.mjs` (композит: модули стен +
   крыша — наш бесплатный путь); апскейл nearest из 24×24 (только болванка —
   чужая плотность пикселя рядом со спрайтами 1×); RD API (умеет большие
   размеры и тайлы, платный).

### Этапы

- **C0 (движок) ✅**: `props` в `TileMapData` (footprint, коллизия по всему
  прямоугольнику, земля под ним), `IsoDepthLayer.addRect` (глубина по
  диагонали юго-восточного угла footprint'а), валидация в `mapFormat`,
  тесты; `LocationScene` вставляет `map.propViews` в общий depth-слой.
- **C1 (арт-библия) ✅**: слоты крупных спрайтов в `docs/art-style.md`
  (дом 2×2 — 64×80, дом 3×3 — 96×96, здание 4×4 — 128×112; якорь ставит
  движок). Плотность пикселя — как у тайлов; AI-апскейл в слоты не попадает.
- **C2 (проба) ✅**: большой дом 2×2 кодом в `gen.mjs`
  (`tiles/house_big.png`, 64×80, композит: модули щитов + крыша шпал +
  козырёк + окна) против апскейл-болванок 24→48 из B1
  (`v1/apps/game/tools/aiart/probe-c2.png`). Вывод: **для крупных объектов —
  код-композит в `gen.mjs`**; апскейл даёт мыло с чужой плотностью пикселя и
  залитым фоном (только черновая болванка), нативно больших размеров локальные
  модели не умеют. RD API остаётся опцией, если код-композита не хватит.
  Неквадратные footprint'ы (2×1, 3×1, 4×2): движок умеет (`props` w×h;
  поверхности заполняются обычными тайлами 1×1); AI-генерация — только квадраты
  (неквадрат собираем из 1×1 на уровне карты), код-композит — любой размер,
  RD API Tile — нативно прямоугольные.

---

## Работа A — AI-генерация аудио

### Классы потребностей (по пользовательскому сценарию)

| Класс | Что это сейчас | AI-кандидат | Железо |
|-------|----------------|-------------|--------|
| **Окружение** (ветер, пепел, вода прудов) | процедурный шум в gen.mjs | **Stable Audio 3 Small-SFX** (433M, CPU, 120 с) — длинные лупы; луповальность через crossfade-склейку | CPU ок |
| **SFX взаимодействия** (шаги, клик, удар, звон) | gen.mjs, приемлемо | **Stable Audio 3 Small-SFX** (тот же рантайм; seed-детерминизм для вариантов) | CPU ок (~секунды-десятки секунд на клип на 4 ядрах) |
| **Музыка: фоновые лупы** | gen.mjs (треки 3 областей) | **Stable Audio 3 Small-Music** (433M, CPU, 120 с) — короткие лупы; либо **ACE-Step 1.5** 2B turbo INT8 (CPU-медленно: минуты-десятки минут на трек, но офлайн-пакет) | Small-Music — да; ACE-Step — терпимо для пакета |
| **Музыка: катсцены/сюжетные моменты** | нет (трек + звон) | **ACE-Step 1.5** — полноценные треки с настроением/структурой, стемы; MusicGen small — фолбэк для мелодических заготовок | офлайн-пакет, не hot-reload |

### Кандидаты (сводно)

| Модель | Размер | VRAM/железо | Длина | Лицензия | Роль у нас |
|--------|--------|-------------|-------|----------|------------|
| **Stable Audio 3 Small-SFX / Small-Music** | 433M | CPU (TFLite), ~1.9 ГБ пик | 120 с | веса: Stability Community License; код MIT | основной локальный рантайм для SFX + лупов |
| **ACE-Step 1.5** | 2B (turbo INT8) | CPU (медленно), GPU <4 ГБ | 10 с–10 мин, стемы | MIT | музыка катсцен/сюжета, офлайн-пакет |
| MusicGen small (AudioCraft) | 300M | CPU ~4 ГБ RAM | ~30 с | MIT/CC-BY-NC веса | фолбэк, мелодия-условность |
| Stable Audio Open 1.0 | 1.2B | GPU ~2.5–8 ГБ | 47 с | Stability Community | зрелый, но на нашей машине безнадёжно медленный; пропускаем |

### Разведка A0 (2026-09-09, новая машина)

- **Железо не CPU-only, как предполагалось: RTX 3090 24 ГБ + 12 ядер + 62 ГБ RAM.**
  Генерация 10 с аудио (100 шагов): **GPU fp16 ~15 с** (CPU fp32 — 3.5 мин).
  Это снимает оговорки про «терпимо» у ACE-Step и делает GPU-инференс
  основным путём для всех работ AI-контента (и аудио, и изображений).
- **Stable Audio Open Small закрыт**: `gated: auto`, анонимно 401 — нужен
  HF-токен + принятие Stability Community License (весов Small-SFX/Small-Music
  как отдельных репо нет; доступен общий stable-audio-open-small).
- **PoC сделан на фолбэке AudioLDM2** (`cvssp/audioldm2`, CC-BY-NC-SA —
  в основной пайплайн с некоммерческими весами не идёт, как и договаривались).
  Стек: `~/.cache/rpg-ai/venv-aiaudio`, torch 2.14.0+cu130, **diffusers 0.35.2 +
  transformers 4.49.0** (diffusers 0.40 требует transformers 5.x и падает на
  AudioLDM2: `_update_model_kwargs_for_generation` удалён в 5.x).
- Пайплайн: `v1/apps/game/tools/aiaudio/gen_audioldm2.py` → `post.py`
  (ресэмпл 22050 моно 16-bit, нормализация 0.85, loopify — хвост кроссфейдом
  в голову за 1 с, проверка стыка `--check`). Сырые клипы — `review/`
  (в gitignore, перегенерируются по seed). Seed-детерминизм — по-устройству
  (fp16 GPU ≠ fp32 CPU).

### Пайплайн (как сделано, A1)

```
npm run aiaudio gen [id[:seed,seed]...]   # AudioLDM2 + post.py (ресэмпл 22050 моно
                                          # 16-bit, нормализация, loopify 1 с, --check
                                          # шва; sfx — без лупа)
npm run aiaudio list | promote <id> <seed> # апрув-гейт: review/ → assets/audio/,
                                           # gate=approved в манифесте
```

Манифест — `v1/apps/game/tools/aiaudio/manifest.json` (промпты, сиды, duration,
`peak` — цель нормализации, gate, заметки). Принципы aiart: манифест, апрув-гейт
(ключ promote проверяется на объявление в `data/audio.ts`), seed-детерминизм,
формат игры не меняется (шины master/music/sfx/ambience готовы). Лупы проверяются
скриптом (стык начала/конца без щелчка) и метриками `analyze.py` (огибающая/
спектр — отбор при делегированном апруве). SFX остаются на спек-синтезе —
промотировать некуда (SFX_KEYS пуст), файлового SFX-пути в движке нет.
«Взгляд» агента на звук: движковая тулза `v1/packages/engine/tools/spectro.mjs`
(спектрограмма log-частоты + огибающая RMS + маркер шва → PNG; `npm run aiaudio
view <id>`) — замена числового отбора визуальной оценкой на гейтах A2/A3.

### Этапы

- **A0 (PoC)**: локальный text-to-audio; амбиент-луп прудов. Критерий: звук
  лучше процедурного на слух и формат сходится. *(SFX-замены выпали: после
  E4 файловых SFX нет — все на спек-синтезе `data/sfxSpecs.ts`.)*
  ✅ **сделано 2026-09-09** (PoC на фолбэке AudioLDM2, слушание — у пользователя).
- **A1**: пайплайн aiaudio + манифест; амбиент-лупы областей.
  ✅ **сделано 2026-09-09** (решение A1: AudioLDM2, игра некоммерческая):
  манифест-driven раннер `npm run aiaudio` (gen/post/promote/list, python из
  `~/.cache/rpg-ai/venv-aiaudio`), пост-обработка с пер-позиционным `peak`,
  метрики `analyze.py`. Промотированы: **роща** (свой луп вместо подстановки
  прудов, seed 2), **разъезд** (seed 3), **ветер** (фидбек: завывание +
  периодическое затухание + тише — пик 0.6, seed 3). Ночью Звенца / дома / вода —
  оставлены процедурные (огибающая живее у эталонов, кандидаты hold в манифесте).
  SFX — остались спек-синтезом (см. выше).
- **A2**: музыкальные лупы. Уточнение: в игре нет музыкальных WAV — темы это
  спек-музыка (`data/music.ts` → `renderMusicStereo` в рантайме), поэтому
  сравнение не с gen.mjs, а со спек-темами. Кандидаты на фолбэке AudioLDM2
  сгенерированы (2026-09-09: meadows/combat/tension × 2 сида, швы чистые,
  на спектро-листах гармоническая структура без артефактов, `music_*` в
  манифесте). **Решение A2 (2026-09-09, делегировано): спек-темы остаются** —
  AI-музыка не слушалась, а AudioLDM2 музыкально слабейшая его часть; менять
  вслепую — риск регресса проверенной шины. Кандидаты hold в манифесте;
  файловый путь на шине music реализуем за час при первых апрувнутых лупах
  (движковых изменений не нужно: WAV → `createBuffer` → `playLoopBuffer`,
  как tension-стем). Small-Music — главный путь при появлении HF-токена.
- **A3**: ACE-Step офлайн-пакет для катсцен акта 2 (1–2 трека под моменты).

---

## Работа D — возможности аудио-движка

Итог аудио-работ (2026-09, коммиты 57bce47…4c1c56c): в движке — 4 шины,
кроссфейд слотов music/ambience, `playLoop` без слота, pan/rate, шпион
`onPlayed`; в игре — позиционный `AudioSystem`, локальные амбиент-слои,
событийные звуки боя, DEV-лог `__gameAudioLog`. Ниже — потребности, которые
упёрлись в границы `AudioManager`, по убыванию полезности. Игровая логика
(кто слушатель, затухание по дистанции, `playAt`) остаётся в игре — в движок
её не тащить.

### Потребности

1. **Управление играющим разовым звуком.** `play()` возвращает `void`:
   нельзя остановить, перезапустить «с нуля» (опция «убить прежний экземпляр
   ключа») или изменить volume/pan/rate в полёте. Самый чувствительный кейс —
   движущийся источник: панорама фиксируется в момент запуска.
2. **Лимит полифонии в движке.** Число одновременных источников ничем не
   ограничено — игру спасает только `MIN_INTERVAL` на ключ в игровом
   `AudioSystem`. Нужен `maxVoices` с воровством самых тихих/старых голосов —
   защита от каскада (десяток `combat:*` в один тик).
3. **Шпион на все запуски + окончание.** `onPlayed` зовётся только для sfx —
   лупы (музыка/амбиент/слои) шпион не видит, игре пришлось логировать
   амбиент вручную в `Game.playAmbience`. Нет `onEnded` — нечем ловить «звук
   доиграл» для синхронизации с анимацией.
4. **Длительность буфера наружу.** `duration(key)` — сейчас её не достать,
   а нужна катсценам и генератору шагов (не играть следующий шаг раньше конца
   предыдущего).
5. **Играть буфер из памяти.** `playBuffer(AudioBuffer)` — синтез сейчас
   только оффлайн (`tools/wav.mjs`); генерировать вариации звука в рантайме
   движок не может (сейчас джиттер имитируется `rate`).

### Этапы

- ✅ **D0 (движок): sfx-хендл + полифония.** `SfxHandle { stop(fadeSeconds?),
  setVolume(v), setPan(v), setRate(v) }` — по образцу `MusicHandle`; опция
  `restart`/«один экземпляр на ключ»; `maxVoices` с воровством голосов.
- ✅ **D1 (движок): наблюдаемость.** `onPlayed` на все запуски (sfx + лупы,
  опции нормализованы); `onEnded(key)`; `duration(key)`. Игра: ручное
  логирование амбиента убрано (лог целиком из шпиона).
- ✅ **D2 (движок + игра): буферы из памяти.** `playBuffer(key, buffer, opts)`
  + `createBuffer(data)`; примитивы синтеза вынесены в чистый
  `@rpg/engine/tools/synth.mjs` (без node-импортов — wav.mjs в браузере не
  соберётся); игра — `systems/StepVariants.ts`: 3 процедурных варианта шага
  на поверхность (сид ~253–256 + сдвиг, полосы как в gen.mjs), до unlock —
  файловый шаг. Гвард: `@rpg/engine/tools/*` — публичное API.

### Работа E — синтез звука по запросу (агент без слуха)

Движок строится агент-первым: у агента нет слуха, и искать готовые сэмплы
в библиотеках он не может. «Генерация по запросу» для агента — параметрический
синтез: агент описывает звук **спеком** (форма, полоса шума, тон, огибающая,
сид), движок детерминированно компилирует спек в буфер (база — D2:
`createBuffer`/`playBuffer` + `tools/synth.mjs`), факт запуска наблюдаем через
`__gameAudioLog`. Нейросеть позже — опциональный «описание → спек» маппер
поверх того же API; сэмпл-библиотеки как основной путь не используются.

- **E1 (движок) ✅ (2026-09-07): компилятор спеков.** `audio.playSpec(key, spec, opts?)` —
  kinds `hit`/`chime`/`scrape`/`hum`, параметры: `dur` (обяз.), `low`/`high`
  (полоса шума), `tone` (низкая подложка/основной тон гула), `freq` (звон),
  `power` (крутизна затухания), `seed` (детерминизм), `peak`, `loop`
  (кроссфейд краёв). Кэш буферов по ключу. Тесты: детерминизм (одинаковые
  спеки/сиды → одинаковые сэмплы), kinds, кэш.
- **E2 (мост) ✅ (2026-09-07): агентный доступ.** Op `scene:synthesize` (спек →
  playSpec), документация форм спеков в `docs/engine/agent.md`; проба
  `checks/synth.mjs` (в `agent:check`) — агент слепо синтезирует удар/звон/
  скрип/гул, проверка фактов запуска в аудио-логе + отрицательные случаи.
- **E4 (палитра спеков + полная миграция) ✅ (2026-09-07):** новый kind
  `tone` (чистый тон, свип `freqTo`) и общие параметры `w`/`attack`/`fadeTo`/
  `wobbleHz|Depth|Base|Phase` (тремоло) / `layers` (доп. голоса `SpecVoice` с
  `delay`). **Все файловые SFX переведены на спеки** (16 ключей в
  `data/sfxSpecs.ts`, включая шаги — `StepVariants` рендерит варианты
  `renderSpec` с сидами base+i·10); `tools/audio/gen.mjs` остался только
  амбиенту, каталог `assets/audio/sfx/` удалён. Парити-тест `sfxSpecs.test.ts`
  сверяет каждый спек с формулой бывшего генератора (bit-в-бит или eps 1e-5) —
  звучание игры не изменилось. Ауди-тулза игры больше не нужна для новых
  sfx: ключ + спек в `SPEC_SFX` + `playSfx` на play-сайте.
- **E3 (контент) ✅ (2026-09-07): миграция файловых SFX на спеки.**
  `sfx/bell_hit` и `sfx/whoosh` — теперь спек-синтез в `data/sfxSpecs.ts`
  (реестр `SPEC_SFX` + хелпер `playSfx`, единая точка проигрывания sfx);
  мёртвый `sfx/door.wav` (ключ без play-сайтов) удалён. Для свуша у scrape
  добавлен флаг `swell` (огибающая «0 → 1 → 0»), у chime — 4-й партиал и
  константная амплитуда клика: формулы совпали с gen.mjs дословно, парити-тест
  `sfxSpecs.test.ts` сверяет `renderSpec` с формулами генератора бит-в-бит
  (генератор формулы экспортирует, WAV при импорте не пишет — CLI-гвард).
  Остальные файловые SFX не выражаются текущими kinds (тон/тремоло/свип) —
  по мере расширения палитры спеков мигрируются так же.

### Работа F — инфраструктура аудио (хвосты D)

✅ **Сделана (2026-09-07)**: mute/suspend при `visibilitychange` (опция
`pauseOnHide`, по умолчанию включена); `bus` в `PlayOptions` разового `play`
(по умолчанию sfx); exponential-фейд на затуханиях (нарастание — линейное:
экспонента не определена от нуля).

### Работа G — музыкальный слой

- Движок: секвенсор (ноты → инструменты поверх `synth.mjs`: осциллятор +
  огибающая, партия = трек = стем), плейлист/секвенция тем («тема лугов →
  тема боя → назад»), стем-миксинг — параллельные лупы-слои с независимой
  громкостью (спокойно/настороженно/бой; ложится на существующий `playLoop`).
- Тулзы: правило-базовый генератор тем офлайн (детерминированный, сиды), тема
  лугов — первый носитель. Позже — маленькая нейронка «описание → спек/ноты»
  как опция, не как база.

✅ **Частично сделано (2026-09-07)**: секвенсор движка (`renderMusic`, чистый,
инструменты pluck/bell/pad) + `playLoopBuffer` (луп из памяти на любой шине);
правило-базовый генератор темы лугов в `data/music.ts` (сид, пентатоника),
компиляция в рантайме — WAV для музыки не нужен; тема области — поле
`theme` + `Game.playTheme` (guard + кроссфейд), проба `checks/music.mjs`.
Стем-миксинг уже покрыт (`playLoop` + тик-фейд, амбиент-слои).
✅ **Динамическая музыка (2026-09-07)**: стерео-рендер `renderMusicStereo`
(interleaved L,R, `pan` треков equal-power, нормализация по общему пику
каналов — `createBuffer(data, RATE, 2)`); тема боя + tension-стем в
`data/music.ts`; threat-тик в `LocationScene` (состояния врагов: chase →
тема боя, wary → тихий стем под темой области, деаггро → тема назад);
room tone интерьеров (`ROOM_TONES` — hum-спеки, шина ambience).
**Осталось**: нейронка «описание → ноты» — опционально.

---

## Открытые решения (принять перед стартом работ)

1. ~~B0: какая из GPT-пиксель-моделей заходит лучше~~ — **закрыто B0+B1**:
   PXG-Tiny — инвентарь/декор, PixelGPT-24×24 — домен частично, но лицензии нет.
2. ~~Персонажи-атласы: дообучение локально vs RD API vs ручной арт~~ —
   дообучение под игру **отвергнуто**; осталось: платный RD API vs ручной арт.
3. ~~Python-окружение~~ — **закрыто**: venv вне репо (`~/.cache/rpg-ai/venv`,
   `venv-pxgpt`), веса в `~/.cache/rpg-ai/`; в git — манифесты и review-PNG.
   Пересоздано 2026-09-09 на новой машине: venv на python3.11 (torch 2.14 CPU
   + diffusers 0.40 + sentence-transformers), веса PXG-Tiny / PixelGPT-24×24 /
   SD_PixelArt скачаны заново; `gen_pxgpt.py` восстановлен в репо
   (`tools/aiart/gen_pxgpt.py`, маппинг токенов сверен с review-b1:
   токен 0 — прозрачный, токен t — цвет `t-1` палитры промпта). PXG-Tiny
   детерминирован между машинами (сверка с review B0 — идентичные пиксели);
   PixelGPT и SD — воспроизводимы до шума сэмплирования между версиями torch.
4. Порог апрува: кто смотрит review-лист (пользователь) и где фиксируется
   вердикт (пометка в манифесте).

## Что НЕ делаем

- Облачные API в основном пайплайне (только как опциональный фолбэк для спрайтов).
- Модели с некоммерческими весами в основном пайплайне (DreamLite CC BY-NC —
  только эксперименты); веса без лицензии (PixelGPT) — то же.
- Дообучение моделей под конкретную игру.
- TTS-голоса персонажей — вне среза, кандидат на будущее: Kokoro-82M (CPU).
---

## Уроки черновика → видение движка v2 (черновик, 2026-09-09)

Решение (2026-09-09): текущий движок считается **черновиком (v1)** — он
остаётся рабочим витринным движком, чиним и допиливаем его без больших
рефакторингов; накопленные уроки формируют видение **v2**. Этот раздел —
живой: пользователь и агент пополняют его по мере осознания.

### Что в v1 удалось (в v2 сохранить как принципы)

- **Фиксированный шаг и детерминизм** (`GameLoop` 60 Гц + `stepTicks`,
  ручной шаг без рендера) — основа воспроизводимости и агентного моста.
- **Разделение «чистая математика / вьюхи»**: `lightSim`, `shadowSim`,
  `postSim`, `particleSim`, `fxSim` — без Pixi, тестируются в Vitest без
  браузера. Лучшее решение проекта: баги ловятся тестами до запуска игры.
- **Агентный мост с первого дня**: `window.__agent`, снапшоты-слои,
  инварианты, CLI-сценарии проверок, `practices.md`. Состояние читается
  снапшотом, скриншот — только для визуальных вопросов.
- **Жёсткая граница движок/игра** с автоматическим гвардом.
- **Инъекция зависимостей** (`StorageLike` и другие) — тесты без DOM.
- **Доки — точка входа**: `llms.txt`, доки движка по модулям, «грабли».

### Что v1 показал болью (в v2 переделать)

1. **Координатные пространства** — главный источник багов: виртуальные px,
   CSS-px, мировые юниты, тайловые `{x,y}`, offset `worldRoot` смешивались
   в сигнатурах и головах; направление тени считалось прямо в экранных
   координатах (баг «летающих» объектов). В v2: все направления/позиции —
   в одном мировом пространстве, проекция — единственная точка на границе
   рендера; типы различают пространства (юниты ≠ px ≠ тайлы).
2. **«Сделано» ≠ «видно»**: RT-тьма запекалась, но не выводилась —
   снапшот-пробы зелёные, визуально подсистема мёртвая. В v2: визуальная
   подсистема считается готовой только со скриншот-гейтом, а не только
   снапшот-ассертами.
3. **Сцена-оркестратор** (`LocationScene` ~900 строк) разрезалась вручную
   и всё равно разрастается. В v2: декларативная композиция сцены из
   систем с явным порядком тика (карта → свет → тени → постэффекты).
4. **Pixi протекает в API** (`Renderer` в сигнатурах хелперов). В v2:
   рендер-бэкенд за узким интерфейсом, ядро движка его не знает.
5. **Ручные пулы вьюх** (`toneFree/holeFree/...`) — многословно и
   ошибкоёмко. В v2: декларативные слои с авторазмещением по ключу.
6. **Мёртвый код переживает проверки** — нужен принцип: не оставлять
   подключённый, но неиспользуемый путь; удалять или связывать сразу.

### Направление v2: воксельный движок (решено в беседе, 2026-09-09)

**Фокус прежний: движок + инструментарий ИИ-агента; игра — демо-полигон.
Демка остаётся изометрией** (фиксированная камера), движок v2 — жанронезависимый.

- **Стиль**: воксель как продолжение пиксель-арта — рендер сцены в низкое
  разрешение (nearest-апскейл, тот же приём, что виртуальные 480×270 в v1),
  палитра вокселей квантуется в арт-библию (B*/F* — «жизнь»). Настоящие
  свет/тени: shadow map + воксельный AO (по занятым соседям) вместо фейков v1.
- **Рендер-бэкенд**: three.js на низком уровне (роль Pixi в v1 — «только
  рендер-бэкенд»), ядро движка своё и о бэкенде не знает (урок №4: рендер за
  узким интерфейсом). Свободная камера — позже, не в старте.
- **Формат моделей**: гибрид — внутренний формат свой (валидация агентом),
  импорт/экспорт `.vox` (MagicaVoxel) как мост к готовым инструментам
  (аналогия: Tiled-импорт в v1).
- **Генерация моделей — 3 взаимодополняющих маршрута**:
  1. Процедурные генераторы кодом (аналог `tools/pixelart/gen.mjs`) —
     основной путь: детерминизм, ревью диффом, юнит-тесты;
  2. Image/text-to-3D локально (RTX 3090: TripoSR/Shap-E-класс) →
     вокселизация → квантование в палитру → review-лист → апрув-гейт
     (аналог пайплайна A0/B4);
  3. Ручной MagicaVoxel для ключевых героев; агент — вариации и производные.
  Общий визуальный гейт — turntable-листы (6–8 ракурсов в один PNG) + апрув.
- **Анимация = данные, не кадры**: рига + кривые ключевых кадров в JSON
  (кости деформируют близкие воксели линейным блендингом), процедурная
  локомоция (шаг от скорости, secondary motion), вторичная анимация кодом.
  Валидация числовая: непрерывность цикла, контакт стопы (y кости ≈ 0 в
  фазе опоры), дистанции костей — юнит-тесты + снапшот поз в агентный мост
  + превью-листы кадров. Агент авторит кривые/параметры текстом.
- **Перенос из v1**: аудио-трак целиком (wav/spectro/манифесты/шины —
  рендер-агностичен); `tools/pixelart` остаётся для UI/иконок/частиц;
  **агентный мост + CLI-чеки — ядро v2, а не «порт»** (согласовано).

Открытые вопросы v2 (копить здесь): ~~каркас ядра (ECS v1 или новый)~~ —
решено (трек 8, строка 29): минимальный ECS v1-схемы (`EntityWorld`),
GameLoop v1-схемы с ручным режимом, ввод как данные; ~~физика/коллизии в
воксельном мире~~ (трек 7), ~~тайлинг мира (чанки)~~ (трек 3),
~~диалоги как данные~~ (трек 9, строка 30: граф + рантайм семантики v1 +
валидатор + точки взаимодействия), ~~инвентарь~~ (трек 10, строка 31:
Inventory v1 1:1, эффекты giveItem/takeItem исполняются игрой),
~~сохранение~~ (трек 11, строка 32: SaveManager v1 1:1, состояние демки
восстанавливается попиксельно), ~~сцены/переходы~~ (трек 12, строка 33:
SceneManager — стек + фейд, параллельные переходы глотаются), ~~бой~~
(трек 13, строка 34: PlayerCombat/EnemyBrain/CombatWorld — перенос v1,
хуки вместо EventBus, 1 боевой юнит = 3 вокселя, конвертация на границе
демки), ~~аудио~~ (трек 14, строка 35: AudioManager/Voices/Loops +
спек-синтез без WAV, позиционный звук и шаги в демке, DEV-шпион).
Все 14 треков переноса подсистем v1 на v2 закрыты — дальше: воспроизведение
демки v1 («Пепельные луга») на v2 по мере движения.

#### Воспроизведение демки v1 на v2 — дорожная карта (2026-09-09)

Цель: на движке v2 воспроизвести демо-срез v1 («Пепельные луга») — 8
областей (5 уличных + 3 интерьера), 3 NPC, 2 квеста, переходы, пикапы,
накат-зоны, фауна, день/ночь, темы областей. Референс — v1; точность
визуала не критична (воксель ≠ спрайт), но раскладка тайлов v1
(позиции NPC/переходов/квестов) переносится данными — сходится сюжет.
Состав референса (разведка 2026-09-09): `v1/apps/game/src/data/` —
locations.ts (AREAS: meadows/ponds/zvenets/rust_grove/old_siding/
machine_hall/house_elder/shop, переходы step/click с requiresFlag/
requiresItem/lockedText, hazard-зоны наката, свет/атмосфера/fauna),
map.ts (TILES ~35 id, BLOCKED, tall-объекты), карты assets/maps/*.map
(RLE, parseMap), npcs.ts (Ирвин, Мила; Клинт по флагу act2_hook),
dialogues/ (9 графов: elder × 3, trader × 3, clint × 3), ids.ts
(18 флагов + vars), items.ts (7 предметов), interactables.ts,
quests.ts + quest/FlowerQuest.ts, ResonatorQuest.ts, music.ts (THEMES —
спек-музыка, рендер уже портирован в v2), fauna, пепел/моты.

Треки (нумерация продолжается с 15):

- **Трек 15 — карты и области** ✅ (2026-09-10, коммит): конвертация карт v1
  (`apps/game/tools/convertMaps.mjs` → `src/data/maps.json`, RLE как есть),
  воксельный генератор областей (`src/areas.ts`: 1 тайл = 3×3 вокселя,
  земля y0-1, вода слоем y=2 = blocked, штампы — компактные генераторы
  в тайл 3×3 mapTree/mapBush/mapBoulder + дома/колодец/башня/машина/
  рельсы/стены/декор, всё детерминировано); новое приложение `apps/game`
  (демка не тронута — её 11 гейтов завязаны на сценку); переходы step/click
  с фейдом out→swap→in и стеком возврата 'return' (`src/main.ts`); мост
  areaInfo/gotoArea/setManual/stepTicks/heroInfo/heroTeleport/setInput/
  setFollow/setSun/solidAt/interact; гейт `tools/checks/areas.mjs` —
  16 проверок (детерминизм пересборки, blocked, зажим у воды, step-переходы
  луга⇄Звенец/луга⇄пруды, люк→зал→return по стеку, click «E», интерьер).
  Уроки: экранная ось (1,0) — диагональ мира (триггер срезается углом —
  чистые оси комбинированным стиком); штампы шире тайла запирают проходы —
  контент карты строго в тайл 3×3; solidAt — колонка над землёй (y≥2).
- **Трек 16 — NPC и квестовый каркас** ✅ (2026-09-10, коммит): контент v1
  1:1 — `src/data/ids.ts` (FLAGS 17/VARS/usedFlag), `src/data/items.ts`
  (7 предметов), `src/data/npcs.ts` (Ирвин/Мила статично в Звенце, Клинт —
  NPC_PLACEMENTS по флагу act2_hook), 9 диалоговых графов v1 в
  `src/data/dialogues/*.json` + реестр с validateDialogue при старте,
  `src/data/quests.ts` (стадии с doneFlag/ready/progressKey,
  questDialogueFor/questLog/questTracker), `src/state.ts` (GameState —
  DialogueState движка, сериализуема под сейв). В main.ts: NPC-манекены
  (Character speed 0) + Interactable-точки (radius 5), выбор графа —
  квест-стадия → repeat → dialogueFirst; в диалоге мир замер; onEffect —
  giveItem/takeItem в Inventory, toast/custom → тосты; HUD: трекер/сумка/
  тосты; E = диалог/дальше, цифры = выбор варианта. Мост: npcInfo/talkTo/
  dialogueInfo/dialoguePick/dialogueAdvance/dialogueAbort/setState/stateInfo.
  Гейт `tools/checks/npc.mjs` — 27 проверок (раскладка NPC, появление Клинта
  по флагу, выбор графа по стадиям включая сдачи flowers/tones ≥3, выбор
  ответа, сумка, флаги, трекер, motes_note по whenVar, мир замер, панель).
  Уроки: после сдачи квеста активная стадия (npc trader) перекрывает
  dialogueFirst этого NPC графом эпилога — порядок знакомств в сценарии
  гейта существенен; подстроки имён с падежами («Мила» в «Милой») в
  проверках текста ненадёжны. Экспортирован `evalConditions` из движка
  (ready-условия стадий — те же предикаты, что в графах).
- **Трек 17 — интерактив, пикапы, квест «Три цветка»** ✅ (2026-09-10,
  коммит): контент v1 1:1 — `src/data/interactables.ts` (столбы у троп,
  моты, деревья-механизмы тонов, резонатор, сундук/записка/очаг, прилавок,
  верёвка колокола, Старая Машина; реакции по `when` с флагами/vars,
  requiresItem → lockedText, once → used:<id>), `src/data/hazards.ts`
  (накат прудов/разъезда), `src/areas.ts` + interactables/hazards/
  harvestTile (цветки — только пруды). Рантайм: `src/interactables.ts`
  (resolveInteractable → apply через sink: флаги → вар → предмет → used →
  custom → тост с прогрессом), `src/props.ts` (пропы строго в тайл 3×3:
  мот — искра, столб/сундук/записка/верёвка/резонатор; clearProp при once),
  `src/flowers.ts` (FlowerQuest: сбор/посадка/пересев/зелень; правки —
  воксель в центре тайла + журнал FlowerEdits по областям, переживает
  пересборку мира в setArea). В main.ts: кандидаты «E» с приоритетом v1
  (npc → click-переход → интерактив → цветок; радиус 4.5 вокселя, цветы —
  окно ±2 тайла по isFlower), накат — тост при входе + ход ×0.5 без
  полотна (масштабирование вектора движения), катсцены — стек шагов
  {dur, fn?, cam?} с глайдом камеры (мир замер, детерминизм в stepTicks);
  custom-эффекты: диалоговые — plant_flowers отложен до onFinish (v1
  pendingCustom), интерактивные — sink.onCustom (lull_machine → тост).
  Мост: interactInfo (kind/near/target/flower), voxAt, flowerTiles. Гейт
  `tools/checks/interact.mjs` — 27 проверок (реакции/ветки объектов, накат
  ×2 с полотном, сбор цветка, Машина: заперто → усыплена → спит, катсцена
  сдачи: герой стоит, камера у башни на середине глайда, 3 тайла цветут,
  кадр изменился). Уроки: квест надо взять ДО сдачи (questDialogueFor по
  активной стадии — без elder_first сдача не открывается, зависший выбор
  блокирует interact); vars.<ключ> до первой записи undefined (не `=== 0`);
  setTimeout-тосты в headless не тикают и копятся (чистые скриншоты —
  отдельным прогоном); тайлы зелени башни — тропа (PATH=1), а
  raiseTowerGreen сажает только на GRASS/ASH (v1-faithful — зелень у башни
  не всходит и в v1).
- **Трек 18 — время суток и атмосфера** ✅ (2026-09-10): часы суток
  (GameClock, 1 сек = 1 игровая минута, старт 8:00 — порт v1 1:1),
  кривая день/ночь из движка (dayNightFactor + dayNightAmbient, пик
  золотого часа 0.45 в 18:54) через новые API рендера: setNight(k, tint)
  (hemisphere → tint, фон = tint, солнце × (1 − 0.92·k)) и
  setDayAmbient(color) (дневная база области; тёмные интерьеры гасят
  hemisphere и солнце по яркости — аналог multiply-ambient v1),
  addPointLight (distance = radius×UNIT, decay 1.6). Игра: данные
  освещения 8 областей v1 1:1 (ambient/nightAmbient/duskColor/sources с
  whenFlag/notFlag/whenNight), GameLighting (diff по id, мерцание
  flickerFactor, лампа героя HERO_LAMP в темноте/ночью, MAX_LIGHTS 24),
  пепел лугов (AshParticles, 36 вокселей, детерминированный дрейф от
  elapsed), темы областей (GameAudio: playTheme с кроссфейдом, тема
  только у лугов — v1 1:1). Мост: clockInfo/setClock/lightingInfo/
  audioInfo/setAsh. Гейт `tools/checks/atmosphere.mjs` — 18 проверок
  (кривая ночи по часам, источники по флагам/ночи, мерцание, кадр ночи
  темнее, пепел дрейфует, очаг hearth_low → hearth_high, отсвет башни по
  rang_tower, тема лугов / тишина в интерьере). Вне скоупа (отложено):
  туман прудов, тёмные ауры наката (darkSpot), виньетка, локальные
  амбиент-слои. Уроки: страница в гейте живёт в реальном времени до
  setManual (время уплывает — фиксировать setClock в начале); пепел —
  живой слой кадра (гейты кадр-равенств зовут setAsh(false) + stepTicks
  перед скриншотами); интерьеры без nightAmbient — только setDayAmbient;
  пик золотого часа t = 18 + 0.45×2 = 18:54, не 18:48.
- **Трек 19 — акт 2** ✅ (2026-09-10): контент акта 2 уже лёг с треками
  16-17 (диалоги Клинта, квест resonator, интерактивы тон-деревьев и
  Машины) — трек добавил рантайм и фауну: Клинт появляется по act2_hook
  (спавн при перезаходе), сдача резонатора — катсцена настройки (камера к
  резонатору → звон → роща отвечает → тост), эпилог machine_hook,
  усыпление Машины — катсцена «правильного звона» (камера к устью),
  олени — воксельная FaunaSystem v1 1:1 (брожение по соседним тайлам,
  подход на звон, детерминированный Rng). Мост: faunaInfo/startleFauna/
  setCam/cutsceneInfo/tileCenter + тулза `tools/shot.mjs` — оперативный
  скриншот участка карты с состоянием сюжета (агентный просмотр). Гейт
  `act2.mjs` — 27 проверок. Грабли: questTracker показывает первую
  активную стадию первого квеста (bells) — квест резонатора в трекере
  не виден до сдачи цветов (v1-faithful); тосты катсцен — в финальном
  шаге (гейтам stepTicks до конца); pkill -f убивает свою сессию —
  зомби-vite только по PID. Вне скоупа: sfx-звуки реакций (поле sound
  v1), боевой звон как триггер оленей (мост startleFauna до переноса
  боя).
- **Трек 20 — скрипты как данные + агентный просмотр** (новый, из
  практики трека 19): катсцены — третий повтор паттерна «стек шагов
  {dur, fn?, cam?}» в main.ts — поднять в движок жанронезависимый
  CutsceneRunner (стек шагов, глайд камеры, валидация шагов), игра даёт
  данные (data/cutscenes.ts: метки точек камеры + реестр именованных
  действий) и валидацию при старте (как validateDialogue); dry-run гейт —
  «каждая катсцена доигрывается stepTicks-ом до конца и возвращает камеру
  к герою». Полноценную событийную систему (триггеры onEnter/onFlag →
  скрипты) не заводить до третьего кейса, который не ложится на
  квест-стадии.
- **Трек 21 — сборка**: сейв полного состояния, меню (подтверждено
  фидбеком: пользователь заметил, что меню нет — игра стартует сразу в
  мир), полный прогон, сравнительные скриншоты с v1 как референсом.
  **Меню и сейв закрыты раньше срока** — раундом полировки (ниже); из
  трека 21 осталось: полный прогон и сравнительные скриншоты с v1.
- **Раунд полировки** (2026-09-10, «улучшим игру технически и визуально»):
  недоделки визуального слоя трека 18 + техполировка. Меню (Esc) +
  автосейв/«Продолжить» (движковый SaveManager, localStorage `rpg2:`;
  `GameSave` — полный снапшот сюжета: область/тайл, флаги+вары, сумка,
  часы, правки цветков — применяются до `setArea`, мир строится цветущим);
  виньетка опасности + пелена наката (`screenFx.ts`, DOM); частицы v1
  Atmosphere (`ambientParticles.ts`: мотыльки лугов, дымка прудов — клочья
  якорятся на клетки воды); шиммер воды прудов (`waterShimmer.ts`: редкие
  блики-вспышки по фазам sin). Мост: `ambientInfo`, `menuInfo`,
  `startGame(load)`; страница стартует в меню — всем гейтам и `shot.mjs`
  сначала `startGame(false)`. Грабли: **`addVoxelCloud` после пересоздания
  облака возвращает castShadow=true** — гасить после каждого update;
  **z-fighting копланарных instanced-кубов с world-мешем** (блик на воде
  мерцает на кромках пруда от порядка отрисовки, кадр-равенства падают) —
  сдвиг EPS 0.03 из плоскости; **зомби-vite переживает process.exit
  тулзы** — kill по PID из `ss -tlnp`.
