diff --git a/docs/plan.md b/docs/plan.md index 0746884..cea9708 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -824,3 +824,15 @@ мерцает на кромках пруда от порядка отрисовки, кадр-равенства падают) — сдвиг EPS 0.03 из плоскости; **зомби-vite переживает process.exit тулзы** — kill по PID из `ss -tlnp`. +- **Трек 25** (2026-09-10, «персонажи — не ходячие кубики, покажи максимум + дизайна персонажей»): манекен движка 9×16×4 → **14×24×8 «путник»** + (~2.4 тайла UNIT=10, 498 вокс): торс силуэтом (юбка плаща шире, пояс, + грудь, плечи), рукава с кистями кожи, сапоги с мысками, шея, голова + с лицом — глаза в отдельном слоте EYE, нос выступает к +Z, шапка волос. + Палитра 8 слотов: `MannequinPalette {skin?, hair?, torso?, legs?, arms?, + boots?, belt?, eyes?}`. Главная грабля: **камера смотрит сверху под ~55° — + луч к фасаду проходит через воксель уровнем выше и той же z-колонкой**, + шапка волос на весь фасад прятала глаза (снапшоты зелёные!) — череп + ступенькой: лицо до y21, шапка назад до z4. Ирвину тёплая кожа в тон + седине сливалась в «череп». 6 гейтов игры + 11 демок зелёные, 297 тестов; + визуальная итерация по `shot.mjs --at 16,28` + `crop-zoom.mjs`. diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index ff9bda2..82b3842 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -93,9 +93,13 @@ слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox; - `src/models/mannequin.ts` — `mannequin(palette?)`: эталонная модель - 9×16×4 с ригой (root → legL/legR/armL/armR, pivot в связочном - пространстве); `MannequinPalette {torso?, legs?, head?, arms?}` — - дизайн персонажа параметром (форма не зависит от палитры); + 14×24×8 «путник» с ригой (root → legL/legR/armL/armR, pivot в + связочном пространстве); силуэт деталями (сапоги с мысками, ремень, + плащ, кисти, голова с глазами/носом и ступенькой черепа — фасад лица + до y21, шапка волос назад до z4, иначе с игрового ракурса ~55° шапка + перекрывает глаза); `MannequinPalette {skin?, hair?, torso?, legs?, + arms?, boots?, belt?, eyes?}` — дизайн персонажа параметром (форма не + зависит от палитры; глаза — отдельный тёмный слот, не от ремня); - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine, порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера; - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest` @@ -826,6 +830,36 @@ - Тулза `tools/crop-zoom.mjs cx cy half scale` — зум-кроп скриншота для агентной визуальной проверки (png-кодек движка). +## Трек 25 (2026-09-10): детализированный манекен 14×24×8 + +Фидбек: «персонажи — ходячие кубики, покажи максимум дизайна персонажей». +Манекен 9×16×4 (180 вокс) → **14×24×8 (~2.4 тайла, 498 вокс)**: торс +силуэтом (юбка плаща шире, пояс, грудь, плечи), рукава с кистями кожи, +сапоги с мысками к +Z, шея, голова 6×5 с лицом (глаза-слот EYE, нос +выступает к +Z), волосы шапкой и затылком. Палитра расширена до 8 слотов: +`MannequinPalette {skin?, hair?, torso?, legs?, arms?, boots?, belt?, eyes?}`. + +- **Ракурс и лицо — главная грабля**: камера рендера смотрит сверху под + ~55°, луч к вокселю глаза (y21, z5) проходит через воксель (x, y+1, z) — + любой «навес» над лицом (шапка волос на весь фасад) прячет глаза, снапшоты + при этом зелёные. Лечение: череп ступенькой — фасад лица только до y21, + шапка волос назад до z4. Правило: **над тем, что должно быть видно на + фасаде +Z, не должно быть вокселей на уровень выше в той же z-колонке**. +- Глаза — отдельный слот EYE (дефолт #2a2624), не от ремня: у Милы ремень + золотой (#c9a04a) — глаза «от ремня» получались ярко-золотыми. +- Палитры персонажей: герой — светлый лик/капюшон/ржаво-красный плащ; + Ирвин — седина + **тёплая кожа** (кожа в тон седине сливалась — лицо + читалось как череп); Мила — каштан/сарафан/золотой пояс; Клинт — шапка/ + медный фартук. BODY_SIZE [5,24,5], stride 12.8 (каденция 1.64 цикла/с — + ноги длинные), speed 21. +- Гейты/тесты: счёт вокселей 180→498, byBone [346, 44, 44, 32, 32]; anim-гейт + без правок порогов (ноги выше — мах читается). Все 6 гейтов игры + 11 + демок зелёные, 297 тестов. Визуальная проверка — `shot.mjs --at 16,28` + (герой спавнится на тайле (16,28), не там, где ставит камеру + конструктор main.ts) + `crop-zoom.mjs` на лицо. +- Тулза `tools/probe-hero.mjs [port]` — одноразовая проба «где герой» + (startGame + heroInfo до/после gotoArea). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/demo/src/anim.ts b/v2/apps/demo/src/anim.ts index f8dd12e..899f0e5 100644 --- a/v2/apps/demo/src/anim.ts +++ b/v2/apps/demo/src/anim.ts @@ -22,7 +22,7 @@ const COLS = 4; // кадры 0..3 по оси X const ROWS = 2; // кадры 4..7 по оси Z const FRAMES = 8; -const PITCH = 9; +const PITCH = 18; // манекен 14 шириной + шаг ног — с запасом // walk-цикл манекена: параметры локомоции (агент правит их текстом через клип) const DURATION = 0.8; @@ -32,7 +32,7 @@ const sheetW = COLS * PITCH; const sheetD = ROWS * PITCH; const target: [number, number, number] = [sheetW / 2, 2, sheetD / 2]; -const VIEW_SIZE = 22; // лист 36×18 + рост манекена — с запасом +const VIEW_SIZE = 44; // лист 72×36 + рост манекена — с запасом const canvas = document.getElementById('scene') as HTMLCanvasElement; const renderer = new VoxelRenderer(canvas, { width: 480, height: 360, target, viewSize: VIEW_SIZE }); @@ -42,14 +42,14 @@ return [col * PITCH + Math.floor(PITCH / 2), row * PITCH + Math.floor(PITCH / 2)]; } -/** Подставки ячеек: одна сетка на весь лист, плита 8×8×2 в центре каждой. */ +/** Подставки ячеек: одна сетка на весь лист, плита 14×14×2 в центре каждой. */ function buildPads(): VoxelGrid { const grid = new VoxelGrid(sheetW, 2, sheetD); for (let col = 0; col < COLS; col++) for (let row = 0; row < ROWS; row++) { const [cx, cz] = cellCenter(col, row); - for (let dx = -4; dx < 4; dx++) - for (let dz = -4; dz < 4; dz++) { + for (let dx = -7; dx < 7; dx++) + for (let dz = -7; dz < 7; dz++) { grid.set(cx + dx, 0, cz + dz, 2); const patch = ((cx + dx) * 7 + (cz + dz) * 13) % 11 < 3; grid.set(cx + dx, 1, cz + dz, patch ? 3 : 1); @@ -66,7 +66,7 @@ const [cx, cz] = cellCenter(col, row); const pose = samplePose(clip, (DURATION * i) / FRAMES); const posed = poseVoxels(rig, model, pose).map((v) => ({ - // манекен 5×11×3 — центр по XZ ячейки, на подставке (y=2) + // манекен 14×24×8 — центр по XZ ячейки, на подставке (y=2) pos: [v.pos[0] + cx - model.size[0] / 2, v.pos[1] + 2, v.pos[2] + cz - model.size[2] / 2] as [number, number, number], color: v.color, })); diff --git a/v2/apps/demo/tools/checks/anim.mjs b/v2/apps/demo/tools/checks/anim.mjs index f3781c1..6b6d81b 100644 --- a/v2/apps/demo/tools/checks/anim.mjs +++ b/v2/apps/demo/tools/checks/anim.mjs @@ -77,11 +77,11 @@ for (let i = 0; i < info.frames; i++) ps.push(await client.call('poseStats', i)); const mid = ps[2], zero = ps[0]; check('ноги и руки в противофазе (i=2)', mid.legR === -mid.legL && mid.armR === -mid.armL, { legL: mid.legL, armL: mid.armL }); - // Подъём стопы в махе: у манекена 9×16×4 ноги 6 вокселей, при swing - // 0.55 рад стопа поднимается на ~0.8 вокселя (у 11×21×4 было >1) - check('контакт стоп в i=0/i=4, мах в i=2 поднимает стопы', + // Мах в i=2: маховая нога поднимает стопу высоко; у опорной ноги + // мысок сапога (торчит к +Z за пивот) в крайнем шаге скользит у земли + check('контакт стоп в i=0/i=4, мах в i=2 поднимает стопу', Math.abs(ps[0].footMinL - 0.5) < 0.05 && Math.abs(ps[4].footMinL - 0.5) < 0.05 && - mid.footMinL > 0.7 && mid.footMinR > 0.7, + Math.max(mid.footMinL, mid.footMinR) > 1.0 && Math.min(mid.footMinL, mid.footMinR) > 0.25, { contact: ps[0].footMinL, midL: mid.footMinL, midR: mid.footMinR }); check('корень: в контакте y=0, в шаге приседает и качается вбок', Math.abs(zero.rootPos[1]) < 0.01 && mid.rootPos[1] < -0.2 && diff --git a/v2/apps/demo/tools/checks/combat.mjs b/v2/apps/demo/tools/checks/combat.mjs index 1b71ebf..7f2e596 100644 --- a/v2/apps/demo/tools/checks/combat.mjs +++ b/v2/apps/demo/tools/checks/combat.mjs @@ -47,14 +47,15 @@ check('на лугу 3 сгустка, двое спят', stats0.enemies === 3 && stats0.asleep === 2 && stats0.dead === 0, stats0); - // Кучка спящего рисуется: кадр с врагом vs чистый кадр (кучка 4×4 - // мировых вокселя — десятки пикселей апскейла; луг после combatClear - // пуст — дифф только от кучки) + // Кучка спящего рисуется: кадр с врагом vs чистый кадр. Герой (манекен + // v3) 14 вокселей шириной — кучка в 3.5 вокселя от его центра скрыта + // телом; для кадра спавним в 5 юнитах (18.5, 20), дальше по гейту + // боевые проверки идут на своих местах await client.call('combatClear'); await client.call('heroTeleport', H.x, H.z); await client.call('stepTicks', 2); const clearShot = await client.screenshot('/tmp/v2_combat_clear.png'); - await client.call('combatSpawn', 'crawler', 14.5, 20); + await client.call('combatSpawn', 'crawler', 18.5, 20); await client.call('stepTicks', 2); const pileShot = await client.screenshot('/tmp/v2_combat_pile.png'); const pileDiff = countDiff(png(clearShot), png(pileShot)); diff --git a/v2/apps/game/src/data/npcs.ts b/v2/apps/game/src/data/npcs.ts index 5723bcf..bff8265 100644 --- a/v2/apps/game/src/data/npcs.ts +++ b/v2/apps/game/src/data/npcs.ts @@ -46,8 +46,12 @@ dialogueFirst: 'elder_first', dialogueRepeat: 'elder_repeat', flagKey: FLAGS.met_elder, - // Старейшина: седая голова, зелёный плащ, тёмные штаны/рукава - colors: { head: '#b8b4a4', torso: '#5d7050', legs: '#4a4440', arms: '#4a4440' }, + // Старейшина: седые волосы, тёплая кожа (не в тон волосам — иначе + // лицо сливается с сединой и читается как череп), зелёный плащ + colors: { + skin: '#a89a82', hair: '#b8b4a4', torso: '#5d7050', legs: '#4a4440', + arms: '#4a4440', boots: '#3a3532', belt: '#6e5a3a', + }, }), npcFrom({ id: 'trader', @@ -58,8 +62,11 @@ dialogueFirst: 'trader_first', dialogueRepeat: 'trader_repeat', flagKey: FLAGS.met_mila, - // Мила: каштановая голова, ржаво-красный сарафан, светлые штаны - colors: { head: '#8a6a4e', torso: '#a05a44', legs: '#c9b18a', arms: '#a05a44' }, + // Мила: тёмные волосы, ржаво-красный сарафан, светлые штаны + colors: { + skin: '#a87f5c', hair: '#4a3223', torso: '#a05a44', legs: '#c9b18a', + arms: '#a05a44', boots: '#5a4632', belt: '#c9a04a', + }, }) ]; @@ -85,8 +92,11 @@ dialogueFirst: 'clint_first', dialogueRepeat: 'clint_repeat', flagKey: FLAGS.met_clint, - // Клинт: тёмная голова (шапка), медный фартук, тёмные ноги - colors: { head: '#5f5648', torso: '#c07a3a', legs: '#4d4a46', arms: '#8a5230' }, + // Клинт: тёмная шапка, медный фартук, тёмные штаны + colors: { + skin: '#8a7565', hair: '#4a4038', torso: '#c07a3a', legs: '#4d4a46', + arms: '#8a5230', boots: '#3a3632', belt: '#5a4632', + }, }) } ]; diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 982f447..0a724f7 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -108,17 +108,19 @@ } // ---- Герой: манекен + walk-цикл (как в демке) ---- -// Палитра героя: светлая голова (лицо), плащ путника, тёмные штаны/рукава +// Палитра героя: светлое лицо, тёмный капюшон, ржаво-красный плащ путника, +// тёмные штаны/рукава, сапоги и ремень const { model: MAN, rig: MAN_RIG } = mannequin({ - head: '#c9a882', torso: '#7a5a40', legs: '#4a4440', arms: '#5f4a38', + skin: '#c9a882', hair: '#6b4d36', torso: '#7a5a40', legs: '#4a4440', + arms: '#5f4a38', boots: '#3a3532', belt: '#8a6a3a', }); const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.4, sway: 0.25 }); -const BODY_SIZE: [number, number, number] = [5, 16, 5]; +const BODY_SIZE: [number, number, number] = [5, 24, 5]; const hero = new Character(MAN_RIG, MAN, WALK, [80, 120], { size: BODY_SIZE, speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с - strideLength: 9.6, // каденция выше (ноги короче): 2.19 цикла/с при 2.1 юнита/с + strideLength: 12.8, // темп v1: 1.64 цикла/с при 2.1 юнита/с (ноги длинные) }); const heroCloud = renderer.addVoxelCloud(hero.visual()); @@ -225,7 +227,7 @@ const c = tileCenter(def.tile.x, def.tile.y); const { model, rig } = mannequin(def.colors); // у каждого NPC свой дизайн const char = new Character(rig, model, WALK, [c.x, c.z], { - size: BODY_SIZE, speed: 0, strideLength: 9.6, + size: BODY_SIZE, speed: 0, strideLength: 12.8, }); char.body.pos = [c.x, GROUND_Y, c.z]; return { diff --git a/v2/apps/game/tools/probe-hero.mjs b/v2/apps/game/tools/probe-hero.mjs new file mode 100644 index 0000000..8d5375d --- /dev/null +++ b/v2/apps/game/tools/probe-hero.mjs @@ -0,0 +1,25 @@ +/** + * Одноразовая проба: где герой и куда смотрит камера. + * Использование: node tools/probe-hero.mjs [port] + */ +import { fileURLToPath } from 'node:url'; +import { launchBrowser, openGame, startDevServer } from '@rpg/engine/tools/agent-lib.mjs'; + +const port = Number(process.argv[2] ?? 5350); +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../', import.meta.url)) }); +try { + await server.waitReady; + const browser = await launchBrowser(); + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); + await client.call('setManual', true); + await client.call('stepTicks', 10); + console.log('до gotoArea:', JSON.stringify(await client.call('heroInfo'))); + await client.call('gotoArea', 'meadows'); + await client.call('stepTicks', 10); + console.log('после gotoArea:', JSON.stringify(await client.call('heroInfo'))); + await browser.close(); +} finally { + server.stop(); +} +process.exit(0); \ No newline at end of file diff --git a/v2/packages/engine/src/models/__tests__/mannequin.test.ts b/v2/packages/engine/src/models/__tests__/mannequin.test.ts index 9b87ce6..ad04ac4 100644 --- a/v2/packages/engine/src/models/__tests__/mannequin.test.ts +++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts @@ -13,23 +13,33 @@ expect(validateRig(m.rig, m.model)).toEqual([]); }); - it('состав частей: ноги 24 ×2, руки 24 ×2, торс 60, голова 24', () => { + it('состав частей: ноги 44 ×2, руки 32 ×2, корень 358 (торс+голова+детали)', () => { const { model, rig } = mannequin(); const g = decodeModel(model); - expect(g.count()).toBe(180); // 48 ноги + 48 руки + 60 торс + 24 голова + expect(g.count()).toBe(498); // 44×2 ноги + 32×2 руки + 346 корень const byBone = [0, 0, 0, 0, 0]; for (let i = 0; i < g.data.length; i++) if (g.data[i] !== 0) byBone[rig.binding[i]]++; - expect(byBone).toEqual([84, 24, 24, 24, 24]); // root: торс+голова + expect(byBone).toEqual([346, 44, 44, 32, 32]); + }); + + it('детали силуэта на месте: глаза-акценты, нос к +Z, мыски сапог', () => { + const g = decodeModel(mannequin().model); + const at = (x: number, y: number, z: number) => g.data[x + g.sx * (y + g.sy * z)]; + expect(at(5, 21, 5)).toBe(10); // глаз (тёмный, слот EYE) на фасаде + expect(at(8, 21, 5)).toBe(10); + expect(at(6, 20, 6)).toBe(7); // нос (кожа) выступает к +Z + expect(at(3, 0, 6)).toBe(2); // мысок сапога + expect(at(6, 18, 3)).toBe(7); // шея }); it('палитра — параметр: слоты красятся, форма не меняется', () => { const plain = mannequin().model; - const tinted = mannequin({ torso: '#111111', head: '#222222' }).model; + const tinted = mannequin({ torso: '#111111', skin: '#222222' }).model; expect(JSON.stringify(mannequin().model)).toBe(JSON.stringify(plain)); // дефолт детерминирован expect(tinted.palette[4]).toBe('#111111'); // торс перекрашен - expect(tinted.palette[7]).toBe('#222222'); // голова перекрашена - expect(tinted.palette[6]).toBe(plain.palette[6]); // ноги — дефолт + expect(tinted.palette[7]).toBe('#222222'); // кожа перекрашена + expect(tinted.palette[6]).toBe(plain.palette[6]); // штаны — дефолт expect(tinted.data).toBe(plain.data); // форма не зависит от палитры }); diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts index b138737..97f1248 100644 --- a/v2/packages/engine/src/models/mannequin.ts +++ b/v2/packages/engine/src/models/mannequin.ts @@ -1,7 +1,9 @@ /** * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6). - * Деревянный болванчик 9×16×4 (~1.6 тайла UNIT=10 — персонаж мельче строений, - * но силуэт читается: голова, торс, руки/ноги 2×2). Палитра — параметр: + * Путник 14×24×8 (~2.4 тайла UNIT=10; фасад — сторона +Z: глаза, нос, + * мыски сапог). Силуэт собирается деталями, а не кубом: сапоги с мысками, + * штаны, ремень, плащ (расширяется книзу), рукава с кистями, шея, голова + * с глазами и волосами (по палитре — капюшоном). Палитра — параметр: * у героя и NPC свои цвета (по умолчанию — деревянный болванчик). Части * известны при генерации, поэтому привязка к костям передаётся явно * (bindNearest здесь не нужен: торс рядом с плечами и Voronoi по pivot @@ -13,21 +15,37 @@ import { makeRig } from '../animation/rig'; import type { VoxelRig } from '../animation/rig'; -// Слоты арт-библии: как в генераторах деревьев/валунов -const TORSO = 4; // R1 — тёмное дерево / плащ -const LEG = 6; // P4 — камень / штаны -const HEAD = 7; // P6 — свет / голова -const ARM = 8; // R2 — ржавчина / рукава +// Слоты модели (палитра — параметр mannequin()) +const TORSO = 4; // плащ +const LEG = 6; // штаны +const SKIN = 7; // лицо/кисти +const ARM = 8; // рукава +const BOOT = 2; // сапоги +const HAIR = 5; // волосы/капюшон +const BELT = 9; // ремень +const EYE = 10; // глаза (тёмный, не зависит от ремня) export const MANNEQUIN_BONES = ['root', 'legL', 'legR', 'armL', 'armR'] as const; export type MannequinBone = (typeof MANNEQUIN_BONES)[number]; /** Палитра персонажа: цвет слота модели (по умолчанию — дерево). */ export interface MannequinPalette { + /** Лицо и кисти рук. */ + skin?: string; + /** Волосы или капюшон. */ + hair?: string; + /** Торс / плащ. */ torso?: string; + /** Штаны. */ legs?: string; - head?: string; + /** Рукава. */ arms?: string; + /** Сапоги. */ + boots?: string; + /** Ремень. */ + belt?: string; + /** Глаза (по умолчанию тёмные — не зависят от ремня). */ + eyes?: string; } export interface Mannequin { @@ -37,47 +55,70 @@ /** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */ export function mannequin(palette: MannequinPalette = {}): Mannequin { - const g = new VoxelGrid(9, 16, 4); + const g = new VoxelGrid(14, 24, 8); // привязка: индекс вокселя → индекс кости (root=0, legL=1, legR=2, armL=3, armR=4) const binding = new Array(g.data.length).fill(-1); const put = (x: number, y: number, z: number, slot: number, bone: number): void => { g.set(x, y, z, slot); binding[x + g.sx * (y + g.sy * z)] = bone; }; - // ноги: колонны 2×2 (x2..3 и x5..6), y=0..5 — между ними просвет - for (let y = 0; y <= 5; y++) - for (let z = 1; z <= 2; z++) - for (let dx = 0; dx <= 1; dx++) { - put(2 + dx, y, z, LEG, 1); - put(5 + dx, y, z, LEG, 2); - } - // торс: x=2..6, y=6..11; голова: x=3..5, y=12..15 (куб на плечах) - for (let y = 6; y <= 11; y++) - for (let z = 1; z <= 2; z++) - for (let x = 2; x <= 6; x++) put(x, y, z, TORSO, 0); - for (let y = 12; y <= 15; y++) - for (let z = 1; z <= 2; z++) - for (let x = 3; x <= 5; x++) put(x, y, z, HEAD, 0); - // руки: колонны 2×2 по бокам торса, y=6..11 (свободно висят) - for (let y = 6; y <= 11; y++) - for (let z = 1; z <= 2; z++) - for (let dx = 0; dx <= 1; dx++) { - put(0 + dx, y, z, ARM, 3); - put(7 + dx, y, z, ARM, 4); - } + // параллелепипед [x0..x1]×[y0..y1]×[z0..z1] одной костью + const box = ( + x0: number, x1: number, y0: number, y1: number, z0: number, z1: number, + slot: number, bone: number, + ): void => { + for (let y = y0; y <= y1; y++) + for (let z = z0; z <= z1; z++) + for (let x = x0; x <= x1; x++) put(x, y, z, slot, bone); + }; + // ноги: колонны 2×2 (x3..4 и x9..10, z3..4), y2..9; сапоги y0..1 с мыском к +Z + box(3, 4, 2, 9, 3, 4, LEG, 1); + box(9, 10, 2, 9, 3, 4, LEG, 2); + for (const [x0, bone] of [[3, 1], [9, 2]] as const) { + box(x0, x0 + 1, 0, 1, 3, 4, BOOT, bone); + box(x0, x0 + 1, 0, 0, 5, 6, BOOT, bone); // мысок + } + // торс: юбка плаща y10..12 шире, пояс y13, грудь y14..15, плечи y16..17 + box(3, 10, 10, 12, 2, 5, TORSO, 0); + box(4, 9, 13, 13, 2, 5, BELT, 0); + box(4, 9, 14, 15, 2, 5, TORSO, 0); + box(3, 10, 16, 17, 2, 5, TORSO, 0); + // руки: рукава 2×2 (x1..2 и x11..12, z3..4) y12..17, кисти (кожа) y10..11 + for (const [x0, bone] of [[1, 3], [11, 4]] as const) { + box(x0, x0 + 1, 12, 17, 3, 4, ARM, bone); + box(x0, x0 + 1, 10, 11, 3, 4, SKIN, bone); + } + // шея и голова: y18 шея, голова y19..23. Череп ступенькой: фасад лица + // (z5) только до y21, волосы-шапка назад до z4 — с игрового ракурса + // (камера сверху ~55°) над глазами не должно быть вокселей, иначе + // луч к ним проходит через шапку и лицо не читается + box(6, 7, 18, 18, 3, 4, SKIN, 0); + box(4, 9, 19, 23, 2, 4, SKIN, 0); + box(4, 9, 19, 21, 5, 5, SKIN, 0); + box(4, 9, 22, 23, 2, 4, HAIR, 0); + box(4, 9, 19, 21, 2, 2, HAIR, 0); + // лицо на фасаде (+Z): глаза, нос выступает к +Z + put(5, 21, 5, EYE, 0); + put(8, 21, 5, EYE, 0); + put(6, 20, 6, SKIN, 0); + put(7, 20, 6, SKIN, 0); const model = encodeModel(g, { 4: palette.torso ?? '#5c3b2e', 6: palette.legs ?? '#5b5b66', - 7: palette.head ?? '#94949e', + 7: palette.skin ?? '#94949e', 8: palette.arms ?? '#82503a', + 2: palette.boots ?? '#44454e', + 5: palette.hair ?? '#6e4a30', + 9: palette.belt ?? '#3a3438', + 10: palette.eyes ?? '#2a2624', }); const rig = makeRig( [ - { name: 'root', parent: -1, pivot: [4.5, 0, 2] }, // земля, центр пят - { name: 'legL', parent: 0, pivot: [3, 6, 2] }, // бедро левой - { name: 'legR', parent: 0, pivot: [6, 6, 2] }, // бедро правой - { name: 'armL', parent: 0, pivot: [1, 12, 2] }, // плечо левой - { name: 'armR', parent: 0, pivot: [8, 12, 2] }, // плечо правой + { name: 'root', parent: -1, pivot: [6.5, 0, 4] }, // земля, центр пят + { name: 'legL', parent: 0, pivot: [3.5, 10, 4] }, // бедро левой + { name: 'legR', parent: 0, pivot: [9.5, 10, 4] }, // бедро правой + { name: 'armL', parent: 0, pivot: [1.5, 17, 4] }, // плечо левой + { name: 'armR', parent: 0, pivot: [11.5, 17, 4] }, // плечо правой ], model, binding, diff --git a/v2/packages/engine/src/physics/__tests__/character.test.ts b/v2/packages/engine/src/physics/__tests__/character.test.ts index 47bbc22..fe6c436 100644 --- a/v2/packages/engine/src/physics/__tests__/character.test.ts +++ b/v2/packages/engine/src/physics/__tests__/character.test.ts @@ -66,12 +66,12 @@ c.setMove([1, 0]); for (let i = 0; i < 30; i++) c.step(w, 1 / 60); const voxels = c.visual(); - expect(voxels.length).toBe(180); // все воксели манекена + expect(voxels.length).toBe(498); // все воксели манекена for (const v of voxels) { - expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(4.5); // модель 9 шириной + expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(8); // модель 14 шириной expect(v.pos[1]).toBeGreaterThanOrEqual(c.body.pos[1]); } const idle = makeChar(); - expect(idle.visual().length).toBe(180); + expect(idle.visual().length).toBe(498); }); }); \ No newline at end of file