/**
* Контроллер персонажа: тело физики + walk-анимация от скорости.
* Фаза клипа растёт пропорционально горизонтальной скорости (шаги не
* «скользят» по земле), в покое замирает; персонаж поворачивается по
* направлению движения. Мир/клип/модель приходят снаружи — контроллер
* только связывает физику и анимацию (чистая композиция, урок №3).
*/
import type { Body } from './body';
import { makeBody, stepBody } from './body';
import type { SolidWorld } from './body';
import type { Vec3 } from '../animation/mat';
import type { AnimClip } from '../animation/clip';
import { samplePose } from '../animation/clip';
import type { VoxelRig, PosedVoxel } from '../animation/rig';
import { poseVoxels } from '../animation/rig';
import type { VoxelModel } from '../models/format';
export interface CharacterOptions {
/** AABB тела [w, h, d]. */
size?: readonly [number, number, number];
/** Скорость ходьбы, вокс/с. */
speed?: number;
/** Путь за полный цикл шагов, вокселей (фаза = путь/stride). */
strideLength?: number;
/** Высота спавна (по умолчанию 2 — грань земли демо; выше — спавн в воздухе). */
spawnY?: number;
}
/** Деформированный воксель позы для рендера (облако движка). */
export type CharacterVoxel = Pick<PosedVoxel, 'pos' | 'color'>;
export class Character {
readonly body: Body;
readonly rig: VoxelRig;
readonly model: VoxelModel;
readonly clip: AnimClip;
readonly speed: number;
readonly strideLength: number;
/** Фаза walk-цикла в циклах (растёт без заворота — гейт читает прирост). */
phase = 0;
/** Текущий поворот вокруг оси Y (рад): куда смотрит модель. */
yaw = 0;
constructor(
rig: VoxelRig,
model: VoxelModel,
clip: AnimClip,
spawn: readonly [number, number],
opts: CharacterOptions = {},
) {
this.rig = rig;
this.model = model;
this.clip = clip;
this.speed = opts.speed ?? 3;
this.strideLength = opts.strideLength ?? 3.2;
// спавн: y=2 — грань земли демо; выше — спавн в воздухе (гейт физики)
this.body = makeBody([spawn[0], opts.spawnY ?? 2, spawn[1]], opts.size ?? [3, 10, 3]);
}
/** Желаемое движение в МИРОВЫХ осях (длина ≤ 1); [0,0] — покой. */
setMove(dir: readonly [number, number]): void {
this.body.vel[0] = dir[0] * this.speed;
this.body.vel[2] = dir[1] * this.speed;
}
/** Один шаг: физика, фаза от пройденного пути, поворот по движению. */
step(world: SolidWorld, dt: number): void {
stepBody(world, this.body, dt);
const speed = Math.hypot(this.body.vel[0], this.body.vel[2]);
if (speed > 1e-6) {
this.phase += (speed * dt) / this.strideLength;
this.yaw = Math.atan2(this.body.vel[0], this.body.vel[2]);
}
}
/** Деформированные воксели позы для рендера (центр модели по XZ тела). */
visual(): CharacterVoxel[] {
const posed = poseVoxels(this.rig, this.model, samplePose(this.clip, this.loopPhase()));
const b = this.body;
const c = Math.cos(this.yaw), s = Math.sin(this.yaw);
const halfW = this.model.size[0] / 2, halfD = this.model.size[2] / 2;
return posed.map((v) => {
const dx = v.pos[0] - halfW, dz = v.pos[2] - halfD;
return {
pos: [
b.pos[0] + dx * c + dz * s,
b.pos[1] + v.pos[1],
b.pos[2] - dx * s + dz * c,
] as Vec3,
color: v.color,
};
});
}
/** Фаза в секундах клипа: циклы → длительность (для семплинга позы). */
loopPhase(): number {
const c = ((this.phase % 1) + 1) % 1;
return c * this.clip.duration;
}
}