diablo2-web/tests/actor-animator.test.ts

209 lines
5.7 KiB
TypeScript

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)
})
})