Newer
Older
rpg / v2 / packages / engine / src / models / mannequin.ts
/**
 * Манекен — эталонная модель для рига и анимации (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 };
}