Newer
Older
rpg / v2 / packages / engine / src / animation / clip.ts
/**
 * Анимационный клип — ключевые кадры как данные (docs/plan.md, «анимация =
 * данные, не кадры»). Клип = набор ключей {t, кости: rot/pos}; поза в момент
 * t — линейная интерполяция между соседними ключами (по каждой кости и каналу
 * отдельно; канал, отсутствующий в ключе, берётся из ближайшего ключа с ним).
 * Валидация числовая: границы и монотонность ключей, для зацикленных клипов —
 * непрерывность цикла (первый ключ = последнему).
 */
import type { Pose, VoxelRig } from './rig';
import { poseVoxels } from './rig';
import type { VoxelModel } from '../models/format';
import type { Vec3 } from './mat';

/** Кадр одной кости: поворот (рад) и/или сдвиг (воксельных единиц). */
export interface BoneFrame {
    rot?: Vec3;
    pos?: Vec3;
}

/** Ключевой кадр: момент t (сек) + каналы костей по именам. */
export interface ClipKey {
    t: number;
    bones: Record<string, BoneFrame>;
}

export interface AnimClip {
    name: string;
    /** Длительность цикла в секундах (> 0). */
    duration: number;
    /** Зацикленный клип: первый ключ на t=0 обязан совпадать с последним на t=duration. */
    loop: boolean;
    /** Ключи отсортированы по t строго возрастание. */
    keys: ClipKey[];
}

const EPS = 1e-6;

/** Поза в момент t: линейная интерполяция каналов между соседними ключами. */
export function samplePose(clip: AnimClip, t: number): Pose {
    const pose: Record<string, BoneFrame> = {};
    if (clip.keys.length === 0) return pose;
    const time = clip.loop
        ? ((t % clip.duration) + clip.duration) % clip.duration
        : Math.min(clip.duration, Math.max(0, t));
    // соседние ключи: keys[i].t ≤ time < keys[i+1].t (после последнего — последний)
    let i = 0;
    while (i + 1 < clip.keys.length && clip.keys[i + 1].t <= time) i++;
    const a = clip.keys[i], b = clip.keys[Math.min(i + 1, clip.keys.length - 1)];
    const span = b.t - a.t;
    const k = span > EPS ? (time - a.t) / span : 0;
    const names = new Set([...Object.keys(a.bones), ...Object.keys(b.bones)]);
    for (const name of names) {
        const fa = a.bones[name], fb = b.bones[name];
        const rot = lerpVec(fa?.rot, fb?.rot, k);
        const pos = lerpVec(fa?.pos, fb?.pos, k);
        if (rot || pos) pose[name] = { ...(rot && { rot }), ...(pos && { pos }) };
    }
    return pose;
}

/** Линейная интерполяция вектора; отсутствующая сторона — вектор с другой стороны. */
function lerpVec(a: Vec3 | undefined, b: Vec3 | undefined, k: number): Vec3 | undefined {
    if (!a && !b) return undefined;
    const va = a ?? b!, vb = b ?? a!;
    return [
        va[0] + (vb[0] - va[0]) * k,
        va[1] + (vb[1] - va[1]) * k,
        va[2] + (vb[2] - va[2]) * k,
    ];
}

/** Список проблем клипа; пустой — клип корректен. */
export function validateClip(clip: AnimClip): string[] {
    const problems: string[] = [];
    if (clip.duration <= 0) problems.push(`длительность ${clip.duration} ≤ 0`);
    if (clip.keys.length === 0) problems.push('нет ключевых кадров');
    clip.keys.forEach((key, i) => {
        if (key.t < 0 || key.t > clip.duration) problems.push(`ключ ${i}: t=${key.t} вне [0, ${clip.duration}]`);
        if (i > 0 && key.t <= clip.keys[i - 1].t) problems.push(`ключ ${i}: t=${key.t} не больше предыдущего`);
    });
    if (clip.loop && clip.keys.length >= 2) {
        const first = clip.keys[0], last = clip.keys[clip.keys.length - 1];
        if (first.t !== 0) problems.push(`цикл: первый ключ на t=${first.t}, жду 0`);
        if (last.t !== clip.duration) problems.push(`цикл: последний ключ на t=${last.t}, жду ${clip.duration}`);
        for (const name of Object.keys(first.bones)) {
            const fa = first.bones[name], fb = last.bones[name];
            if (!fb) continue; // кость есть только в начале — поза берётся из ближайшего ключа
            for (const ch of ['rot', 'pos'] as const) {
                const va = fa[ch], vb = fb[ch];
                if (!va || !vb) continue;
                if (va.some((v, j) => Math.abs(v - vb[j]) > EPS)) {
                    problems.push(`цикл: кость «${name}» канал ${ch} рвётся на стыке (${va} → ${vb})`);
                }
            }
        }
    }
    return problems;
}

/**
 * Контакт стопы: минимальная мировая Y вокселей кости в фазе опоры.
 * Числовая проверка «y кости ≈ 0 в фазе опоры» (docs/plan.md) — работает
 * на связочной модели, где стопы стоят на y ≈ 0.
 */
export function footMinY(rig: VoxelRig, model: VoxelModel, clip: AnimClip, bone: string, t: number): number {
    const posed = poseVoxels(rig, model, samplePose(clip, t));
    const index = rig.bones.findIndex((b) => b.name === bone);
    const ys = posed.filter((v) => rig.binding[v.index] === index).map((v) => v.pos[1]);
    return ys.length ? Math.min(...ys) : NaN;
}