# 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/`)

```bash
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.ts` — `meshChunk` через
    `world.get`, шов чанков бесшовный (culled и AO через границы);
  - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1,
    dirLight + shadow map + hemisphere, низкое разрешение (480×270);
    `addVoxelCloud` — instanced-кубы для поз анимации (не выровнены по
    сетке; обновление позы без пересборки геометрии);
  - `src/math/rng.ts` — детерминированный Rng (mulberry32): next/int/range/chance/pick;
  - `src/models/format.ts` — формат модели (JSON: size + плотный base64 +
    слоты-палитра), encode/decode/validate (числовая валидация агентом);
  - `src/models/generators.ts` — процедурные генераторы: `treeModel` (варианты
    0 круглая / 1 колонна / 2 ель), `boulderModel` (union сфер, свет на
    верхушках столбцов); (вид, сид) → VoxelModel, детерминировано;
  - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом
    (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.ts` — `remapSlots(model, palette)`: перекладка
    слотов модели на палитру сцены (точный hex или ближайший RGB,
    при равенстве — меньший слот); нужен после импорта .vox;
  - `src/models/mannequin.ts` — `mannequin(palette?)`: эталонная модель
    9×16×4 с ригой (root → legL/legR/armL/armR, pivot в связочном
    пространстве); `MannequinPalette {torso?, legs?, head?, arms?}` —
    дизайн персонажа параметром (форма не зависит от палитры);
  - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine,
    порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера;
  - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest`
    по ближайшему pivot), `validateRig` (родители раньше детей, длина и
    согласованность привязки), `poseVoxels(rig, model, pose)` — деформация;
  - `src/animation/clip.ts` — клипы: ключи {t, кость: rot/pos}, `samplePose`
    (линейный семплинг, заворот t для циклов), `validateClip` (границы,
    монотонность, замкнутость цикла), `footMinY` (контакт стопы);
  - `src/animation/locomotion.ts` — `walkClip(params)`: процедурный walk-цикл
    (ноги в противофазе, руки против одноимённой ноги, корень приседает
    дважды за цикл и качается вбок); детерминирован, проходит валидацию;
  - `src/physics/body.ts` — физика тела: AABB (pos = центр низа, vel, size,
    grounded) против вокселей (`SolidWorld` — точечная выборка get, чистая
    математика), `makeBody`/`stepBody` (полунеявный Эйлер), ось-раздельное
    разрешение X→Z→Y с зажимом к граням вокселей (пол — ровно на грань,
    EPS-зазор), сабстепы (< 0.9 вокселя за подшаг) против туннелирования;
  - `src/physics/character.ts` — `Character`: контроллер персонажа — тело
    физики + walk-анимация, фаза клипа растёт пропорционально пути
    (путь/stride — стопы не скользят), в покое замирает, поворот по
    движению; `visual()` — деформированные воксели для облака рендера;
  - `src/core/loop.ts` — `GameLoop`: фиксированный шаг (60 Гц) + рендер,
    аккумулятор с maxStepsPerFrame; `setManual` + `stepTick(n)` — ручной
    режим для моста/гейтов (детерминизм); now/schedule инъектируются;
  - `src/core/ecs.ts` — `EntityWorld`: минимальный 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.ts` — `DialogueRunner`: рантайм семантики v1,
    отделён от отрисовки (`DialogueView` — игра рисует сама). Узел с
    текстом ЖДЁТ игрока: `advance()` → next или конец, `pick(i)` →
    вариант; advance во время выбора игнорируется; узел без текста —
    «действие»: эффекты и автопровал в next; MAX_STEPS против циклов;
    эффекты `do[]` движок только эмитит (`onEffect`), исполнение — игра;
    геттеры nodeId/node/choices/waitingForChoice/path/result — снапшоты
    для моста;
  - `src/interaction/interactables.ts` — точки взаимодействия как данные:
    `Interactable` {id, pos, radius, label, dialogueId?}, чистые
    `nearestInteractable` (ближайшая в радиусе, Y не учитывается) и
    `interactablesInRadius` (список с дистанциями);
  - `src/inventory/inventory.ts` — `Inventory` (перенос v1 1:1): контейнер
    предметов-счётчиков (Map по id), лимиты слотов/стака обрезают и
    возвращают факт (модель не бросает), `onChange` с деталями изменения,
    serialize/load (мусор игнорируется, кламп под лимиты),
    `inventoryFromData`;
  - `src/save/saveManager.ts` — `SaveManager` (перенос v1 1:1): JSON-слоты
    `save:<slot>`, мета в параллельном ключе `meta:<slot>` (старые сейвы
    читаемы), `load` битого JSON → null; хранилище — `StorageLike`
    инъекцией (localStorage в демке, Map в тестах);
  - `src/core/scene.ts` — `SceneManager` (перенос схемы v1, без привязки
    к бэкенду): стек сцен push/pop/replace; переход {duration} фазами
    затухание → смена → проявление (каждая фаза длится duration — полный
    переход 2×duration); `FadeOverlay {setAlpha}` инъекцией (демка —
    DOM-слой, тесты — заглушка); параллельные переходы глотаются
    (pending-проверка), enter новой сцены не ожидается (void); update
    тикает текущую сцену и во время перехода, render рисует весь стек;
  - `src/core/cutscene.ts` — `CutsceneRunner` (катсцены как данные,
    жанронезависимо): стек шагов `{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`, позы —
  `samplePose`→`poseVoxels`, рисуются `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;
  `PointLightHandle` setIntensity/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-кодек движка).

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

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