feat(anim): 实现基于 8.8 定点数时钟的实体动画状态机与事件调度器 (ActorAnimator)

This commit is contained in:
troytt 2026-09-21 13:50:23 +00:00
parent 5586c50c8b
commit 3fa29ec0b8
2 changed files with 363 additions and 0 deletions

155
src/game/actor-animator.ts Normal file
View File

@ -0,0 +1,155 @@
/**
* Actor animator: fractional-speed 8.8 fixed-point playback with animation events.
*
* Strictly aligns with Diablo II v1.13c:
* - 25Hz simulation tick base.
* - Animation speed encoded as 8.8 fixed-point (256 = 1.0 frame per tick).
* - Exact fractional duration: cycleTicks = (framesPerDirection * 256) / speed.
* - Frame events (code 1 = ATTACK, code 2 = MISSILE, code 3 = SOUND) triggered in chronological order.
* - Looping clips (NU, WL, RN, TN, TW) wrap seamlessly.
* - One-shot clips (A1, A2, SC, GH, DT) stop at the final frame and assert `finished = true`.
*/
export interface AnimFrameEvent {
readonly frame: number
readonly code: number
}
export interface AnimClipMeta {
readonly group: number
readonly directions?: number
readonly frames: number
readonly speed: number
readonly loop: boolean
readonly events: readonly { readonly f: number; readonly c: number }[]
}
export class ActorAnimator {
private currentMode: string | null = null
private currentClip: AnimClipMeta | null = null
private acc = 0 // 8.8 fixed-point accumulator
private index = 0 // current frame index (0 .. frames - 1)
private isFinished = false
/**
* Current playing mode name (e.g. 'nu', 'wl', 'a1').
*/
get mode(): string | null {
return this.currentMode
}
/**
* Current active clip metadata.
*/
get clip(): AnimClipMeta | null {
return this.currentClip
}
/**
* Current frame index within the active clip.
*/
get frameIndex(): number {
return this.index
}
/**
* Whether a one-shot clip has completed playback and reached the terminal frame.
*/
get finished(): boolean {
return this.isFinished
}
/**
* Play an animation mode with its associated clip metadata.
* By default, switching to the same mode that is already active does NOT restart playback,
* preventing walk cycle stutter on re-press. Pass `opts.restart = true` to force restart.
*/
play(mode: string, clip: AnimClipMeta, opts?: { restart?: boolean }): void {
const normalizedMode = mode.toLowerCase()
if (!opts?.restart && this.currentMode === normalizedMode && this.currentClip === clip && !this.isFinished) {
return
}
this.currentMode = normalizedMode
this.currentClip = clip
this.acc = 0
this.index = 0
this.isFinished = clip.frames <= 1 && !clip.loop
}
/**
* Advance one 25Hz simulation tick.
*
* @param speedScale - Optional speed scale multiplier in 8.8 fixed-point (default 256 = 1.0x).
* @returns Array of frame events crossed during this tick, in chronological order.
*/
tick(speedScale = 256): readonly AnimFrameEvent[] {
const clip = this.currentClip
if (!clip || clip.frames <= 0) {
return []
}
// Already finished one-shot clip remains static at final frame
if (this.isFinished) {
return []
}
// Single frame non-looping clip (e.g. corpse DD) finishes immediately
if (clip.frames <= 1 && !clip.loop) {
this.isFinished = true
return []
}
const effectiveSpeed = Math.round((clip.speed * speedScale) / 256)
if (effectiveSpeed <= 0) {
return []
}
this.acc += effectiveSpeed
const triggeredEvents: AnimFrameEvent[] = []
while (this.acc >= 256) {
this.acc -= 256
const nextIndex = this.index + 1
if (nextIndex >= clip.frames) {
if (clip.loop) {
this.index = 0
// Check for events at loop restart frame 0
this.collectEventsForFrame(clip, 0, triggeredEvents)
} else {
this.index = clip.frames - 1
this.isFinished = true
this.acc = 0
break
}
} else {
this.index = nextIndex
this.collectEventsForFrame(clip, this.index, triggeredEvents)
}
}
return triggeredEvents
}
/**
* Reset the animator to idle state.
*/
reset(): void {
this.currentMode = null
this.currentClip = null
this.acc = 0
this.index = 0
this.isFinished = false
}
private collectEventsForFrame(clip: AnimClipMeta, frame: number, out: AnimFrameEvent[]): void {
if (!clip.events || clip.events.length === 0) return
for (let i = 0; i < clip.events.length; i += 1) {
const e = clip.events[i]!
if (e.f === frame) {
out.push({ frame: e.f, code: e.c })
}
}
}
}

View File

