diff --git a/docs/plan.md b/docs/plan.md index 729b1ba..a411ab6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -47,6 +47,7 @@ | 25 | v2 трек 4: turntable-листы — визуальный гейт генераторов: страница со всеми вариантами моделей (деревья/валуны) × 4 поворота, скриншот в /tmp, проверки: ячейки непустые, варианты попарно различаются; основа для апрува сгенерированного контента агентом | ✅ (2026-09-09). `apps/demo/turntable.html` + `src/turntable.ts`: 6 моделей × 4 ракурса (0/45/90/135° — диагонали дают другой силуэт) на подставках 8×8×2, ракурс — `mesh.rotation.y` вокруг центра (geometry.translate −sx/2). Гейт `tools/checks/turntable.mjs`: центры ячеек считаются той же проекцией, что камера (зеркало lookAt-базиса), референсный кадр без моделей через `setModels(false)`, выборка — эллипс 48×42 в ромбе подставки; 5/5 зелёных, minDiff 570/490 при пороге 150. `launchBrowser({viewport})` в agent-lib (страница 720px выше дефолтного вьюпорта). Уроки: дробные координаты VoxelGrid.set молчу no-op (Uint8Array[4.5]); скриншот = вьюпорт браузера, канвас без центрирования; малые боксы у края ромба дают мало пикселей — выборка эллипсом. | | 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. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 77baee1..1fae547 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -41,6 +41,7 @@ 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) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -87,6 +88,11 @@ - `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 вокселя за подшаг) против туннелирования; - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -98,7 +104,15 @@ многокрасочность, 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()`. + Мост демки: `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()`. +- `apps/demo/tools/checks/physics.mjs` — скриншот-гейт физики (7 проверок): + спавн в воздухе, падение на землю y=2 ровно на грань, фаза walk-цикла + растёт в движении, стена дома останавливает по грани + (pos.x ≈ 44 − w/2 − eps), кадр изменился от движения, манекен рисуется + (после `bodyRemove` кадр возвращается к пустому месту). - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!) @@ -152,6 +166,16 @@ - **Соседние фазы 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, заспавненное краем под плитой потолка, цепляет её воксель + и «мгновенно приземляется» — тест падает с непонятной позицией. ## Рабочие привычки diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 5a99310..3032262 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -8,7 +8,9 @@ import { VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, + mannequin, walkClip, samplePose, poseVoxels, makeBody, stepBody, } from '@rpg/engine'; +import type { Body, VoxelCloud } from '@rpg/engine'; import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex @@ -86,6 +88,50 @@ /** Валун — из генератора boulderModel (ручной вариант удалён: урок №4). */ +// ---- Тело-манекен: физика (stepBody) + walk-анимация (walkClip) ---- +// 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, + }; +} + const world = new VoxelWorld(24); const buildStart = performance.now(); const sceneInfo = buildScene(world); @@ -144,6 +190,31 @@ remeshDirty(); return placed; } +/** Заспавнить тело-манекен в воздухе (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(); + 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(); + return bodyInfo(); +} +/** Убрать тело и его облако (референсный кадр гейта «манекен рисуется»). */ +function bodyRemove(): void { + manCloud?.dispose(); + manCloud = null; + manBody = null; +} const agent = { setSun(k: number): void { sunK = k; renderer.setSun(k); }, setVoxel(x: number, y: number, z: number, color: number): void { @@ -155,6 +226,10 @@ modelStats, stampTree, stampVoxB64, + bodySpawn, + bodyStep, + bodyInfo, + bodyRemove, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, diff --git a/v2/apps/demo/tools/checks/physics.mjs b/v2/apps/demo/tools/checks/physics.mjs new file mode 100644 index 0000000..2c7878d --- /dev/null +++ b/v2/apps/demo/tools/checks/physics.mjs @@ -0,0 +1,102 @@ +/** + * Скриншот-гейт физики v2 (трек 28: AABB-тело vs воксели + гравитация). + * Проверяет в реальном Chromium (swiftshader) и через мост: тело падает и + * встаёт ровно на землю, идёт walk-циклом, стена дома останавливает по грани, + * кадр меняется от движения, манекен реально рисуется. Скриншоты: + * /tmp/v2_physics_*.png. + * + * Запуск: node apps/demo/tools/checks/physics.mjs [--port 5301] + */ +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 ?? 5301); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'physics', 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, земля — грань y=2. +const HOUSE_WALL_X = 44; +const GROUND_Y = 2; +const SPAWN = [38, 46]; // перед южной стеной дома, путь чист + +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)); + + // Спавн в воздухе (y=6): гейт сразу видит тело до шагов физики + const spawnInfo = await client.call('bodySpawn', SPAWN[0], SPAWN[1], 0, 0); + check('тело спавнится в воздухе (y=6, не grounded)', + spawnInfo && spawnInfo.pos[1] === 6 && spawnInfo.grounded === false, spawnInfo); + + // Гравитация: падение до земли, встаёт ровно на грань y=2 + const landed = await client.call('bodyStep', 180); + check('гравитация: падает и встаёт на землю y=2, grounded', + landed && landed.grounded === true && Math.abs(landed.pos[1] - GROUND_Y) < 0.02, landed); + + // Референсный кадр: манекен стоит на старте + await client.call('setSun', 0.5); + await sleep(300); + const startShot = await client.screenshot('/tmp/v2_physics_start.png'); + const start = decodePng(readFileSync(startShot)); + + // Ходьба на восток к стене дома: тело движется, фаза walk-цикла растёт + const walker = await client.call('bodySpawn', SPAWN[0], SPAWN[1], 2, 0); + await client.call('bodyStep', 240); + const atWall = await client.call('bodyInfo'); + check('ходьба: фаза walk-цикла растёт в движении', atWall && atWall.walkT > 1, atWall); + + // Стена останавливает по своей западной грани: pos.x ≈ 44 − w/2 − eps + const expectedX = HOUSE_WALL_X - atWall.size[0] / 2 - 1e-4; + check('стена дома останавливает тело по грани (vel=0)', + atWall && atWall.vel[0] === 0 && Math.abs(atWall.pos[0] - expectedX) < 0.01, + { pos: atWall.pos[0], expectedX }); + + // Кадр изменился: манекен сместился к стене и в другой фазе цикла + const wallShot = await client.screenshot('/tmp/v2_physics_wall.png'); + const wall = decodePng(readFileSync(wallShot)); + const walkDiff = countDiff(start.rgba, wall.rgba); + check('кадр изменился: тело дошло до стены', walkDiff > 150, { walkDiff }); + + // Манекен реально рисуется: без тела кадр возвращается к пустому месту + await client.call('bodyRemove'); + await sleep(300); + const emptyShot = await client.screenshot('/tmp/v2_physics_empty.png'); + const empty = decodePng(readFileSync(emptyShot)); + const bodyDiff = countDiff(wall.rgba, empty.rgba); + check('тело-манекен рисуется в сцене', bodyDiff > 150, { bodyDiff }); + + 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/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index ed1626f..de33d40 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -42,4 +42,6 @@ export { samplePose, validateClip, footMinY } from './animation/clip'; export type { AnimClip, ClipKey, BoneFrame } from './animation/clip'; export { walkClip } from './animation/locomotion'; -export type { WalkParams } from './animation/locomotion'; \ No newline at end of file +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 diff --git a/v2/packages/engine/src/physics/__tests__/body.test.ts b/v2/packages/engine/src/physics/__tests__/body.test.ts new file mode 100644 index 0000000..1927753 --- /dev/null +++ b/v2/packages/engine/src/physics/__tests__/body.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { VoxelWorld } from '../../world/world'; +import { makeBody, stepBody, collides, GRAVITY } from '../body'; +import type { Body } from '../body'; + +/** Мир сцены теста: земля/платформы/стены/потолок. Высота мира — 16 (аргумент VoxelWorld). */ +function testWorld(): VoxelWorld { + const w = new VoxelWorld(16); + // земля y=0..1 для x=0..9; уступ ниже (y=0) для x≥10 + 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 x = 10; x < 16; x++) + for (let z = 0; z < 8; z++) w.set(x, 0, z, 1); + // стена-колонна x=6 (толщина 1), y=2..4 — торчит над землёй + for (let y = 2; y <= 4; y++) + for (let z = 0; z < 8; z++) w.set(6, y, z, 1); + // потолок-плита y=7 над x=0..3 (не задевает x=5, где падает тестовое тело) + for (let x = 0; x <= 3; x++) + for (let z = 0; z < 8; z++) w.set(x, 7, z, 1); + // вторая стена z=0 (x=4..8, y=2..4) — вместе со стеной x=6 образует угол + for (let x = 4; x <= 8; x++) + for (let y = 2; y <= 4; y++) w.set(x, y, 0, 1); + return w; +} + +/** Прогнать шагов физики, пока не выполнится предикат (с лимитом). */ +function runUntil(body: Body, world: VoxelWorld, pred: () => boolean, dt = 1 / 60, limit = 600): void { + for (let i = 0; i < limit && !pred(); i++) stepBody(world, body, dt); +} + +describe('collides (AABB против вокселей)', () => { + it('в свободном пространстве — нет, с пересечением вокселя — да', () => { + const w = testWorld(); + expect(collides(w, [5, 3, 5], [1, 2, 1])).toBe(false); // над землёй, вне стены + expect(collides(w, [5, 1, 5], [1, 2, 1])).toBe(true); // ноги в земле + }); + + it('касание грани снаружи — не столкновение', () => { + const w = testWorld(); + expect(collides(w, [5, 2, 5], [1, 2, 1])).toBe(false); // ноги ровно на земле (y=2) + }); +}); + +describe('stepBody (падение и опора)', () => { + it('тело падает и встаёт на землю ровно на грань', () => { + const w = testWorld(); + const body = makeBody([5, 6, 5], [1, 2, 1]); + runUntil(body, w, () => body.grounded); + expect(body.pos[1]).toBeCloseTo(2); + expect(body.vel[1]).toBe(0); + }); + + it('стоит на месте: позиция не плывёт, grounded держится', () => { + const w = testWorld(); + const body = makeBody([5, 2, 5], [1, 2, 1]); + body.grounded = true; + for (let i = 0; i < 120; i++) stepBody(w, body, 1 / 60); + expect(body.pos[1]).toBeCloseTo(2); + expect(body.grounded).toBe(true); + expect(body.pos[0]).toBeCloseTo(5); + }); + + it('сход с уступа: падает с верхней площадки на нижнюю', () => { + const w = testWorld(); + const body = makeBody([9, 2, 5], [1, 2, 1]); + runUntil(body, w, () => body.grounded); // сначала встать на верхнюю площадку + body.vel[0] = 3; // идём на восток, за x=10 земля ниже + runUntil(body, w, () => !body.grounded); + expect(body.pos[0]).toBeGreaterThanOrEqual(9.5); + runUntil(body, w, () => body.grounded && body.pos[0] > 10.5); + expect(body.pos[1]).toBeCloseTo(1); // нижний уровень: грань y=1 + }); +}); + +describe('stepBody (стены и зажимы)', () => { + it('стена толщиной 1 останавливает, скорость обнуляется', () => { + const w = testWorld(); + const body = makeBody([5, 2, 5], [1, 2, 1]); // запад от стены x=6 + body.vel[0] = 4; + runUntil(body, w, () => body.vel[0] === 0); + expect(body.pos[0]).toBeCloseTo(6 - 0.5 - 1e-4, 3); // упёрся в западную грань стены + expect(body.vel[0]).toBe(0); + expect(body.grounded).toBe(true); + }); + + it('быстрое движение не туннелирует сквозь тонкую стену', () => { + const w = testWorld(); + const body = makeBody([5, 2, 5], [1, 2, 1]); + body.vel[0] = 50; // 50 вокс/с при dt=0.1 — 5 вокселей за шаг + stepBody(w, body, 0.1); + expect(body.pos[0]).toBeLessThan(6); + expect(body.pos[0]).toBeCloseTo(6 - 0.5 - 1e-4, 3); + }); + + it('угол: две стены по осям зажимают независимо', () => { + const w = testWorld(); + const body = makeBody([5, 2, 2], [1, 2, 1]); // у угла стен x=6 и z=0 + body.vel[0] = 4; + body.vel[2] = -4; + runUntil(body, w, () => body.vel[0] === 0 && body.vel[2] === 0); + expect(body.pos[0]).toBeCloseTo(6 - 0.5 - 1e-4, 3); // зажат западной гранью стены x=6 + expect(body.pos[2]).toBeCloseTo(1 + 0.5 + 1e-4, 3); // ...и северной гранью стены z=0 + expect(body.pos[1]).toBeCloseTo(2); // вертикаль не пострадала + }); + + it('потолок гасит подъём', () => { + const w = testWorld(); + const body = makeBody([3, 2, 5], [1, 2, 1]); // под плитой y=7 + body.vel[1] = 20; + runUntil(body, w, () => body.vel[1] === 0 && body.pos[1] > 3); + expect(body.pos[1]).toBeCloseTo(7 - 2 - 1e-4, 2); // голова упёрлась в грань y=7 + expect(body.grounded).toBe(false); + }); +}); + +describe('гравитация (число)', () => { + it('полунеявный Эйлер: за шаг dt скорость −G·dt, позиция −G·dt²', () => { + const w = testWorld(); + const body = makeBody([8, 20, 5], [1, 2, 1]); // вдали от стен, высоко + stepBody(w, body, 0.5); + expect(body.vel[1]).toBeCloseTo(-GRAVITY * 0.5, 6); + expect(body.pos[1]).toBeCloseTo(20 - GRAVITY * 0.5 * 0.5, 6); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/physics/body.ts b/v2/packages/engine/src/physics/body.ts new file mode 100644 index 0000000..ebe65a2 --- /dev/null +++ b/v2/packages/engine/src/physics/body.ts @@ -0,0 +1,95 @@ +/** + * Физика тела в воксельном мире (docs/plan.md, открытый вопрос «физика/ + * коллизии»): AABB-тело против вокселей, занятый воксель = твёрдый. + * Тело — центр низа (ноги) + полный размер [w,h,d] + скорость. + * Разрешение коллизий ось-раздельное X→Z→Y с зажимом к граням вокселей — + * стандартная схема без застревания на стыках. Сабстепы (смещение за шаг + * < 1 вокселя) защищают от туннелирования сквозь тонкие стены. + * Чистая математика без рендера (урок №3): мир — точечная выборка get(). + */ +import type { Vec3 } from '../animation/mat'; + +/** Мир с точечной выборкой твёрдости (структурно подходит VoxelWorld). */ +export interface SolidWorld { + get(x: number, y: number, z: number): number; +} + +export interface Body { + /** Центр низа AABB (точка под ногами). Мутабельный кортеж — физика его двигает. */ + pos: [number, number, number]; + vel: [number, number, number]; + /** Полные размеры AABB: [w, h, d]. */ + size: [number, number, number]; + /** Стоит на твёрдом вокселе (обновляется за шаг). */ + grounded: boolean; +} + +/** Гравитация, вокс/с². */ +export const GRAVITY = 24; +const EPS = 1e-4; // зазор от грани вокселя — не ловить свою же опору +const MAX_SUBSTEP = 0.9; // макс смещение за сабстеп (доля вокселя) + +export function makeBody(pos: Vec3, size: readonly [number, number, number]): Body { + return { pos: [...pos], vel: [0, 0, 0], size: [...size], grounded: false }; +} + +/** Пересекает ли AABB тела хоть один твёрдый воксель (касание грани — нет). */ +export function collides(world: SolidWorld, pos: Vec3, size: readonly [number, number, number]): boolean { + const [w, h, d] = size; + const x0 = Math.floor(pos[0] - w / 2), x1 = Math.ceil(pos[0] + w / 2) - 1; + const y0 = Math.floor(pos[1]), y1 = Math.ceil(pos[1] + h) - 1; + const z0 = Math.floor(pos[2] - d / 2), z1 = Math.ceil(pos[2] + d / 2) - 1; + for (let y = y0; y <= y1; y++) + for (let z = z0; z <= z1; z++) + for (let x = x0; x <= x1; x++) + if (world.get(x, y, z) !== 0) return true; + return false; +} + +/** Один шаг физики: гравитация + перемещение с разрешением коллизий. */ +export function stepBody(world: SolidWorld, body: Body, dt: number): void { + const maxDisp = Math.max(Math.abs(body.vel[0]), Math.abs(body.vel[1]), Math.abs(body.vel[2])) * dt; + const n = Math.max(1, Math.ceil(maxDisp / MAX_SUBSTEP)); + const h = dt / n; + for (let s = 0; s < n; s++) substep(world, body, h); +} + +/** Подшаг: оси по очереди; зажим к грани вокселя при столкновении. */ +function substep(world: SolidWorld, body: Body, h: number): void { + const [w, hh, d] = body.size; + // X + if (body.vel[0] !== 0) { + const nx = body.pos[0] + body.vel[0] * h; + if (collides(world, [nx, body.pos[1], body.pos[2]], body.size)) { + body.pos[0] = body.vel[0] > 0 + ? Math.floor(nx + w / 2) - w / 2 - EPS + : Math.floor(nx - w / 2) + 1 + w / 2 + EPS; + body.vel[0] = 0; + } else body.pos[0] = nx; + } + // Z — симметрично X + if (body.vel[2] !== 0) { + const nz = body.pos[2] + body.vel[2] * h; + if (collides(world, [body.pos[0], body.pos[1], nz], body.size)) { + body.pos[2] = body.vel[2] > 0 + ? Math.floor(nz + d / 2) - d / 2 - EPS + : Math.floor(nz - d / 2) + 1 + d / 2 + EPS; + body.vel[2] = 0; + } else body.pos[2] = nz; + } + // Y: гравитация, пол/потолок + body.vel[1] -= GRAVITY * h; + const ny = body.pos[1] + body.vel[1] * h; + if (collides(world, [body.pos[0], ny, body.pos[2]], body.size)) { + if (body.vel[1] < 0) { + body.pos[1] = Math.floor(ny) + 1; // пол: встать ровно на грань + body.grounded = true; + } else { + body.pos[1] = Math.floor(ny + hh) - hh - EPS; // потолок + } + body.vel[1] = 0; + } else { + body.pos[1] = ny; + body.grounded = false; + } +} \ No newline at end of file