anim-state.js 4.86 KB
// 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<string, {clipId:string,elapsed:number,prevClipId:(string|null),prevElapsed:number,mixDuration:number,mixElapsed:number,fallbackId:(string|null)}>} */
    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<string, import('./types.js').BoneOffset>}
   */
  sampleBones() {
    /** @type {Record<string, import('./types.js').BoneOffset>} */
    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<string, import('./types.js').BoneOffset>} */
          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
  }
}