@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import { ActorAnimator, type AnimClipMeta } from '../src/game/actor-animator.ts'
describe('ActorAnimator: 8.8 fixed-point timing & frame events', () => {
it('advances exactly 1 frame per tick at speed 256 (1.0x)', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 0,
frames: 8,
speed: 256,
loop: true,
events: [],
}
animator.play('wl', clip)
expect(animator.frameIndex).toBe(0)
expect(animator.finished).toBe(false)
for (let tick = 1; tick <= 7; tick += 1) {
animator.tick()
expect(animator.frameIndex).toBe(tick)
}
// 8th tick wraps back to frame 0
animator.tick()
expect(animator.frameIndex).toBe(0)
})
it('advances 1 frame every 2 ticks at speed 128 (0.5x, e.g. SONUHTH)', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 16,
frames: 8,
speed: 128,
loop: true,
events: [],
}
animator.play('nu', clip)
expect(animator.frameIndex).toBe(0)
// Tick 1: acc = 128 -> frame 0
animator.tick()
expect(animator.frameIndex).toBe(0)
// Tick 2: acc = 256 -> frame 1
animator.tick()
expect(animator.frameIndex).toBe(1)
// Run remaining ticks to complete full 8-frame cycle (16 ticks total)
for (let t = 3; t <= 16; t += 1) {
animator.tick()
}
expect(animator.frameIndex).toBe(0)
})
it('accurately reproduces fractional pacing (BAWL1HS: 8 frames at speed 168 -> 12.19 ticks)', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 0,
frames: 8,
speed: 168,
loop: true,
events: [],
}
animator.play('wl', clip)
let cycleCompletedAtTick = -1
for (let t = 1; t <= 25; t += 1) {
const prevFrame = animator.frameIndex
animator.tick()
if (prevFrame === 7 && animator.frameIndex === 0) {
cycleCompletedAtTick = t
break
}
}
// 8 * 256 = 2048 accumulator units needed for a cycle.
// At speed 168 per tick:
// 12 ticks * 168 = 2016 (< 2048, still frame 7)
// 13 ticks * 168 = 2184 (>= 2048, wraps to frame 0 on tick 13)
expect(cycleCompletedAtTick).toBe(13)
})
it('dispatches frame events in chronological order on the tick they are crossed', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 48,
frames: 16,
speed: 256,
loop: false,
events: [{ f: 9, c: 1 }], // SOA1HTH: attack event code 1 at frame 9
}
animator.play('a1', clip)
const collectedEvents: { tick: number; frame: number; code: number }[] = []
for (let t = 1; t <= 16; t += 1) {
const events = animator.tick()
for (const e of events) {
collectedEvents.push({ tick: t, frame: e.frame, code: e.code })
}
}
expect(collectedEvents).toEqual([{ tick: 9, frame: 9, code: 1 }])
expect(animator.finished).toBe(true)
expect(animator.frameIndex).toBe(15) // clamps to last frame (15)
})
it('dispatches multiple events in chronological order on fast ticks (speed > 256)', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 0,
frames: 10,
speed: 512, // 2 frames per tick
loop: false,
events: [
{ f: 2, c: 3 }, // sound
{ f: 3, c: 1 }, // attack
],
}
animator.play('a1', clip)
// Tick 1: advances frames 0 -> 1 -> 2 (triggers event at 2)
const events1 = animator.tick()
expect(events1).toEqual([{ frame: 2, code: 3 }])
expect(animator.frameIndex).toBe(2)
// Tick 2: advances frames 2 -> 3 -> 4 (triggers event at 3)
const events2 = animator.tick()
expect(events2).toEqual([{ frame: 3, code: 1 }])
expect(animator.frameIndex).toBe(4)
})
it('stops at final frame and marks finished = true for one-shot clips', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 80,
frames: 5,
speed: 256,
loop: false,
events: [],
}
animator.play('dt', clip)
for (let t = 1; t <= 4; t += 1) {
animator.tick()
expect(animator.finished).toBe(false)
}
// On 5th tick, reaches frame 4 and marks finished
animator.tick()
expect(animator.frameIndex).toBe(4)
expect(animator.finished).toBe(true)
// Further ticks remain at frame 4 and finished = true
animator.tick()
expect(animator.frameIndex).toBe(4)
expect(animator.finished).toBe(true)
})
it('marks single-frame one-shot clip (e.g. DD corpse) as finished immediately', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 96,
frames: 1,
speed: 256,
loop: false,
events: [],
}
animator.play('dd', clip)
expect(animator.frameIndex).toBe(0)
expect(animator.finished).toBe(true)
const events = animator.tick()
expect(events).toEqual([])
expect(animator.frameIndex).toBe(0)
expect(animator.finished).toBe(true)
})
it('preserves playback state when re-requesting the same mode without restart flag', () => {
const animator = new ActorAnimator()
const clip: AnimClipMeta = {
group: 0,
frames: 8,
speed: 256,
loop: true,
events: [],
}
animator.play('wl', clip)
animator.tick()
animator.tick()
animator.tick()
expect(animator.frameIndex).toBe(3)
// Re-play same clip without restart -> no jitter / reset
animator.play('wl', clip)
expect(animator.frameIndex).toBe(3)
// Re-play with restart -> resets to 0
animator.play('wl', clip, { restart: true })
expect(animator.frameIndex).toBe(0)
})
})