247 lines
8.4 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|
|
})
|