Newer
Older
rpg / v2 / apps / demo / src / anim.ts
/**
 * Аним-лист: 8 кадров walk-цикла манекена (4×2) на подставках.
 * Превью-лист кадров анимации (docs/plan.md, трек 6): клип процедурный
 * (walkClip), позы считаются samplePose→poseVoxels, рисуются instanced-облаком.
 * Мост отдаёт геометрию листа и числовые снапшоты поз — гейт проверяет
 * и картинку, и данные (контакт стопы, противофазы, замкнутость цикла).
 */
import {
    VoxelRenderer, VoxelGrid, meshVoxels, mannequin, walkClip, samplePose, poseVoxels,
    validateClip, footMinY,
} from '@rpg/engine';
import type { VoxelPalette, VoxelCloud } from '@rpg/engine';

// Палитра арт-библии — та же, что в демке (слоты → hex)
const PALETTE: VoxelPalette = {
    colors: {
        1: '#4a5340', 2: '#343b2c', 3: '#626c4c', 4: '#5c3b2e', 5: '#7d8662',
        6: '#5b5b66', 7: '#94949e', 8: '#82503a', 9: '#d99a32', 10: '#44454e',
    },
};

const COLS = 4;        // кадры 0..3 по оси X
const ROWS = 2;        // кадры 4..7 по оси Z
const FRAMES = 8;
const PITCH = 9;

// walk-цикл манекена: параметры локомоции (агент правит их текстом через клип)
const DURATION = 0.8;
const clip = walkClip({ duration: DURATION, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 });
const { model, rig } = mannequin();

const sheetW = COLS * PITCH;
const sheetD = ROWS * PITCH;
const target: [number, number, number] = [sheetW / 2, 2, sheetD / 2];
const VIEW_SIZE = 22; // лист 36×18 + рост манекена — с запасом

const canvas = document.getElementById('scene') as HTMLCanvasElement;
const renderer = new VoxelRenderer(canvas, { width: 480, height: 360, target, viewSize: VIEW_SIZE });

/** Центр ячейки (целый: воксельные координаты должны быть целыми). */
function cellCenter(col: number, row: number): [number, number] {
    return [col * PITCH + Math.floor(PITCH / 2), row * PITCH + Math.floor(PITCH / 2)];
}

/** Подставки ячеек: одна сетка на весь лист, плита 8×8×2 в центре каждой. */
function buildPads(): VoxelGrid {
    const grid = new VoxelGrid(sheetW, 2, sheetD);
    for (let col = 0; col < COLS; col++)
        for (let row = 0; row < ROWS; row++) {
            const [cx, cz] = cellCenter(col, row);
            for (let dx = -4; dx < 4; dx++)
                for (let dz = -4; dz < 4; dz++) {
                    grid.set(cx + dx, 0, cz + dz, 2);
                    const patch = ((cx + dx) * 7 + (cz + dz) * 13) % 11 < 3;
                    grid.set(cx + dx, 1, cz + dz, patch ? 3 : 1);
                }
        }
    return grid;
}
renderer.addMesh(meshVoxels(buildPads(), PALETTE));

// Кадры цикла: поза i → instanced-облако в ячейке (row = i / COLS)
const clouds: VoxelCloud[] = [];
for (let i = 0; i < FRAMES; i++) {
    const col = i % COLS, row = Math.floor(i / COLS);
    const [cx, cz] = cellCenter(col, row);
    const pose = samplePose(clip, (DURATION * i) / FRAMES);
    const posed = poseVoxels(rig, model, pose).map((v) => ({
        // манекен 5×11×3 — центр по XZ ячейки, на подставке (y=2)
        pos: [v.pos[0] + cx - model.size[0] / 2, v.pos[1] + 2, v.pos[2] + cz - model.size[2] / 2] as [number, number, number],
        color: v.color,
    }));
    clouds.push(renderer.addVoxelCloud(posed));
}

function stats(): { clouds: number; voxels: number } {
    return { clouds: clouds.length, voxels: clouds.reduce((n, c) => n + c.mesh.count, 0) };
}

/** Геометрия листа для расчёта ячеек в гейте (та же проекция, что у камеры). */
function sheetInfo() {
    return {
        cols: COLS, rows: ROWS, pitch: PITCH, frames: FRAMES, duration: DURATION,
        target, viewSize: VIEW_SIZE, width: 480, height: 360, scale: 2,
    };
}

/** Числовый снапшот позы кадра i: углы, качание корня, контакт стоп. */
function poseStats(i: number) {
    const t = (DURATION * i) / FRAMES;
    const pose = samplePose(clip, t);
    return {
        t,
        legL: pose.legL?.rot?.[0] ?? 0,
        legR: pose.legR?.rot?.[0] ?? 0,
        armL: pose.armL?.rot?.[0] ?? 0,
        armR: pose.armR?.rot?.[0] ?? 0,
        rootPos: pose.root?.pos ?? [0, 0, 0],
        footMinL: footMinY(rig, model, clip, 'legL', t),
        footMinR: footMinY(rig, model, clip, 'legR', t),
    };
}

const agent = {
    stats,
    sheetInfo,
    poseStats,
    /** Числовая валидация клипа (замкнутость цикла) — через мост, как валидация агентом. */
    clipCheck(): string[] {
        return validateClip(clip);
    },
    /** Скрыть/показать кадры (подставки остаются) — референсный кадр гейта. */
    setModels(on: boolean): void {
        for (const c of clouds) c.mesh.visible = on;
    },
};
(window as unknown as Record<string, unknown>).__agent = agent;

function frame(): void {
    renderer.render();
    requestAnimationFrame(frame);
}
frame();
(window as unknown as Record<string, unknown>).__beacon = true;