/**
* Процедурная локомоция (docs/plan.md): walk-цикл генерируется параметрами,
* а не авторится кадрами. Два шага за цикл: левая и правая нога в противофазе
* (sin θ и sin(θ+π)), руки — в противофазе одноимённой ноге, корень приседает
* дважды за цикл (в фазах полного шага) и качается вбок один раз.
* Требования к костям: «legL»/«legR» (pitch вокруг X), «armL»/«armR», «root».
* Отсутствующие кости просто не получают каналов — семплинг это допускает.
*/
import type { AnimClip, ClipKey } from './clip';
export interface WalkParams {
/** Длительность цикла (сек), > 0. */
duration: number;
/** Амплитуда ног (рад, ± вокруг нуля). */
legSwing: number;
/** Амплитуда рук (рад, ± вокруг нуля). */
armSwing: number;
/** Приседание корня в фазах полного шага (воксельных единиц, ≥ 0). */
bob: number;
/** Боковое качание корня (воксельных единиц; 0 — выключено). */
sway?: number;
}
const KEY_STEPS = 8; // ключей на цикл + 1 (замыкающий) — линейный семплинг гладкости не даёт, но дёшев
/** Walk-цикл из параметров; детерминирован, проходит validateClip (цикл замкнут). */
export function walkClip(params: WalkParams, name = 'walk'): AnimClip {
const keys: ClipKey[] = [];
for (let i = 0; i <= KEY_STEPS; i++) {
const t = (params.duration * i) / KEY_STEPS;
const th = (2 * Math.PI * i) / KEY_STEPS; // фаза цикла
const legL = params.legSwing * Math.sin(th);
const armL = -params.armSwing * Math.sin(th); // рука против одноимённой ноги
const bobY = -params.bob * Math.abs(Math.sin(th)); // приседание дважды за цикл
const swayX = (params.sway ?? 0) * Math.sin(th);
keys.push({
t,
bones: {
root: { pos: [swayX, bobY, 0] },
legL: { rot: [legL, 0, 0] },
legR: { rot: [-legL, 0, 0] },
armL: { rot: [armL, 0, 0] },
armR: { rot: [-armL, 0, 0] },
},
});
}
return { name, duration: params.duration, loop: true, keys };
}