Всегда общайся с пользователем на русском.
Направление и решения — 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) 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.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 строк, панель диалога — нижняя половина).display:flex на элементе перебивает атрибут hidden: #menu «скрывался» (hidden=true), но панель оставалась на каждом кадре — нужен отдельный #menu[hidden]{display:none}. Ловится попиксельным сравнением гейта (меню затемняло/перекрывало кадры).startGame(false) + 60 тиков), иначе stepTicks тикает только меню, а скриншоты снимаются поверх панели меню.AgentClient.call вариадический: call('combatSpawn', 'crawler', 15, 20), а не call('combatSpawn', ['crawler', 15, 20]) — массив уходит ОДНИМ аргументом (ENEMY_KINDS[Array] → undefined).