diablo2-web/tests/object-animation-113c.test.ts

247 lines
8.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import {
computeObjectPhaseOffsetMs,
updateAnimatedObject,
updateAnimatedObjects,
type ObjectDrawable,
type AnimatedObjectFrame,
} from '../src/scene/act-scene.ts'
describe('Diablo II 1.13c Map Object Animations (Issue #387)', () => {
describe('Spatial phase offset (SplitMix64 anti-resonance)', () => {
it('returns 0 when totalDurationMs is 0 or negative', () => {
expect(computeObjectPhaseOffsetMs(100, 200, 0)).toBe(0)
expect(computeObjectPhaseOffsetMs(100, 200, -10)).toBe(0)
})
it('is strictly deterministic for identical coordinates', () => {
const p1 = computeObjectPhaseOffsetMs(152, 364, 1024)
const p2 = computeObjectPhaseOffsetMs(152, 364, 1024)
expect(p1).toBe(p2)
})
it('produces diverse phase offsets for different torch locations', () => {
const torchCoords = [
{ x: 100, y: 200 },
{ x: 105, y: 200 },
{ x: 120, y: 250 },
{ x: 300, y: 400 },
{ x: 310, y: 400 },
{ x: 500, y: 600 },
]
const totalDuration = 20 * 51.2 // 1024ms for 20-frame torch
const phases = torchCoords.map(c => computeObjectPhaseOffsetMs(c.x, c.y, totalDuration))
// All phases must be within [0, totalDuration)
for (const phase of phases) {
expect(phase).toBeGreaterThanOrEqual(0)
expect(phase).toBeLessThan(totalDuration)
}
// Check entropy: no two distinct torch coordinates should have the exact same phase
const uniquePhases = new Set(phases)
expect(uniquePhases.size).toBe(torchCoords.length)
})
})
describe('Runtime frame ticking (updateAnimatedObject)', () => {
const dummyAtlasFrame = (i: number) => ({
x: i * 32,
y: 0,
width: 32,
height: 64,
})
const createTorchDrawable = (phaseOffsetMs = 0): ObjectDrawable => {
const frameCount = 20
const frameDurationMs = 51.2
const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({
frame: dummyAtlasFrame(i),
page: 0,
offsetX: -16,
offsetY: -32 + (i % 2), // slightly fluctuating DC6/DCC offset
}))
return {
frame: frames[0]!.frame,
x: 100 + frames[0]!.offsetX,
y: 200 + frames[0]!.offsetY,
depth: 60,
page: 0,
baseX: 100,
baseY: 200,
animatedFrames: frames,
frameDurationMs,
cycleAnim: true,
phaseOffsetMs,
currentFrameIndex: 0,
}
}
it('ignores static or single-frame objects', () => {
const staticObj: ObjectDrawable = {
frame: dummyAtlasFrame(0),
x: 100,
y: 200,
depth: 60,
page: 0,
}
expect(updateAnimatedObject(staticObj, 1000)).toBe(false)
expect(staticObj.currentFrameIndex).toBeUndefined()
})
it('advances frames sequentially with time', () => {
const torch = createTorchDrawable(0)
// At t=0ms, frame should be 0
expect(updateAnimatedObject(torch, 0)).toBe(false)
expect(torch.currentFrameIndex).toBe(0)
// At t=60ms (> 51.2ms), frame should be 1
expect(updateAnimatedObject(torch, 60)).toBe(true)
expect(torch.currentFrameIndex).toBe(1)
expect(torch.frame.x).toBe(32)
expect(torch.x).toBe(100 - 16)
expect(torch.y).toBe(200 - 32 + 1)
// At t=110ms (> 2 * 51.2 = 102.4ms), frame should be 2
expect(updateAnimatedObject(torch, 110)).toBe(true)
expect(torch.currentFrameIndex).toBe(2)
expect(torch.frame.x).toBe(64)
// Advancing to the same time interval does not re-trigger update
expect(updateAnimatedObject(torch, 115)).toBe(false)
})
it('wraps smoothly in a loop when cycleAnim is true', () => {
const torch = createTorchDrawable(0)
const cycleDuration = 20 * 51.2 // 1024 ms
// Exactly at one full cycle (1024ms), it wraps back to frame 0
updateAnimatedObject(torch, 1024 + 10)
expect(torch.currentFrameIndex).toBe(0)
// Halfway through next cycle: frame 10
updateAnimatedObject(torch, 1024 + 520)
expect(torch.currentFrameIndex).toBe(10)
})
it('clamps to the final frame when cycleAnim is false', () => {
const nonLoopingObj = createTorchDrawable(0)
// Modify to non-cycling
;(nonLoopingObj as any).cycleAnim = false
// Beyond end of cycle (2000 ms > 1024 ms)
updateAnimatedObject(nonLoopingObj, 2000)
expect(nonLoopingObj.currentFrameIndex).toBe(19) // clamped to last frame (index 19)
})
it('respects spatial phase offset', () => {
// Torch A at phase 0, Torch B at phase 256ms (~5 frames ahead)
const torchA = createTorchDrawable(0)
const torchB = createTorchDrawable(256)
updateAnimatedObject(torchA, 0)
updateAnimatedObject(torchB, 0)
expect(torchA.currentFrameIndex).toBe(0)
expect(torchB.currentFrameIndex).toBe(5) // floor(256 / 51.2) = 5
})
it('updates array of objects via updateAnimatedObjects', () => {
const list = [
createTorchDrawable(0),
createTorchDrawable(100),
createTorchDrawable(200),
]
const changedCount = updateAnimatedObjects(list, 60)
expect(changedCount).toBeGreaterThan(0)
for (const obj of list) {
expect(obj.currentFrameIndex).toBeDefined()
}
})
})
describe('Ground truth 1.13c baked pack validation', () => {
const sceneJsonPath = join(process.cwd(), 'samples/d2-packs/act1/1-act-1-town-towne1/scene.json')
it('has baked animatedFrames and 1.13c timing in towne1 pack', () => {
if (!existsSync(sceneJsonPath)) {
console.warn(`Skipping pack validation test: ${sceneJsonPath} does not exist`)
return
}
const scene = JSON.parse(readFileSync(sceneJsonPath, 'utf8'))
expect(scene.objects).toBeDefined()
expect(Array.isArray(scene.objects)).toBe(true)
// Check Campfire (RB)
const campfires = scene.objects.filter((o: any) => o.token === 'RB')
expect(campfires.length).toBeGreaterThan(0)
for (const fire of campfires) {
expect(fire.mode).toBe('ON')
expect(fire.animatedFrames).toBeDefined()
expect(fire.animatedFrames.length).toBe(20)
expect(fire.frameDelta).toBe(256) // 25 FPS
expect(fire.frameDurationMs).toBe(40)
expect(fire.cycleAnim).toBe(true)
expect(fire.sync).toBe(false)
}
// Check Torches (TO)
const torches = scene.objects.filter((o: any) => o.token === 'TO')
expect(torches.length).toBeGreaterThan(0)
for (const torch of torches) {
expect(torch.mode).toBe('ON')
expect(torch.animatedFrames).toBeDefined()
expect(torch.animatedFrames.length).toBe(20)
expect(torch.frameDelta).toBe(200) // ~19.5 FPS
expect(torch.frameDurationMs).toBe(51.2)
expect(torch.cycleAnim).toBe(true)
expect(torch.sync).toBe(false)
}
// Check Flags (N1, N2)
const flagsN1 = scene.objects.filter((o: any) => o.token === 'N1')
const flagsN2 = scene.objects.filter((o: any) => o.token === 'N2')
expect(flagsN1.length).toBeGreaterThan(0)
expect(flagsN2.length).toBeGreaterThan(0)
for (const flag of [...flagsN1, ...flagsN2]) {
expect(flag.mode).toBe('NU')
expect(flag.animatedFrames).toBeDefined()
expect(flag.animatedFrames.length).toBe(10)
expect(flag.frameDelta).toBe(128) // 12.5 FPS
expect(flag.frameDurationMs).toBe(80)
expect(flag.cycleAnim).toBe(true)
expect(flag.sync).toBe(false)
}
// Check Waypoint (WP)
const waypoints = scene.objects.filter((o: any) => o.token === 'WP')
expect(waypoints.length).toBeGreaterThan(0)
for (const wp of waypoints) {
expect(wp.mode).toBe('ON')
expect(wp.animatedFrames).toBeDefined()
expect(wp.animatedFrames.length).toBe(15)
expect(wp.frameDelta).toBe(200)
expect(wp.cycleAnim).toBe(true)
expect(wp.sync).toBe(true)
}
// Check Stash (B6)
const stashes = scene.objects.filter((o: any) => o.token === 'B6')
expect(stashes.length).toBeGreaterThan(0)
for (const stash of stashes) {
expect(stash.token).toBe('B6')
// Static object has no animatedFrames
expect(stash.animatedFrames).toBeUndefined()
// Single frame is preserved
expect(stash.frame).toBeDefined()
}
})
})
})