Newer
Older
rpg / v2 / CLAUDE.md

CLAUDE.md — движок v2 (воксельный)

Всегда общайся с пользователем на русском.

Направление и решения — docs/plan.md (в корне репо), разделы «Уроки черновика → видение движка v2» и «Направление v2». Открытые вопросы v2 — дописывать туда, не решать молча.

Что v2 есть (решено)

  • Воксельный движок как продолжение пиксель-арта: рендер сцены в низкое разрешение (nearest-апскейл), палитра вокселей = арт-библии v1 (docs/art-style.md), shadow map + воксельный AO.
  • three.js — только низкоуровневый рендер-бэкенд (роль Pixi в v1); ядро движка о бэкенде не знает — рендер за узким интерфейсом.
  • Формат моделей: внутренний JSON (валидация агентом) + импорт/экспорт .vox (MagicaVoxel).
  • Генерация моделей: процедурные генераторы кодом (основной путь), image-to-3D + вокселизация с апрув-гейтом, ручной MagicaVoxel для героев; визуальный гейт — turntable-листы.
  • Анимация = данные: рига + кривые ключевых кадров в JSON, процедурная локомоция; валидация числовая (непрерывность цикла, контакт стопы) + снапшоты поз в агентный мост.
  • Агентный мост и CLI-проверки — часть ядра v2, не надстройка. Демка — изометрия с фиксированной камерой; фокус работы — движок и инструментарий.

Уроки v1, обязательные к соблюдению (docs/plan.md)

  1. Все позиции/направления — в одном мировом пространстве; проекция на границе рендера. Не смешивать пространства (главный источник багов v1).
  2. Визуальная подсистема считается готовой только со скриншот-гейтом, не только снапшот-ассертами.
  3. Чистая математика — отдельно от вьюх, тестируется без рендера.
  4. Не оставлять подключённый, но неиспользуемый путь; удалять или связывать.

Команды (из v2/)

npm install          # установка зависимостей (workspaces)
npm test             # юнит-тесты (Vitest)
npm run typecheck    # tsc --noEmit (движок + демка)
npm run check:fast   # typecheck + тесты
node apps/demo/tools/checks/render.mjs   # скриншот-гейт рендера (Chromium, /tmp/v2_render_*.png)
node apps/demo/tools/checks/physics.mjs  # скриншот-гейт физики (/tmp/v2_physics_*.png)
node apps/demo/tools/checks/hero.mjs     # скриншот-гейт героя (/tmp/v2_hero_*.png)
node apps/demo/tools/checks/dialogue.mjs # скриншот-гейт диалогов (/tmp/v2_dialog_*.png)
node apps/demo/tools/checks/inventory.mjs # скриншот-гейт инвентаря (/tmp/v2_inv_*.png)
node apps/demo/tools/checks/save.mjs     # скриншот-гейт сохранений (/tmp/v2_save_*.png)
node apps/demo/tools/checks/scenes.mjs   # скриншот-гейт сцен/меню (/tmp/v2_scenes_*.png)
node apps/demo/tools/checks/combat.mjs   # скриншот-гейт боя (/tmp/v2_combat_*.png)
node apps/demo/tools/checks/audio.mjs    # гейт аудио (DEV-шпион, /tmp/v2_audio_*.png)
node apps/game/tools/checks/areas.mjs    # гейт областей игры (/tmp/v2_areas_*.png)
node apps/game/tools/checks/npc.mjs      # гейт NPC/квестов игры (/tmp/v2_npc_*.png)
node apps/game/tools/checks/interact.mjs # гейт интерактива/цветков игры (/tmp/v2_interact_*.png)
node apps/game/tools/checks/atmosphere.mjs # гейт времени суток/света/музыки игры (/tmp/v2_atmosphere_*.png)
node apps/game/tools/checks/act2.mjs      # гейт акта 2: резонатор/Машина/олени (/tmp/v2_act2_*.png)
node apps/game/tools/checks/cutscenes.mjs # гейт катсцен: dry-run сцен, камера к герою (/tmp/v2_cutscene_*.png)
node apps/game/tools/compare-shot.mjs --dir /tmp   # сравнительные снимки с v1 (5 участков, пары к v1/apps/game/tools/compare-shot.mjs)
node apps/game/tools/shot.mjs --area rust_grove --at 24,16 --flags act2_hook --out /tmp/s.png
                                          # оперативный скриншот участка карты агентом
npx vite --port 5299                     # демка (в apps/demo), http://localhost:5299
npx vite --port 5315                     # игра «Пепельные луга» (в apps/game)

