diff --git a/docs/plan.md b/docs/plan.md index 0361b0b..0746884 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -789,6 +789,21 @@ лесенки-сорта). Герой 2.1 юнита/с (stride 12.8 — темп v1), камера viewSize 80, радиусы интеракта 15. Гейты переведены (`t*10+5`, ±10, диапазон камеры tune 125..255): 6 игры + 11 демок зелёные, 295 тестов. +- **Трек 24 — дизайн персонажей и объектов, персонажи мельче** (ЗАКРЫТ + 2026-09-10, фидбек: «задизайнить персонажей и объекты, а персонажей и + мобов сделать меньше»): герой 22 → **16 вокс** (~1.6 тайла). Манекен + движка 11×21×4 → **9×16×4** (180 вокс) и новое API — + `mannequin(palette?: MannequinPalette)`: палитра {torso/legs/head/arms} + красит слоты, форма не зависит (палитра на `model.palette`); тип + экспортируется из движка. У героя и NPC свои палитры (`NpcDef.colors`): + герой — светлый лик/ржаво-красный плащ, Ирвин — седина/зелёный плащ, + Мила — каштан/сарафан, Клинт — шапка/медный фартук; олень ~10 вокс + (ноги 4). Объекты: дом — двускатная крыша коньком вдоль X + деревянная + дверь на видимом фасаде (z=D−1; z=0 от камеры невидим — изометрия + показывает фасады +x/+z) + труба; дерево — крона с профилем (низ + вздут, верх сходит на нет; ель ярусами). Гейты аним-листа под мелкий + манекен (diff фаз 50→40, подъём стопы 0.8→0.7). 6 гейтов игры + 11 + демок зелёные, 296 тестов. Тулза `crop-zoom.mjs` — зум-кроп снимка. Грабли: UNIT=10 чётный — центр тайла `tile*UNIT + 5`; ствол дерева 2×2 (1×1 на этом масштабе — щепка); в проверках гейтов легко оставить старый `+5` (сравнение возврата камеры) — искать grep-ом `+ 5)` после смены diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index dc03da2..ff9bda2 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -92,8 +92,10 @@ - `src/models/paletteMap.ts` — `remapSlots(model, palette)`: перекладка слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox; - - `src/models/mannequin.ts` — `mannequin()`: эталонная модель 11×21×4 - с ригой (root → legL/legR/armL/armR, pivot в связочном пространстве); + - `src/models/mannequin.ts` — `mannequin(palette?)`: эталонная модель + 9×16×4 с ригой (root → legL/legR/armL/armR, pivot в связочном + пространстве); `MannequinPalette {torso?, legs?, head?, arms?}` — + дизайн персонажа параметром (форма не зависит от палитры); - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine, порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера; - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest` @@ -794,6 +796,36 @@ возврата камеры `rez.x + 10`. Все 6 гейтов игры + 11 демок зелёные, 295 тестов. +## Трек 24 (2026-09-10): дизайн персонажей и объектов, персонажи мельче + +Фидбек: «задизайнить персонажей и объекты, а персонажей и мобов сделать +меньше». Герой 22 → **16 вокс** (~1.6 тайла), proportions к нему — дерево +~2.2–3.5×, дом ~2.5×. + +- Манекен движка: 11×21×4 → **9×16×4** (180 вокс: торс 60, голова/ноги/ + руки по 24 ×2; ноги 2×2 y0..5, торс y6..11, голова y12..15). Новое API — + `mannequin(palette?: MannequinPalette)` + экспорт типа `MannequinPalette` + из движка: палитра {torso?, legs?, head?, arms?} красит слоты, форма + (`model.data`) не зависит от палитры (палитра живёт на `model.palette`). + Дефолт — деревянный болванчик. +- Игра: у героя и каждого NPC своя палитра (`NpcDef.colors` в npcs.ts; + герой — светлый лик/ржаво-красный плащ, Ирвин — седой/зелёный плащ, + Мила — каштан/ржаво-красный сарафан, Клинт — шапка/медный фартук). + BODY_SIZE [5,16,5], stride 9.6 (каденция 2.19 цикла/с — ноги короче), + speed 21. Олень мельче: ~10 вокс ростом (ноги 4, туловище 9×2×5). +- Объекты (`areas.ts`): дом — двускатная ржавая крыша с коньком вдоль X + (слои сужаются по z с двух сторон, конёк светлее) + деревянная дверь + на фасаде **z=D−1** и труба 2×2 над скатом; окна — тёмное стекло. + Дверь была на z=0 — от камеры невидима (в изометрии видны фасады +x/+z). + Дерево: крона с профилем (низ вздут `r*(1.15−t*0.9)`, верх сходит на + нет; ель — ярусы каждый 3-й слой; колонна — сужается к верху), рваный + край сохранён. +- Гейты под новую модель: `anim.mjs` — diff соседних фаз порог 50→40, + подъём стопы в махе 0.8→0.7 (ноги 6 вокс при swing 0.55 рад дают ~0.8). + Все 6 гейтов игры + 11 демок зелёные, 296 тестов. +- Тулза `tools/crop-zoom.mjs cx cy half scale` — зум-кроп + скриншота для агентной визуальной проверки (png-кодек движка). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/demo/tools/checks/anim.mjs b/v2/apps/demo/tools/checks/anim.mjs index 4ccb9ae..f3781c1 100644 --- a/v2/apps/demo/tools/checks/anim.mjs +++ b/v2/apps/demo/tools/checks/anim.mjs @@ -59,15 +59,16 @@ check('во всех 8 ячейках манекен виден', empty.length === 0, empty.length ? empty : { minDiff: Math.min(...cells.map((c) => c.diff)) }); - // Соседние фазы различаются (внутри ряда; шаг фазы 45° даёт ~110 px — - // манекен мелкий, порог 50; фазы 0 и 4 цикла совпадают — норма двухшагового цикла) + // Соседние фазы различаются (внутри ряда; шаг фазы 45°: у манекена + // 9×16×4 даёт ~50–110 px — порог 40; фазы 0 и 4 цикла совпадают — + // норма двухшагового цикла) const pairs = []; for (const row of [0, 1]) for (let col = 0; col + 1 < info.cols; col++) { const a = cells[row * info.cols + col], b = cells[row * info.cols + col + 1]; pairs.push(cellDiff(full, full, a.px, a.py, b.px, b.py)); } - const same = pairs.filter((d) => d <= 50); + const same = pairs.filter((d) => d <= 40); check('соседние фазы различаются (6 пар)', same.length === 0, same.length ? { same } : { minDiff: Math.min(...pairs) }); @@ -76,9 +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 поднимает стопы', Math.abs(ps[0].footMinL - 0.5) < 0.05 && Math.abs(ps[4].footMinL - 0.5) < 0.05 && - mid.footMinL > 0.8 && mid.footMinR > 0.8, + mid.footMinL > 0.7 && mid.footMinR > 0.7, { 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/game/src/areas.ts b/v2/apps/game/src/areas.ts index f7bd3eb..363dc4b 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -1,7 +1,7 @@ /** * Области «Пепельных луга» на v2: раскладка тайлов v1 (maps.json, референс) * → воксельный мир. 1 тайл v1 = 10×10 вокселей (1 юнит = 10 вокселей; герой - * 22 ≈ 2.2 тайла ростом — дом ~4 тайла, дерево ~3.6, пропорции «человек + * 16 ≈ 1.6 тайла ростом — дом ~2.5 тайла, дерево ~2.2–3.5, пропорции «человек * и мир»). Рельеф — плоский (земля y0-1), объекты — штампы генераторов * движка (деревья/валуны) и локальных генераторов контента (кусты, дома, * колодец, башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются @@ -28,7 +28,7 @@ export const AREA_LAYOUTS = mapsData as unknown as Record; -/** Вокселей на тайл v1 (1 юнит = 10 вокселей — герой 22 ≈ 2.2 тайла ростом). */ +/** Вокселей на тайл v1 (1 юнит = 10 вокселей — герой 16 ≈ 1.6 тайла ростом). */ export const UNIT = 10; /** Точка ног героя в вокселях (верх земли). */ export const GROUND_Y = 2; @@ -62,9 +62,10 @@ // ---- Генераторы контента игры (деревья/кусты/валуны в тайл 10×10, дома...) ---- -/** Дерево карты: высокое (в ~1.7 раза выше героя — 22), footprint тайла - * 10×10 (крона не вылезает на соседей — blocked-семантика v1). - * Варианты: 0 круглая крона, 1 узкая колонна, 2 ель. */ +/** Дерево карты: высокое (в ~2.3 раза выше героя — 16), footprint тайла + * 10×10 (крона не вылезает на соседей — blocked-семантика v1). Крона + * с профилем: низ вздут, к верху сходит на нет; рваный край. + * Варианты: 0 круглая крона, 1 узкая колонна, 2 ель с ярусами. */ export function mapTreeModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x7ee1); const trunkH = variant === 1 ? rng.range(20, 24) : rng.range(16, 21); @@ -74,16 +75,23 @@ g.set(4, y, 4, 4); g.set(5, y, 4, 4); g.set(4, y, 5, 4); g.set(5, y, 5, 4); } - const r = 4 + rng.next() * 1; // джиттер радиуса фиксируем заранее + const r = 3.6 + rng.next() * 0.9; // джиттер радиуса фиксируем заранее for (let dy = 0; dy < crownH; dy++) { const y = trunkH - 1 + dy; + const t = dy / crownH; // 0..1 по высоте кроны for (let dz = -4; dz <= 4; dz++) for (let dx = -4; dx <= 4; dx++) { const d2 = dx * dx + dz * dz; let ok: boolean; - if (variant === 0) ok = d2 + dy * dy * 0.6 <= r * r + 4; - else if (variant === 1) ok = d2 <= 8; - else ok = d2 <= (dy < crownH / 2 ? 16 : 8); // ель: ярусы сужаются + if (variant === 0) { + const rdy = r * (1.15 - t * 0.9); // низ вздут, верх сходит на нет + ok = d2 <= rdy * rdy + 1; + } else if (variant === 1) { + ok = d2 <= (t > 0.6 ? 3 : 6); // колонна: к верху уже + } else { + // ель: ярусы — каждый третий слой шире (ступень), выше уже + ok = d2 <= (dy % 3 === 0 ? 16 : t < 0.5 ? 9 : 4); + } if (!ok) continue; if (dy > 0 && d2 > 8 && !rng.chance(0.7)) continue; // рваный край const light = dy === crownH - 1 || rng.chance(0.25); @@ -126,26 +134,37 @@ return encodeModel(g, { 6: '#5b5b66', 7: '#94949e' }); } -/** Дом: каменные стены с окнами + двускатная ржавая крыша (~4 тайла — - * вдвое выше героя: строение, в которое заходят, а не будка). */ +/** Дом: каменные стены с окнами и дверью + двускатная ржавая крыша + * (конёк вдоль X, свес над стенами) + труба (~4 тайла — вдвое выше + * героя: строение, в которое заходят, а не будка). */ export function houseModel(kind: number, _seed: number): VoxelModel { const tall = kind === T.HOUSE_V3 || kind === T.HOUSE_V4; - const W = 24, H = tall ? 40 : 30, D = 18; + const W = 24, H = tall ? 40 : 30, D = 18, ROOF = 5; const g = new VoxelGrid(W, H, D); - for (let y = 0; y < H - 4; y++) + for (let y = 0; y < H - ROOF; y++) for (let z = 0; z < D; z++) for (let x = 0; x < W; x++) { const edge = x === 0 || z === 0 || x === W - 1 || z === D - 1; if (!edge) continue; - const win = y > 2 && y < H - 6 && (x + z) % 5 === 0; - g.set(x, y, z, win ? 10 : 6); + // проём на фасаде z=D-1 (видимом камере в изометрии) + const door = z === D - 1 && y <= 4 && x >= 10 && x <= 13; + const win = !door && y > 4 && y < H - ROOF - 1 && (x + z) % 5 === 0; + // дверь — тёплое дерево (окна — тёмное стекло, стена — камень) + g.set(x, y, z, door ? 9 : win ? 10 : 6); } - for (let dy = 0; dy < 4; dy++) { - const y = H - 4 + dy; - for (let z = 0; z < D; z++) - for (let x = dy * 2; x < W - dy * 2; x++) g.set(x, y, z, 8); + // крыша: слои сужаются с двух сторон по z (конёк вдоль X), свес шире стен + for (let dy = 0; dy < ROOF; dy++) { + const y = H - ROOF + dy; + const z0 = dy * 2 - 1, z1 = D - dy * 2; + for (let z = Math.max(0, z0); z < Math.min(D, z1); z++) + for (let x = Math.max(0, dy - 1); x < Math.min(W, W - dy + 1); x++) + g.set(x, y, z, dy === ROOF - 1 ? 7 : 8); // конёк — светлее } - return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e' }); + // труба: 2×2 у края, торчит над скатом + for (let y = H - ROOF; y < H; y++) + for (let dz = 4; dz <= 5; dz++) + for (let dx = W - 6; dx <= W - 5; dx++) g.set(dx, y, dz, 6); + return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e', 7: '#94949e', 9: '#6e4a30' }); } /** Колодец: каменное кольцо + ворот с брусом (tall 1 юнит). */ diff --git a/v2/apps/game/src/data/npcs.ts b/v2/apps/game/src/data/npcs.ts index 8294b5f..5723bcf 100644 --- a/v2/apps/game/src/data/npcs.ts +++ b/v2/apps/game/src/data/npcs.ts @@ -6,6 +6,7 @@ * (tileCenter). Перенос v1, тайл-мост тот же. */ import { FLAGS, type FlagId } from './ids'; +import type { MannequinPalette } from '@rpg/engine'; /** NPC локации: диалоги и флаг сюжета после первого разговора. */ export interface NpcDef { @@ -23,6 +24,8 @@ dialogueRepeat: string; /** Флаг, поднимаемый после первого диалога (ставит сам граф). */ flagKey: FlagId; + /** Палитра модели (голова/торс/ноги/рукава) — дизайн персонажа. */ + colors: MannequinPalette; } /** NPC из raw-определения (тайл-мост общий для статичных и размещений). */ @@ -42,7 +45,9 @@ pos: { x: 12.5, y: 15.5 }, dialogueFirst: 'elder_first', dialogueRepeat: 'elder_repeat', - flagKey: FLAGS.met_elder + flagKey: FLAGS.met_elder, + // Старейшина: седая голова, зелёный плащ, тёмные штаны/рукава + colors: { head: '#b8b4a4', torso: '#5d7050', legs: '#4a4440', arms: '#4a4440' }, }), npcFrom({ id: 'trader', @@ -52,7 +57,9 @@ pos: { x: 40.5, y: 20.5 }, dialogueFirst: 'trader_first', dialogueRepeat: 'trader_repeat', - flagKey: FLAGS.met_mila + flagKey: FLAGS.met_mila, + // Мила: каштановая голова, ржаво-красный сарафан, светлые штаны + colors: { head: '#8a6a4e', torso: '#a05a44', legs: '#c9b18a', arms: '#a05a44' }, }) ]; @@ -77,7 +84,9 @@ pos: { x: 23.5, y: 17.5 }, dialogueFirst: 'clint_first', dialogueRepeat: 'clint_repeat', - flagKey: FLAGS.met_clint + flagKey: FLAGS.met_clint, + // Клинт: тёмная голова (шапка), медный фартук, тёмные ноги + colors: { head: '#5f5648', torso: '#c07a3a', legs: '#4d4a46', arms: '#8a5230' }, }) } ]; diff --git a/v2/apps/game/src/deer.ts b/v2/apps/game/src/deer.ts index 27f55e7..5313e5c 100644 --- a/v2/apps/game/src/deer.ts +++ b/v2/apps/game/src/deer.ts @@ -32,8 +32,8 @@ } /** - * Олень в мировых координатах: ноги на земле (GROUND_Y), высота ~13 вокселей - * (герой — 21). step — фаза шага (0/1), поднимает диагональные пары ног; + * Олень в мировых координатах: ноги на земле (GROUND_Y), высота ~10 вокселей + * (герой — 16). step — фаза шага (0/1), поднимает диагональные пары ног; * dir — направление взгляда (см. rotXZ). */ export function deerVoxels(cx: number, cz: number, step: boolean, dir = 0): DeerVoxel[] { @@ -44,25 +44,24 @@ }; // Ноги: диагональные пары (перед-лево + зад-право) поднимаются на шаге. const lift = step ? 2 : 0; - for (const [lx, lz] of [[-5, -2], [5, 2], [-5, 2], [5, -2]] as const) { - const up = (lx === -5) === (lz === -2) ? lift : 0; // диагональ шагает - for (let y = 0; y < 6; y++) put(lx, GROUND_Y + y + up, lz, DARK); + for (const [lx, lz] of [[-4, -2], [4, 2], [-4, 2], [4, -2]] as const) { + const up = (lx === -4) === (lz === -2) ? lift : 0; // диагональ шагает + for (let y = 0; y < 4; y++) put(lx, GROUND_Y + y + up, lz, DARK); } - // Туловище: 11×2×5, спина светлее. - for (let dx = -5; dx <= 5; dx++) + // Туловище: 9×2×5, спина светлее. + for (let dx = -4; dx <= 4; dx++) for (let dz = -2; dz <= 2; dz++) { - put(dx, GROUND_Y + 6, dz, dx % 2 === 0 ? BODY : DARK); - put(dx, GROUND_Y + 7, dz, LIGHT); + put(dx, GROUND_Y + 4, dz, dx % 2 === 0 ? BODY : DARK); + put(dx, GROUND_Y + 5, dz, LIGHT); } // Шея и голова: вперед-вверх, нос темный, уши-рожки. - put(5, GROUND_Y + 8, 0, BODY); - put(6, GROUND_Y + 8, 0, BODY); - put(6, GROUND_Y + 9, 0, BODY); - put(7, GROUND_Y + 9, 0, DARK); // нос - put(6, GROUND_Y + 10, 0, LIGHT); - put(6, GROUND_Y + 11, -1, ANTLER); - put(6, GROUND_Y + 11, 1, ANTLER); + put(4, GROUND_Y + 6, 0, BODY); + put(5, GROUND_Y + 6, 0, BODY); + put(5, GROUND_Y + 7, 0, DARK); // нос + put(5, GROUND_Y + 8, 0, LIGHT); + put(5, GROUND_Y + 9, -1, ANTLER); + put(5, GROUND_Y + 9, 1, ANTLER); // Хвост. - put(-6, GROUND_Y + 7, 0, LIGHT); + put(-5, GROUND_Y + 5, 0, LIGHT); return out; } \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 6840253..982f447 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -108,14 +108,17 @@ } // ---- Герой: манекен + walk-цикл (как в демке) ---- -const { model: MAN, rig: MAN_RIG } = mannequin(); -const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.5, sway: 0.3 }); -const BODY_SIZE: [number, number, number] = [5, 21, 5]; +// Палитра героя: светлая голова (лицо), плащ путника, тёмные штаны/рукава +const { model: MAN, rig: MAN_RIG } = mannequin({ + head: '#c9a882', torso: '#7a5a40', legs: '#4a4440', arms: '#5f4a38', +}); +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 hero = new Character(MAN_RIG, MAN, WALK, [80, 120], { size: BODY_SIZE, speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с - strideLength: 12.8, // темп шага как в v1 (1.64 цикла/с при 2.1 юнита/с) + strideLength: 9.6, // каденция выше (ноги короче): 2.19 цикла/с при 2.1 юнита/с }); const heroCloud = renderer.addVoxelCloud(hero.visual()); @@ -220,8 +223,9 @@ /** Поставить NPC-манекен в центр его тайла (поза покоя, без walk-цикла). */ function spawnNpc(def: NpcDef): NpcEntity { const c = tileCenter(def.tile.x, def.tile.y); - const char = new Character(MAN_RIG, MAN, WALK, [c.x, c.z], { - size: BODY_SIZE, speed: 0, strideLength: 12.8, + 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, }); char.body.pos = [c.x, GROUND_Y, c.z]; return { diff --git a/v2/apps/game/tools/crop-zoom.mjs b/v2/apps/game/tools/crop-zoom.mjs new file mode 100644 index 0000000..bc44395 --- /dev/null +++ b/v2/apps/game/tools/crop-zoom.mjs @@ -0,0 +1,26 @@ +/** + * Зум-кроп скриншота для визуальной проверки агентом. + * Использование: node tools/crop-zoom.mjs cx cy half scale + * (cx, cy — центр кропа в пикселях, half — полуширина, scale — кратность). + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; + +const [inPath, outPath, cx, cy, half, scale] = process.argv.slice(2); +const img = decodePng(readFileSync(inPath)); +const { width: W, height: H, rgba } = img; +const px = Math.max(0, Number(cx) - Number(half)); +const py = Math.max(0, Number(cy) - Number(half)); +const sw = Math.min(W - px, Number(half) * 2); +const sh = Math.min(H - py, Number(half) * 2); +const s = Number(scale); +const out = Buffer.alloc(sw * sh * s * s * 4); +for (let y = 0; y < sh * s; y++) + for (let x = 0; x < sw * s; x++) { + const sx = px + Math.floor(x / s), sy = py + Math.floor(y / s); + const si = (sy * W + sx) * 4, di = (y * sw * s + x) * 4; + out[di] = rgba[si]; out[di + 1] = rgba[si + 1]; + out[di + 2] = rgba[si + 2]; out[di + 3] = 255; + } +writeFileSync(outPath, encodePng(sw * s, sh * s, out)); +console.log('ok', outPath); \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index b6c57c2..40658be 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -34,7 +34,7 @@ export { parseVox, exportVox } from './models/vox'; export { remapSlots } from './models/paletteMap'; export { mannequin, MANNEQUIN_BONES } from './models/mannequin'; -export type { Mannequin, MannequinBone } from './models/mannequin'; +export type { Mannequin, MannequinBone, MannequinPalette } from './models/mannequin'; export { eulerMat, mat3Mul, composeAffine, affinePoint, rotateAround, addVec, IDENTITY_AFFINE } from './animation/mat'; export type { Vec3, Mat3, Affine } from './animation/mat'; export { makeRig, bindNearest, validateRig, boneWorlds, poseVoxels } from './animation/rig'; diff --git a/v2/packages/engine/src/models/__tests__/mannequin.test.ts b/v2/packages/engine/src/models/__tests__/mannequin.test.ts index efac4f9..9b87ce6 100644 --- a/v2/packages/engine/src/models/__tests__/mannequin.test.ts +++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts @@ -13,14 +13,24 @@ expect(validateRig(m.rig, m.model)).toEqual([]); }); - it('состав частей: ноги 36 ×2, руки 24 ×2, торс 70, голова 30', () => { + it('состав частей: ноги 24 ×2, руки 24 ×2, торс 60, голова 24', () => { const { model, rig } = mannequin(); const g = decodeModel(model); - expect(g.count()).toBe(220); // 72 ноги + 48 рук + 70 торс + 30 голова + expect(g.count()).toBe(180); // 48 ноги + 48 руки + 60 торс + 24 голова 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([100, 36, 36, 24, 24]); // root: торс+голова + expect(byBone).toEqual([84, 24, 24, 24, 24]); // root: торс+голова + }); + + it('палитра — параметр: слоты красятся, форма не меняется', () => { + const plain = mannequin().model; + const tinted = mannequin({ torso: '#111111', head: '#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.data).toBe(plain.data); // форма не зависит от палитры }); it('каждая часть — связное тело (6-связность)', () => { diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts index be4ed5a..b138737 100644 --- a/v2/packages/engine/src/models/mannequin.ts +++ b/v2/packages/engine/src/models/mannequin.ts @@ -1,11 +1,11 @@ /** * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6). - * Деревянный болванчик 11×21×4 (масштаб мира UNIT=10: герой ~2 тайла - * ростом — на кубической голове/торсе/конечностях силуэт читается): - * ноги (P4), торс (R1), голова (P6), руки (R2 — ржавые, видны в махе). - * Части известны при генерации, поэтому привязка к костям передаётся - * явно (bindNearest здесь не нужен: торс рядом с плечами и Voronoi - * по pivot отнёс бы его к рукам). + * Деревянный болванчик 9×16×4 (~1.6 тайла UNIT=10 — персонаж мельче строений, + * но силуэт читается: голова, торс, руки/ноги 2×2). Палитра — параметр: + * у героя и NPC свои цвета (по умолчанию — деревянный болванчик). Части + * известны при генерации, поэтому привязка к костям передаётся явно + * (bindNearest здесь не нужен: торс рядом с плечами и Voronoi по pivot + * отнёс бы его к рукам). */ import { VoxelGrid } from '../voxel/grid'; import { encodeModel } from './format'; @@ -14,57 +14,70 @@ import type { VoxelRig } from '../animation/rig'; // Слоты арт-библии: как в генераторах деревьев/валунов -const TORSO = 4; // R1 — тёмное дерево -const LEG = 6; // P4 — камень (ноги-колонны) -const HEAD = 7; // P6 — свет -const ARM = 8; // R2 — ржавчина (махи рук заметны) +const TORSO = 4; // R1 — тёмное дерево / плащ +const LEG = 6; // P4 — камень / штаны +const HEAD = 7; // P6 — свет / голова +const ARM = 8; // R2 — ржавчина / рукава export const MANNEQUIN_BONES = ['root', 'legL', 'legR', 'armL', 'armR'] as const; export type MannequinBone = (typeof MANNEQUIN_BONES)[number]; +/** Палитра персонажа: цвет слота модели (по умолчанию — дерево). */ +export interface MannequinPalette { + torso?: string; + legs?: string; + head?: string; + arms?: string; +} + export interface Mannequin { model: VoxelModel; rig: VoxelRig; } /** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */ -export function mannequin(): Mannequin { - const g = new VoxelGrid(11, 21, 4); +export function mannequin(palette: MannequinPalette = {}): Mannequin { + const g = new VoxelGrid(9, 16, 4); // привязка: индекс вокселя → индекс кости (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 (x3..4 и x6..7), y=0..8 — между ними просвет - for (let y = 0; y <= 8; y++) + // ноги: колонны 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(3 + dx, y, z, LEG, 1); - put(6 + dx, y, z, LEG, 2); + put(2 + dx, y, z, LEG, 1); + put(5 + dx, y, z, LEG, 2); } - // торс: x=3..7, y=9..15; голова: x=4..6, y=16..20 (куб на плечах) - for (let y = 9; y <= 15; y++) + // торс: 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 = 3; x <= 7; x++) put(x, y, z, TORSO, 0); - for (let y = 16; y <= 20; y++) + 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 = 4; x <= 6; x++) put(x, y, z, HEAD, 0); - // руки: колонны 2×2 по бокам торса, y=9..14 (свободно висят) - for (let y = 9; y <= 14; y++) + 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(1 + dx, y, z, ARM, 3); - put(8 + dx, y, z, ARM, 4); + put(0 + dx, y, z, ARM, 3); + put(7 + dx, y, z, ARM, 4); } - const model = encodeModel(g, { 4: '#5c3b2e', 6: '#5b5b66', 7: '#94949e', 8: '#82503a' }); + const model = encodeModel(g, { + 4: palette.torso ?? '#5c3b2e', + 6: palette.legs ?? '#5b5b66', + 7: palette.head ?? '#94949e', + 8: palette.arms ?? '#82503a', + }); const rig = makeRig( [ - { name: 'root', parent: -1, pivot: [5.5, 0, 2] }, // земля, центр пят - { name: 'legL', parent: 0, pivot: [4, 9, 2] }, // бедро левой - { name: 'legR', parent: 0, pivot: [7, 9, 2] }, // бедро правой - { name: 'armL', parent: 0, pivot: [2, 15, 2] }, // плечо левой - { name: 'armR', parent: 0, pivot: [9, 15, 2] }, // плечо правой + { 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] }, // плечо правой ], 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 6154760..47bbc22 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(220); // все воксели манекена + expect(voxels.length).toBe(180); // все воксели манекена for (const v of voxels) { - expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(5.5); // модель 11 шириной + expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(4.5); // модель 9 шириной expect(v.pos[1]).toBeGreaterThanOrEqual(c.body.pos[1]); } const idle = makeChar(); - expect(idle.visual().length).toBe(220); + expect(idle.visual().length).toBe(180); }); }); \ No newline at end of file