Живой документ планирования: сюда сходятся планы на развитие, актуальные задачи и проработка крупных направлений. Срез-статус архитектуры — 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) приемлемо.
tools/pixelart/palette.mjs), размеры спрайтов по docs/art-style.md, pixel-perfect. Любая AI-генерация — только как сырьё с обязательной пост-обработкой: даунскейл nearest, квантизация в палитру, проверка чек-листом арт-библии. AI-вывод не попадает в assets/ напрямую.tools/audio/gen.mjs). AI-вывод приводится к нему (ffmpeg: sample rate, моно, нормализация, обрезка тишины).npm run maps:fresh).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 + скриншот-смоук.
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 фигуры в ряд стабильна).tools/aiart/recolor.mjs (CLI по ключам палитры/hex) + поле remap в манифесте атласов — применяется в build после квантизации (плащ звонаря приведён к C0/C1).assets/ — размер в слоте, все цвета из палитры, детект «мыла» (градиентные пары соседних пикселей — чужая плотность пикселя). ✅ Сделан (2026-09-06): tools/aiart/lint-art.mjs (npm run art:lint по всем assets, негативы проверены); в agent:check не заведён — по мере надобности.tools/aiart/ (post.mjs — кроп/квантизация в палитру + обзорный лист; manifest.json — вердикты). Pixel Forge отложен (нет Rust-тулчейна). Ограничение: критичные для игры ассеты (колодец, столб, рычаг, колокольчик, моты) вне 38 классов модели — для них дообучение / RD API / ручной арт.manifest-b1.json.probe-b3/sd_q8_fss_42_broken.png. Вердикт: путь рабочий для разовых атласов (4 вида ≈ 30 мин/персонажа), но кадры-позы придётся дорабатывать руками; полный прогон — при потребности акта 2. Пробы: v1/apps/game/tools/aiart/probe-b3/. Фолбэк — RD API или ручной арт.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, у границы объекта скачок).ui/icon_*) через механизм InteractableDef.view.tex (якорь низ-центр по тайлу, вместо Graphics- плейсхолдера) и Sprite в InventoryScene (строки с value: '' — метка прижата влево, иконки слева синхронны окну скролла). Старая Машина — AI.gen.mjs (toneTreeTile/resonatorTile) — стиль единый с остальными тайлами. Вывод: AI — для мелких/средних объектов с чистым силуэтом; крупные — код.tools/aiart/review-b4/ (raw+processed, 160+), в git не идут.Акт 2 захочет здания и крупные деревья — объекты больше 1×1 тайла. Сегодня движок умеет только TallSpec (1 тайл footprint + произвольная высота) и ручной blocked. Большой спрайт, якоренный в один тайл, сортируется глубиной по точке — герой сбоку от дома перекрывается неверно.
(x+w-1)+(y+h-1), IsoDepthLayer.addRect), а не по точке якоря.gen.mjs (композит: модули стен + крыша — наш бесплатный путь); апскейл nearest из 24×24 (только болванка — чужая плотность пикселя рядом со спрайтами 1×); RD API (умеет большие размеры и тайлы, платный).props в TileMapData (footprint, коллизия по всему прямоугольнику, земля под ним), IsoDepthLayer.addRect (глубина по диагонали юго-восточного угла footprint'а), валидация в mapFormat, тесты; LocationScene вставляет map.propViews в общий depth-слой.docs/art-style.md (дом 2×2 — 64×80, дом 3×3 — 96×96, здание 4×4 — 128×112; якорь ставит движок). Плотность пикселя — как у тайлов; AI-апскейл в слоты не попадает.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 — нативно прямоугольные.| Класс | Что это сейчас | 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 | зрелый, но на нашей машине безнадёжно медленный; пропускаем |
gated: auto, анонимно 401 — нужен HF-токен + принятие Stability Community License (весов Small-SFX/Small-Music как отдельных репо нет; доступен общий stable-audio-open-small).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).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.
data/sfxSpecs.ts.) ✅ сделано 2026-09-09 (PoC на фолбэке AudioLDM2, слушание — у пользователя).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 — остались спек-синтезом (см. выше).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-токена.Итог аудио-работ (2026-09, коммиты 57bce47…4c1c56c): в движке — 4 шины, кроссфейд слотов music/ambience, playLoop без слота, pan/rate, шпион onPlayed; в игре — позиционный AudioSystem, локальные амбиент-слои, событийные звуки боя, DEV-лог __gameAudioLog. Ниже — потребности, которые упёрлись в границы AudioManager, по убыванию полезности. Игровая логика (кто слушатель, затухание по дистанции, playAt) остаётся в игре — в движок её не тащить.
play() возвращает void: нельзя остановить, перезапустить «с нуля» (опция «убить прежний экземпляр ключа») или изменить volume/pan/rate в полёте. Самый чувствительный кейс — движущийся источник: панорама фиксируется в момент запуска.MIN_INTERVAL на ключ в игровом AudioSystem. Нужен maxVoices с воровством самых тихих/старых голосов — защита от каскада (десяток combat:* в один тик).onPlayed зовётся только для sfx — лупы (музыка/амбиент/слои) шпион не видит, игре пришлось логировать амбиент вручную в Game.playAmbience. Нет onEnded — нечем ловить «звук доиграл» для синхронизации с анимацией.duration(key) — сейчас её не достать, а нужна катсценам и генератору шагов (не играть следующий шаг раньше конца предыдущего).playBuffer(AudioBuffer) — синтез сейчас только оффлайн (tools/wav.mjs); генерировать вариации звука в рантайме движок не может (сейчас джиттер имитируется rate).SfxHandle { stop(fadeSeconds?), setVolume(v), setPan(v), setRate(v) } — по образцу MusicHandle; опция restart/«один экземпляр на ключ»; maxVoices с воровством голосов.onPlayed на все запуски (sfx + лупы, опции нормализованы); onEnded(key); duration(key). Игра: ручное логирование амбиента убрано (лог целиком из шпиона).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.Движок строится агент-первым: у агента нет слуха, и искать готовые сэмплы в библиотеках он не может. «Генерация по запросу» для агента — параметрический синтез: агент описывает звук спеком (форма, полоса шума, тон, огибающая, сид), движок детерминированно компилирует спек в буфер (база — D2: createBuffer/playBuffer + tools/synth.mjs), факт запуска наблюдаем через __gameAudioLog. Нейросеть позже — опциональный «описание → спек» маппер поверх того же API; сэмпл-библиотеки как основной путь не используются.
audio.playSpec(key, spec, opts?) — kinds hit/chime/scrape/hum, параметры: dur (обяз.), low/high (полоса шума), tone (низкая подложка/основной тон гула), freq (звон), power (крутизна затухания), seed (детерминизм), peak, loop (кроссфейд краёв). Кэш буферов по ключу. Тесты: детерминизм (одинаковые спеки/сиды → одинаковые сэмплы), kinds, кэш.scene:synthesize (спек → playSpec), документация форм спеков в docs/engine/agent.md; проба checks/synth.mjs (в agent:check) — агент слепо синтезирует удар/звон/ скрип/гул, проверка фактов запуска в аудио-логе + отрицательные случаи.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-сайте.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 (тон/тремоло/свип) — по мере расширения палитры спеков мигрируются так же.✅ Сделана (2026-09-07): mute/suspend при visibilitychange (опция pauseOnHide, по умолчанию включена); bus в PlayOptions разового play (по умолчанию sfx); exponential-фейд на затуханиях (нарастание — линейное: экспонента не определена от нуля).
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). Осталось: нейронка «описание → ноты» — опционально.
~/.cache/rpg-ai/venv, venv-pxgpt), веса в ~/.cache/rpg-ai/; в git — манифесты и review-PNG. Пересоздано 2026-09-09 на новой машине: venv на python3.11 (torch 2.14 CPU
gen_pxgpt.py восстановлен в репо (tools/aiart/gen_pxgpt.py, маппинг токенов сверен с review-b1: токен 0 — прозрачный, токен t — цвет t-1 палитры промпта). PXG-Tiny детерминирован между машинами (сверка с review B0 — идентичные пиксели); PixelGPT и SD — воспроизводимы до шума сэмплирования между версиями torch.Решение (2026-09-09): текущий движок считается черновиком (v1) — он остаётся рабочим витринным движком, чиним и допиливаем его без больших рефакторингов; накопленные уроки формируют видение v2. Этот раздел — живой: пользователь и агент пополняют его по мере осознания.
GameLoop 60 Гц + stepTicks, ручной шаг без рендера) — основа воспроизводимости и агентного моста.lightSim, shadowSim, postSim, particleSim, fxSim — без Pixi, тестируются в Vitest без браузера. Лучшее решение проекта: баги ловятся тестами до запуска игры.window.__agent, снапшоты-слои, инварианты, CLI-сценарии проверок, practices.md. Состояние читается снапшотом, скриншот — только для визуальных вопросов.StorageLike и другие) — тесты без DOM.llms.txt, доки движка по модулям, «грабли».{x,y}, offset worldRoot смешивались в сигнатурах и головах; направление тени считалось прямо в экранных координатах (баг «летающих» объектов). В v2: все направления/позиции — в одном мировом пространстве, проекция — единственная точка на границе рендера; типы различают пространства (юниты ≠ px ≠ тайлы).LocationScene ~900 строк) разрезалась вручную и всё равно разрастается. В v2: декларативная композиция сцены из систем с явным порядком тика (карта → свет → тени → постэффекты).Renderer в сигнатурах хелперов). В v2: рендер-бэкенд за узким интерфейсом, ядро движка его не знает.toneFree/holeFree/...) — многословно и ошибкоёмко. В v2: декларативные слои с авторазмещением по ключу.Фокус прежний: движок + инструментарий ИИ-агента; игра — демо-полигон. Демка остаётся изометрией (фиксированная камера), движок v2 — жанронезависимый.
.vox (MagicaVoxel) как мост к готовым инструментам (аналогия: Tiled-импорт в v1).tools/pixelart/gen.mjs) — основной путь: детерминизм, ревью диффом, юнит-тесты;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 по мере движения.
Цель: на движке 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):
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).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-условия стадий — те же предикаты, что в графах).src/data/interactables.ts (столбы у троп, моты, деревья-механизмы тонов, резонатор, сундук/записка/очаг, прилавок, верёвка колокола, Старая Машина; реакции по when с флагами/vars, requiresItem → lockedText, once → used:), 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).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.tools/shot.mjs — оперативный скриншот участка карты с состоянием сюжета (агентный просмотр). Гейт act2.mjs — 27 проверок. Грабли: questTracker показывает первую активную стадию первого квеста (bells) — квест резонатора в трекере не виден до сдачи цветов (v1-faithful); тосты катсцен — в финальном шаге (гейтам stepTicks до конца); pkill -f убивает свою сессию — зомби-vite только по PID. Вне скоупа: sfx-звуки реакций (поле sound v1), боевой звон как триггер оленей (мост startleFauna до переноса боя).packages/engine/src/core/cutscene.ts: CutsceneRunner — стек шагов, глайд камеры по инъекции CutsceneCamera, глайд по времени после инкремента — последний тик достигает цели точно; действие шага может перезапустить сцену; validateCutscenes — числовая валидация каталога, 8 юнит-тестов). Игра даёт данные (src/data/cutscenes.ts: метки точек камеры CUTSCENE_CAMS тайлами v1, реестр CUTSCENES — hand_in / tune / machine_lull) и валидацию при старте (resolveCutscenes(actions) — неизвестная метка/действие → throw, как validateDialogue). Мост: playCutscene(id) + каталог в cutsceneInfo; dry-run гейт tools/checks/cutscenes.mjs (13 проверок: каждая сцена доигрывается stepTicks-ом до конца и возвращает камеру к герою). Грабли: 40 тиков = 0.67 с — ещё первый шаг (dur 0.8–1.2 с): прогресс проверять t > 0, не step >= 1. Полноценную событийную систему (триггеры onEnter/onFlag → скрипты) не заводить до третьего кейса, который не ложится на квест-стадии.v1/apps/game/tools/compare-shot.mjs и v2/apps/game/tools/compare-shot.mjs): 5 общих участков (поляна лугов, пруды, Звенец у колодца, дом Ирвина, роща у резонатора) — герой в одном тайле, день 10:00, листы попарно v1|v2 (/tmp/cmp_sheet_*.png). Итог сверки: раскладка мира, объекты, переходы и подсказки «E» совпадают 1:1 (карты конвертированы без искажений); допустимые расхождения референса — v2-камера крупнее (~2×, UNIT=3), тон тёмных областей светлее (пруды/роща/дом без сумеречного затемнения v1), вода бледнее, интерьер дома беднее деталями (полки/камин v1 — пропы строго в тайл 3×3).tile*UNIT + 2, не 1.5; рельсы — нить d=0..UNIT-1 (была с дырками); spotVoxel тайла — tile*5+2.wrap) — при движении героя стоят на месте. Генераторы под тайл 10×10 (ствол 2×2, дерево ~30–38, дом 24×30–40×18, башня 47, Машина 24×34×18), высота мира 52, стены 13. Манекен движка переписан: 5×11×3 → 11×21×4 (голова-куб, торс, руки/ноги 2×2 — силуэт читается; 220 вокселей; тест связности — BFS по 6-связности вместо лесенки-сорта). Герой 2.1 юнита/с (stride 12.8 — темп v1), камера viewSize 80, радиусы интеракта 15. Гейты переведены (t*10+5, ±10, диапазон камеры tune 125..255): 6 игры + 11 демок зелёные, 295 тестов.mannequin(palette?: MannequinPalette): палитра {torso/legs/head/arms} красит слоты, форма не зависит (палитра на model.palette); тип экспортируется из движка. У героя и NPC свои палитры (NpcDef.colors): герой — светлый лик/ржаво-красный плащ, Ирвин — седина/зелёный плащ, Мила — каштан/сарафан, Клинт — шапка/медный фартук; олень ~10 вокс (ноги 4). Объекты: дом — двускатная крыша коньком вдоль X + деревянная дверь на видимом фасаде (z=D−1; z=0 от камеры невидим — изометрия показывает фасады +x/+z) + труба; дерево — крона с профилем (низ вздут, верх сходит на нет; ель ярусами). Гейты аним-листа под мелкий манекен (diff фаз 50→40, подъём стопы 0.8→0.7). 6 гейтов игры + 11 демок зелёные, 296 тестов. Тулза crop-zoom.mjs — зум-кроп снимка. Грабли: UNIT=10 чётный — центр тайла tile*UNIT + 5; ствол дерева 2×2 (1×1 на этом масштабе — щепка); в проверках гейтов легко оставить старый +5 (сравнение возврата камеры) — искать grep-ом + 5) после смены масштаба.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.MannequinPalette {skin?, hair?, torso?, legs?, arms?, boots?, belt?, eyes?}. Главная грабля: камера смотрит сверху под ~55° — луч к фасаду проходит через воксель уровнем выше и той же z-колонкой, шапка волос на весь фасад прятала глаза (снапшоты зелёные!) — череп ступенькой: лицо до y21, шапка назад до z4. Ирвину тёплая кожа в тон седине сливалась в «череп». 6 гейтов игры + 11 демок зелёные, 297 тестов; визуальная итерация по shot.mjs --at 16,28 + crop-zoom.mjs.Трек 26 (2026-09-10, «гигант; натяните текстуру и сделайте меньше вокселей»): деталь — текстурой, не геометрией. Формат моделей — слой tiles/tex: тайль = пиксель-арт на грани вокселя (VoxelTile, тексель = слот палитры — перекраска красит и текстуры), tex — id тайля на воксель; валидация числовая. Рендер — addVoxelCloud(voxels, tiles): атлас-канвас (ячейка = 6 суб-граней тайля, 0 — белая), инстанс-атрибут aTileId + шейдерная инъция (грань куба из нормали); воксель с тайлем белый, цвет дают тексели. Манекен 14×24×8 → 10×16×4 (164 вокс): лицо — арт 8×8 текселей (бахрома, глаза, рот) тайлями 2×2 на фасаде головы, волосы — макушка/затылок; правило ракурса ~55° сохранено. Игра: BODY_SIZE [5,16,5], stride 9.6; порог маха anim-гейта 1.0→0.8 (ноги вдвое короче). 6 гейтов игры + 11 демок зелёные, 300 тестов.
Трек 27 (2026-09-11, «воксели должны быть частью модельки и поворачиваться вместе с ней — на диагонали торчат углами»): поворот персонажа стал жёстким — PosedVoxel.rotY (опциональный поворот самого куба), Character.visual() ставит rotY = yaw на каждый воксель, рендер строит матрицу инстанса makeRotationY(rotY) + setPosition (раньше крутились только позиции — кубы оставались осями-выровненными и «пилили» силуэт). Олень без изменений: четверти оборота держат сетку на осях. Проба apps/game/tools/probe-diag.mjs (диагональ, yaw 3π/4). 6 гейтов игры + 11 демок зелёные, 301 тест.