Newer
Older
rpg / v2 / CLAUDE.md

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

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

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

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

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

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

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

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

npm install          # установка зависимостей (workspaces)
npm test             # юнит-тесты (Vitest)
npm run typecheck    # tsc --noEmit (движок + демка)
npm run check:fast   # typecheck + тесты
node apps/demo/tools/checks/render.mjs   # скриншот-гейт рендера (Chromium, /tmp/v2_render_*.png)
node apps/demo/tools/checks/physics.mjs  # скриншот-гейт физики (/tmp/v2_physics_*.png)
node apps/demo/tools/checks/hero.mjs     # скриншот-гейт героя (/tmp/v2_hero_*.png)
node apps/demo/tools/checks/dialogue.mjs # скриншот-гейт диалогов (/tmp/v2_dialog_*.png)
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.tsmeshChunk через 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.tsstampModel: штамповка модели в мир с клиппингом (half-open прямоугольник + вертикаль мира), возвращает число вокселей;
    • src/models/vox.ts — импорт/экспорт .vox (MagicaVoxel): чанки MAIN/SIZE/XYZI/RGBA (PACK>1 — ошибка, неизвестные чанки пропускаются с детьми), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей; при экспорте слоты модели сжимаются в подряд 1..N;
    • src/models/paletteMap.tsremapSlots(model, palette): перекладка слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox;
    • src/models/mannequin.tsmannequin(): эталонная модель 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.tswalkClip(params): процедурный walk-цикл (ноги в противофазе, руки против одноимённой ноги, корень приседает дважды за цикл и качается вбок); детерминирован, проходит валидацию;
    • src/physics/body.ts — физика тела: AABB (pos = центр низа, vel, size, grounded) против вокселей (SolidWorld — точечная выборка get, чистая математика), makeBody/stepBody (полунеявный Эйлер), ось-раздельное разрешение X→Z→Y с зажимом к граням вокселей (пол — ровно на грань, EPS-зазор), сабстепы (< 0.9 вокселя за подшаг) против туннелирования;
    • src/physics/character.tsCharacter: контроллер персонажа — тело физики + walk-анимация, фаза клипа растёт пропорционально пути (путь/stride — стопы не скользят), в покое замирает, поворот по движению; visual() — деформированные воксели для облака рендера;
    • src/core/loop.tsGameLoop: фиксированный шаг (60 Гц) + рендер, аккумулятор с maxStepsPerFrame; setManual + stepTick(n) — ручной режим для моста/гейтов (детерминизм); now/schedule инъектируются;
    • src/core/ecs.tsEntityWorld: минимальный ECS (перенос v1): сущность-число, компоненты по имени, query, системы по порядку;
    • src/core/controls.ts — ввод как данные: ControlState (движение в ЭКРАННЫХ осях) + screenMoveToWorld(move, yaw) — проекция экранных осей в мировую горизонталь на границе (урок №1), диагональ не быстрее;
    • src/dialogue/graph.ts — диалоговые графы как данные (перенос v1): узлы (speaker/text/choices/next/end) + условия (when/whenNot/whenVar/ hasItem) + эффекты (setFlags/setVars/do[]), evalConditions (все группы AND; не-число в gt/lt/ge/le — ложно), validateDialogue — числовая валидация графа агентом (битые ссылки, тупики, выборы без текста); DialogueState — интерфейс состояния (игра реализует поверх своего стейта), DialogueWorld — предикаты мира (сумка);
    • src/dialogue/runner.tsDialogueRunner: рантайм семантики v1, отделён от отрисовки (DialogueView — игра рисует сама). Узел с текстом ЖДЁТ игрока: advance() → next или конец, pick(i) → вариант; advance во время выбора игнорируется; узел без текста — «действие»: эффекты и автопровал в next; MAX_STEPS против циклов; эффекты do[] движок только эмитит (onEffect), исполнение — игра; геттеры nodeId/node/choices/waitingForChoice/path/result — снапшоты для моста;
    • src/interaction/interactables.ts — точки взаимодействия как данные: Interactable {id, pos, radius, label, dialogueId?}, чистые nearestInteractable (ближайшая в радиусе, Y не учитывается) и interactablesInRadius (список с дистанциями);
    • 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). Герой демки: 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, тост-эффекты; табличка: один узел + тост), валидируются validateDialogue при старте (graphErrors в мосту).
  • 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/turntable.html + src/turntable.ts — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост sheetInfo() (геометрия листа для расчёта ячеек — viewSize как у камеры!) и setModels(on) (скрыть модели для референсного кадра). Канвас в левом верхнем углу — пиксели скриншота считаются от (0,0). Гейт tools/checks/turntable.mjs: проекция ячеек той же матрицей, что камера, эллипс 48×42 в ромбе подставки, 5 проверок (ячейки непустые, варианты попарно различаются).
  • apps/demo/anim.html + src/anim.ts — аним-лист: 8 кадров walk-цикла манекена (4×2, фазы i/8) на подставках; клип — walkClip, позы — samplePoseposeVoxels, рисуются addVoxelCloud. Мост: sheetInfo(), poseStats(i) (числовой снапшот позы: углы, качание корня, footMinY обеих стоп), clipCheck() (validateClip через мост), setModels(on). Гейт tools/checks/anim.mjs: 8 проверок — картинка (ячейки видны, соседние фазы различаются, порог 50) + числа через мост (клип замкнут, противофазы ног/рук, контакт стоп в i=0/i=4, приседание и качание корня). Фазы 0 и 4 двухшагового цикла совпадают — это норма, не баг.

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

  • Знак 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 строк, панель диалога — нижняя половина).

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

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