diff --git a/docs/plan.md b/docs/plan.md index a411ab6..2c22dc2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -48,6 +48,7 @@ | 26 | v2 трек 5: .vox импорт/экспорт (MagicaVoxel) — parseVox/exportVox (чанки MAIN/SIZE/XYZI/RGBA, PACK>1 — ошибка), оси vox z-up → движок y-up, слоты модели → подряд 1..N при экспорте; тесты roundtrip; мост демки `stampVoxB64` + гейт: штамп .vox меняет кадр | ✅ (2026-09-09). `models/vox.ts`: полный обход чанков (неизвестные пропускаются с детьми, обрыв файла и обрыв в заголовке — ошибки), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей. Обнаружили «слоты модели ≠ слоты сцены» → `models/paletteMap.ts`: `remapSlots` (точный hex или ближайший RGB, детерминированно). Демка: мост `stampVoxB64(b64,x,z)` (парсинг → перекладка на палитру сцены → штамп + ремешинг). Гейт 13 проверок: `.vox`-фикстура (собрана exportVox-ом, base64 вкладывается в скрипт) штампует 12 вокселей и меняет кадр. Уроки: **Node не импортирует `@rpg/engine`** (TS-исходники с extensionless-импортами) — фиксстуры для гейтов генерятся tsx-ом и вкладываются в скрипт готовыми; точка штампа для проверки «кадр изменился» обязана быть в кадре камеры (штамп вне вью дал placed=12 при voxDiff=0); заголовок чанка — 12 байт (id+contentSize+childrenSize), обрыв в нём ловить границей в `id()`, иначе голый RangeError из DataView. | | 27 | v2 трек 6: рига/анимация («анимация = данные») — рига: кости (имя, родитель, pivot в связочном пространстве), детерминированная привязка вокселей к ближайшей кости; клипы: ключевые кадры {t, кость: rot/pos}, линейный семплинг поз, числовая валидация (монотонность ключей, непрерывность цикла первый=последний, контакт стопы); процедурная локомоция (walk-цикл из параметров); генератор манекена с ригой; рендер деформированных вокселей (instanced-облако); превью-лист кадров + мост (числовые снапшоты поз) + гейт | ✅ (2026-09-09). `animation/mat.ts` (чистая аффинная математика, порядок Эйлера X→Y→Z), `animation/rig.ts` (кости + pivot в связочном пространстве; привязка: у генератора — явная, для чужих — `bindNearest` по pivot; `poseVoxels` — жёсткая деформация на кость), `animation/clip.ts` (ключи {t, кость: rot/pos}, линейный семплинг с заворотом t для циклов, `validateClip` — границы/монотонность/замкнутость цикла, `footMinY` — контакт стопы), `animation/locomotion.ts` (walkClip: ноги в противофазе, руки против одноимённой ноги, корень приседает дважды и качается), `models/mannequin.ts` (5×11×3, 5 костей, явная привязка — Voronoi по pivot отнёс бы торс к рукам). Рендер: `addVoxelCloud` (InstancedMesh, ось-выровненные кубы, обновление позы без пересборки геометрии). Демка: `anim.html` + `anim.ts` — 8 кадров цикла (4×2), мост `poseStats(i)`/`clipCheck()`/`setModels`. Гейт `anim.mjs` 8 проверок: картинка (ячейки видны, соседние фазы различаются) + числа через мост (клип замкнут, противофазы, контакт стоп ≈0.5 в опоре / подъём в махе, приседание и качание корня). 91 юнит-тест. Уроки: **composition affine — перенос parent добавлялся дважды** (тест иерархии поймал: ребёнок уезжал на 2·t вместо t); **binding индексируется по сетке, posed-массив только из занятых вокселей** — сверять через `index` вокселя, не позицией в массиве; **Voronoi-привязка по pivot семантически неверна** для внутренних вокселей (торс у плеч ближе к кости руки) — генератор привязывает явно; соседние фазы 45° дают ~110 px разницы на мелком манекене (порог гейта 50, не 150); sheetInfo/gейт должен отдавать РЕАЛЬНЫЙ viewSize камеры (в turntable 30 vs 32 проходило только за счёт запаса эллипса — исправлено). | | 28 | v2 трек 7: физика/коллизии воксельного мира — AABB-тело (pos = центр низа, vel, size, grounded) против вокселей (занятый воксель = твёрдый), гравитация, ось-раздельное разрешение X→Z→Y с зажимом к граням вокселей, сабстепы против туннелирования; юнит-тесты (падение/остановка, стена, угол, сход с уступа, потолок, тонкая стена); демка: тело-манекен в сцене с walk-анимацией в движении, мост bodySpawn/bodyStep/bodyInfo + гейт (гравитация, стена, кадр меняется) | ✅ (2026-09-09). `physics/body.ts`: `SolidWorld` (структурный интерфейс — точечная выборка `get`, чистая математика без рендера), `makeBody`/`stepBody` (полунеявный Эйлер: скорость до позиции), `collides` (AABB по воксельным диапазонам, касание грани снаружи — не столкновение), ось-раздельные подшаги X→Z→Y с зажимом к грани вокселя и EPS-зазором (пол — ровно на грань, без ловли своей же опоры), сабстепы (смещение за подшаг < 0.9 вокселя) против туннелирования сквозь тонкие стены. 10 юнит-тестов: падение на грань, покой, сход с уступа, стена останавливает, туннелирование (50 вокс/с при dt=0.1), угол двух стен, потолок, число гравитации. Демка: тело-манекен (AABB 3×10×3 — узкий силуэт; махи ног визуально выходят за AABB, это норма) с walk-анимацией и поворотом по направлению движения; мост `bodySpawn(x,z,vx,vz)` (спавн в воздухе y=6)/`bodyStep(n)` (фиксированный dt=1/60 — детерминизм гейта)/`bodyInfo`/`bodyRemove`. Гейт `physics.mjs` 7 проверок: спавн в воздухе, падение на землю y=2, фаза walk-цикла растёт в движении, стена дома останавливает по грани (pos.x ≈ 44 − w/2 − eps), кадр изменился, манекен рисуется (без тела кадр возвращается). 101 юнит-тест. Уроки: **аргумент `VoxelWorld(n)` — это ВЫСОТА чанка, а не число чанков** (`set` вне y-границ молча возвращает false — тест-мир «потолка» не получил и тест падал загадочно); **`Vec3` в mat.ts — readonly**: мутирующее состояние физики (pos/vel) — обычные кортежи `[number,number,number]`, Vec3 только на входах; **спавн тестового тела проверять на пересечение AABB с миром** — тело 3×10×3 у плиты потолка цепляло её край и «мгновенно приземлялось» на y=6. | +| 29 | v2 трек 8: каркас ядра + герой — GameLoop (фиксированный шаг 60 Гц, ручной режим для моста/гейтов), минимальный ECS (решение открытого вопроса «каркас ядра»), ввод как данные (экранные оси, проекция в мир на границе), контроллер персонажа (тело физики + walk от скорости), камера следит; демка: герой на WASD и виртуальным стиком агента, мост setInput/heroInfo/heroTeleport/stepTicks/setManual/setFollow + гейт | ✅ (2026-09-09). `core/loop.ts` (GameLoop: аккумулятор, maxStepsPerFrame, `setManual`+`stepTick` — детерминизм гейтов; now/schedule инъектируются для Node-тестов), `core/ecs.ts` (`EntityWorld` — перенос проверенной минимальной ECS v1: сущность-число, компоненты по имени, query, системы), `core/controls.ts` (ControlState + `screenMoveToWorld` — экранные оси → мировая горизонталь по yaw камеры, диагональ не быстрее), `physics/character.ts` (Character: тело + фаза клипа ∝ путь/stride — стопы не скользят, покой замирает, поворот по движению; `spawnY` — спавн в воздухе для гейта физики; мост тела физики переехал на Character — один путь, урок №4). Рендер: `setTarget` (камера+солнце за целью) + `currentTarget`. Демка: герой на WASD/стрелках (клавиатура демки, `screenMoveToWorld` с ISO_YAW), камера следит с шагом пиксельной сетки (round), мост `setInput`/`heroInfo`/`heroTeleport`/`stepTicks`/`setManual`/`setFollow` (гейт физики держит камеру фиксированной). Гейт `hero.mjs` 9 проверок: герой на земле, ввод агента двигает (+x мира из (1,1) экранного), фаза ровно 1.25 цикла/с (скорость/stride), стена по грани (42.4999), покой замирает, кадр меняется, камера следует, телепорт уезжает. 115 юнит-тестов. Уроки: **фаза = путь/stride, не время** — при скорости 4 и stride 3.2 это 1.25 цикла/с (совпало с длительностью клипа 0.8 с намеренно: stride = speed·duration); **виртуальный стик агента живёт между вызовами моста** — перед чистой проверкой кадра отпускать setInput(null,null); **cancelAnimationFrame нет в Node** — typeof-guard в GameLoop.stop (юнит-тесты с инъекцией schedule). | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -579,6 +580,9 @@ рендер-агностичен); `tools/pixelart` остаётся для UI/иконок/частиц; **агентный мост + CLI-чеки — ядро v2, а не «порт»** (согласовано). -Открытые вопросы v2 (копить здесь): каркас ядра (ECS v1 или новый), физика/ -коллизии в воксельном мире, тайлинг мира (чанки), производственный порядок -треков (каркас+бэкенд+мост → генераторы моделей → рига/анимация → демо). +Открытые вопросы v2 (копить здесь): ~~каркас ядра (ECS v1 или новый)~~ — +решено (трек 8, строка 29): минимальный ECS v1-схемы (`EntityWorld`), +GameLoop v1-схемы с ручным режимом, ввод как данные; ~~физика/коллизии в +воксельном мире~~ (трек 7), ~~тайлинг мира (чанки)~~ (трек 3). Остаётся: +производственный порядок следующих треков (диалоги/инвентарь/сохранение/ +аудио-перенос — по мере движения к воспроизведению демки v1 на v2). diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 1fae547..7115c28 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -42,6 +42,7 @@ 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) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -93,6 +94,18 @@ математика), `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), диагональ не быстрее; - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -105,14 +118,23 @@ `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 с walk-анимацией и - поворотом по движению)/`bodyStep(n)` (n шагов физики с фиксированным - dt=1/60)/`bodyInfo()` (pos/vel/grounded/size/walkT)/`bodyRemove()`. + /`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 — центр сцены). + Герой демки: WASD/стрелки → `screenMoveToWorld` с ISO_YAW, Character + (скорость 4, stride 3.2), камера следит с шагом пиксельной сетки. - `apps/demo/tools/checks/physics.mjs` — скриншот-гейт физики (7 проверок): спавн в воздухе, падение на землю y=2 ровно на грань, фаза walk-цикла растёт в движении, стена дома останавливает по грани (pos.x ≈ 44 − w/2 − eps), кадр изменился от движения, манекен рисуется - (после `bodyRemove` кадр возвращается к пустому месту). + (после `bodyRemove` кадр возвращается к пустому месту). Начинается с + `setFollow(false)` — камера фиксированная. +- `apps/demo/tools/checks/hero.mjs` — скриншот-гейт героя (9 проверок): + цикл в ручной режим (`setManual(true)`), герой на земле, ввод агента + двигает (экранный (1,1) = +x мира), фаза ровно 1.25 цикла/с + (скорость/stride), стена по грани, покой замирает, кадр меняется, + камера следует (camTarget = герой), телепорт уезжает. - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!) @@ -176,6 +198,14 @@ - **Спавн тела в тесте/демке проверять на пересечение 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`. ## Рабочие привычки diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 3032262..1b9fc4a 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -6,11 +6,11 @@ * меняют ровно затронутые чанки. Палитра — слоты арт-библии v1. */ import { - VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, + VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, ISO_YAW, treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, - mannequin, walkClip, samplePose, poseVoxels, makeBody, stepBody, + mannequin, walkClip, Character, GameLoop, screenMoveToWorld, } from '@rpg/engine'; -import type { Body, VoxelCloud } from '@rpg/engine'; +import type { VoxelCloud } from '@rpg/engine'; import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex @@ -88,49 +88,17 @@ /** Валун — из генератора boulderModel (ручной вариант удалён: урок №4). */ -// ---- Тело-манекен: физика (stepBody) + walk-анимация (walkClip) ---- -// AABB тела — узкий силуэт манекена; махи ног визуально выходят за AABB, -// это норма (деформация поз не участвует в коллизиях). +// ---- Манекен: тело физики + walk-анимация через Character ---- +// AABB — узкий силуэт манекена; махи ног визуально выходят за AABB, это норма +// (деформация поз не участвует в коллизиях). const { model: MAN, rig: MAN_RIG } = mannequin(); const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 }); const BODY_SIZE: [number, number, number] = [3, 10, 3]; const DT = 1 / 60; // фиксированный шаг физики (детерминизм гейта) -let manBody: Body | null = null; -let manCloud: VoxelCloud | null = null; -let walkT = 0; // фаза walk-цикла; растёт, только пока тело движется - -/** Поворот (dx,dz) вокруг нуля на yaw вокруг оси Y (камера смотрит сверху). */ -function rotXZ(dx: number, dz: number, yaw: number): [number, number] { - const c = Math.cos(yaw), s = Math.sin(yaw); - return [dx * c + dz * s, -dx * s + dz * c]; -} - -/** Перестроить облако манекена: поза из клипа, поворот по направлению движения. */ -function updateManVisual(): void { - const b = manBody; - if (!b) return; - const yaw = Math.atan2(b.vel[0], b.vel[2]); - const posed = poseVoxels(MAN_RIG, MAN, samplePose(WALK, walkT)); - const voxels = posed.map((v) => { - const [rx, rz] = rotXZ(v.pos[0] - MAN.size[0] / 2, v.pos[2] - MAN.size[2] / 2, yaw); - return { - pos: [b.pos[0] + rx, b.pos[1] + v.pos[1], b.pos[2] + rz] as [number, number, number], - color: v.color, - }; - }); - if (manCloud) manCloud.update(voxels); - else manCloud = renderer.addVoxelCloud(voxels); -} - -/** Статистика тела для гейта (или null, если тела нет). */ -function bodyInfo() { - if (!manBody) return null; - return { - pos: [...manBody.pos], vel: [...manBody.vel], grounded: manBody.grounded, - size: BODY_SIZE, walkT, - }; -} +/** Тело гейта физики: отдельный Character со спавном в воздухе. */ +let bodyChar: Character | null = null; +let bodyCloud: VoxelCloud | null = null; const world = new VoxelWorld(24); const buildStart = performance.now(); @@ -157,6 +125,57 @@ remeshDirty(); const buildMs = performance.now() - buildStart; +// ---- Герой: Character + игровой цикл + клавиатура (агент — виртуальным стиком) ---- +const hero = new Character(MAN_RIG, MAN, WALK, [40, 60], { + size: BODY_SIZE, + speed: 4, + strideLength: 3.2, // 4 вокс/с × 0.8 с цикла — шаг без скольжения стоп +}); +const heroCloud = renderer.addVoxelCloud(hero.visual()); + +// Клавиатура: экранные оси (урок №1: проекцию делает screenMoveToWorld на границе) +const heldKeys = new Set(); +const KEYMAP: Record = { + KeyW: [0, -1], KeyS: [0, 1], KeyA: [-1, 0], KeyD: [1, 0], + ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], +}; +window.addEventListener('keydown', (e) => { + if (KEYMAP[e.code]) { + heldKeys.add(e.code); + e.preventDefault(); + } +}); +window.addEventListener('keyup', (e) => heldKeys.delete(e.code)); +function keyboardMove(): [number, number] { + let x = 0, y = 0; + for (const code of heldKeys) { + const m = KEYMAP[code]; + x += m[0]; + y += m[1]; + } + return [x, y]; +} + +/** Виртуальный стик агента (мост); null — рулит клавиатура. */ +let agentInput: readonly [number, number] | null = null; +/** Слежение камеры за героем (мост отключает для фиксированного кадра). */ +let followHero = true; + +/** Один шаг логики: ввод → герой → облако → камера следит (шаг пиксельной сетки). */ +function tick(dt: number): void { + const move = agentInput ?? keyboardMove(); + hero.setMove(screenMoveToWorld(move, ISO_YAW)); + hero.step(world, dt); + heroCloud.update(hero.visual()); + if (followHero) { + const b = hero.body.pos; + renderer.setTarget([Math.round(b[0]), 2, Math.round(b[2])]); + } +} + +const loop = new GameLoop(60, { update: tick, render: () => renderer.render() }); +loop.start(); + // Агентный мост (DEV): солнце, статистика, правка мира атомарным ремешингом let sunK = 0.5; function stats(): { vertices: number; triangles: number; sun: number } { @@ -190,30 +209,35 @@ remeshDirty(); return placed; } -/** Заспавнить тело-манекен в воздухе (y=6) с заданной скоростью — гейт проверит падение и ходьбу. */ +/** Заспавнить тело-манекен в воздухе (y=6) с заданной скоростью — гейт физики. */ function bodySpawn(x: number, z: number, vx: number, vz: number) { bodyRemove(); - manBody = makeBody([x, 6, z], BODY_SIZE); - manBody.vel = [vx, 0, vz]; - walkT = 0; - updateManVisual(); + bodyChar = new Character(MAN_RIG, MAN, WALK, [x, z], { size: BODY_SIZE, spawnY: 6 }); + bodyChar.body.vel = [vx, 0, vz]; + bodyCloud = renderer.addVoxelCloud(bodyChar.visual()); return bodyInfo(); } /** Прогнать n шагов физики фиксированным dt; фаза walk-цикла растёт в движении. */ function bodyStep(n: number) { - if (!manBody) return null; - for (let i = 0; i < n; i++) { - stepBody(world, manBody, DT); - if (manBody.vel[0] !== 0 || manBody.vel[2] !== 0) walkT += DT; - } - updateManVisual(); + if (!bodyChar || !bodyCloud) return null; + for (let i = 0; i < n; i++) bodyChar.step(world, DT); + bodyCloud.update(bodyChar.visual()); return bodyInfo(); } +/** Статистика тела для гейта (или null, если тела нет). */ +function bodyInfo() { + if (!bodyChar) return null; + const b = bodyChar.body; + return { + pos: [...b.pos], vel: [...b.vel], grounded: b.grounded, + size: BODY_SIZE, walkT: bodyChar.phase, + }; +} /** Убрать тело и его облако (референсный кадр гейта «манекен рисуется»). */ function bodyRemove(): void { - manCloud?.dispose(); - manCloud = null; - manBody = null; + bodyCloud?.dispose(); + bodyCloud = null; + bodyChar = null; } const agent = { setSun(k: number): void { sunK = k; renderer.setSun(k); }, @@ -230,15 +254,42 @@ bodyStep, bodyInfo, bodyRemove, + /** Виртуальный стик агента (экранные оси −1..1); null — снова клавиатура. */ + setInput(x: number | null, z: number | null): void { + agentInput = x === null || z === null ? null : [x, z]; + }, + /** Снапшот героя для гейта (плюс текущая цель камеры). */ + heroInfo() { + return { + pos: [...hero.body.pos], vel: [...hero.body.vel], grounded: hero.body.grounded, + phase: hero.phase, yaw: hero.yaw, + camTarget: renderer.currentTarget(), + }; + }, + /** Телепорт героя (проверка слежения камеры). */ + heroTeleport(x: number, z: number): void { + hero.body.pos = [x, 2, z]; + hero.body.vel = [0, 0, 0]; + heroCloud.update(hero.visual()); + renderer.setTarget([Math.round(x), 2, Math.round(z)]); + }, + /** Ручные шаги логики + рендер (детерминизм гейта). */ + stepTicks(n: number): number { + return loop.stepTick(n, true); + }, + /** Ручной/автоматический режим цикла. */ + setManual(on: boolean): void { + loop.setManual(on); + }, + /** Слежение камеры за героем (off — камера в центре сцены, кадр гейта физики). */ + setFollow(on: boolean): void { + followHero = on; + if (!on) renderer.setTarget([S / 2, 2, S / 2]); + }, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, }; (window as unknown as Record).__agent = agent; -function frame(): void { - renderer.render(); - requestAnimationFrame(frame); -} -frame(); (window as unknown as Record).__beacon = true; \ No newline at end of file diff --git a/v2/apps/demo/tools/checks/hero.mjs b/v2/apps/demo/tools/checks/hero.mjs new file mode 100644 index 0000000..e904189 --- /dev/null +++ b/v2/apps/demo/tools/checks/hero.mjs @@ -0,0 +1,120 @@ +/** + * Скриншот-гейт героя v2 (трек «каркас ядра + герой»): управление от агента + * через виртуальный стик (мост), проекция экранных осей в мир, стена дома + * останавливает, фаза walk-цикла растёт в движении и замирает в покое, + * камера следит за героем. Ручной режим цикла (setManual + stepTicks) — + * детерминизм. Скриншоты: /tmp/v2_hero_*.png. + * + * Запуск: node apps/demo/tools/checks/hero.mjs [--port 5302] + */ +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { sleep, launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5302); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'hero', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +// Геометрия сцены: стена дома на x=44 (z=44..48), земля — грань y=2. +const HOUSE_WALL_X = 44; +const GROUND_Y = 2; + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + + const errs = client.errors(); + check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + + // Детерминизм: цикл в ручной режим, шаги только через stepTicks + await client.call('setManual', true); + const idle = await client.call('heroInfo'); + check('герой на сцене, стоит на земле', + idle && idle.grounded === true && Math.abs(idle.pos[1] - GROUND_Y) < 0.02, idle); + + // Управление: экранный стик (1,1) при yaw 45° = ровно +x мира + await client.call('heroTeleport', 36, 46); // перед южной стеной дома + await client.call('setInput', 1, 1); + await client.call('stepTicks', 10); + const moving = await client.call('heroInfo'); + check('ввод агента двигает героя: +x мира, z не плывёт', + moving.pos[0] > 36.1 && Math.abs(moving.pos[2] - 46) < 0.02 && moving.vel[0] > 0, moving); + + // Walk-фаза растёт пропорционально пути (без скольжения стоп) + const phaseA = moving.phase; + await client.call('stepTicks', 60); + const phaseB = await client.call('heroInfo'); + const grown = phaseB.phase - phaseA; + // 60 тиков = 1 с при скорости 4 и stride 3.2 → ровно 1.25 цикла + check('фаза walk-цикла растёт пропорционально пути (1.25 цикла/с)', + grown > 1.0 && grown < 1.4, { grown, speed: phaseB.vel[0] }); + + // Стена дома: герой зажат по западной грани, дальше не идёт + await client.call('stepTicks', 600); + const atWall = await client.call('heroInfo'); + const expectedX = HOUSE_WALL_X - 1.5 - 1e-4; // AABB 3 шириной + check('стена дома останавливает героя по грани', + Math.abs(atWall.pos[0] - expectedX) < 0.01, { pos: atWall.pos[0], expectedX }); + + // Покой: ввод отпущен — фаза замирает + await client.call('setInput', null, null); + const phaseWall = atWall.phase; + await client.call('stepTicks', 120); + const rested = await client.call('heroInfo'); + check('в покое фаза не растёт', rested.phase === phaseWall, { before: phaseWall, after: rested.phase }); + + // Скриншоты: герой у стены (кадр «стоит») vs идёт (кадр меняется) + await client.call('heroTeleport', 38, 46); + await client.call('setInput', 1, 1); + await client.call('stepTicks', 30); + const goShot = await client.screenshot('/tmp/v2_hero_go.png'); + await client.call('stepTicks', 210); + const stopShot = await client.screenshot('/tmp/v2_hero_stop.png'); + await client.call('setInput', null, null); // стик отпущен перед проверкой камеры + const go = decodePng(readFileSync(goShot)); + const stop = decodePng(readFileSync(stopShot)); + const walkDiff = countDiff(go.rgba, stop.rgba); + check('кадр меняется: герой идёт к стене', walkDiff > 150, { walkDiff }); + + // Камера следит: телепорт далеко — кадр уезжает за героем + await client.call('heroTeleport', 60, 12); + await client.call('stepTicks', 30); + const farInfo = await client.call('heroInfo'); + const far = Math.abs(farInfo.camTarget[0] - 60) + Math.abs(farInfo.camTarget[2] - 12); + check('камера следит: цель камеры = герой', far < 1.5, farInfo.camTarget); + const farShot = await client.screenshot('/tmp/v2_hero_far.png'); + const farImg = decodePng(readFileSync(farShot)); + const camDiff = countDiff(stop.rgba, farImg.rgba); + check('камера переехала: кадр у стены vs дальняя точка различаются', camDiff > 5000, { camDiff }); + + report.ok = report.checks.every((c) => c.ok); + } finally { + await browser.close(); + } +} catch (e) { + check('прогон без исключений', false, String(e)); +} finally { + server.stop(); +} + +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); + +/** Число различающихся RGBA-пикселей двух кадров одного размера. */ +function countDiff(a, b) { + let n = 0; + for (let i = 0; i < a.length; i += 4) { + if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++; + } + return n; +} \ No newline at end of file diff --git a/v2/apps/demo/tools/checks/physics.mjs b/v2/apps/demo/tools/checks/physics.mjs index 2c7878d..c371309 100644 --- a/v2/apps/demo/tools/checks/physics.mjs +++ b/v2/apps/demo/tools/checks/physics.mjs @@ -37,6 +37,9 @@ const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + // Камера фиксированная: слежение за героем off (герой проверяет hero.mjs) + await client.call('setFollow', false); + // Спавн в воздухе (y=6): гейт сразу видит тело до шагов физики const spawnInfo = await client.call('bodySpawn', SPAWN[0], SPAWN[1], 0, 0); check('тело спавнится в воздухе (y=6, не grounded)', diff --git a/v2/packages/engine/src/core/__tests__/controls.test.ts b/v2/packages/engine/src/core/__tests__/controls.test.ts new file mode 100644 index 0000000..975c146 --- /dev/null +++ b/v2/packages/engine/src/core/__tests__/controls.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { screenMoveToWorld } from '../controls'; +import { ISO_YAW } from '../../render/voxelRenderer'; + +describe('screenMoveToWorld (экранные оси → мир, граница камеры)', () => { + it('при yaw 45° «вправо» = (+x, −z), «к зрителю» = (+x, +z)', () => { + const r = screenMoveToWorld([1, 0], ISO_YAW); + expect(r[0]).toBeCloseTo(Math.SQRT1_2, 9); + expect(r[1]).toBeCloseTo(-Math.SQRT1_2, 9); + const d = screenMoveToWorld([0, 1], ISO_YAW); + expect(d[0]).toBeCloseTo(Math.SQRT1_2, 9); + expect(d[1]).toBeCloseTo(Math.SQRT1_2, 9); + }); + + it('диагональ WASD не быстрее: длина обрезается до 1', () => { + const [x, z] = screenMoveToWorld([1, 1], ISO_YAW); + expect(x).toBeCloseTo(1, 9); // (+1,+1) экранное = ровно +x мира + expect(z).toBeCloseTo(0, 9); + const w = screenMoveToWorld([0.7, 0.7], ISO_YAW); // < 1 не растягивается + expect(Math.hypot(w[0], w[1])).toBeCloseTo(0.7 * Math.SQRT2, 6); + expect(Math.hypot(...screenMoveToWorld([1, 1], 0))).toBeCloseTo(1, 9); + }); + + it('ноль — ноль', () => { + expect(screenMoveToWorld([0, 0], ISO_YAW)).toEqual([0, 0]); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/core/__tests__/ecs.test.ts b/v2/packages/engine/src/core/__tests__/ecs.test.ts new file mode 100644 index 0000000..96a02e1 --- /dev/null +++ b/v2/packages/engine/src/core/__tests__/ecs.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { EntityWorld, type EcsSystem } from '../ecs'; + +describe('EntityWorld (минимальный ECS, перенос v1)', () => { + it('создание/удаление сущностей, живучесть', () => { + const w = new EntityWorld(); + const e = w.createEntity(); + expect(w.isAlive(e)).toBe(true); + w.destroyEntity(e); + expect(w.isAlive(e)).toBe(false); + }); + + it('компоненты: add/get/has/remove, типобезопасно по имени', () => { + const w = new EntityWorld(); + const e = w.createEntity(); + expect(w.getComponent(e, 'pos')).toBeUndefined(); + w.addComponent(e, 'pos', { x: 1, y: 2 }); + expect(w.getComponent<{ x: number; y: number }>(e, 'pos')?.x).toBe(1); + expect(w.hasComponent(e, 'pos')).toBe(true); + w.removeComponent(e, 'pos'); + expect(w.hasComponent(e, 'pos')).toBe(false); + }); + + it('query возвращает живые сущности со всеми компонентами сразу', () => { + const w = new EntityWorld(); + const a = w.createEntity(), b = w.createEntity(), c = w.createEntity(); + w.addComponent(a, 'pos', {}); + w.addComponent(a, 'vel', {}); + w.addComponent(b, 'pos', {}); + w.addComponent(c, 'vel', {}); + w.destroyEntity(b); + expect(w.query('pos')).toEqual([a]); + expect(w.query('pos', 'vel')).toEqual([a]); + expect(w.query('vel')).toEqual([a, c]); + }); + + it('системы идут по порядку добавления, получают dt', () => { + const w = new EntityWorld(); + const log: string[] = []; + const sys = (name: string): EcsSystem => ({ + update: (_world, dt) => log.push(`${name}:${dt.toFixed(3)}`), + }); + w.addSystem(sys('a')); + w.addSystem(sys('b')); + w.update(1 / 60); + expect(log).toEqual([`a:${(1 / 60).toFixed(3)}`, `b:${(1 / 60).toFixed(3)}`]); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/core/__tests__/loop.test.ts b/v2/packages/engine/src/core/__tests__/loop.test.ts new file mode 100644 index 0000000..db12a44 --- /dev/null +++ b/v2/packages/engine/src/core/__tests__/loop.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { GameLoop } from '../loop'; + +/** Завод с инъекцией времени/планировщика: шаги без реального rAF. */ +function makeLoop(fps = 60) { + const updates: number[] = []; + let renders = 0; + let now = 0; + let pending: ((t: number) => void) | null = null; + const loop = new GameLoop(fps, { + update: (dt) => updates.push(dt), + render: () => { + renders++; + }, + }, { + now: () => now, + schedule: (cb) => { + pending = cb; + }, + }); + return { + loop, + updates, + get renders() { + return renders; + }, + /** Продвинуть «время» на мс и исполнить запланированный кадр. */ + advance(ms: number) { + now += ms; + const cb = pending; + pending = null; + cb?.(now); + }, + }; +} + +describe('GameLoop (фиксированный шаг)', () => { + it('шаг логики всегда dt = 1/fps, рендер после пачки', () => { + const t = makeLoop(60); + t.loop.start(); + for (let i = 0; i < 60; i++) t.advance(16.7); // ~секунда реального времени кадрами + expect(t.updates.length).toBe(60); + expect(t.updates.every((dt) => Math.abs(dt - 1 / 60) < 1e-12)).toBe(true); + expect(t.renders).toBe(60); + t.loop.stop(); + }); + + it('долг после лага догоняет пачкой, но не более maxStepsPerFrame', () => { + const t = makeLoop(60); + t.loop.start(); + t.advance(300); // лаг одним кадром: долг 18 шагов > лимита 5 + expect(t.updates.length).toBe(5); + expect(t.loop.maxStepsPerFrame).toBe(5); + t.loop.stop(); + }); + + it('ручной режим: rAF не делает шагов, stepTick крутит логику', () => { + const t = makeLoop(60); + t.loop.start(); + t.loop.setManual(true); + t.advance(1000); + expect(t.updates.length).toBe(0); + t.loop.stepTick(3); + expect(t.updates.length).toBe(3); + expect(t.updates.every((dt) => Math.abs(dt - 1 / 60) < 1e-12)).toBe(true); + // выход из ручного режима не «догоняет» пропущенное время + t.loop.setManual(false); + t.advance(17); // один шаг (16.7 мс), без пачки за пропущенную секунду + expect(t.updates.length).toBe(4); + t.loop.stop(); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/core/controls.ts b/v2/packages/engine/src/core/controls.ts new file mode 100644 index 0000000..3f72b02 --- /dev/null +++ b/v2/packages/engine/src/core/controls.ts @@ -0,0 +1,34 @@ +/** + * Ввод как данные (решение v2): состояние управления — простой объект, + * его заполняют клавиатура демки и виртуальный стик агентного моста. + * Менеджера ввода с DOM-событиями в ядре нет (урок №4: не делать путь + * без потребителя) — кнопки/указатель появятся вместе с потребителями. + * + * Направление движения — в ЭКРАННЫХ осях ([x] вправо, [y] к зрителю): + * игрок думает «вправо относительно экрана». Перевод в мировые оси — + * граница (урок v1 №1: единое мировое пространство, проекция на границе). + */ + +/** Состояние управления на шаг логики. */ +export interface ControlState { + /** Желаемое движение в экранных осях, компоненты −1..1 (вправо, к зрителю). */ + move: readonly [number, number]; +} + +/** + * Экранное направление → мировая горизонталь при изо-камере с yaw θ + * (θ — угол «target → камера» в плоскости XZ). Экранный «вправо» ложится + * на (sinθ, 0, −cosθ), «к зрителю» — на (cosθ, 0, sinθ); длина результата + * обрезается до 1 (диагональ WASD не быстрее). + */ +export function screenMoveToWorld( + move: readonly [number, number], + yaw: number, +): [number, number] { + const [sx, sy] = move; + const x = sx * Math.sin(yaw) + sy * Math.cos(yaw); + const z = -sx * Math.cos(yaw) + sy * Math.sin(yaw); + const len = Math.hypot(x, z); + if (len <= 1 || len === 0) return [x, z]; + return [x / len, z / len]; +} \ No newline at end of file diff --git a/v2/packages/engine/src/core/ecs.ts b/v2/packages/engine/src/core/ecs.ts new file mode 100644 index 0000000..3fe5c6d --- /dev/null +++ b/v2/packages/engine/src/core/ecs.ts @@ -0,0 +1,80 @@ +/** + * Минимальный ECS (проверенное решение v1, перенос): сущность — число, + * компоненты — объекты по имени, системы — функции. Хватает для игр: + * без метапрограммирования, всё видно в отладчике. + * Открытый вопрос v2 «каркас ядра (ECS v1 или новый)» решён: берём v1-схему. + */ + +export type Entity = number; + +export interface EcsSystem { + /** Вызывается каждый тик фиксированного шага. dt — секунды. */ + update(world: EntityWorld, dt: number): void; +} + +export class EntityWorld { + private nextId = 1; + private alive = new Set(); + /** entity -> (имя компонента -> компонент). */ + private components = new Map>(); + private systems: EcsSystem[] = []; + + createEntity(): Entity { + const e = this.nextId++; + this.alive.add(e); + this.components.set(e, new Map()); + return e; + } + + destroyEntity(e: Entity): void { + this.alive.delete(e); + this.components.delete(e); + } + + isAlive(e: Entity): boolean { + return this.alive.has(e); + } + + addComponent(e: Entity, name: string, component: T): T { + this.components.get(e)!.set(name, component); + return component; + } + + getComponent(e: Entity, name: string): T | undefined { + return this.components.get(e)?.get(name) as T | undefined; + } + + hasComponent(e: Entity, name: string): boolean { + return this.components.get(e)?.has(name) ?? false; + } + + removeComponent(e: Entity, name: string): void { + this.components.get(e)?.delete(name); + } + + /** Все живые сущности с указанными компонентами (все сразу). */ + query(...names: string[]): Entity[] { + const out: Entity[] = []; + for (const e of this.alive) { + const map = this.components.get(e)!; + let ok = true; + for (const n of names) { + if (!map.has(n)) { + ok = false; + break; + } + } + if (ok) out.push(e); + } + return out; + } + + addSystem(system: EcsSystem): void { + this.systems.push(system); + } + + /** Прогнать системы по порядку добавления. */ + update(dt: number): void { + for (const s of this.systems) s.update(this, dt); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/core/loop.ts b/v2/packages/engine/src/core/loop.ts new file mode 100644 index 0000000..f8b2aa9 --- /dev/null +++ b/v2/packages/engine/src/core/loop.ts @@ -0,0 +1,101 @@ +/** + * Игровой цикл v2: фиксированный шаг логики (по умолчанию 60 Гц) + рендер + * после пачки шагов (проверенная схема v1). Ручной режим (setManual) — + * rAF живёт, но шаги не идут: их крутит мост/гейт через stepTick — так + * агентные проверки детерминированы (урок v1: мост — часть ядра). + * now/schedule инъектируются для юнит-тестов без реального времени. + */ +export interface LoopCallbacks { + /** Один шаг логики; dt всегда равен step. */ + update(dt: number): void; + render(): void; +} + +export interface LoopOptions { + /** now() в миллисекундах (по умолчанию performance.now). */ + now?: () => number; + /** Планировщик кадра (по умолчанию requestAnimationFrame). */ + schedule?: (cb: (t: number) => void) => void; + /** Лимит отрисовки, кадров/с (0 — без лимита). */ + maxFps?: number; +} + +export class GameLoop { + /** Длительность фиксированного шага, сек. */ + readonly step: number; + /** Максимум шагов за кадр (защита от «догоняния» после лагов). */ + maxStepsPerFrame = 5; + private readonly callbacks: LoopCallbacks; + private readonly now: () => number; + private readonly schedule: (cb: (t: number) => void) => void; + private readonly minFrameMs: number; + private rafId = 0; + private lastTime = 0; + private lastFrame = 0; + private accumulator = 0; + private running = false; + private manual = false; + + constructor(fps: number, callbacks: LoopCallbacks, opts: LoopOptions = {}) { + this.step = 1 / fps; + this.callbacks = callbacks; + this.now = opts.now ?? (() => performance.now()); + this.schedule = + opts.schedule ?? + ((cb) => { + this.rafId = requestAnimationFrame(cb); + }); + this.minFrameMs = opts.maxFps && opts.maxFps > 0 ? 1000 / opts.maxFps : 0; + } + + start(): void { + if (this.running) return; + this.running = true; + this.lastTime = this.now(); + this.lastFrame = this.lastTime; + this.schedule(this.tick); + } + + stop(): void { + this.running = false; + // cancelAnimationFrame нет в Node (юнит-тесты с инъекцией schedule) + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.rafId); + } + + /** Ручной режим: кадры rAF пустые, шаги крутит stepTick. */ + setManual(on: boolean): void { + this.manual = on; + this.accumulator = 0; + this.lastTime = this.now(); + this.lastFrame = this.lastTime; + } + + /** Шаги логики вручную (мост/гейт); render — отрисовать после пачки. */ + stepTick(n = 1, render = false): number { + for (let i = 0; i < n; i++) this.callbacks.update(this.step); + if (render) this.callbacks.render(); + return n; + } + + private tick = (t: number): void => { + if (!this.running) return; + this.schedule(this.tick); + if (this.manual) { + this.accumulator = 0; // время не копим — иначе «догоним» пропущенное + this.lastTime = t; + return; + } + if (t - this.lastFrame < this.minFrameMs - 1) return; + this.lastFrame = t; + this.accumulator += Math.min((t - this.lastTime) / 1000, 0.25); + this.lastTime = t; + let steps = 0; + while (this.accumulator >= this.step && steps < this.maxStepsPerFrame) { + this.callbacks.update(this.step); + this.accumulator -= this.step; + steps++; + } + if (steps === this.maxStepsPerFrame) this.accumulator = 0; // сброс долга + this.callbacks.render(); + }; +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index de33d40..0973b48 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -44,4 +44,13 @@ export { walkClip } from './animation/locomotion'; export type { WalkParams } from './animation/locomotion'; export { makeBody, stepBody, collides, GRAVITY } from './physics/body'; -export type { Body, SolidWorld } from './physics/body'; \ No newline at end of file +export type { Body, SolidWorld } from './physics/body'; +export { Character } from './physics/character'; +export type { CharacterOptions, CharacterVoxel } from './physics/character'; +export { GameLoop } from './core/loop'; +export type { LoopCallbacks, LoopOptions } from './core/loop'; +export { EntityWorld } from './core/ecs'; +export type { Entity, EcsSystem } from './core/ecs'; +export { screenMoveToWorld } from './core/controls'; +export type { ControlState } from './core/controls'; +export { ISO_YAW } from './render/voxelRenderer'; \ No newline at end of file diff --git a/v2/packages/engine/src/physics/__tests__/character.test.ts b/v2/packages/engine/src/physics/__tests__/character.test.ts new file mode 100644 index 0000000..dd46dfe --- /dev/null +++ b/v2/packages/engine/src/physics/__tests__/character.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { VoxelWorld } from '../../world/world'; +import { mannequin } from '../../models/mannequin'; +import { walkClip } from '../../animation/locomotion'; +import { Character } from '../character'; + +/** Плоский мир с землёй y=0..1 и стеной x=6 (как в тестах body). */ +function testWorld(): VoxelWorld { + const w = new VoxelWorld(16); + for (let x = 0; x < 10; x++) + for (let z = 0; z < 8; z++) { + w.set(x, 0, z, 1); + w.set(x, 1, z, 1); + } + for (let y = 2; y <= 4; y++) + for (let z = 0; z < 8; z++) w.set(6, y, z, 1); + return w; +} + +function makeChar(spawn: readonly [number, number] = [3, 5]): Character { + const { model, rig } = mannequin(); + const clip = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.25 }); + return new Character(rig, model, clip, spawn, { size: [3, 10, 3], speed: 3, strideLength: 3 }); +} + +describe('Character (тело + ходьба от скорости)', () => { + it('стоит на земле, в покое фаза не растёт', () => { + const w = testWorld(); + const c = makeChar(); + for (let i = 0; i < 60; i++) c.step(w, 1 / 60); + expect(c.body.pos[1]).toBeCloseTo(2); + expect(c.body.grounded).toBe(true); + expect(c.phase).toBe(0); + }); + + it('движение: скорость из setMove, фаза растёт пропорционально пути', () => { + const w = testWorld(); + const c = makeChar(); + c.setMove([1, 0]); // скорость 3 вокс/с вправо мира + for (let i = 0; i < 10; i++) c.step(w, 1 / 60); // 0.167 с свободного хода + expect(c.body.vel[0]).toBeCloseTo(3); + expect(c.body.pos[0]).toBeCloseTo(3.5, 3); + expect(c.yaw).toBeCloseTo(Math.PI / 2, 5); // смотрит по +x + for (let i = 0; i < 590; i++) c.step(w, 1 / 60); // упёрся в стену x=6 + expect(c.body.pos[0]).toBeCloseTo(4.5, 3); // путь 1.5 вокселя до грани + expect(c.phase).toBeCloseTo(0.5, 3); // 1.5 вокселя / stride 3 = полцикла + }); + + it('у стены: зажат по грани, фаза замирает, yaw помнит последнее движение', () => { + const w = testWorld(); + const c = makeChar([3, 5]); + c.setMove([1, 0]); + for (let i = 0; i < 600; i++) c.step(w, 1 / 60); // упёрся в стену x=6 + const stopped = c.body.pos[0]; + expect(stopped).toBeCloseTo(6 - 1.5 - 1e-4, 3); + const phaseAtWall = c.phase; + for (let i = 0; i < 60; i++) c.step(w, 1 / 60); // ввод держится, но движения нет + expect(c.body.pos[0]).toBeCloseTo(stopped, 6); + expect(c.phase).toBeCloseTo(phaseAtWall, 9); // фаза не ползёт у стены + expect(c.yaw).toBeCloseTo(Math.PI / 2, 5); + }); + + it('visual(): воксели вокруг тела, поворот по движению, поза от фазы', () => { + const w = testWorld(); + const c = makeChar(); + c.setMove([1, 0]); + for (let i = 0; i < 30; i++) c.step(w, 1 / 60); + const voxels = c.visual(); + expect(voxels.length).toBe(32); // все воксели манекена + for (const v of voxels) { + expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(2.5); // модель 5 шириной + expect(v.pos[1]).toBeGreaterThanOrEqual(c.body.pos[1]); + } + const idle = makeChar(); + expect(idle.visual().length).toBe(32); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/physics/character.ts b/v2/packages/engine/src/physics/character.ts new file mode 100644 index 0000000..7d54db8 --- /dev/null +++ b/v2/packages/engine/src/physics/character.ts @@ -0,0 +1,100 @@ +/** + * Контроллер персонажа: тело физики + walk-анимация от скорости. + * Фаза клипа растёт пропорционально горизонтальной скорости (шаги не + * «скользят» по земле), в покое замирает; персонаж поворачивается по + * направлению движения. Мир/клип/модель приходят снаружи — контроллер + * только связывает физику и анимацию (чистая композиция, урок №3). + */ +import type { Body } from './body'; +import { makeBody, stepBody } from './body'; +import type { SolidWorld } from './body'; +import type { Vec3 } from '../animation/mat'; +import type { AnimClip } from '../animation/clip'; +import { samplePose } from '../animation/clip'; +import type { VoxelRig, PosedVoxel } from '../animation/rig'; +import { poseVoxels } from '../animation/rig'; +import type { VoxelModel } from '../models/format'; + +export interface CharacterOptions { + /** AABB тела [w, h, d]. */ + size?: readonly [number, number, number]; + /** Скорость ходьбы, вокс/с. */ + speed?: number; + /** Путь за полный цикл шагов, вокселей (фаза = путь/stride). */ + strideLength?: number; + /** Высота спавна (по умолчанию 2 — грань земли демо; выше — спавн в воздухе). */ + spawnY?: number; +} + +/** Деформированный воксель позы для рендера (облако движка). */ +export type CharacterVoxel = Pick; + +export class Character { + readonly body: Body; + readonly rig: VoxelRig; + readonly model: VoxelModel; + readonly clip: AnimClip; + readonly speed: number; + readonly strideLength: number; + /** Фаза walk-цикла в циклах (растёт без заворота — гейт читает прирост). */ + phase = 0; + /** Текущий поворот вокруг оси Y (рад): куда смотрит модель. */ + yaw = 0; + + constructor( + rig: VoxelRig, + model: VoxelModel, + clip: AnimClip, + spawn: readonly [number, number], + opts: CharacterOptions = {}, + ) { + this.rig = rig; + this.model = model; + this.clip = clip; + this.speed = opts.speed ?? 3; + this.strideLength = opts.strideLength ?? 3.2; + // спавн: y=2 — грань земли демо; выше — спавн в воздухе (гейт физики) + this.body = makeBody([spawn[0], opts.spawnY ?? 2, spawn[1]], opts.size ?? [3, 10, 3]); + } + + /** Желаемое движение в МИРОВЫХ осях (длина ≤ 1); [0,0] — покой. */ + setMove(dir: readonly [number, number]): void { + this.body.vel[0] = dir[0] * this.speed; + this.body.vel[2] = dir[1] * this.speed; + } + + /** Один шаг: физика, фаза от пройденного пути, поворот по движению. */ + step(world: SolidWorld, dt: number): void { + stepBody(world, this.body, dt); + const speed = Math.hypot(this.body.vel[0], this.body.vel[2]); + if (speed > 1e-6) { + this.phase += (speed * dt) / this.strideLength; + this.yaw = Math.atan2(this.body.vel[0], this.body.vel[2]); + } + } + + /** Деформированные воксели позы для рендера (центр модели по XZ тела). */ + visual(): CharacterVoxel[] { + const posed = poseVoxels(this.rig, this.model, samplePose(this.clip, this.loopPhase())); + const b = this.body; + const c = Math.cos(this.yaw), s = Math.sin(this.yaw); + const halfW = this.model.size[0] / 2, halfD = this.model.size[2] / 2; + return posed.map((v) => { + const dx = v.pos[0] - halfW, dz = v.pos[2] - halfD; + return { + pos: [ + b.pos[0] + dx * c + dz * s, + b.pos[1] + v.pos[1], + b.pos[2] - dx * s + dz * c, + ] as Vec3, + color: v.color, + }; + }); + } + + /** Фаза в секундах клипа: циклы → длительность (для семплинга позы). */ + loopPhase(): number { + const c = ((this.phase % 1) + 1) % 1; + return c * this.clip.duration; + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 1146bca..f89f4a4 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -30,7 +30,7 @@ } /** Изометрия демо: диметрия 2:1 — yaw 45°, тангенс угла возвышения 1/2. */ -const ISO_YAW = Math.PI / 4; +export const ISO_YAW = Math.PI / 4; const ISO_TAN = 0.5; export class VoxelRenderer { @@ -41,6 +41,7 @@ private readonly ambient: THREE.HemisphereLight; private readonly target: THREE.Vector3; private readonly viewSize: number; + private sunK = 0.5; constructor(canvas: HTMLCanvasElement, opts: VoxelRendererOptions = {}) { const width = opts.width ?? 480; @@ -72,6 +73,19 @@ this.scene.add(this.sun); this.scene.add(this.sun.target); this.setSun(0.5); + this.repositionCamera(); + } + + /** Перенести цель камеры (и солнце за ней) — слежение камеры за героем. */ + setTarget(t: readonly [number, number, number]): void { + this.target.set(t[0], t[1], t[2]); + this.repositionCamera(); + this.setSun(this.sunK); + } + + /** Текущая цель камеры (для моста/гейта). */ + currentTarget(): [number, number, number] { + return [this.target.x, this.target.y, this.target.z]; } /** Материал вокселей: вершинные цвета (палитра × AO) + ламберт от солнца. */ @@ -144,6 +158,7 @@ * у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт. */ setSun(k: number): void { + this.sunK = k; const kk = Math.min(1, Math.max(0, k)); const az = Math.PI * (1 - kk); // 180°→0°: восток → юг → запад const elev = Math.sin(kk * Math.PI) * (Math.PI / 3) + 0.12; // не ниже ~7° @@ -170,17 +185,19 @@ private makeCamera(width: number, height: number): THREE.OrthographicCamera { const aspect = width / height; const half = this.viewSize; - const cam = new THREE.OrthographicCamera( + return new THREE.OrthographicCamera( -half * aspect, half * aspect, half, -half, 0.1, this.viewSize * 10, ); + } + + /** Поставить камеру в изо-направление от текущего target. */ + private repositionCamera(): void { const dir = new THREE.Vector3( Math.cos(ISO_YAW) * ISO_TAN, 1, // камера НАД сценой (минус давал вид из-под земли: верхние грани без света) Math.sin(ISO_YAW) * ISO_TAN, ).normalize(); - const dist = this.viewSize * 4; - cam.position.copy(this.target).addScaledVector(dir, dist); - cam.lookAt(this.target); - return cam; + this.camera.position.copy(this.target).addScaledVector(dir, this.viewSize * 4); + this.camera.lookAt(this.target); } } \ No newline at end of file