Newer
Older
rpg / v2 / packages / engine / src / models / mannequin.ts
/**
 * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6).
 * Путник 14×24×8 (~2.4 тайла UNIT=10; фасад — сторона +Z: глаза, нос,
 * мыски сапог). Силуэт собирается деталями, а не кубом: сапоги с мысками,
 * штаны, ремень, плащ (расширяется книзу), рукава с кистями, шея, голова
 * с глазами и волосами (по палитре — капюшоном). Палитра — параметр:
 * у героя и NPC свои цвета (по умолчанию — деревянный болванчик). Части
 * известны при генерации, поэтому привязка к костям передаётся явно
 * (bindNearest здесь не нужен: торс рядом с плечами и Voronoi по pivot
 * отнёс бы его к рукам).
 */
import { VoxelGrid } from '../voxel/grid';
import { encodeModel } from './format';
import type { VoxelModel } 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;  // глаза (тёмный, не зависит от ремня)

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;
}

export interface Mannequin {
    model: VoxelModel;
    rig: VoxelRig;
}

/** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */
export function mannequin(palette: MannequinPalette = {}): Mannequin {
    const g = new VoxelGrid(14, 24, 8);
    // привязка: индекс вокселя → индекс кости (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);
    };
    // ноги: колонны 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.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: [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,
    );
    return { model, rig };
}