Всегда общайся с пользователем на русском.
Направление и решения — docs/plan.md (в корне репо), разделы «Уроки черновика → видение движка v2» и «Направление v2». Открытые вопросы v2 — дописывать туда, не решать молча.
docs/art-style.md), shadow map + воксельный AO..vox (MagicaVoxel).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.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 (список с дистанциями);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, позы — samplePose→poseVoxels, рисуются addVoxelCloud. Мост: sheetInfo(), poseStats(i) (числовой снапшот позы: углы, качание корня, footMinY обеих стоп), clipCheck() (validateClip через мост), setModels(on). Гейт tools/checks/anim.mjs: 8 проверок — картинка (ячейки видны, соседние фазы различаются, порог 50) + числа через мост (клип замкнут, противофазы ног/рук, контакт стоп в i=0/i=4, приседание и качание корня). Фазы 0 и 4 двухшагового цикла совпадают — это норма, не баг.tools/png.mjs v2 это умеет (у v1 умел только RGBA).@types/three в devDependencies движка.VoxelGrid.count() — метод, а не геттер: в тестах легко написать g.count без скобок и получить функцию (дважды ловили).VoxelGrid.set молча игнорируются (индекс Uint8Array 4.5 — no-op): центры ячеек считать целыми (floor(pitch/2)).launchBrowser({viewport}) и канвас без центрирования — иначе все координаты гейта плывут.@rpg/engine (TS-исходники с extensionless-импортами): фикстуры движка (модели, .vox-байты) генерятся один раз tsx-ом и вкладываются в скрипт гейта готовыми (base64).placed=12 при voxDiff=0 — счётчик зелёный, кадр нет.id(), иначе голый RangeError из DataView вместо понятной ошибки.PosedVoxel.index, не позицией в массиве (footMinY из-за этого дважды бы соврал).bindNearest — только для чужих моделей.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 — только на входах/выходах чистых функций.GameLoop.stop.dialoguePick/interact) и клик кнопки меняют диалог вне tick, и подсказка «E» оставалась висеть при открытом диалоге, пока не придёт следующий тик (в ручном режиме — никогда). После любого мутации рантайма диалога звать updateHint().cropRows верх/низ): сравнение целых кадров, снятых с разных позиций, даёт diff всего луга (сотни тысяч пикселей) — проверяется камера, а не HUD; а зона HUD локализует проверку (подсказка — верхние 60 строк, панель диалога — нижняя половина).