feat(anim): 实现基于 8.8 定点数时钟的实体动画状态机与事件调度器 (ActorAnimator)
This commit is contained in:
parent
5586c50c8b
commit
3fa29ec0b8
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue