/**
* Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6/26).
* Путник 8×12×4 (~1.2 тайла, ~134 вокс; фидбек — персонажи меньше на фоне
* выросших деревьев): деталя живёт ТЕКСТУРОЙ, а не геометрией (слой
* tiles/tex формата) — при уменьшении сетки проработка не теряется. Лицо —
* арт 8×8 текселей (4×4 вокселя фасада × тайли 2×2), волосы — бахрома в
* арте и шапка на затылке/макушке. Правило ракурса (трек 25): камера
* сверху ~55° — над фасадом лица (+Z) в той же z-колонке вокселей выше нет.
* Палитра — параметр: у героя и NPC свои цвета (тексели ссылаются на слоты
* палитры, перекраска красит и текстуры). Части известны при генерации —
* привязка к костям передаётся явно.
*/
import { VoxelGrid } from '../voxel/grid';
import { encodeModel } from './format';
import type { VoxelModel, VoxelTile } from './format';
import { makeRig } from '../animation/rig';
import type { VoxelRig } from '../animation/rig';
// Слоты модели (палитра — параметр 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; // глаза и рот (тёмный, не зависит от ремня)
const METAL = 11; // пряжка ремня
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;
/** Рукава. */
arms?: string;
/** Сапоги. */
boots?: string;
/** Ремень. */
belt?: string;
/** Глаза и рот (по умолчанию тёмные — не зависят от ремня). */
eyes?: string;
/** Пряжка ремня (по умолчанию светлый металл). */
metal?: string;
}
export interface Mannequin {
model: VoxelModel;
rig: VoxelRig;
}
/**
* Арт лица 8×8 текселей, строки сверху вниз (фасад головы 4×4 вокселя,
* каждый несёт тайль 2×2 = блок 2×2 арта). Волосы — бахрома по верху
* и пряди по бокам, глаза 2 текселя, рот под ними.
*/
const FACE_ART: number[][] = [
[HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR],
[HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR],
[HAIR, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, HAIR],
[HAIR, EYE, EYE, SKIN, SKIN, EYE, EYE, HAIR],
[SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN],
[SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN],
[SKIN, SKIN, SKIN, EYE, EYE, SKIN, SKIN, SKIN],
[SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN],
];
const b64 = (bytes: number[]): string => btoa(String.fromCharCode(...bytes));
/** Тайль лица для вокселя (dx,dy) фасада: +z — блок арта, прочие грани — слот вокселя. */
function faceTile(slot: number, dx: number, dy: number): VoxelTile {
const flat = b64([slot, slot, slot, slot]);
const block = [0, 1].flatMap((r) => [0, 1].map((c) => FACE_ART[dy * 2 + r][dx * 2 + c]));
return { size: 2, faces: [flat, flat, flat, flat, b64(block), flat] };
}
/**
* Тайль детали скина (трек 29): арт 2×2 текселей (строка 0 — верх грани)
* на +z (или на ВСЕ грани при all), прочие грани — плоский цвет слота.
*/
function skinTile(slot: number, art: number[], all = false): VoxelTile {
const flat = b64([slot, slot, slot, slot]);
const tex = b64(art);
return { size: 2, faces: all ? [tex, tex, tex, tex, tex, tex] : [flat, flat, flat, flat, tex, flat] };
}
/** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */
export function mannequin(palette: MannequinPalette = {}): Mannequin {
const g = new VoxelGrid(8, 12, 4);
// привязка: индекс вокселя → индекс кости (root=0, legL=1, legR=2, armL=3, armR=4)
const binding = new Array<number>(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;
};
// параллелепипед [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);
};
// ноги: колонны 1×1 (x2 и x5, z2), y1..3; сапоги y0 с мыском к +Z
box(2, 2, 1, 3, 2, 2, LEG, 1);
box(5, 5, 1, 3, 2, 2, LEG, 2);
put(2, 0, 2, BOOT, 1); put(2, 0, 3, BOOT, 1);
put(5, 0, 2, BOOT, 2); put(5, 0, 3, BOOT, 2);
// торс: юбка плаща y4..5 шире, пояс y6, грудь/плечи y7 (один слой)
box(1, 6, 4, 5, 1, 3, TORSO, 0);
box(2, 5, 6, 6, 1, 3, BELT, 0);
box(1, 6, 7, 7, 1, 3, TORSO, 0);
put(3, 6, 3, METAL, 0); put(4, 6, 3, METAL, 0); // пряжка на поясе
// руки: рукава (x0 и x7, z2) y5..7, кисти (кожа) y3..4 — у юбки
box(0, 0, 5, 7, 2, 2, ARM, 3);
box(7, 7, 5, 7, 2, 2, ARM, 4);
put(0, 3, 2, SKIN, 3); put(0, 4, 2, SKIN, 3);
put(7, 3, 2, SKIN, 4); put(7, 4, 2, SKIN, 4);
// голова y8..11 (без шеи — голова сидит на плечах); волосы — макушка
// (y11) и затылок (z1)
box(2, 5, 8, 11, 1, 3, SKIN, 0);
box(2, 5, 11, 11, 1, 3, HAIR, 0);
box(2, 5, 8, 10, 1, 1, HAIR, 0);
// фасад лица (+Z, z3): арт 8×8 текселей тайлями 2×2
const tiles: Record<number, VoxelTile> = {};
const tex = new Uint8Array(g.data.length);
const setTex = (x: number, y: number, z: number, id: number, tile: VoxelTile): void => {
tiles[id] = tile;
tex[x + g.sx * (y + g.sy * z)] = id;
};
for (let dy = 0; dy < 4; dy++) {
for (let dx = 0; dx < 4; dx++) {
const x = 2 + dx, y = 11 - dy;
setTex(x, y, 3, 1 + dy * 4 + dx, faceTile(g.get(x, y, 3), dx, dy));
}
}
// скины деталей (трек 29): тёмная кромка подола, застёжка на груди,
// подошвы сапогов — тайли 2×2
const hem = skinTile(TORSO, [TORSO, TORSO, TORSO, BELT]);
const clasp = skinTile(TORSO, [TORSO, BELT, TORSO, BELT]);
const sole = skinTile(BOOT, [BOOT, BOOT, EYE, EYE], true);
for (let x = 1; x <= 6; x++) for (let z = 1; z <= 3; z++) setTex(x, 4, z, 17, hem);
for (let z = 1; z <= 3; z++) setTex(5, 7, z, 18, clasp);
for (const [bx, bz] of [[2, 2], [2, 3], [5, 2], [5, 3]] as const) setTex(bx, 0, bz, 19, sole);
const model = encodeModel(g, {
4: palette.torso ?? '#5c3b2e',
6: palette.legs ?? '#5b5b66',
7: palette.skin ?? '#94949e',
8: palette.arms ?? '#82503a',
2: palette.boots ?? '#44454e',
5: palette.hair ?? '#6e4a30',
9: palette.belt ?? '#3a3438',
10: palette.eyes ?? '#2a2624',
11: palette.metal ?? '#56575f',
}, { tiles, tex });
const rig = makeRig(
[
{ name: 'root', parent: -1, pivot: [4, 0, 2.5] }, // земля, центр пят
{ name: 'legL', parent: 0, pivot: [2.5, 4, 2.5] }, // бедро левой
{ name: 'legR', parent: 0, pivot: [5.5, 4, 2.5] }, // бедро правой
{ name: 'armL', parent: 0, pivot: [0.5, 8, 2.5] }, // плечо левой
{ name: 'armR', parent: 0, pivot: [7.5, 8, 2.5] }, // плечо правой
],
model,
binding,
);
return { model, rig };
}