209 lines
5.7 KiB
TypeScript
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)
|
|
})
|
|
})
|