/**
* Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6/26).
* Путник 10×16×4 (~1.6 тайла, ~164 вокс — втрое меньше трека 25): деталя
* живёт ТЕКСТУРОЙ, а не геометрией (слой 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(10, 16, 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 (x3 и x6, z2), y1..4; сапоги y0 с мыском к +Z
box(3, 3, 1, 4, 2, 2, LEG, 1);
box(6, 6, 1, 4, 2, 2, LEG, 2);
put(3, 0, 2, BOOT, 1); put(3, 0, 3, BOOT, 1);
put(6, 0, 2, BOOT, 2); put(6, 0, 3, BOOT, 2);
// торс: юбка плаща y5..6 шире, пояс y7, грудь y8..9, плечи y10
box(2, 7, 5, 6, 1, 3, TORSO, 0);
box(3, 6, 7, 7, 1, 3, BELT, 0);
box(3, 6, 8, 9, 1, 3, TORSO, 0);
box(2, 7, 10, 10, 1, 3, TORSO, 0);
put(4, 7, 3, METAL, 0); put(5, 7, 3, METAL, 0); // пряжка на поясе
// руки: рукава (x1 и x8, z2) y7..10, кисти (кожа) y5..6
box(1, 1, 7, 10, 2, 2, ARM, 3);
box(8, 8, 7, 10, 2, 2, ARM, 4);
put(1, 5, 2, SKIN, 3); put(1, 6, 2, SKIN, 3);
put(8, 5, 2, SKIN, 4); put(8, 6, 2, SKIN, 4);
// шея и голова: y11 шея, голова y12..15; волосы — макушка (y15) и затылок (z1)
box(4, 5, 11, 11, 2, 2, SKIN, 0);
box(3, 6, 12, 15, 1, 3, SKIN, 0);
box(3, 6, 15, 15, 1, 3, HAIR, 0);
box(3, 6, 12, 14, 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 = 3 + dx, y = 15 - 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 = 2; x <= 7; x++) for (let z = 1; z <= 3; z++) setTex(x, 5, z, 17, hem);
for (let y = 8; y <= 9; y++) for (let z = 1; z <= 3; z++) setTex(6, y, z, 18, clasp);
for (const [bx, bz] of [[3, 2], [3, 3], [6, 2], [6, 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: [5, 0, 2.5] }, // земля, центр пят
{ name: 'legL', parent: 0, pivot: [3.5, 5, 2.5] }, // бедро левой
{ name: 'legR', parent: 0, pivot: [6.5, 5, 2.5] }, // бедро правой
{ name: 'armL', parent: 0, pivot: [1.5, 11, 2.5] }, // плечо левой
{ name: 'armR', parent: 0, pivot: [8.5, 11, 2.5] }, // плечо правой
],
model,
binding,
);
return { model, rig };
}