Newer
Older
rpg / v2 / packages / engine / src / animation / attach.ts
/**
 * Крепления предметов к костям риги (механизм «в руке / на персонаже»):
 * предмет — обычная VoxelModel, размещённая в связочном пространстве базовой
 * модели (где центр вокселя (x,y,z) — это (x+0.5,y+0.5,z+0.5)) и едущая вместе
 * с указанной костью через её мир-трансформ (boneWorlds). Рюкзак на торсе едет
 * с корнем (покачивание шага), факел в кисти машет вместе с рукой.
 * Чистая математика поверх rig.ts, без рендера — тестируется без three.
 */
import { decodeModel, texBytes, resolveTiles } from '../models/format';
import type { VoxelModel, ResolvedTile } from '../models/format';
import { eulerMat, affinePoint, addVec } from './mat';
import type { Vec3 } from './mat';
import type { VoxelRig, Pose, PosedVoxel } from './rig';
import { boneWorlds, poseVoxels } from './rig';

/** Предмет на кости: модель + размещение в связочном пространстве базы. */
export interface Attachment {
    /** Имя кости риги (неизвестная кость — ошибка, см. MANNEQUIN_BONES). */
    bone: string;
    /** Модель предмета (своя палитра; тайли — со сдвигом id, см. attachTiles). */
    model: VoxelModel;
    /** Угол-origine предмета [0,0,0] в связочном пространстве базовой модели. */
    pos?: Vec3;
    /** Поворот предмета вокруг его угла (рад, порядок Эйлера X→Y→Z). */
    rot?: Vec3;
}

/** Смещения id тайлей предметов (id тайля предмета + смещение → общий слой). */
function tileOffsets(base: VoxelModel, atts: readonly Attachment[]): number[] {
    let max = 0;
    for (const id of Object.keys(resolveIds(base))) max = Math.max(max, Number(id));
    return atts.map((att) => {
        const itemMax = Math.max(0, ...Object.keys(resolveIds(att.model)).map(Number));
        const off = max; // предметы начинаются после базы и предыдущих предметов
        max = Math.max(max, itemMax + off);
        return off;
    });
}

/** Id тайлей модели (ключи Record без разрешения пикселей). */
function resolveIds(model: VoxelModel): Record<number, unknown> {
    return model.tiles ?? {};
}

/**
 * Деформированные воксели базы + всех предметов в позе. Порядок: база,
 * затем предметы по списку; tile предмета сдвинут на смещение из
 * tileOffsets — парный вызов attachTiles даёт согласованный CloudTiles.
 */
export function attachVoxels(
    rig: VoxelRig, base: VoxelModel, pose: Pose, atts: readonly Attachment[],
): PosedVoxel[] {
    const worlds = boneWorlds(rig, pose);
    const offs = tileOffsets(base, atts);
    const out = poseVoxels(rig, base, pose);
    atts.forEach((att, ai) => {
        const bi = rig.bones.findIndex((b) => b.name === att.bone);
        if (bi < 0) throw new Error(`attach: кость «${att.bone}» не найдена в риге`);
        const grid = decodeModel(att.model);
        const tex = texBytes(att.model);
        const world = worlds[bi];
        const rot = att.rot ? eulerMat(att.rot) : null;
        for (let i = 0; i < grid.data.length; i++) {
            const slot = grid.data[i];
            if (slot === 0) continue;
            const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy));
            let p: Vec3 = [x + 0.5, y + 0.5, z + 0.5];
            if (rot) p = affinePoint({ m: rot, t: [0, 0, 0] }, p);
            p = affinePoint(world, addVec(p, att.pos ?? [0, 0, 0]));
            const tile = tex ? tex[i] : 0;
            out.push({
                pos: p,
                color: att.model.palette[String(slot)] ?? '#000000',
                index: i,
                ...(tile ? { tile: tile + offs[ai]! } : {}),
            });
        }
    });
    return out;
}

/** Общий CloudTiles: тайли базы + тайли предметов со сдвинутыми id. */
export function attachTiles(base: VoxelModel, atts: readonly Attachment[]): Record<number, ResolvedTile> {
    const out: Record<number, ResolvedTile> = {};
    for (const [id, t] of Object.entries(resolveTiles(base))) out[Number(id)] = t;
    const offs = tileOffsets(base, atts);
    atts.forEach((att, ai) => {
        for (const [id, t] of Object.entries(resolveTiles(att.model))) {
            out[Number(id) + offs[ai]!] = t;
        }
    });
    return out;
}