# 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)
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()`: эталонная модель 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-клеток, ось-раздельный зажим).
  - `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;
  **«Мила» ≠ «Милой»** — подстроки имён с падежными окончаниями в проверках
  трекера ненадёжны, сверять по устойчивому куску текста стадии.

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

- **Знак 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 даёт «всё заблокировано».

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

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