diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 257caa2..1efa853 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -112,6 +112,10 @@ - `src/models/generators.ts` — процедурные генераторы: `treeModel` (варианты 0 круглая / 1 колонна / 2 ель), `boulderModel` (union сфер, свет на верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; + снаряжение (трек 32): `backpackModel` (6×7×2, 84 вокс — пряжка + ПЕРЕКРАШИвает воксели кожи, не добавляет), `torchModel` (1×6×1, 6 вокс); + палитра `GearPalette {leather?, strap?, metal?, wood?, flame?, + flameCore?}`; - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом (half-open прямоугольник + вертикаль мира), возвращает число вокселей; `opts.texRemap` штампует текстурный слой модели в мир (локальный id @@ -146,6 +150,14 @@ - `src/animation/clip.ts` — клипы: ключи {t, кость: rot/pos}, `samplePose` (линейный семплинг, заворот t для циклов), `validateClip` (границы, монотонность, замкнутость цикла), `footMinY` (контакт стопы); + - `src/animation/attach.ts` — крепление предметов к костям риги (трек 32): + `Attachment {bone, model, pos?, rot?}` — предмет (обычная VoxelModel со + СВОЕЙ палитрой) размещается в СВЯЗОЧНОМ пространстве базовой модели + (центр вокселя предмета (x,y,z) → (x+0.5, y+0.5, z+0.5) + pos), rot — + Эйлер X→Y→Z вокруг угла; `attachVoxels(rig, base, pose, atts)` — воксели + базы позой + предметы через `boneWorlds` (едет с костью и поворотом + модели; неизвестная кость — throw), `attachTiles(base, atts)` — общий + реестр тайлей (id предметов сдвинуты за max id базы — tileOffsets); - `src/animation/locomotion.ts` — `walkClip(params)`: процедурный walk-цикл (ноги в противофазе, руки против одноимённой ноги, корень приседает дважды за цикл и качается вбок); детерминирован, проходит валидацию; @@ -161,6 +173,9 @@ физики + walk-анимация, фаза клипа растёт пропорционально пути (путь/stride — стопы не скользят), в покое замирает, поворот по движению; `visual()` — деформированные воксели для облака рендера; + `CharacterOptions.attachments` + `setAttachments(atts)` — предметы на + костях (трек 32): visual() мержит attachVoxels ДО yaw-маппинга — + предмет едет с костью и поворачивается вместе с моделью; - `src/core/loop.ts` — `GameLoop`: фиксированный шаг (60 Гц) + рендер, аккумулятор с maxStepsPerFrame; `setManual` + `stepTick(n)` — ручной режим для моста/гейтов (детерминизм); now/schedule инъектируются; @@ -631,7 +646,9 @@ - **CSS `display:flex` на элементе перебивает атрибут `hidden`**: `#menu` «скрывался» (hidden=true), но панель оставалась на каждом кадре — нужен отдельный `#menu[hidden]{display:none}`. Ловится попиксельным - сравнением гейта (меню затемняло/перекрывало кадры). + сравнением гейта (меню затемняло/перекрывало кадры). То же случилось с + `#dev` (дев-панель была видна с самого старта страницы поверх меню — + трек 32): правило `#dev[hidden]{display:none}` обязательно. - **Полный фейд-переход — это out+in, 2×duration**: при 0.4 с это 48 тиков по 1/60, а не 24. Гейты в ручном режиме обязаны пройти все 48 тиков после startGame/gotoMenu, иначе фейд замерает на частичной прозрачности @@ -1187,6 +1204,55 @@ - попутно: гейт panels ждал 16 тайлей манекена (стало 19 со скинами трека 29) — статы гейта синхронны с треком 29. +## Трек 32 (2026-09-12): крепления предметов к костям риги + снаряжение героя + +Фидбек: «придумать систему держания предметов в руке/руках и надевания +объектов (рюкзак) на персонажей; для примера — рюкзак на нашего героя». + +- **Движок** (`src/animation/attach.ts` — см. структуру): предмет — обычная + VoxelModel в связочном пространстве базы, едет с костью через + `boneWorlds`. Юнит-тест доказывает «предмет едет ровно как воксель кисти» + (равенство сдвигов предмета и вокселя руки до 1e-9 при повороте кости), + тайли предметов сдвигаются за базу и в вокселях, и в CloudTiles. +- **Генераторы** (`generators.ts`): `backpackModel` (84 вокс), `torchModel` + (6 вокс), палитра `GearPalette`. +- **Игра**: герой по умолчанию с рюкзаком (`bone 'root'`, pos [2,5,-1] — + за спиной, колонка z0 пуста в модели) и факелом (`bone 'armR'`, + pos [8.5,5,2] — сбоку кисти, колонка x9 не пересекается с рукавом x8). + Мост `heroGear(ids|null)` (null — снять всё; обновляет облако сам — мир + может стоять в ручном режиме), `heroInfo.gear/gearVoxels`. + `export type GearId` — реестр снаряжения в main.ts. +- Гейт `tools/checks/gear.mjs` (18-й гейт игры, 6 проверок): счёт вокселей + сходится с генераторами (90/84/0), снятие меняет кадр (скриншоты + /tmp/v2_gear_*.png), ходьба со снаряжением не ломает физику. Визуально — + одноразовая проба `tools/probe-gear.mjs` (дневные кадры, зум-кропы: + рюкзак за спиной, пламя факела машет с рукой). +- Попутно — **два багфикса, найденных пробой**: + - **пост-проход затемнял день ~втрое** (яркость дневного кадра 98→30 + с трека 31): сцена рендерится в RT без выходной конверсии (linear), + а пост-квад рисовал linear-значения в sRGB-канвас. Фикс — + `linearToOutputTexel` сразу после сэмпла в сборщике `postPass` + (ДО эффектов: виньетка/зерно работают на отображаемом кадре — в + linear виньетка выглядела бы вдвое слабее; юнит-тест на порядок). + Гейты сравнивали кадры между состояниями и не видели глобального + затемнения — ловится только абсолютный замер/сравнение с эталоном. + - **дев-панель видна с самого старта страницы**: `#dev {display:flex}` + без `#dev[hidden]{display:none}` — flex перебивает hidden (та же + грабля, что с #menu). +- **Грабли**: + - **пряжка рюкзака ПЕРЕКРАШИвает воксели кожи** (g.set на занятые + координаты), а не добавляет — счёт вокселей в гейте 84, а не 86. + - **точки крепления подбираются по модели базы**: перекрытие предмета с + телом исключается выбором пустой колонки/строки связочного + пространства (факел x9 при рукаве x8, рюкзак z0 при торсе с z1). + - **cwd пробы зависит от ГЛУБИНЫ каталога**: пробы в `tools/` и гейты в + `tools/checks/` пишут `new URL('../../')` с разным результатом + (tools/ → apps/game, tools/checks/ → тоже apps/game, а tools/* из + глубже — v2-корень без index.html: маяка нет, openGame тихо + таймаутит). Проверять cwd через page.goto вручную, если маяк не пришёл. + - **`spawn npx ENOENT` бывает и от несуществующего cwd** (spawn падает + ENOENT не только на бинарнике — на рабочей папке). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html index cd6ba7f..0b38bd8 100644 --- a/v2/apps/game/index.html +++ b/v2/apps/game/index.html @@ -164,6 +164,7 @@ flex-direction: column; } #dev h2 { margin: 0 0 8px; color: #d99a32; font-size: 15px; letter-spacing: 1px; } + #dev[hidden] { display: none; } /* display:flex перебивает атрибут hidden */ #dev-list { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; min-height: 0; overflow-y: auto; } #dev-list button { text-align: left; diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 3f6a749..2dc94d8 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -10,11 +10,11 @@ VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, DialogueRunner, Inventory, CutsceneRunner, validateCutscenes, - resolveTiles, collides, worldTileAtlas, + resolveTiles, collides, worldTileAtlas, backpackModel, torchModel, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueView, - DialogueEffectOp, Interactable, WorldTileAtlas, + DialogueEffectOp, Interactable, WorldTileAtlas, Attachment, } from '@rpg/engine'; import * as THREE from 'three'; import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode, type AreaLayout } from './areas'; @@ -153,6 +153,24 @@ speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с strideLength: 9.6, // темп v1 при ногах 5 вокс: 2.19 цикла/с (трек 24) }); +const MAN_VOXELS = hero.visual().length; // воксели тела без снаряжения + +// ---- Снаряжение героя (трек 32, демо механизма креплений): рюкзак за +// спиной на корне (едет с покачиванием шага), факел в правой кисти +// (машет с рукой). Точки — в связочном пространстве манекена 10×16×4: +// торс x2..7/y6..11 (спина z1), кисть руки ≈ [8.5, 6, 2.5]. ---- +const GEAR_VARIANTS = { + backpack: (): Attachment => ({ + bone: 'root', model: backpackModel(), pos: [2, 5, -1], + }), + torch: (): Attachment => ({ + bone: 'armR', model: torchModel(), pos: [8.5, 5, 2], + }), +} as const; +export type GearId = keyof typeof GEAR_VARIANTS; +let gear: GearId[] = ['backpack', 'torch']; +const applyGear = (): void => hero.setAttachments(gear.map((g) => GEAR_VARIANTS[g]())); +applyGear(); const heroCloud = renderer.addVoxelCloud(hero.visual(), resolveTiles(MAN)); // ---- Состояние сюжета, сумка: диалоги и квест-стадии пишут прямо сюда ---- @@ -1138,8 +1156,16 @@ phase: hero.phase, yaw: hero.yaw, tile: heroTile(), camTarget: renderer.currentTarget(), + gear: [...gear], // снаряжение героя (крепления трека 32) + gearVoxels: hero.visual().length - MAN_VOXELS, }; }, + /** Снаряжение героя (мост/гейт): список id креплений или пусто. */ + heroGear(ids: string[] | null): void { + gear = (ids ?? []).filter((g): g is GearId => g in GEAR_VARIANTS); + applyGear(); + heroCloud.update(hero.visual()); // мир может стоять (ручной режим) + }, /** Телепорт героя (гейт: слежение камеры, непроходимость). */ heroTeleport(x: number, z: number): void { hero.body.pos = [x, GROUND_Y, z]; diff --git a/v2/apps/game/tools/checks/gear.mjs b/v2/apps/game/tools/checks/gear.mjs new file mode 100644 index 0000000..bb91987 --- /dev/null +++ b/v2/apps/game/tools/checks/gear.mjs @@ -0,0 +1,91 @@ +/** + * Гейт снаряжения (трек 32 «крепления предметов»): герой по умолчанию с + * рюкзаком и факелом (механизм креплений к костям риги), число вокселей + * снаряжения сходится с генераторами, снятие/надевание меняет кадр, + * крепления не ломают физику/ходьбу. Скриншоты: /tmp/v2_gear_*.png. + * + * Запуск: node apps/game/tools/checks/gear.mjs [--port 5328] + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { 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 ?? 5328); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'gear', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +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; +} + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); + await client.call('setManual', true); + await client.call('setAsh', false); + await client.call('setFollow', false); // камера фикс — диф только от снаряжения + await client.call('stepTicks', 5); + + // ---- Снаряжение по умолчанию: рюкзак + факел ---- + const hi = await client.call('heroInfo'); + // рюкзак 6×6×2 (72) + клапан 6×2 (12) = 84 (пряжка — перекраска); факел 6 + check('герой по умолчанию: рюкзак + факел (90 вокс)', + hi.gear.includes('backpack') && hi.gear.includes('torch') && hi.gearVoxels === 90, hi.gear); + await client.call('stepTicks', 1); + const shotOn = await client.screenshot('/tmp/v2_gear_on.png'); + + // ---- Снятие всего: вокселей снаряжения нет, кадр другой ---- + await client.call('heroGear', null); + const hi0 = await client.call('heroInfo'); + check('без снаряжения: gearVoxels 0', hi0.gear.length === 0 && hi0.gearVoxels === 0, hi0.gear); + await client.call('stepTicks', 1); + const shotOff = await client.screenshot('/tmp/v2_gear_off.png'); + const diff = countDiff(decodePng(readFileSync(shotOn)).rgba, decodePng(readFileSync(shotOff)).rgba); + check('снаряжение меняет кадр', diff > 100, { diff }); + await client.call('heroGear', ['backpack', 'torch']); + await client.call('stepTicks', 1); + + // ---- Только рюкзак: 84 вокс (факел — 6) ---- + await client.call('heroGear', ['backpack']); + const hiB = await client.call('heroInfo'); + check('только рюкзак: 84 вокс', hiB.gearVoxels === 84, hiB.gear); + await client.call('heroGear', ['backpack', 'torch']); + + // ---- Ходьба со снаряжением: герой ходит (механизм не ломает физику) ---- + await client.call('heroTeleport', 40, 60); + await client.call('setInput', 1, 1); + await client.call('stepTicks', 60); // 21 вокс/с × 1 с = 2+ тайла + await client.call('setInput', null, null); + const hi2 = await client.call('heroInfo'); + check('ходьба со снаряжением: герой сместился и фаза идёт', + hi2.grounded && hi2.phase > 0 && (hi2.tile.x !== 4 || hi2.tile.y !== 6), hi2); + await client.call('stepTicks', 1); + const shotWalk = await client.screenshot('/tmp/v2_gear_walk.png'); + + const errs = client.errors(); + check('нет pageerror за прогон', errs.length === 0, errs.slice(0, 3)); + } finally { + await browser.close(); + } +} catch (e) { + console.error(e); + report.fatal = String(e); +} + +report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok); +console.log(JSON.stringify(report)); +process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно \ No newline at end of file diff --git a/v2/packages/engine/src/animation/__tests__/attach.test.ts b/v2/packages/engine/src/animation/__tests__/attach.test.ts new file mode 100644 index 0000000..883aee9 --- /dev/null +++ b/v2/packages/engine/src/animation/__tests__/attach.test.ts @@ -0,0 +1,79 @@ +/** + * Тесты креплений предметов к костям риги: покой/поворот кости (предмет + * едет с кистью), сдвиг id тайлей, неизвестная кость, детерминизм. + */ +import { describe, expect, it } from 'vitest'; +import { VoxelGrid } from '../../voxel/grid'; +import { encodeModel } from '../../models/format'; +import type { VoxelModel } from '../../models/format'; +import { mannequin } from '../../models/mannequin'; +import { backpackModel } from '../../models/generators'; +import { attachVoxels, attachTiles } from '../attach'; + +/** Модель-кубик 1×1×1 одного слота. */ +function dot(color: string): VoxelModel { + const g = new VoxelGrid(1, 1, 1); + g.set(0, 0, 0, 1); + return encodeModel(g, { 1: color }); +} + +const man = mannequin(); +const IDLE = { root: { rot: [0, 0, 0] as const } }; + +describe('attachVoxels', () => { + it('пустой список креплений = число вокселей базы', () => { + const base = attachVoxels(man.rig, man.model, IDLE, []); + expect(base.length).toBe(164); // все воксели манекена + }); + + it('предмет у точки крепления, цвет — из палитры предмета', () => { + const atts = [{ bone: 'root', model: dot('#ff0000'), pos: [2, 5, -1] as const }]; + const v = attachVoxels(man.rig, man.model, IDLE, atts); + const item = v[v.length - 1]!; + expect(item.color).toBe('#ff0000'); + expect(item.pos).toEqual([2.5, 5.5, -0.5]); // центр вокселя предмета + expect(v.length).toBe(165); + }); + + it('предмет на кости руки едет вместе с её поворотом', () => { + const atts = [{ bone: 'armR', model: dot('#00ff00'), pos: [8, 5, 2] as const }]; + const idle = attachVoxels(man.rig, man.model, IDLE, atts); + const swing = attachVoxels(man.rig, man.model, { armR: { rot: [0.55, 0, 0] as const } }, atts); + // предмет сместился ровно как воксель кисти (та же аффинна кости) + const armVoxel = (list: typeof idle) => list.find((v) => v.index === 0)!; + const dxItem = idle[idle.length - 1]!.pos[1] - swing[swing.length - 1]!.pos[1]; + const dxArm = armVoxel(idle).pos[1] - armVoxel(swing).pos[1]; + expect(Math.abs(dxItem - dxArm)).toBeLessThan(1e-9); + expect(Math.abs(dxItem)).toBeGreaterThan(0.5); // рука с предметом реально качнулась + }); + + it('неизвестная кость — ошибка', () => { + expect(() => attachVoxels(man.rig, man.model, IDLE, [{ bone: 'tail', model: dot('#fff') }])) + .toThrow(/tail/); + }); + + it('тайли предметов сдвигаются за базу (и в вокселях, и в CloudTiles)', () => { + const g = new VoxelGrid(1, 1, 1); + g.set(0, 0, 0, 1); + const face = btoa(String.fromCharCode(1, 1, 1, 1)); // size 2, слот 1 + const item = encodeModel(g, { 1: '#123456' }, { + tiles: { 1: { size: 2, faces: [face, face, face, face, face, face] } }, // тайль id 1 + tex: new Uint8Array([1]), + }); + const atts = [{ bone: 'root', model: item, pos: [2, 5, -1] as const }]; + const v = attachVoxels(man.rig, man.model, IDLE, atts); + // у манекена 19 тайлей (id 1..19) — тайль предмета стал id 20 + expect(v[v.length - 1]!.tile).toBe(20); + const tiles = attachTiles(man.model, atts); + expect(Object.keys(tiles).map(Number).sort((a, b) => a - b)).toEqual( + [...Array(20).keys()].map((i) => i + 1), + ); + expect(tiles[20]!.faces.length).toBe(6); + }); + + it('детерминизм: два вызова — глубокое равенство', () => { + const atts = [{ bone: 'root', model: backpackModel(), pos: [2, 5, -1] as const }]; + expect(attachVoxels(man.rig, man.model, IDLE, atts)) + .toEqual(attachVoxels(man.rig, man.model, IDLE, atts)); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/animation/attach.ts b/v2/packages/engine/src/animation/attach.ts new file mode 100644 index 0000000..3e0f019 --- /dev/null +++ b/v2/packages/engine/src/animation/attach.ts @@ -0,0 +1,93 @@ +/** + * Крепления предметов к костям риги (механизм «в руке / на персонаже»): + * предмет — обычная VoxelModel, размещённая в связочном пространстве базовой + * модели (где центр вокселя (x,y,z) — это (x+0.5,y+0.5,z+0.5)) и едущая вместе + * с указанной костью через её мир-трансформ (boneWorlds). Рюкзак на торсе едет + * с корнем (покачивание шага), факел в кисти машет вместе с рукой. + * Чистая математика поверх rig.ts, без рендера — тестируется без three. + */ +import { decodeModel, texBytes, resolveTiles } from '../models/format'; +import type { VoxelModel, ResolvedTile } from '../models/format'; +import { eulerMat, affinePoint, addVec } from './mat'; +import type { Vec3 } from './mat'; +import type { VoxelRig, Pose, PosedVoxel } from './rig'; +import { boneWorlds, poseVoxels } from './rig'; + +/** Предмет на кости: модель + размещение в связочном пространстве базы. */ +export interface Attachment { + /** Имя кости риги (неизвестная кость — ошибка, см. MANNEQUIN_BONES). */ + bone: string; + /** Модель предмета (своя палитра; тайли — со сдвигом id, см. attachTiles). */ + model: VoxelModel; + /** Угол-origine предмета [0,0,0] в связочном пространстве базовой модели. */ + pos?: Vec3; + /** Поворот предмета вокруг его угла (рад, порядок Эйлера X→Y→Z). */ + rot?: Vec3; +} + +/** Смещения id тайлей предметов (id тайля предмета + смещение → общий слой). */ +function tileOffsets(base: VoxelModel, atts: readonly Attachment[]): number[] { + let max = 0; + for (const id of Object.keys(resolveIds(base))) max = Math.max(max, Number(id)); + return atts.map((att) => { + const itemMax = Math.max(0, ...Object.keys(resolveIds(att.model)).map(Number)); + const off = max; // предметы начинаются после базы и предыдущих предметов + max = Math.max(max, itemMax + off); + return off; + }); +} + +/** Id тайлей модели (ключи Record без разрешения пикселей). */ +function resolveIds(model: VoxelModel): Record { + return model.tiles ?? {}; +} + +/** + * Деформированные воксели базы + всех предметов в позе. Порядок: база, + * затем предметы по списку; tile предмета сдвинут на смещение из + * tileOffsets — парный вызов attachTiles даёт согласованный CloudTiles. + */ +export function attachVoxels( + rig: VoxelRig, base: VoxelModel, pose: Pose, atts: readonly Attachment[], +): PosedVoxel[] { + const worlds = boneWorlds(rig, pose); + const offs = tileOffsets(base, atts); + const out = poseVoxels(rig, base, pose); + atts.forEach((att, ai) => { + const bi = rig.bones.findIndex((b) => b.name === att.bone); + if (bi < 0) throw new Error(`attach: кость «${att.bone}» не найдена в риге`); + const grid = decodeModel(att.model); + const tex = texBytes(att.model); + const world = worlds[bi]; + const rot = att.rot ? eulerMat(att.rot) : null; + for (let i = 0; i < grid.data.length; i++) { + const slot = grid.data[i]; + if (slot === 0) continue; + const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy)); + let p: Vec3 = [x + 0.5, y + 0.5, z + 0.5]; + if (rot) p = affinePoint({ m: rot, t: [0, 0, 0] }, p); + p = affinePoint(world, addVec(p, att.pos ?? [0, 0, 0])); + const tile = tex ? tex[i] : 0; + out.push({ + pos: p, + color: att.model.palette[String(slot)] ?? '#000000', + index: i, + ...(tile ? { tile: tile + offs[ai]! } : {}), + }); + } + }); + return out; +} + +/** Общий CloudTiles: тайли базы + тайли предметов со сдвинутыми id. */ +export function attachTiles(base: VoxelModel, atts: readonly Attachment[]): Record { + const out: Record = {}; + for (const [id, t] of Object.entries(resolveTiles(base))) out[Number(id)] = t; + const offs = tileOffsets(base, atts); + atts.forEach((att, ai) => { + for (const [id, t] of Object.entries(resolveTiles(att.model))) { + out[Number(id) + offs[ai]!] = t; + } + }); + return out; +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 9ce2c17..66fc65e 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -35,8 +35,8 @@ export { Rng } from './math/rng'; export { encodeModel, decodeModel, validateModel, assertModelValid, texIdAt, texBytes, resolveTiles } from './models/format'; export type { VoxelModel, VoxelTile, ModelTexture, ResolvedTile } from './models/format'; -export { treeModel, boulderModel } from './models/generators'; -export type { Variant } from './models/generators'; +export { treeModel, boulderModel, backpackModel, torchModel } from './models/generators'; +export type { Variant, GearPalette } from './models/generators'; export { stampModel } from './models/stamp'; export type { StampClip, StampOptions } from './models/stamp'; export { parseVox, exportVox } from './models/vox'; @@ -49,6 +49,8 @@ export type { RigBone, VoxelRig, Pose, PosedVoxel } from './animation/rig'; export { samplePose, validateClip, footMinY } from './animation/clip'; export type { AnimClip, ClipKey, BoneFrame } from './animation/clip'; +export { attachVoxels, attachTiles } from './animation/attach'; +export type { Attachment } from './animation/attach'; export { walkClip } from './animation/locomotion'; export type { WalkParams } from './animation/locomotion'; export { makeBody, stepBody, collides, GRAVITY } from './physics/body'; diff --git a/v2/packages/engine/src/models/__tests__/generators.test.ts b/v2/packages/engine/src/models/__tests__/generators.test.ts index 2ff6e1c..650cfbf 100644 --- a/v2/packages/engine/src/models/__tests__/generators.test.ts +++ b/v2/packages/engine/src/models/__tests__/generators.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { treeModel, boulderModel } from '../generators'; +import { treeModel, boulderModel, backpackModel, torchModel } from '../generators'; import { decodeModel } from '../format'; import { VoxelWorld } from '../../world/world'; import { stampModel } from '../stamp'; @@ -124,4 +124,21 @@ const m = treeModel(1, 5); expect(stampModel(w, m, 0, 3, 0)).toBeLessThan(decodeModel(m).count()); }); -}); \ No newline at end of file +}); +describe('генераторы снаряжения (крепления)', () => { + it('рюкзак/факел детерминированы и непусты', () => { + for (const m of [backpackModel(), torchModel(), backpackModel({ leather: '#111111' })]) { + expect(m.size[0] * m.size[1] * m.size[2]).toBeGreaterThan(0); + } + expect(backpackModel()).toEqual(backpackModel()); + expect(torchModel()).toEqual(torchModel()); + }); + + it('палитра перекрашивает слоты, форма не зависит от палитры', () => { + const a = backpackModel(); + const b = backpackModel({ metal: '#ffffff' }); + expect(a.data).toEqual(b.data); + expect(a.palette['3']).not.toBe(b.palette['3']); + expect(torchModel().palette['6']).toBe('#f2c14e'); // ядро пламени по умолчанию + }); +}); diff --git a/v2/packages/engine/src/models/generators.ts b/v2/packages/engine/src/models/generators.ts index bd1a4c0..5fb8454 100644 --- a/v2/packages/engine/src/models/generators.ts +++ b/v2/packages/engine/src/models/generators.ts @@ -93,4 +93,53 @@ for (let y = g.sy - 1; y >= 0; y--) if (g.get(x, y, z) !== 0) { g.set(x, y, z, ROCK_LIGHT); break; } return encodeModel(g, { [ROCK]: '#5b5b66', [ROCK_LIGHT]: '#94949e' }); -} \ No newline at end of file +} +// ---- Снаряжение для креплений к персонажам (трек 32) ---- + +/** Палитра снаряжения: слоты называются, незаданные — дефолт (как у манекена). */ +export interface GearPalette { + /** Кожа изделия (рюкзак, обмотка). */ + leather?: string; + /** Ремни/лямки/обугленное дерево. */ + strap?: string; + /** Пряжка. */ + metal?: string; + /** Древко факела. */ + wood?: string; + /** Пламя (внешний слой). */ + flame?: string; + /** Пламя (ядро). */ + flameCore?: string; +} + +const GEAR_DEFAULTS: Required = { + leather: '#6f4a2a', strap: '#3a2c1e', metal: '#c9a04a', + wood: '#5e4526', flame: '#e0962e', flameCore: '#f2c14e', +}; + +/** + * Рюкзак: кожаный меш 6×7×2 (клапан темным ремнём, пряжка спереди), + * угол-origine — нижний левый задний угол; на манекене ставится за торс + * (attach pos ≈ [2, 5, -1] в связочном пространстве). Без сида — дизайн + * фиксирован, детерминирован. + */ +export function backpackModel(p: GearPalette = {}): VoxelModel { + const c = { ...GEAR_DEFAULTS, ...p }; + const g = new VoxelGrid(6, 7, 2); + for (let y = 0; y <= 5; y++) for (let z = 0; z <= 1; z++) for (let x = 0; x <= 5; x++) g.set(x, y, z, 1); + for (let x = 0; x <= 5; x++) for (let z = 0; z <= 1; z++) g.set(x, 6, z, 2); // клапан + g.set(2, 4, 1, 3); // пряжка клапана спереди + g.set(3, 4, 1, 3); + return encodeModel(g, { 1: c.leather!, 2: c.strap!, 3: c.metal! }); +} + +/** Факел: рукоять 1×6×1 (обмотка у головки), пламя с ярким ядром; угол — низ рукояти. */ +export function torchModel(p: GearPalette = {}): VoxelModel { + const c = { ...GEAR_DEFAULTS, ...p }; + const g = new VoxelGrid(1, 6, 1); + for (let y = 0; y <= 2; y++) g.set(0, y, 0, 4); // древко + g.set(0, 3, 0, 2); // обмотка + g.set(0, 4, 0, 5); // пламя + g.set(0, 5, 0, 6); // ядро пламени + return encodeModel(g, { 2: c.strap!, 4: c.wood!, 5: c.flame!, 6: c.flameCore! }); +} diff --git a/v2/packages/engine/src/physics/character.ts b/v2/packages/engine/src/physics/character.ts index d948dd3..23a8d5b 100644 --- a/v2/packages/engine/src/physics/character.ts +++ b/v2/packages/engine/src/physics/character.ts @@ -13,6 +13,8 @@ import { samplePose } from '../animation/clip'; import type { VoxelRig, PosedVoxel } from '../animation/rig'; import { poseVoxels } from '../animation/rig'; +import { attachVoxels } from '../animation/attach'; +import type { Attachment } from '../animation/attach'; import type { VoxelModel } from '../models/format'; export interface CharacterOptions { @@ -24,6 +26,8 @@ strideLength?: number; /** Высота спавна (по умолчанию 2 — грань земли демо; выше — спавн в воздухе). */ spawnY?: number; + /** Предметы на костях (рюкзак/факел); меняется setAttachments. */ + attachments?: readonly Attachment[]; } /** Деформированный воксель позы для рендера (облако движка). */ @@ -40,6 +44,8 @@ phase = 0; /** Текущий поворот вокруг оси Y (рад): куда смотрит модель. */ yaw = 0; + /** Предметы на костях (рюкзак едет с корнем, факел машет с рукой). */ + private atts: readonly Attachment[]; constructor( rig: VoxelRig, @@ -53,6 +59,7 @@ this.clip = clip; this.speed = opts.speed ?? 3; this.strideLength = opts.strideLength ?? 3.2; + this.atts = opts.attachments ?? []; // спавн: y=2 — грань земли демо; выше — спавн в воздухе (гейт физики) this.body = makeBody([spawn[0], opts.spawnY ?? 2, spawn[1]], opts.size ?? [3, 10, 3]); } @@ -63,6 +70,11 @@ this.body.vel[2] = dir[1] * this.speed; } + /** Заменить предметы на костях (вызвать, когда снаряжение меняется). */ + setAttachments(atts: readonly Attachment[]): void { + this.atts = atts; + } + /** Один шаг: физика, фаза от пройденного пути, поворот по движению. */ step(world: SolidWorld, dt: number): void { stepBody(world, this.body, dt); @@ -80,7 +92,10 @@ * осями-выровненными и силуэт «пилит» углами. */ visual(): CharacterVoxel[] { - const posed = poseVoxels(this.rig, this.model, samplePose(this.clip, this.loopPhase())); + const pose = samplePose(this.clip, this.loopPhase()); + const posed = this.atts.length + ? attachVoxels(this.rig, this.model, pose, this.atts) + : poseVoxels(this.rig, this.model, pose); 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; diff --git a/v2/packages/engine/src/render/__tests__/postShader.test.ts b/v2/packages/engine/src/render/__tests__/postShader.test.ts index 6c60926..fd1f51b 100644 --- a/v2/packages/engine/src/render/__tests__/postShader.test.ts +++ b/v2/packages/engine/src/render/__tests__/postShader.test.ts @@ -40,4 +40,13 @@ expect(s.fragment).toContain('uniform float uGrain;'); expect(s.fragment).toContain('uniform float uTime;'); }); + + it('сэмпл конвертируется linear→sRGB ДО эффектов (RT хранит linear)', () => { + const s = postPass({ vignette: 0.4, grain: 0.02 }); + // конверсия сразу после сэмпла... + expect(s.fragment.indexOf('linearToOutputTexel')).toBeLessThan(s.fragment.indexOf('uVignette * dot(d, d)')); + // ...и единственная (в конце второй конверсии нет — кадр дважды посветлел бы) + expect(s.fragment.split('linearToOutputTexel').length - 1).toBe(1); + expect(s.fragment).toContain('texture2D(tDiffuse, uv).rgb, 1.0'); + }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/render/postShader.ts b/v2/packages/engine/src/render/postShader.ts index f1e172e..63ead7e 100644 --- a/v2/packages/engine/src/render/postShader.ts +++ b/v2/packages/engine/src/render/postShader.ts @@ -33,9 +33,14 @@ /** Дисторсия: сдвиг uv ДО сэмпла, сильнее к низу кадра (земля/машина). */ const WOBBLE = ` uv.x += sin(uv.y * 36.0 - uTime * 2.4) * uWobble * (1.2 - uv.y);`; -/** Сэмпл и выход — общий хвост. */ +/** Сэмпл и выход — общий хвост. Сцена рендерится в RT без выходной + * конверсии (linear), канвас ждёт sRGB: конверсия делается СРАЗУ после + * сэмпла — эффекты (виньетка/зерно) работают на отображаемом кадре, иначе + * виньетка в linear выглядит вдвое слабее задуманного. three кладёт + * linearToOutputTexel в преамбулу фрагментного шейдера любого материала, + * включая ShaderMaterial. Без конверсии кадр темнеет ~втрое. */ const OUT = ` - vec3 c = texture2D(tDiffuse, uv).rgb; + vec3 c = linearToOutputTexel(vec4(texture2D(tDiffuse, uv).rgb, 1.0)).rgb; `; const VIGNETTE = ` vec2 d = vUv - 0.5;