diff --git a/docs/plan.md b/docs/plan.md index 995bc75..b0019cf 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -848,3 +848,13 @@ волосы — макушка/затылок; правило ракурса ~55° сохранено. Игра: BODY_SIZE [5,16,5], stride 9.6; порог маха anim-гейта 1.0→0.8 (ноги вдвое короче). 6 гейтов игры + 11 демок зелёные, 300 тестов. + +- **Трек 27** (2026-09-11, «воксели должны быть частью модельки и + поворачиваться вместе с ней — на диагонали торчат углами»): поворот + персонажа стал жёстким — `PosedVoxel.rotY` (опциональный поворот самого + куба), `Character.visual()` ставит `rotY = yaw` на каждый воксель, + рендер строит матрицу инстанса `makeRotationY(rotY) + setPosition` + (раньше крутились только позиции — кубы оставались осями-выровненными + и «пилили» силуэт). Олень без изменений: четверти оборота держат сетку + на осях. Проба `apps/game/tools/probe-diag.mjs` (диагональ, yaw 3π/4). + 6 гейтов игры + 11 демок зелёные, 301 тест. diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index ba14826..75f076c 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -81,6 +81,7 @@ (строка ячеек = 6 суб-граней тайля, ячейка 0 белая), инстанс-атрибут `aTileId` + шейдерная инъция (грань куба — из нормали, сэмпл атласа); воксель с тайлем красится белым instanceColor (цвет дают тексели); + матрица инстанса с поворотом `rotY` (куб вращается вместе с моделью); - `src/math/rng.ts` — детерминированный Rng (mulberry32): next/int/range/chance/pick; - `src/models/format.ts` — формат модели (JSON: size + плотный base64 + слоты-палитра), encode/decode/validate (числовая валидация агентом); @@ -117,7 +118,8 @@ - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest` по ближайшему pivot), `validateRig` (родители раньше детей, длина и согласованность привязки), `poseVoxels(rig, model, pose)` — деформация - (у textured-моделей тянет `tile` — id тайля вокселя); + (у textured-моделей тянет `tile` — id тайля вокселя; опциональный + `rotY` — поворот самого куба, см. Character.visual); - `src/animation/clip.ts` — клипы: ключи {t, кость: rot/pos}, `samplePose` (линейный семплинг, заворот t для циклов), `validateClip` (границы, монотонность, замкнутость цикла), `footMinY` (контакт стопы); @@ -130,6 +132,9 @@ разрешение X→Z→Y с зажимом к граням вокселей (пол — ровно на грань, EPS-зазор), сабстепы (< 0.9 вокселя за подшаг) против туннелирования; - `src/physics/character.ts` — `Character`: контроллер персонажа — тело + физики + walk-анимация, поворот по движению ЖЁСТКИЙ: позиции вокселей + вращаются вокруг тела и каждый куб несёт `rotY = yaw` (иначе на + диагональном ходе осями-выровненные кубы «пилят» силуэт углами); тело физики + walk-анимация, фаза клипа растёт пропорционально пути (путь/stride — стопы не скользят), в покое замирает, поворот по движению; `visual()` — деформированные воксели для облака рендера; @@ -905,6 +910,20 @@ `shot.mjs --at 16,28` + `crop-zoom.mjs`: лицо читается (бахрома/глаза/ рот), герой ~1.6 тайла, палитры NPC (пояс Милы, седина Ирвина) на месте. +## Трек 27 (2026-09-11): кубы поворачиваются вместе с моделью + +Фидбек: «воксели должны быть частью модельки и поворачиваться вместе с +ней — при диагональном ходе воксели торчат углами». `Character.yaw` +(произвольный угол от atan2 скорости) раньше вращал только ПОЗИЦИИ +вокселей — сами кубы оставались осями-выровненными, силуэт на диагонали +«пилил». Теперь `PosedVoxel.rotY` (опциональный поворот самого куба): +`Character.visual()` ставит `rotY = yaw` на каждый воксель, рендер +строит матрицу инстанса `makeRotationY(rotY) + setPosition` — жёсткое +вращение модели целиком. Олень не тронут: его rotXZ крутит четвертями +оборота, при 90° сетка кубов совпадает с осями. 6 гейтов игры + 11 демок +зелёные, 301 тест; визуальная проба `tools/probe-diag.mjs` (герой на +мировой диагонали, yaw 3π/4 — силуэт чистый). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/tools/probe-diag.mjs b/v2/apps/game/tools/probe-diag.mjs new file mode 100644 index 0000000..b732145 --- /dev/null +++ b/v2/apps/game/tools/probe-diag.mjs @@ -0,0 +1,45 @@ +/** + * Одноразовая проба (трек 27): герой идёт по мировой диагонали (экранный + * стик (1,0) = (+x,−z)/√2), камера на герое — кубы должны быть повёрнуты + * вместе с моделью, без «пилы» углами. Кадр: /tmp/t26_diag.png + */ +import { fileURLToPath } from 'node:url'; +import { AgentClient, launchBrowser, startDevServer, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5331); +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../', import.meta.url)) }); +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(String(e))); + await page.goto(`http://localhost:${port}/`, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction('window.__beacon === true', { timeout: 60000 }); + await new Promise((r) => setTimeout(r, 1200)); + const client = new AgentClient(page); + client.pageErrors = pageErrors; + await client.call('startGame', false); + await client.call('setManual', true); + await client.call('setAsh', false); + await client.call('setFauna', false); + const hero = await client.call('heroInfo'); + const c = await client.call('tileCenter', hero.tile.x, hero.tile.y); + await client.call('setCam', c.x, c.z); + await client.call('setInput', 1, 0); // мировая диагональ + await client.call('stepTicks', 6); + const info = await client.call('heroInfo'); + console.log('yaw:', info.yaw, 'pos:', info.pos); + await client.call('setInput', null, null); + await client.screenshot('/tmp/t26_diag.png'); + console.log('снимок: /tmp/t26_diag.png'); + } finally { + await browser.close(); + } +} catch (e) { + console.error(e); + process.exit(1); +} +process.exit(0); diff --git a/v2/packages/engine/src/animation/rig.ts b/v2/packages/engine/src/animation/rig.ts index 9a50ec2..89b1e8e 100644 --- a/v2/packages/engine/src/animation/rig.ts +++ b/v2/packages/engine/src/animation/rig.ts @@ -39,6 +39,12 @@ index: number; /** Id тайля текстур (0 = плоский цвет) — только у моделей со слоем tex. */ tile?: number; + /** + * Поворот самого куба вокруг Y (рад) — рендер строит матрицу инстанса + * с вращением, чтобы куб был частью модели (жёсткое вращение всей + * модели: позиции уже повёрнуты снаружи). Не задан — куб осями-выровнен. + */ + rotY?: number; } /** Привязка «ближайший pivot» для моделей без явной разметки частей. */ diff --git a/v2/packages/engine/src/physics/__tests__/character.test.ts b/v2/packages/engine/src/physics/__tests__/character.test.ts index c9e1c5a..a928ca7 100644 --- a/v2/packages/engine/src/physics/__tests__/character.test.ts +++ b/v2/packages/engine/src/physics/__tests__/character.test.ts @@ -74,4 +74,17 @@ const idle = makeChar(); expect(idle.visual().length).toBe(164); }); + + it('кубы поворачиваются с моделью: rotY = yaw на каждом вокселе', () => { + const w = testWorld(); + const c = makeChar(); + c.setMove([1, 1]); // мировая диагональ → yaw 45° + c.step(w, 1 / 60); + expect(c.yaw).toBeCloseTo(Math.PI / 4, 5); + for (const v of c.visual()) expect(v.rotY).toBeCloseTo(c.yaw, 6); + // в покое yaw помнит последнее движение — кубы не сбрасываются + c.setMove([0, 0]); + for (let i = 0; i < 30; i++) c.step(w, 1 / 60); + for (const v of c.visual()) expect(v.rotY).toBeCloseTo(Math.PI / 4, 6); + }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/physics/character.ts b/v2/packages/engine/src/physics/character.ts index d05a73c..d948dd3 100644 --- a/v2/packages/engine/src/physics/character.ts +++ b/v2/packages/engine/src/physics/character.ts @@ -27,7 +27,7 @@ } /** Деформированный воксель позы для рендера (облако движка). */ -export type CharacterVoxel = Pick; +export type CharacterVoxel = Pick; export class Character { readonly body: Body; @@ -73,7 +73,12 @@ } } - /** Деформированные воксели позы для рендера (центр модели по XZ тела). */ + /** + * Деформированные воксели позы для рендера (центр модели по XZ тела). + * Модель повёрнута по yaw жёстко: позиции вращаются вокруг тела, и каждый + * куб несёт rotY = yaw — иначе на диагональном ходе кубы остаются + * осями-выровненными и силуэт «пилит» углами. + */ visual(): CharacterVoxel[] { const posed = poseVoxels(this.rig, this.model, samplePose(this.clip, this.loopPhase())); const b = this.body; @@ -89,6 +94,7 @@ ] as Vec3, color: v.color, ...(v.tile ? { tile: v.tile } : {}), + rotY: this.yaw, }; }); } diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index e29c199..45fd917 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -15,8 +15,8 @@ /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ export interface VoxelCloud { - /** Заменить воксели облака (позиции, цвета, тайли — количество). */ - update(voxels: readonly Pick[]): void; + /** Заменить воксели облака (позиции, цвета, тайли, rotY — количество). */ + update(voxels: readonly Pick[]): void; dispose(): void; mesh: THREE.InstancedMesh; } @@ -187,7 +187,7 @@ * tiles — текстуры граней (см. resolveTiles): воксель с tile>0 рисуется * пиксель-артом тайля (цвет инстанса — белый), без tile — плоским цветом. */ - addVoxelCloud(voxels: readonly Pick[], tiles?: CloudTiles): VoxelCloud { + addVoxelCloud(voxels: readonly Pick[], tiles?: CloudTiles): VoxelCloud { return createCloud(this.scene, voxels, tiles); } @@ -306,7 +306,7 @@ /** Фабрика облака (отдельная функция — рекурсия при росте ёмкости). */ function createCloud( scene: THREE.Scene, - voxels: readonly Pick[], + voxels: readonly Pick[], tiles?: CloudTiles, ): VoxelCloud { const textured = !!tiles && Object.keys(tiles).length > 0; @@ -344,6 +344,9 @@ const m = new THREE.Matrix4(); const c = new THREE.Color(); list.forEach((v, i) => { + // куб поворачивается вместе с моделью (rotY), иначе на + // диагональном ходе оси-выровненные кубы «пилят» углами + m.makeRotationY(v.rotY ?? 0); mesh.setMatrixAt(i, m.setPosition(v.pos[0], v.pos[1], v.pos[2])); mesh.setColorAt(i, c.set(v.tile ? '#ffffff' : v.color)); tileAttr.array[i] = v.tile ?? 0;