// anim-state.js —— 每角色的多轨道动画状态机。移植自原型 evaluator.js 的 AnimationState。 // // 核心算法(「走路 + 说话 + 指人」并行的根): // · 每角色维护 base / lower_body / arm_l / arm_r / head 五条轨道,各自有当前 clip // 与播放进度; // · 轨道切换做交叉淡入(mix)——在过渡窗口内把旧 clip 与新 clip 按权重混合; // · oneshot 播完自动回落 fallback; // · sampleBones() 逐轨道采样,再用「后写覆盖」叠成每根 bone 的局部增量—— // 每条轨道**只写它拥有的 bone 子集**(TRACK_BONES),所以三条轨道互不串扰。 // // 依赖注入:clip 库(getClip / TRACKS / TRACK_BONES / TRACK_DEFAULTS)从构造参数传入, // 默认取 @drama/rig。这样 core 既不在包内顶层 import 死数据、又能在 Node 喂假 clip 库 // 做确定性单测。 import { sampleClip, blendOffset } from './sampling.js' import { PROP_DEFAULT } from './matrix.js' /** @typedef {import('./types.js').ClipLib} ClipLib */ export class AnimationState { /** * @param {import('./types.js').ClipLib} clipLib 注入的 clip 库(@drama/rig 或测试假库)。 */ constructor(clipLib) { /** @type {import('./types.js').ClipLib} */ this.lib = clipLib /** @type {Record} */ this.tracks = {} for (const name of clipLib.TRACKS) { this.tracks[name] = { clipId: clipLib.TRACK_DEFAULTS[name], elapsed: 0, // 交叉淡入:从 prevClipId 过渡到 clipId prevClipId: null, prevElapsed: 0, mixDuration: 0, mixElapsed: 0, // oneshot 播完后回落的目标 fallbackId: null, } } } /** * 切换某轨道动作。 * @param {string} trackName * @param {string} clipId * @param {{mix?:number,fallback?:(string|null)}=} opts mix=交叉淡入时长(ms),fallback=oneshot 回落目标。 */ setAnimation(trackName, clipId, { mix = 160, fallback = null } = {}) { const tr = this.tracks[trackName] if (!tr || !this.lib.getClip(clipId)) return if (tr.clipId === clipId && !tr.prevClipId) return // 已在播且无过渡,不打断 tr.prevClipId = tr.clipId tr.prevElapsed = tr.elapsed tr.mixDuration = mix tr.mixElapsed = 0 tr.clipId = clipId tr.elapsed = 0 tr.fallbackId = fallback } /** 复位到各轨道默认 clip(打断 / 退场时用)。 */ reset() { for (const name of this.lib.TRACKS) { this.setAnimation(name, this.lib.TRACK_DEFAULTS[name], { mix: 120 }) } } /** * 推进时间,处理 mix 结束与 oneshot 回落。 * @param {number} dtMs */ update(dtMs) { for (const name of this.lib.TRACKS) { const tr = this.tracks[name] tr.elapsed += dtMs if (tr.prevClipId) { tr.prevElapsed += dtMs tr.mixElapsed += dtMs if (tr.mixElapsed >= tr.mixDuration) { tr.prevClipId = null // 过渡结束 } } // oneshot 播完 → 回落 const clip = this.lib.getClip(tr.clipId) if (clip && clip.kind === 'oneshot' && tr.elapsed >= clip.duration && tr.fallbackId) { const fb = tr.fallbackId tr.fallbackId = null this.setAnimation(name, fb, { mix: 160 }) } } } /** * 采样所有轨道 → 每根 bone 的最终局部增量(后写覆盖叠加结果)。 * @returns {Record} */ sampleBones() { /** @type {Record} */ const offsets = {} for (const name of this.lib.TRACKS) { const tr = this.tracks[name] const owned = this.lib.TRACK_BONES[name] const clip = this.lib.getClip(tr.clipId) if (!clip) continue let sampled = sampleClip(clip, tr.elapsed) // 交叉淡入:与上一个 clip 混合 if (tr.prevClipId) { const prevClip = this.lib.getClip(tr.prevClipId) if (prevClip) { const prevSampled = sampleClip(prevClip, tr.prevElapsed) const w = tr.mixDuration > 0 ? Math.min(tr.mixElapsed / tr.mixDuration, 1) : 1 /** @type {Record} */ const merged = {} const bones = new Set([...Object.keys(sampled), ...Object.keys(prevSampled)]) for (const b of bones) { const cur = sampled[b] || { ...PROP_DEFAULT } const prv = prevSampled[b] || { ...PROP_DEFAULT } merged[b] = blendOffset(prv, cur, w) } sampled = merged } } // 后写覆盖:只写该轨道拥有的 bone for (const boneName of owned) { if (sampled[boneName]) offsets[boneName] = sampled[boneName] } } return offsets } }