Структура

  • packages/engine (@rpg/engine) — ядро:
    • src/voxel/grid.ts — воксельная сетка (Uint8, 0 = пусто, иначе индекс палитры);
    • src/voxel/mesher.ts — culled-меш + классический AO 0..3 (схема 0fps, флип диагонали); meshVoxels для сетки, meshRegion — область по функции доступа; чистая математика, тестируется без рендера;
    • src/world/chunk.ts — чанк 16×H×16; src/world/world.ts — мир-словарь чанков (get/set через границы, dirty-трекинг: правка боковой кромки помечает соседа); src/world/worldMesher.tsmeshChunk через world.get, шов чанков бесшовный (culled и AO через границы);
    • src/render/voxelRenderer.ts — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); addVoxelCloud — instanced-кубы для поз анимации (не выровнены по сетке; обновление позы без пересборки геометрии); второй аргумент tiles (resolveTiles(model)) включает текстурный слой: атлас-канвас (строка ячеек = 6 суб-граней тайля, ячейка 0 белая), инстанс-атрибут aTileId + шейдерная инъция (грань куба — из нормали, сэмпл атласа); воксель с тайлем красится белым instanceColor (цвет дают тексели);
    • src/math/rng.ts — детерминированный Rng (mulberry32): next/int/range/chance/pick;
    • src/models/format.ts — формат модели (JSON: size + плотный base64 + слоты-палитра), encode/decode/validate (числовая валидация агентом); текстурный слой (трек 26): tiles — тайли пиксель-арта на грани вокселя (VoxelTile {size 1..8, faces[6] +x,−x,+y,−y,+z,−z}, тексель = слот палитры — перекраска красит и текстуры) + tex (байт на воксель — id тайля); encodeModel(g, palette, texture?), texIdAt/texBytes/ resolveTiles (texels→hex, вход рендера); валидация ловит tex без tiles, size вне 1..8, тексель 0, слот вне палитры, битые ссылки tex;
    • src/models/generators.ts — процедурные генераторы: treeModel (варианты 0 круглая / 1 колонна / 2 ель), boulderModel (union сфер, свет на верхушках столбцов); (вид, сид) → VoxelModel, детерминировано;
    • src/models/stamp.tsstampModel: штамповка модели в мир с клиппингом (half-open прямоугольник + вертикаль мира), возвращает число вокселей;
    • src/models/vox.ts — импорт/экспорт .vox (MagicaVoxel): чанки MAIN/SIZE/XYZI/RGBA (PACK>1 — ошибка, неизвестные чанки пропускаются с детьми), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей; при экспорте слоты модели сжимаются в подряд 1..N;
    • src/models/paletteMap.tsremapSlots(model, palette): перекладка слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox;
    • src/models/mannequin.tsmannequin(palette?): эталонная модель 10×16×4 «путник» (~1.6 тайла, 164 вокс; трек 26 — меньше вокселей, деталь на текстурах) с ригой (root → legL/legR/armL/armR): сапоги с мысками, юбка плаща шире, пояс, плечи, кисти, шея, голова; лицо — текстурой (арт 8×8 текселей: бахрома волос, глаза 2 текселя, рот — тайли 2×2 на 16 вокселях фасада z3 головы), волосы — макушка и затылок геометрией; над фасадом лица в той же z-колонке вокселей нет (ракурс ~55°, урок трека 25); MannequinPalette {skin?, hair?, torso?, legs?, arms?, boots?, belt?, eyes?} — дизайн параметром (тексели лица ссылаются на те же слоты — перекраска красит и лицо);
    • src/animation/mat.ts — аффинная математика без three (Vec3/Mat3/Affine, порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера;
    • src/animation/rig.ts — рига: makeRig (привязка явная или bindNearest по ближайшему pivot), validateRig (родители раньше детей, длина и согласованность привязки), poseVoxels(rig, model, pose) — деформация (у textured-моделей тянет tile — id тайля вокселя);
    • src/animation/clip.ts — клипы: ключи {t, кость: rot/pos}, samplePose (линейный семплинг, заворот t для циклов), validateClip (границы, монотонность, замкнутость цикла), footMinY (контакт стопы);
    • src/animation/locomotion.tswalkClip(params): процедурный walk-цикл (ноги в противофазе, руки против одноимённой ноги, корень приседает дважды за цикл и качается вбок); детерминирован, проходит валидацию;
    • src/physics/body.ts — физика тела: AABB (pos = центр низа, vel, size, grounded) против вокселей (SolidWorld — точечная выборка get, чистая математика), makeBody/stepBody (полунеявный Эйлер), ось-раздельное разрешение X→Z→Y с зажимом к граням вокселей (пол — ровно на грань, EPS-зазор), сабстепы (< 0.9 вокселя за подшаг) против туннелирования;
    • src/physics/character.tsCharacter: контроллер персонажа — тело физики + walk-анимация, фаза клипа растёт пропорционально пути (путь/stride — стопы не скользят), в покое замирает, поворот по движению; visual() — деформированные воксели для облака рендера;
    • src/core/loop.tsGameLoop: фиксированный шаг (60 Гц) + рендер, аккумулятор с maxStepsPerFrame; setManual + stepTick(n) — ручной режим для моста/гейтов (детерминизм); now/schedule инъектируются;
    • src/core/ecs.tsEntityWorld: минимальный ECS (перенос v1): сущность-число, компоненты по имени, query, системы по порядку;
    • src/core/controls.ts — ввод как данные: ControlState (движение в ЭКРАННЫХ осях) + screenMoveToWorld(move, yaw) — проекция экранных осей в мировую горизонталь на границе (урок №1), диагональ не быстрее;
    • src/dialogue/graph.ts — диалоговые графы как данные (перенос v1): узлы (speaker/text/choices/next/end) + условия (when/whenNot/whenVar/ hasItem) + эффекты (setFlags/setVars/do[]), evalConditions (все группы AND; не-число в gt/lt/ge/le — ложно), validateDialogue — числовая валидация графа агентом (битые ссылки, тупики, выборы без текста); DialogueState — интерфейс состояния (игра реализует поверх своего стейта), DialogueWorld — предикаты мира (сумка);
    • src/dialogue/runner.tsDialogueRunner: рантайм семантики v1, отделён от отрисовки (DialogueView — игра рисует сама). Узел с текстом ЖДЁТ игрока: advance() → next или конец, pick(i) → вариант; advance во время выбора игнорируется; узел без текста — «действие»: эффекты и автопровал в next; MAX_STEPS против циклов; эффекты do[] движок только эмитит (onEffect), исполнение — игра; геттеры nodeId/node/choices/waitingForChoice/path/result — снапшоты для моста;
    • src/interaction/interactables.ts — точки взаимодействия как данные: Interactable {id, pos, radius, label, dialogueId?}, чистые nearestInteractable (ближайшая в радиусе, Y не учитывается) и interactablesInRadius (список с дистанциями);
    • src/inventory/inventory.tsInventory (перенос v1 1:1): контейнер предметов-счётчиков (Map по id), лимиты слотов/стака обрезают и возвращают факт (модель не бросает), onChange с деталями изменения, serialize/load (мусор игнорируется, кламп под лимиты), inventoryFromData;
    • src/save/saveManager.tsSaveManager (перенос v1 1:1): JSON-слоты save:<slot>, мета в параллельном ключе meta:<slot> (старые сейвы читаемы), load битого JSON → null; хранилище — StorageLike инъекцией (localStorage в демке, Map в тестах);
    • src/core/scene.tsSceneManager (перенос схемы v1, без привязки к бэкенду): стек сцен push/pop/replace; переход {duration} фазами затухание → смена → проявление (каждая фаза длится duration — полный переход 2×duration); FadeOverlay {setAlpha} инъекцией (демка — DOM-слой, тесты — заглушка); параллельные переходы глотаются (pending-проверка), enter новой сцены не ожидается (void); update тикает текущую сцену и во время перехода, render рисует весь стек;
    • src/core/cutscene.tsCutsceneRunner (катсцены как данные, жанронезависимо): стек шагов {dur, fn?, cam?} (dur — сек ручного времени, fn — действие на старте шага, cam — цель камеры), глайд камеры по инъекции CutsceneCamera {get, set} (рендер знает только рантайм — игра), глайд по времени ПОСЛЕ инкремента (последний тик достигает цели точно, k=1); действие шага может перезапустить сцену (start/abort) — тик принадлежит новой сцене; validateCutscenes (числовая валидация каталога агентом: пустых нет, dur конечен > 0, cam — конечная тройка); info() — снапшот для моста; мир снаружи замер (update вместо тика мира), возврат камеры к герою — политика игры;
    • src/combat/ — боевая система (перенос v1, чистые юниты; 1 боевой юнит = 3 вокселя демки, конвертация только на границе демки): shapes.ts (horDist/horNorm/inCircle/inCone), los.ts (hasLineOfSight по opaque-предикату), stats.ts (PLAYER_COMBAT: конус range 1.0/урон 2/ кулдаун 0.45, резонанс заряд 0.6/радиус 3/сон hp≤4, неуязвимость 0.8, отброс 0.75, wakeRadius 4.5), enemies.ts (ENEMY_KINDS: ползун — контактный лип 0.4, плевун — держит дистанцию 2.25 и плюёт, тяжёлый — медленный контакт 0.8; withDefaults), playerCombat.ts (заряд/выпуск: короткий — конус, длинный ≥0.6 — резонанс; hp/неуязвимость), brain.ts (EnemyBrain: стейты dormant/rise/chase/hurt/flee/sleeping, слух — шум 0.35 шаги / 0.6 выстрел слышат бодрые / 1.0 звон будит спящих в wakeRadius; StateMachine + Cooldown), world.ts (CombatWorld: спавн/шум/урон/снаряды/трупы; deps {getPlayerPos, damagePlayer, solid, opaque, hooks?} — мир предикатами; хуки {onEnemyHurt, onEnemyKilled, onEnemyAwake, onAttack, onSpit} вместо EventBus v1; EnemySystem — senses→intent→шаг→толкучка-сепарация (равновесие = сумма радиусов; вталкивание в solid-колонку откатывается), ProjectileSystem — полёт, opaque-колонка или срок жизни убивает; moveCircleHor/circleHitsSolid — круг против bbox-клеток, ось-раздельный зажим).
    • src/audio/ — аудио-подсистема (перенос v1 1:1, без WAV-пайплайна — всё на спек-синтезе): types.ts (шины master/music/sfx/ambience, хендлы, PlayOptions/LoopOptions), decode.ts (BufferCache: fetch+decodeAudioData, in-flight дедуп), Voices.ts (полифония разовых sfx: лимит с воровством самого тихого, restart по ключу, панорама StereoPanner в полёте), Loops.ts (слоты музыки и амбиента с кроссфейдом — независимые, playLoop — база локальных слоёв), SoundSpec.ts/Music.ts (спек-синтез: hit/chime/scrape/ hum/tone детерминированно; треки bpm/beats/tracks, normalize, loopify), synth.ts (примитивы v1 tools/synth.mjs в TS: makeRng/synth/decay/ bellPartial/bandNoise/normalize/loopify — рантайм и будущие тулзы делят одни формулы), AudioManager.ts (фасад: 4 шины, prepare() синхронно заводит контекст без resume (буферы компилируются до первого жеста), unlock() из ввода, playSpec кэширует буфер по ключу, шпион onPlayed, dispose); демка: apps/demo/src/audio.ts — спеки (формулы v1 sfxSpecs.ts), DemoAudio (playAt: затухание (1−d/r)^1.5 + панорама по изо-экрану, дедуп 0.08 с; StepVariants — 3 варианта шага на поверхность).
    • tools/agent-lib.mjs — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless;
    • tools/png.mjs — PNG-кодек (colorType 6 и 2 — скриншоты Chromium RGB).
  • apps/demo — демо-полигон: src/main.ts (мир 6×6 чанков 96×96 «пепельный луг», деревья/валуны — из генераторов через stampModel, дом и табличка — ручной путь, ремешинг по dirty-чанкам), tools/checks/render.mjs — скриншот-гейт: нет pageerror, меш собран, тени ползут (утро/вечер), апскейл кратен 2×2, многокрасочность, 36 чанков, постройка+мешинг < 2 с (замер ~0.5 с), setVoxel на шве меняет кадр, 12+ деревьев всех 3 вариантов, генератор детерминирован и варианты различаются, .vox-штамп меняет кадр (13 проверок). Мост демки: window.__agent.setSun(k)/stats()/setVoxel(x,y,z,c)/chunkStats()/modelStats()/stampTree(v,x,z,seed)/stampVoxB64(b64,x,z) (парсинг → remapSlots на палитру сцены → штамп)/perf() /bodySpawn(x,z,vx,vz) (тело-манекен в воздухе y=6, Character со spawnY)/bodyStep(n) (n шагов физики с фиксированным dt=1/60)/bodyInfo() (pos/vel/grounded/size/walkT)/bodyRemove() /setInput(x,z|null) (виртуальный стик агента, экранные оси; null — клавиатура)/heroInfo() (pos/vel/grounded/phase/yaw/camTarget)/heroTeleport(x,z)/stepTicks(n) (ручные шаги цикла)/setManual(on)/setFollow(on) (камера за героем; off — центр сцены) /interact() («E» агентом)/interactInfo() (near/label/hint) /dialogueOpen(id)/dialogueInfo() (active/nodeId/text/choices/path/flags/vars/toasts/result/graphErrors)/dialoguePick(i)/dialogueAdvance(i) /invInfo() (all/size/maxSlots/empty) /saveTo(slot,title)/loadFrom(slot)/saveList()/saveDelete(slot) (сейв: герой/флаги/переменные/сумка, DemoSave version 1; applySave прерывает активный диалог и обновляет HUD сразу) /sceneInfo() (current/transitioning/autoSaved)/gotoMenu() (игра → меню с автосейвом в слот auto, меню → игра)/startGame(load) («Войти в мир»/«Продолжить»). Сцены демки: стек SceneManager — gameScene (tick+рендер) внизу, menuScene (DOM-панель #menu, мир виден под ней) сверху; демка стартует в меню; переход — DOM-слой #fade (0.4 с, out+in = 0.8 с); Esc — gotoMenu; «Продолжить» disabled без автосейва. Герой демки: WASD/стрелки → screenMoveToWorld с ISO_YAW, Character (скорость 4, stride 3.2), камера следит с шагом пиксельной сетки. NPC: страж у дома (42,47) — Character на скорости 0 (поза покоя, один путь с героем); табличка (30,70); точки взаимодействия — INTERACTABLES (id/pos/radius/label/dialogueId), подсказка «E — …» в #hint (top), диалог — панель #dialog (низ, кнопки вариантов). Диалоговые графы — GRAPHS (страж: выборы → setFlags/setVars, условный узел ask2 по переменной asked, giveItem «цветок», выбор «Отдать цветок» по hasItem → takeItem; табличка: один узел + тост), валидируются validateDialogue при старте (graphErrors в мосту). Сумка героя — Inventory (maxSlots 8), DialogueWorld.hasItem смотрит в неё, эффекты giveItem/takeItem исполняются в onEffect, HUD #inv перерисовывается по inv.onChange.
  • apps/demo/tools/checks/physics.mjs — скриншот-гейт физики (7 проверок): спавн в воздухе, падение на землю y=2 ровно на грань, фаза walk-цикла растёт в движении, стена дома останавливает по грани (pos.x ≈ 44 − w/2 − eps), кадр изменился от движения, манекен рисуется (после bodyRemove кадр возвращается к пустому месту). Начинается с setFollow(false) — камера фиксированная.
  • apps/demo/tools/checks/hero.mjs — скриншот-гейт героя (9 проверок): цикл в ручной режим (setManual(true)), герой на земле, ввод агента двигает (экранный (1,1) = +x мира), фаза ровно 1.25 цикла/с (скорость/stride), стена по грани, покой замирает, кадр меняется, камера следует (camTarget = герой), телепорт уезжает.
  • apps/demo/tools/checks/dialogue.mjs — скриншот-гейт диалогов (15 проверок): графы валидны, подсказка по дистанции (вдали нет / у стража «E — Поговорить»), подсказка и панель рисуются (сравнение ЗОН кадра — верх/низ с одной позиции камеры, cropRows), «E» открывает диалог, advance при выборе игнорируется (семантика v1), эффекты выбора (флаг admitted, тост), путь/результат, повторный заход через dialogueOpen, переменная asked открывает условный узел ask2, второй граф таблички, закрытие возвращает кадр.
  • apps/demo/tools/checks/inventory.mjs — скриншот-гейт инвентаря (8 проверок): сумка пуста на старте, выбор «Отдать цветок» скрыт без предмета и виден с ним (hasItem через DialogueWorld), giveItem диалога наполнил сумку (цветок ×1), HUD сумки рисуется в углу (зона кадра), takeItem забрал (флаг flowerGivenAway), HUD скрылся (diff 0).
  • apps/demo/tools/checks/save.mjs — скриншот-гейт сохранений (9 проверок): слотов нет на старте, saveTo пишет слот+мету, слом состояния (отдача цветка + уезд героя), loadFrom восстанавливает позицию/флаги/ сумку, кадр после загрузки попиксельно равен кадру сохранения (diff 0, рендер — только в stepTicks ручного режима), saveDelete чистит слот.
  • apps/demo/tools/checks/scenes.mjs — скриншот-гейт сцен (11 проверок): старт в меню без перехода и без автосейва, startGame — переход идёт (сцена ещё меню), фейд в конце фазы out — кадр чёрный, переход завершён (меню скрыто), кадры меню/игры различаются, gotoMenu пишет автосейв «auto», возврат в меню, «Продолжить» вернул героя на место сохранения, параллельный переход глотается (startGame во время перехода игнорируется), повторный переход работает. Ручной режим: полный переход = 48 тиков (0.4 с × 2 фазы), гейты входят в игру через startGame(false)+60 тиков.
  • apps/demo/turntable.html + src/turntable.ts — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост sheetInfo() (геометрия листа для расчёта ячеек — viewSize как у камеры!) и setModels(on) (скрыть модели для референсного кадра). Канвас в левом верхнем углу — пиксели скриншота считаются от (0,0). Гейт tools/checks/turntable.mjs: проекция ячеек той же матрицей, что камера, эллипс 48×42 в ромбе подставки, 5 проверок (ячейки непустые, варианты попарно различаются).
  • apps/demo/anim.html + src/anim.ts — аним-лист: 8 кадров walk-цикла манекена (4×2, фазы i/8) на подставках; клип — walkClip, позы — samplePoseposeVoxels, рисуются addVoxelCloud. Мост: sheetInfo(), poseStats(i) (числовой снапшот позы: углы, качание корня, footMinY обеих стоп), clipCheck() (validateClip через мост), setModels(on). Гейт tools/checks/anim.mjs: 8 проверок — картинка (ячейки видны, соседние фазы различаются, порог 50) + числа через мост (клип замкнут, противофазы ног/рук, контакт стоп в i=0/i=4, приседание и качание корня). Фазы 0 и 4 двухшагового цикла совпадают — это норма, не баг.
  • apps/demo/tools/checks/combat.mjs — скриншот-гейт боя (14 проверок): 3 сгустка на лугу (патруль бодр, плевун и тяжёлый спят), кучка спящего рисуется (дифф ~1100 px), звон будит спящего без урона (враг за краем конуса, радиус звука 4.5 → rise), погоня до равновесия сепарации 0.55, конус 3→1→труп→труп исчезает (0.5 с), смерть героя (серия 4,3,2,1,5 — телепорты в упор; респаун на старте (40,60) с полным hp), плевун 5→4 (замах+полёт ~2.2 с) + HUD-зона сердец перерисовалась, резонанс (заряд ≥0.6 с) усыпляет обоих. Мост: combatSpawn(kind, x, z, patrol?) (позиции в ЮНИТАХ; без patrol — dormant), combatClear() (враги И снаряды), combatInfo() (сгустки {kind,state,pos,hp,asleep,dead}, снаряды, hp, заряд), combatStats() (enemies/asleep/dead/projectiles), attackInput(down) (Space агентом: press — заряд, release — конус/резонанс). Все боевые проверки — на чистом ряду z=60 у старта героя (40,60); сцены демки (спавны) проверяются до первого combatClear.
  • apps/demo/tools/checks/audio.mjs — гейт аудио (8 проверок, без скриншотов — на слух в headless не проверить, читаем DEV-шпион): вход в игру, тишина на старте (шпион подключён, амбиента в демке нет), шаги героя играют (варианты step_ash — рантайм-синтез по спеку), атака → bell_hit, плевок плевуна → spit (позиционный звук: сгусток в радиусе слышимости 12, но не в конусе — звон будит, плевун стреляет), резонанс → bell_low, лог ограничен 24 записями. Мост: audioUnlock() (разблокировка контекста гейтом без жестов), audioInfo() (кольцевой буфер 24 записей {key, volume, pan, rate} фактических запусков звука).
  • apps/game — игра «Пепельные луга» на v2: воспроизведение демки v1 (референс — v1/apps/game, раскладка карт переносится данными). tools/convertMaps.mjs — конвертация карт v1 (assets/maps/.map, RLE) → src/data/maps.json (прогон при правке референса: node tools/convertMaps.mjs); src/areas.ts — генератор мира области из раскладки: 1 тайл v1 = 3×3 вокселя (UNIT=3, ноги на y=2), земля y0-1, вода слоем y=2 (непроходима, как v1 blocked), штампы объектов — компактные генераторы в тайл 3×3 (mapTree/mapBush/mapBoulder — крона не вылезает на соседей) + дома/колодец/ башня/машина/рельсы/декор (светлый пиксель на земле, y=1); детерминировано (пятна — хэш координат, сиды штампов — от тайла). src/data/areas.ts — AREA_DEFS: 8 областей с переходами v1 1:1 (step — вход на тайл-триггер, click — «E» рядом с дверью/колодцем, 'return' — стек возврата). src/main.ts: мир области пересоздаётся на переходе (меши сбрасываются), герой-манекен (Character, скорость 4), фейд out→swap→in через DOM #fade (полный переход 2×duration), подсказка «E — …» в #hint, камера follow с шагом пиксельной сетки. Мост: setManual/stepTicks/heroInfo (area/tile/pos/camTarget)/ heroTeleport/setInput/setFollow/setSun/areaInfo (id/переходы/fade/ returns/chunks/meshes)/gotoArea(id, entry?) (прямой прыжок, стек чистится)/ solidAt(x,z) (сплошная ли колонка НАД землёй — y≥2)/interact/interactInfo. Гейт tools/checks/areas.mjs (16 проверок): старт на спавне лугов, пересбор области — тот же кадр (детерминизм), стик (1,1) = чистый +x мира, blocked (дерево/вода сплошные — по 3×3 под-колонкам тайла, тропа нет), тело зажимается гранью воды, скриншоты 7 областей различаются, step-переходы (луга→Звенец, луга⇄пруды) с фейдом и запоминанием возврата, люк→зал→return по стеку, click-переход «E» (подсказка у двери, вдали тишина), интерьер рисуется. Трек 16 «NPC и квестовый каркас»: src/data/ids.ts (реестры FLAGS/VARS/ usedFlag — флаги сюжета только через них), src/data/items.ts (7 предметов, itemName), src/data/npcs.ts (NpcDef: статичные Ирвин/Мила в Звенце + NPC_PLACEMENTS — Клинт в роще по флагу act2_hook; npcsForArea), 9 диалоговых графов v1 в `src/data/dialogues/.json+ реестрsrc/data/dialogues.ts(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 по flagKey → dialogueFirst; в диалоге мир замер (герой стоит, переходы/подсказки не тикаются); onEffect исполняет игру (giveItem/takeItem → Inventory, toast/custom → тосты); HUD — трекер#quest(первая активная стадия), сумка#inv(по inv.onChange), тосты#toasts. Клавиши: E = диалог/дальше/переход, цифры 1-9 = выбор варианта. Мост:npcInfo(кто стоит, tile, dialogue, near)/talkTo(id?)/dialogueInfo(узел/выборы/path/result/флаги/вары/трекер/сумка)/dialoguePick/dialogueAdvance/dialogueAbort/setState({vars,flags})(гейт стадий)/stateInfo. Гейтtools/checks/npc.mjs(27 проверок): раскладка NPC v1, появление Клинта только с act2_hook, выбор графа по стадиям (первый → repeat → сдача при flowers/tones ≥3 → эпилоги), выбор варианта ответа, giveItem → сумка, флаги/вары, трекер с прогрессом, motes_note по whenVar, мир замер в диалоге, панель диалога на скриншоте. Грабли трека 16: **порядок знакомств существенен** — после сдачи цветов активная стадия «bells» (npc trader) перекрывает trader_first графом trader_after: сдача квеста закрывает окно первого диалога этого NPC; **«Мила» ≠ «Милой»** — подстроки имён с падежными окончаниями в проверках трекера ненадёжны, сверять по устойчивому куску текста стадии. Трек 17 «интерактив, пикапы, квест „Три цветка“»:src/data/interactables.ts(контент v1 1:1 — столбы, моты, деревья-механизмы, сундук, записка, очаг, прилавок, верёвка колокола, резонатор, Старая Машина; реакции поwhen, requiresItem → lockedText, once → used:<id>),src/interactables.ts(рантайм: resolveInteractable + apply через sink; порядок эффектов: флаги → вар → предмет → used → custom → тост),src/data/hazards.ts(накат прудов/ разъезда, requiresItem cloth),src/props.ts(воксельные пропсы строго в тайл 3×3: мот — искра, столбы/сундук/записка/верёвка/резонатор; clearProp снимает штамп after once),src/flowers.ts(FlowerQuest: сбор/посадка/ пересев/зелень башни; правки — воксель цветка в центре тайла + журнал FlowerEdits по областям, переживает пересборку мира в setArea). В main.ts: кандидаты «E» с приоритетом v1 (npc → click-переход → интерактив → цветок, радиус 4.5 вокселя = 1.5 юнита; цветы — скан окна ±2 тайла по isFlower), накат — тост при входе + ход ×0.5 без полотна, катсцены — стек шагов {dur, fn?, cam?} с глайдом камеры (мир замер); custom-эффекты: диалоговые — через runner.onEffect (plant_flowers откладывается до onFinish, как в v1 pendingCustom), интерактивные — через sink.onCustom (lull_machine → тост). Мост:interactInfo(kind/near/target/flower + label/hint)/voxAt/flowerTiles. Гейтtools/checks/interact.mjs(27 проверок): столбы/моты/ записка/сундук (проп убран), верёвка — две ветки, очаг/прилавок — до и после сдачи, накат — тосты и ход ×2 с полотном (dx 1 vs 2), сбор цветка (вар 1/3, воксель снят), Машина — «не спеть» → machine_lulled → «спит», катсцена сдачи (герой стоит, камера у башни на середине глайда, поляна цветёт — 3 тайла + воксели, кадр изменился). Грабли трека 17: **квест надо ВЗЯТЬ до сдачи** — questDialogueFor выбирает по активной стадии: без elder_first (знакомство) сдача при flowers=3 не открывается, диалог «висит» на выборе и блокирует interact (гейт шёл дальше с открытым диалогом — Машина «не interact-илась»); **vars.<ключ> может не существовать** — в снапшоте моста vars.motes undefined до первой записи, сверять!(vars.motes > 0), не=== 0; **setTimeout-тосты в headless не тикают** — копятся весь прогон (проверкиsome()зелёные, но кадр завален тостами: чистые скриншоты — отдельным прогоном с минимумом действий); **тайлы зелени башни — тропа (PATH=1)** — raiseTowerGreen ставит цветок только на GRASS/ASH (v1-faithful), у башни зелень не всходит, не считать это багом конвертации. Трек 18 «время суток, атмосфера, музыка»: движок —packages/engine/src/render/daynight.ts(чистая математика суток, перенос v1 lightSim:dayNightFactorпо часам, трёхопорныйdayNightAmbientдень→золотой час→ночь с DUSK_PEAK 0.45,flickerFactorдетерминированного мерцания,lerpColor/dimColor; тесты — перенос lightSim) + рендер:VoxelRenderer.setNight(k, tint)(hemisphere от дневной базы к tint, фон сцены = tint, солнце гасится до 8%),setDayAmbient(дневная база области: тёмный интерьер затемняет hemisphere и гасит прямое солнце — аналог multiply-ambient v1; улицы — P5 по умолчанию),addPointLight(three.js PointLight, distance = radius×2, decay 1.6;PointLightHandlesetIntensity/setPos — мерцание и лампа героя двигаются на стороне игры). Игра:src/clock.ts(GameClock v1 1:1: 1 сек реального = 1 игровая минута, сутки 24 мин, старт 8:00) + тесты (порт clock.test v1);src/data/lighting.ts(LIGHTING 8 областей v1 1:1 — ambient/nightAmbient/ источники bells/окна/очаг по флагам/башня/прилавок + ATMOSPHERE);src/data/music.ts(MEADOW_THEME/COMBAT_THEME — партитуры нот, сиды, рендер renderMusicStereo → playLoopBuffer);src/lighting.ts(GameLighting: setNight+dayNightAmbient каждый тик, источники diff по id с мерцанием flickerFactor(seedOf(id)), фильтры whenFlag/notFlag/whenNight (порог 0.5), лампа героя HERO_LAMP в тёмных областях/ночью, MAX_LIGHTS 24);src/ash.ts(пепел — voxel-облако 36 хлопков вокруг камеры, детерминированные синусы от времени цикла);src/audio.ts(GameAudio: AudioManager + тема области кроссфейдом). В main.ts: часы и свет тикают всегда (и в фейде/ катсцене), setArea пересоздаёт свет, включает пепел по ATMOSPHERE и стартует тему. Мост:clockInfo/setClock(перемотка гейтом)/lightingInfo(nightFactor + активные источники пост-мерцание)/audioInfo(тема + буфер запусков)/setAsh(выключить атмосферу для кадр-равенств). Гейтtools/checks/atmosphere.mjs(18 проверок): часы старт/золотой час/ночь/ mod 1440, кривая 0.45 на пике, bells ночью по тайлам + лампа героя, мерцание пульсирует, кадр ночи темнее дня (яркость), пепел дрейфует, очаг по флагу сдачи, отсвет башни по rang_tower, тема лугов играет/в интерьере тишина. Грабли трека 18: **страница грузится в реальном времени** — до setManual цикла часы уже ушли от 8:00 (первый снапшот 8:01): гейту нормироватьsetClock(480)перед проверками времени; **пепел — живой слой кадра**: попиксельные сравнения «пересборка = тот же кадр» падают (дрейф с тиками) — в гейтах мираsetAsh(false)+ 1 тик перерисовки до скриншотов; **интерьеры без nightAmbient** — тон задаёт только setDayAmbient (тёмный ambient гасит hemisphere и солнце), иначе лавка светится дневным солнцем; **пик золотого часа** — nightFactor 0.45 на 18:54 (18 + 0.45×2 ч), не «полдень заката». Вне скоупа (документировано): туман прудов, тёмные ауры наката (darkSpot), виньетка, амбиент-слои (вода/гул) — вернуть в треке полировки или акта 2. Трек 19 «акт 2: Клинт, резонатор, Машина, фауна»: контент из треков 16-17 (диалоги Клинта, квест resonator, интерактивы tone_tree/old_machine) уже был на месте — трек добавил рантайм сюжета и фауну. Игра:src/deer.ts(воксельный олень ~46 вокселей, walk — подъём диагональных пар ног, ориентация по направлению не крутится — визуальное приближение допустимо);src/fauna.ts(FaunaSystem — перенос v1 1:1: idle→wander по паузе 1.5+rng×3 с, подход на звон HEAR_RADIUS 7 тайлов, скорости 0.35/0.7 тайла/с → воксели ×UNIT, Rng(20260908) детерминирован; blocked-тайлы от main черезtileBlocked— колонка над землёй); спавны вAreaDef.fauna(meadows 2, ponds 1 — v1 1:1). Катсцены — данные + движковый рантайм (трек 20):src/data/cutscenes.ts(метки точек камерыCUTSCENE_CAMSтайлами v1, шаги в данных{dur, action?, text?, cam?, at?}, реестрCUTSCENES: hand_in / tune / machine_lull) +resolveCutscenes(actions)в main.ts (именованные действия: plant_flowers / raise_tower_green / fauna_startle / toast; неизвестная метка/действие — throw при старте, как validateDialogue) →CutsceneRunner(камера — инъекция рендера, мир замер, по окончании камера к герою). Custom-эффекты диалогов идут через pendingTune/pendingHandIn в onFinish (катсцена поверх диалога не идёт), интерактивный lull_machine — сразу. Мост:faunaInfo/startleFauna/setCam(камера на точку мира БЕЗ телепорта героя — просмотр участка)/cutsceneInfo(шаг/таймер/каталог — числовой дебаг сцен)/playCutscene(id)(играть сцену по id — агентный dry-run)/tileCenter/setFauna(выключить живых оленей для кадр-равенств, как setAsh). Тулзаtools/shot.mjs— оперативный скриншот участка: состояние сюжета (область/флаги/вары/время) + камера + снимок одной командой. Гейтtools/checks/act2.mjs(27 проверок): Клинт по флагу, дерево до квеста молчит, 3 тона + повтор, резонатор до/после, clint_tune → катсцена настройки (шаги, глайд камеры, тост), clint_repeat → machine_hook, Машина: «не спеть» → усыплена катсценой → спит, олени: спавны, брожение, подход к звону. Грабли трека 19: **questTracker показывает первую активную стадию первого квеста** — пока «Три цветка» не пройден, квест резонатора в трекере не виден (v1-faithful): проверять флаги/вары, не трекер; **тосты катсцен уходят в финальный шаг** — гейтам stepTicks до конца катсцены перед чтением тостов; **pkill -f убивает свою сессию** (паттерн встречается в её командной строке, exit 144) — зомби-vite убивать по PID изss -tlnp`. Вне скоупа: звуки реакций (поле sound v1 не переносилось — sfx-спеки отдельно), боевой звон как триггер оленей (боёвки в игре нет — мост startleFauna).

Трек 20 «катсцены как данные + CutsceneRunner» (2026-09-10): третий повтор паттерна «стек шагов {dur, fn?, cam?}» в main.ts поднят в движок жанронезависимо (packages/engine/src/core/cutscene.ts — см. структуру; глайд по времени после инкремента — последний тик достигает цели точно, действие шага может перезапустить сцену). Игра: src/data/cutscenes.ts (метки точек камеры тайлами v1 + именованные действия + реестр 3 сцен), resolveCutscenes валидирует при старте (неизвестная метка/действие — throw, как validateDialogue). Мост: playCutscene(id) + каталог в cutsceneInfo. Гейт tools/checks/cutscenes.mjs (13 проверок): каталог в мосте, неизвестный id молча не играет, dry-run каждой сцены в своей области (шаг играет, герой стоит, камера к герою в финале), кадр середины не пустой. Грабли: 40 тиков = 0.67 с — ещё первый шаг (dur 0.8–1.2 с): гейту проверять прогресс t > 0, а не step >= 1; событийную систему (триггеры onEnter/onFlag → скрипты) не заводить до третьего кейса, не ложащегося на квест-стадии.

Фидбек-раунд (2026-09-10): канвас тянется под окно (CSS 16:9, бэкинг-стор 960×540), камера следует за героем по float-цели (без округления — дёрганье ушло), олень поворачивается по ходу движения (rotXZ в deer.ts), деревья/дома/башня/Машина подняты выше юнитов (высота чанка мира 20). Грабля: скриншот в ручном режиме показывает последний отрисованный кадрsetFollow(false) не рендерит; гейтам нужен stepTicks(1) после фиксации камеры, иначе кадр от предыдущего состояния (следящая камера стоит на float-цели → полвокселя сдвига → кадр-равенство падает на всём кадре).

Известные грабли (уже собранные)

  • Знак Y в изо-направлении камеры: вектор «target → камера» должен иметь y = +1. С минусом камера встаёт под землю — сцена рендерится «из-под», верхние грани без света и всё почти чёрное (снапшоты при этом зелёные — ловится только скриншот-гейтом).
  • Скриншоты headless Chromium — RGB (colorType 2), без альфы — декодер tools/png.mjs v2 это умеет (у v1 умел только RGBA).
  • three без типов: нужен @types/three в devDependencies движка.
  • VoxelGrid.count() — метод, а не геттер: в тестах легко написать g.count без скобок и получить функцию (дважды ловили).
  • Дробные координаты в VoxelGrid.set молча игнорируются (индекс Uint8Array 4.5 — no-op): центры ячеек считать целыми (floor(pitch/2)).
  • Скриншот = вьюпорт браузера, не страница: канвас выше дефолтного вьюпорта 960×540 обрезается снизу; launchBrowser({viewport}) и канвас без центрирования — иначе все координаты гейта плывут.
  • Node-скрипты гейтов не импортируют @rpg/engine (TS-исходники с extensionless-импортами): фикстуры движка (модели, .vox-байты) генерятся один раз tsx-ом и вкладываются в скрипт гейта готовыми (base64).
  • Точка правки для проверки «кадр изменился» должна быть в кадре камеры: штамп вне вью дал placed=12 при voxDiff=0 — счётчик зелёный, кадр нет.
  • Заголовок .vox-чанка — 12 байт (id + contentSize + childrenSize); обрыв в заголовке ловить границей в id(), иначе голый RangeError из DataView вместо понятной ошибки.
  • Привязка вокселей индексируется по сетке, а posed-массив — только занятые воксели: сопоставлять через PosedVoxel.index, не позицией в массиве (footMinY из-за этого дважды бы соврал).
  • Voronoi-привязка по pivot семантически неверна для внутренних вокселей: торс у плеча ближе к кости руки, чем к корню. Генератор привязывает части явно; bindNearest — только для чужих моделей.
  • sheetInfo/мост должен отдавать РЕАЛЬНЫЙ viewSize камеры: в turntable стояло 30 при камере 32 — гейт проходил только за счёт запаса эллипса.
  • Соседние фазы walk-цикла (45°) на мелком манекене дают ~110 px разницы — порог гейта «фазы различаются» 50, не 150 как у вариантов моделей; а фазы 0 и 4 совпадают полностью (симметрия двух шагов).
  • Аргумент VoxelWorld(n) — это ВЫСОТА чанка, а не число чанков (new VoxelWorld(4) = плоский мир высотой 4): set вне y-границ молча возвращает false. Тест-мир с «потолком» на y=7 требует new VoxelWorld(16), иначе тесты падают загадочно («тело не упало»).
  • Vec3 в mat.ts — readonly (readonly [n,n,n]): мутирующее состояние физики (Body.pos/vel) — обычные кортежи [number,number,number]; Vec3 — только на входах/выходах чистых функций.
  • Спавн тела в тесте/демке проверять на пересечение AABB с миром: тело 3×10×3, заспавненное краем под плитой потолка, цепляет её воксель и «мгновенно приземляется» — тест падает с непонятной позицией.
  • Фаза walk-цикла = путь/stride, не время: при скорости 4 и stride 3.2 это 1.25 цикла/с при длительности клипа 0.8 с (stride = speed·duration — иначе стопы скользят); гейт героя ждёт именно 1.25, не «>0».
  • Виртуальный стик агента живёт между вызовами моста (setInput(null,null) сбрасывает): забытый включённый стик двигает героя в последующих проверках гейта — перед чистой проверкой кадра отпускать.
  • cancelAnimationFrame нет в Node (юнит-тесты GameLoop с инъекцией schedule): typeof-guard в GameLoop.stop.
  • HUD обновляется из тика — изменения состояния вне тика обязаны дёргать обновление сами: мост (dialoguePick/interact) и клик кнопки меняют диалог вне tick, и подсказка «E» оставалась висеть при открытом диалоге, пока не придёт следующий тик (в ручном режиме — никогда). После любого мутации рантайма диалога звать updateHint().
  • Скриншот-сравнение HUD — по зонам кадра с одной позиции камеры (cropRows верх/низ): сравнение целых кадров, снятых с разных позиций, даёт diff всего луга (сотни тысяч пикселей) — проверяется камера, а не HUD; а зона HUD локализует проверку (подсказка — верхние 60 строк, панель диалога — нижняя половина).
  • CSS display:flex на элементе перебивает атрибут hidden: #menu «скрывался» (hidden=true), но панель оставалась на каждом кадре — нужен отдельный #menu[hidden]{display:none}. Ловится попиксельным сравнением гейта (меню затемняло/перекрывало кадры).
  • Полный фейд-переход — это out+in, 2×duration: при 0.4 с это 48 тиков по 1/60, а не 24. Гейты в ручном режиме обязаны пройти все 48 тиков после startGame/gotoMenu, иначе фейд замерает на частичной прозрачности и затемняет каждый кадр (backDiff сотни тысяч при «рабочем» сейве).
  • Демка стартует в меню (стек сцен): любой новый гейт обязан сначала войти в игру (startGame(false) + 60 тиков), иначе stepTicks тикает только меню, а скриншоты снимаются поверх панели меню.
  • Боевой юнит ≠ воксель: числа v1 — в юнитах (герой ≈ 1 юнит), тело героя в демке 3 вокселя → 1 юнит = 3 вокселя; CombatWorld живёт в чистом юнит-пространстве, конвертация (/3, ×3) — только на границе демки (getPlayerPos, отброс, кучки). Смешение пространств — урок №1.
  • dormant глух к близости героя: спящий сгусток вплотную НЕ просыпается (только шум ≥ 0.7 или урон) — гейту будить звоном издали (за краем конуса, чтобы не нанести урон: враг на 1.3–2 юнита, конус 1.0, звук 4.5).
  • Равновесие сепарации = сумма радиусов, и оно больше attackRange ползуна (0.35+0.2=0.55 > 0.4): ползун «липнет» рядом, но стоящего героя не бьёт (v1-faithful); контактный урон в гейте — телепортом героя прямо на врага (вокс-сетка даёт остаток ≤ ~0.24 < 0.4). Тяжёлый (0.65 ≤ 0.8) бьёт.
  • Толкучка вталкивает врага в strike-радиус: два сгустка с разных сторон героя выдавливают одного внутрь 0.4 — герой теряет сердце «без причины»; спавнить проверочных сгустков с одной стороны.
  • combatClear обязан чистить и снаряды: плевун успевает плюнуть дважды (кулдаун 2.2 с < 240 тиков гейта), снаряд летит после удаления врагов и «призраком» наносит урон герою в следующей проверке.
  • Боевые точки выбирать прогоном, не по глазам: у старта героя (40,60) чисто (равновесие погони ровно 0.55 — маркер открытости), у (14,84) стоит препятствие — сгусток в chase топчется на месте.
  • Кулдаун конуса (0.45 с) не сбрасывается телепортом/очисткой боя: атака-пробуждение через ~9 тиков после атаки-звонка не выходит (release глотает молча) — между боевыми секциями гейта ждать ≥ 30 тиков (0.5 с).
  • Звук в headless работает без жестов: agent-lib уже передаёт --autoplay-policy=no-user-gesture-required, а audio.prepare() заводит контекст синхронно — варианты шагов компилируются до первого resume; факт запуска звука проверяется только DEV-шпионом audioInfo() (на слух в headless не проверить).
  • AgentClient.call вариадический: call('combatSpawn', 'crawler', 15, 20), а не call('combatSpawn', ['crawler', 15, 20]) — массив уходит ОДНИМ аргументом (ENEMY_KINDS[Array] → undefined).
  • Экранная ось (1,0) — диагональ мира (при yaw π/4 это (+x,−z)/√2): герой, идущий «экраном», срезает угол тайла и может ПРОПУСТИТЬ тайл-триггер перехода (x и z пересекают границы в один тик). Чистая мировая ось — комбинированный стик: (1,1) = +x, (−1,−1) = −x, (1,−1) = −z, (−1,1) = +z (проверено гейтом: дрейфа по z нет).
  • Гейт-подход к триггеру — телепортом на соседний тайл + чистая ось: телепорт точно на триггер не срабатывает (prevTile = триггер, «входа» нет); после телепорта prevTile = тайл прибытия, триггер fires только при входе СНАРУЖИ.
  • Штампы шире тайла запирают проходы: модель 7 вокселей, отштампованная от угла тайла 3×3, вылезает на соседей на 2 вокселя — крона (тело героя 3×10×3, крона на y≥4) делает непроходимыми коридоры, которые в v1 свободны (blocked только свой тайл). Контентные объекты карты — генераторы строго в тайл 3×3 (mapTree/mapBush/mapBoulder), штамп центрируется на центр тайла.
  • solidAt для blocked-гейта — колонка НАД землёй (y≥2): земля y0-1 сплошная всюду; проверка с y=0 даёт «всё заблокировано».
  • Voxel-облако после пересоздания снова бросает тень: addVoxelCloud ставит castShadow=true, а при росте списка ОБЛАКО пересоздаётся (update подменяет mesh) — частицам/бликам castShadow=false ставить после КАЖДОГО update, не только в конструкторе (иначе чёрные квадраты теней на воде).
  • Z-fighting копланарных instanced-кубов с world-мешем: куб облака, объёмно перекрывающийся с кубом мира (блик/клочок на воде), имеет копланарные вертикальные грани; у culled-меша воды боковые грани выживают только на кромках пруда — там пиксели «мерцают» от порядка отрисовки, который меняется при пересоздании облака (кадр-равенства гейтов падают на 19 px). Лечится сдвигом позы на EPS 0.03 из плоскости куба воды.
  • Зомби-vite держит порт после process.exit() тулзы (дочерний vite переживает выход): startDevServer падает «vite упал (code 1)» — убивать по PID из ss -tlnp (pkill -f убивает и свою сессию).
  • encodePng принимает Buffer (rgba.copy is not a function с Uint8Array) — Buffer.from(rgba) перед записью.
  • Node-скрипты вне v2 не резолвят @rpg/engine (TS-исходники): одноразовые пробы класть в apps/*/tools/ и запускать с cwd внутри v2.

Ревью-раунд (2026-09-10): багфиксы движка и игры

Системный поиск ошибок (два ревьюера + сверка с v1). Изменения API движка:

  • GameLoop.stepTick вне ручного режима возвращает 0 и не делает шагов — поверх живого цикла шаги удвоились бы. Гейты звали stepTicks после setManual(true) — поведение не изменилось, но защита теперь в движке.
  • EntityWorld.addComponent на мёртвой сущности возвращает undefined (было T, падение TypeError) — хук урона может убить сущность в том же тике, что другая система пишет ей компонент.
  • AudioManager.dispose закрывает AudioContext и гасит ВСЕ лупы: Loops.stopAll гасит и «свободные» слоты startSlot/playLoopBuffer (раньше они не останавливались никогда — слот музыки они не занимают).
  • validateDialogue ловит больше: неизвестный kind в do[] узла (не только выбора) и циклы узлов-действий (рантайм проваливается по next без ожидания игрока — цикл раньше взрывался MAX_STEPS посреди игры).
  • DialogueRunner реентрантность: эффект, синхронно зовущий abort()/start(), больше не оставляет «зомби-диалог» — цикл enterNode проверяет, что граф не подменён/не погашен.
  • dayNightFactor на вырожденных границах spec (day === dawn) даёт 0/1, а не NaN (NaN раньше протекал в интенсивности солнца).
  • remapSlots с пустой палитрой сцены — слот не перекладывается, а не TypeError.

Багфиксы игры (v2/apps/game): тема лугов не останавливалась при уходе и наслаивалась при каждом возвращении (playLoopBuffer-луп не гасился — теперь GameAudio держит хендл и кроссфейдит сам); «E» сквозь катсцену/фейд открывал диалог по протухшим целям (гард); порядок выбора диалога NPC инвертирован против v1 (квест-стадия перекрывала незнакомство — теперь v1: незнакомый всегда first); «E» — ближайшая цель с приоритетом при ничьей (v1 pickInteract), а не жёсткий порядок npc→переход→объект→цветок; радиус NPC 4.5 вокселя (v1 1.5 юнита); скорость героя 5.25 вокс/с (v1 1.75 юнита/с); колокольчик при сборе попадает в сумку (v1); ночной свет колокольчиков следует за сбором/посадкой (живой слой тайлов — аналог setTileTracked v1); пустой return-стек фолбэчится на спавн (иначе фейд зацикливается); RLE раскладки декодируется один раз с кэшем (isFlower зовётся 25 раз/тик); камера входа в область на float-цели без округления (микро-скачок в начале фейда-ин).

Раунд полировки (2026-09-10): меню/сейвы, живые слои воды и воздуха

Игра v2/apps/game, недоделки визуального слоя трека 18 + техполировка:

  • Меню (Esc) + автосейв: страница стартует в меню («Продолжить» disabled без сейва / «Войти в мир»), Esc — пауза с автосейвом в слот auto (движковый SaveManager, localStorage с префиксом rpg2:), #menu поверх канваса (CSS #menu[hidden]{display:none} — грабля flex+hidden). Пейлоад — полный снапшот сюжета src/save.ts (GameSave): область/тайл героя, флаги+вары, сумка, часы суток, правки цветков по областям; правки цветков применяются ДО setArea (мир строится уже цветущим). Мост: menuInfo {open, hasAuto}, startGame(load), gotoMenu() (сбрасывает открытый диалог).
  • Виньетка опасности + пелена наката src/screenFx.ts (DOM-слои #vignette/#haze, alpha из состояний наката — полотно без полотна).
  • Атмосферные частицы src/ambientParticles.ts (v1 Atmosphere): мотыльки лугов (тёплые, орбитальный дрейф вокруг камеры) и дымка прудов (серые клочья 2×2, якорятся на клетки поверхности воды, блуждают на месте — раньше дрейфовал по окну камеры и вылезал на берег).
  • Шиммер воды прудов src/waterShimmer.ts: редкие светлые блики на поверхности воды (кубы-«вспышки» по фазам sin от времени цикла, детерминировано; STEP/GLOW подобраны визуально: ~30 бликов на пруд).
  • Мост: ambientInfo {ash, ambient: {kind, count} | null, shimmer: {cells, visible} | null}. Гейты кадр-равенств гасят живые слои как раньше (setAsh(false) гасит и ambient, и shimmer).
  • Все гейты игры и tools/shot.mjs обязаны начинаться с startGame(false) — страница стартует в меню, тики/скриншоты до входа снимают панель меню.

Грабли раунда (добавлены в «Известные грабли»): тени voxel-облаков после пересоздания, z-fighting копланарных кубов над world-мешем, зомби-vite, encodePng Buffer, резолв @rpg/engine вне v2.

Трек 21 (2026-09-10): полный прогон и сравнительные скриншоты с v1

  • Полный прогон (сборка трека): 11 гейтов демки, затем 6 гейтов игры, затем npm run check:fast (295 тестов). Гейты гонять по одному с паузой и чисткой зомби-vite между прогонами (ss -tlnp → kill по PID). Отчёты гейтов демки — многострочный JSON ("ok": true, с пробелом), игры — однострочный ("ok":true); надёжный критерий — exit code.
  • Сравнительные скриншоты v1↔v2: зеркальные тулзы v1/apps/game/tools/compare-shot.mjs и v2/apps/game/tools/compare-shot.mjs снимают 5 общих участков (поляна, пруды, Звенец, дом Ирвина, роща) — герой в одном и том же тайле (карты v2 конвертированы 1:1, тайлы общие), день 10:00, /tmp/cmp_v1_*.png / /tmp/cmp_v2_*.png. Листы попарно склеиваются скриптом с png.mjs-кодеком движка (v1 слева, v2 справа). Итог: раскладка/объекты/переходы/подсказки совпадают 1:1; допустимые расхождения — камера v2 крупнее (~2×), тёмные области v2 светлее (нет сумеречного затемнения v1), вода бледнее, интерьер беднее деталями. v1-тулза умеет переходы пешком/телепортом к тайлу рядом с триггером (телепорт точно на step-триггер не срабатывает — как в v2).

Трек 22 (2026-09-10): масштаб мира UNIT 3→5

Фидбек «персонажи крупнее деревьев и соразмерны домам» — 1 тайл v1 теперь 5×5 вокселей (герой 11 ≈ 2 тайла; дерево ~2× выше героя, дом ~1.5×).

  • areas.ts: UNIT=5, генераторы под тайл 5×5 и новые высоты (дерево 17–23, дом 12×14–18×9, башня 21, Машина 12×17×9), высота мира 26, стены интерьера 8 (ниже героя — комнаты читаются). Центр тайла — tile*UNIT + 2 (при нечётном UNIT), рельсы — нить d=0..UNIT-1 непрерывно (старый d=1.. оставлял дырки), spotVoxel — tile*5+2.
  • Герой: speed 10.5 (2.1 юнита/с = v1 1.75 + фидбек «быстрее»), stride 6.4 (темп шага v1 сохранён); олень подрос (7→9 вокс), скорости фауны 0.45/1.0 юнита/с. Камера viewSize 40 — тот же охват 8 тайлов, что был при UNIT=3 (тени/дистанция камеры масштабируются от viewSize сами).
  • Гейты: центр тайла в гейтах — дубль tileCenter с хардкодом ×3 — теперь t*5+2.5; подходы телепортом ±3→±5 (1 тайл, в радиусе интеракта 7.5); диапазон камеры катсцены tune пересчитан (цель — тон-дерево [12,10]). Радиусы света в данных — в юнитах, масштабируются ×UNIT в движке.

Трек 23 (2026-09-10): масштаб UNIT 5→10 + детализация героя + частицы из мира

Фидбек после прогона: «частицы летают вместе с персонажем, как приклеенные»; «персонажи низкодетализированные, не читаются; объекты всё ещё сильно меньше — масштаб поднимать кардинально». Выбран UNIT 5→10 (AskUserQuestion).

  • Частицы (ash.ts, ambientParticles.ts): поза считалась прямо от target камеры — хлопья/мотыльки ехали вместе с героем. Теперь мировой якорь (i*61.7 / i*53.1 и т.п.) + дрейф, а окно камеры заворачивает их (wrap(v, min, size)) — ушедший за край появляется с другой стороны; при движении героя частицы стоят на месте в мире.
  • areas.ts: UNIT=10 (чётный — центр тайла tile*UNIT + 5), генераторы под тайл 10×10 (ствол дерева 2×2 — 1×1 на этом масштабе щепка; дерево ~30–38, дом 24×30–40×18, башня 47, Машина 24×34×18), высота мира 52, стены интерьера 13. Цветок/декор — на +5, рельсы — 2 вокселя шириной.
  • Манекен движка (packages/engine/src/models/mannequin.ts): 5×11×3 → 11×21×4 (голова-куб 3×2×5, торс 5×2×7, руки/ноги 2×2, просвет между ногами; 220 вокселей, 5 костей, явная привязка). Тест связности частей — BFS по 6-связности (лесенка-сорт не проходит 2×2-колонны).
  • Герой: BODY_SIZE [5,21,5], speed 21, stride 12.8 (темп v1 сохранён), bob 0.5/sway 0.3; камера viewSize 80 (охват 8 тайлов); NPC_RADIUS/ INTERACT_RANGE 15, PICK_EPS 0.5. Олень подрос (~13 вокс).
  • Гейты: t*10+5, подходы ±10, диапазон камеры tune 125..255, проверка возврата камеры rez.x + 10. Все 6 гейтов игры + 11 демок зелёные, 295 тестов.

Трек 24 (2026-09-10): дизайн персонажей и объектов, персонажи мельче

Фидбек: «задизайнить персонажей и объекты, а персонажей и мобов сделать меньше». Герой 22 → 16 вокс (~1.6 тайла), proportions к нему — дерево ~2.2–3.5×, дом ~2.5×.

  • Манекен движка: 11×21×4 → 9×16×4 (180 вокс: торс 60, голова/ноги/ руки по 24 ×2; ноги 2×2 y0..5, торс y6..11, голова y12..15). Новое API — mannequin(palette?: MannequinPalette) + экспорт типа MannequinPalette из движка: палитра {torso?, legs?, head?, arms?} красит слоты, форма (model.data) не зависит от палитры (палитра живёт на model.palette). Дефолт — деревянный болванчик.
  • Игра: у героя и каждого NPC своя палитра (NpcDef.colors в npcs.ts; герой — светлый лик/ржаво-красный плащ, Ирвин — седой/зелёный плащ, Мила — каштан/ржаво-красный сарафан, Клинт — шапка/медный фартук). BODY_SIZE [5,16,5], stride 9.6 (каденция 2.19 цикла/с — ноги короче), speed 21. Олень мельче: ~10 вокс ростом (ноги 4, туловище 9×2×5).
  • Объекты (areas.ts): дом — двускатная ржавая крыша с коньком вдоль X (слои сужаются по z с двух сторон, конёк светлее) + деревянная дверь на фасаде z=D−1 и труба 2×2 над скатом; окна — тёмное стекло. Дверь была на z=0 — от камеры невидима (в изометрии видны фасады +x/+z). Дерево: крона с профилем (низ вздут r*(1.15−t*0.9), верх сходит на нет; ель — ярусы каждый 3-й слой; колонна — сужается к верху), рваный край сохранён.
  • Гейты под новую модель: anim.mjs — diff соседних фаз порог 50→40, подъём стопы в махе 0.8→0.7 (ноги 6 вокс при swing 0.55 рад дают ~0.8). Все 6 гейтов игры + 11 демок зелёные, 296 тестов.
  • Тулза tools/crop-zoom.mjs <in> <out> cx cy half scale — зум-кроп скриншота для агентной визуальной проверки (png-кодек движка).

Трек 25 (2026-09-10): детализированный манекен 14×24×8

Фидбек: «персонажи — ходячие кубики, покажи максимум дизайна персонажей». Манекен 9×16×4 (180 вокс) → 14×24×8 (~2.4 тайла, 498 вокс): торс силуэтом (юбка плаща шире, пояс, грудь, плечи), рукава с кистями кожи, сапоги с мысками к +Z, шея, голова 6×5 с лицом (глаза-слот EYE, нос выступает к +Z), волосы шапкой и затылком. Палитра расширена до 8 слотов: MannequinPalette {skin?, hair?, torso?, legs?, arms?, boots?, belt?, eyes?}.

  • Ракурс и лицо — главная грабля: камера рендера смотрит сверху под ~55°, луч к вокселю глаза (y21, z5) проходит через воксель (x, y+1, z) — любой «навес» над лицом (шапка волос на весь фасад) прячет глаза, снапшоты при этом зелёные. Лечение: череп ступенькой — фасад лица только до y21, шапка волос назад до z4. Правило: над тем, что должно быть видно на фасаде +Z, не должно быть вокселей на уровень выше в той же z-колонке.
  • Глаза — отдельный слот EYE (дефолт #2a2624), не от ремня: у Милы ремень золотой (#c9a04a) — глаза «от ремня» получались ярко-золотыми.
  • Палитры персонажей: герой — светлый лик/капюшон/ржаво-красный плащ; Ирвин — седина + тёплая кожа (кожа в тон седине сливалась — лицо читалось как череп); Мила — каштан/сарафан/золотой пояс; Клинт — шапка/ медный фартук. BODY_SIZE [5,24,5], stride 12.8 (каденция 1.64 цикла/с — ноги длинные), speed 21.
  • Гейты/тесты: счёт вокселей 180→498, byBone [346, 44, 44, 32, 32]; anim-гейт без правок порогов (ноги выше — мах читается). Все 6 гейтов игры + 11 демок зелёные, 297 тестов. Визуальная проверка — shot.mjs --at 16,28 (герой спавнится на тайле (16,28), не там, где ставит камеру конструктор main.ts) + crop-zoom.mjs на лицо.
  • Тулза tools/probe-hero.mjs [port] — одноразовая проба «где герой» (startGame + heroInfo до/после gotoArea).

Трек 26 (2026-09-10): текстурный слой + маленький манекен 10×16×4

Фидбек: «гигант получается; можно просто натянуть текстуру и сделать его меньше из небольшого количества вокселей». Деталь переезжает с геометрии на текстуры: воксель может нести тайль — пиксель-арт на грани (2×2 текселя у манекена ≈ 1.7px на экране при рендере 960×540/viewSize 80).

  • Формат (format.ts): VoxelTile {size 1..8, faces[6]} (грани в порядке +x,−x,+y,−y,+z,−z, base64 size² байт-слотов, 0 запрещён) + VoxelModel.tiles/VoxelModel.tex (id тайля на воксель). Тексель = слот палитры — перекраска персонажа красит и лицо. Валидация числовая (см. структуру); encodeModel(g, palette, texture?).
  • Рендер (voxelRenderer.ts): addVoxelCloud(voxels, resolveTiles(model)) — атлас-канвас одной строкой (ячейка = 6 суб-граней тайля, ячейка 0 белая = плоский цвет), flipY канваса ставит низ арта на v=0; инстанс- атрибут aTileId, грань куба выбирается в вершиннике из нормали (vFace), fragment умножает diffuse на сэмпл атласа. Воксель с тайлем красится белым (цвет дают тексели), без тайля — цветом слота.
  • Манекен v4: 14×24×8 (498 вокс) → 10×16×4 (164 вокс): сапоги с мысками, ноги-колонны 1×1, юбка плаща шире, пояс, грудь, плечи, рукава с кистями, шея, голова 4×4×3. Лицо — арт 8×8 текселей (бахрома волос, глаза 2 текселя, рот), тайли 2×2 на 16 вокселях фасада z3 головы; волосы — макушка (y15) и затылок (z1) геометрией. Правило ракурса ~55° сохранено: над фасадом лица в той же z-колонке вокселей нет.
  • Игра: BODY_SIZE [5,16,5], stride 9.6 (каденция 2.19 цикла/с — ноги 5 вокс), облака героя и NPC создаются с resolveTiles(model).
  • Гейты: порог маха стопы в anim-гейте 1.0 → 0.8 — ноги 5 вокс против 10 у трека 25, радиус качания вдвое короче (измеренный мах 0.91). Все 6 гейтов игры + 11 демок зелёные, 300 тестов. Визуально — shot.mjs --at 16,28 + crop-zoom.mjs: лицо читается (бахрома/глаза/ рот), герой ~1.6 тайла, палитры NPC (пояс Милы, седина Ирвина) на месте.

Рабочие привычки

  • Для задач из 3+ шагов веди список задач и иди автономно.
  • Комментарии и докстринги — по-русски; хелперы до ~70 строк.
  • Изменения API — с доками в том же коммите.