# 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)
npx vite --port 5299                     # демка (в apps/demo), http://localhost:5299
```

## Структура

- `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()`: эталонная модель 5×11×3
    с ригой (root → legL/legR/armL/armR, pivot в связочном пространстве);
  - `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/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-клеток, ось-раздельный зажим).
  - `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.

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

- **Знак 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 топчется на месте.
- **`AgentClient.call` вариадический**: `call('combatSpawn', 'crawler', 15, 20)`,
  а не `call('combatSpawn', ['crawler', 15, 20])` — массив уходит ОДНИМ
  аргументом (ENEMY_KINDS[Array] → undefined).

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

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