diablo2-web/tests/dungeon-wilderness-animatio...

369 lines
14 KiB
TypeScript

import { describe, it, expect, beforeAll } from 'vitest'
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
computeObjectPhaseOffsetMs,
updateAnimatedObject,
updateAnimatedObjects,
PERSISTENT_ILLUMINATION_TOKENS,
type ObjectDrawable,
type AnimatedObjectFrame,
} from '../src/scene/act-scene.ts'
import {
updateAnimatableTiles,
type AnimatableTile,
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
} from '../src/game/animated-tiles.ts'
describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #398)', () => {
describe('PERSISTENT_ILLUMINATION_TOKENS definitions', () => {
it('includes all canonical illumination and environmental fire tokens', () => {
const requiredTokens = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL']
for (const token of requiredTokens) {
expect(PERSISTENT_ILLUMINATION_TOKENS.has(token)).toBe(true)
}
})
})
describe('Persistent illumination looping parity (updateAnimatedObject)', () => {
const dummyAtlasFrame = (i: number) => ({
x: i * 32,
y: 0,
width: 32,
height: 64,
})
const createTestIlluminationDrawable = (
token: string,
frameCount = 10,
frameDurationMs = 80,
forceCycleAnim?: boolean,
): ObjectDrawable => {
const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({
frame: dummyAtlasFrame(i),
page: 0,
offsetX: -16,
offsetY: -32,
}))
return {
token,
frame: frames[0]!.frame,
x: 100,
y: 200,
depth: 60,
page: 0,
baseX: 100,
baseY: 200,
animatedFrames: frames,
frameDurationMs,
cycleAnim: forceCycleAnim,
phaseOffsetMs: 0,
currentFrameIndex: 0,
}
}
it('cycles continuously even if cycleAnim was false for persistent illumination tokens', () => {
// Tokens like FX, FY, FZ (environmental fires) or BR (braziers) might have cycleAnim=0 in Objects.txt mode 0
const tokensToTest = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL']
for (const token of tokensToTest) {
const obj = createTestIlluminationDrawable(token, 10, 80, false)
const totalDuration = 10 * 80 // 800ms
// At t=0
updateAnimatedObject(obj, 0)
expect(obj.currentFrameIndex).toBe(0)
// At t=160ms (frame 2)
updateAnimatedObject(obj, 160)
expect(obj.currentFrameIndex).toBe(2)
// Beyond cycle: at t=800ms, should loop back to frame 0 (NOT clamped to frame 9)
updateAnimatedObject(obj, 800)
expect(obj.currentFrameIndex).toBe(0)
// At t=1040ms (800 + 3 * 80), should be frame 3
updateAnimatedObject(obj, 1040)
expect(obj.currentFrameIndex).toBe(3)
}
})
it('clamps non-persistent objects when cycleAnim is false', () => {
const nonFireObj = createTestIlluminationDrawable('XX', 10, 80, false)
// At t=1000ms (> 800ms total duration)
updateAnimatedObject(nonFireObj, 1000)
expect(nonFireObj.currentFrameIndex).toBe(9) // clamped to last frame
})
it('maintains independent spatial phase offsets across torches and candles', () => {
const duration = 20 * 51.2
const p1 = computeObjectPhaseOffsetMs(100, 200, duration)
const p2 = computeObjectPhaseOffsetMs(104, 208, duration)
expect(p1).not.toBe(p2)
expect(p1).toBeGreaterThanOrEqual(0)
expect(p1).toBeLessThan(duration)
})
it('prevents sprite coordinate drift across frame transitions and loops', () => {
const frames: AnimatedObjectFrame[] = [
{ frame: dummyAtlasFrame(0), page: 0, offsetX: -10, offsetY: -20 },
{ frame: dummyAtlasFrame(1), page: 0, offsetX: -15, offsetY: -25 },
{ frame: dummyAtlasFrame(2), page: 0, offsetX: -8, offsetY: -18 },
]
const obj: ObjectDrawable = {
token: 'TO',
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: 100,
cycleAnim: true,
currentFrameIndex: 0,
}
for (let clock = 0; clock <= 3000; clock += 50) {
updateAnimatedObject(obj, clock)
const expectedFrameIdx = Math.floor(clock / 100) % 3
expect(obj.currentFrameIndex).toBe(expectedFrameIdx)
const expectedFrame = frames[expectedFrameIdx]!
expect(obj.x).toBe(100 + expectedFrame.offsetX)
expect(obj.y).toBe(200 + expectedFrame.offsetY)
}
})
it('safely handles degenerate frame inputs (empty, single frame, zero/negative duration)', () => {
const singleFrameObj: ObjectDrawable = {
token: 'TO',
frame: dummyAtlasFrame(0),
x: 100,
y: 200,
depth: 60,
page: 0,
baseX: 100,
baseY: 200,
animatedFrames: [{ frame: dummyAtlasFrame(0), page: 0, offsetX: 0, offsetY: 0 }],
frameDurationMs: 80,
currentFrameIndex: 0,
}
expect(updateAnimatedObject(singleFrameObj, 1000)).toBe(false)
expect(singleFrameObj.currentFrameIndex).toBe(0)
const zeroDurationObj = createTestIlluminationDrawable('TO', 5, 0, true)
expect(updateAnimatedObject(zeroDurationObj, 120)).toBe(true)
expect(zeroDurationObj.currentFrameIndex).toBe(3)
const negClockObj = createTestIlluminationDrawable('TO', 5, 40, true)
expect(updateAnimatedObject(negClockObj, -100)).toBe(false)
expect(negClockObj.currentFrameIndex).toBe(0)
})
})
describe('DT1 animated tile serialization and unpacking parity', () => {
it('correctly unpacks extended animated tile rows [frameIndex, x, y, cx, cy, ...animFrames]', () => {
// Simulate scene frames
const dummyFrames = [
{ x: 0, y: 0, width: 160, height: 80 },
{ x: 160, y: 0, width: 160, height: 80 },
{ x: 320, y: 0, width: 160, height: 80 },
{ x: 480, y: 0, width: 160, height: 80 },
]
const framePlacement = [
[0, 0, 0, 160, 80],
[0, 160, 0, 160, 80],
[0, 320, 0, 160, 80],
[0, 480, 0, 160, 80],
]
// Row with 4 animated frames: indices 0, 1, 2, 3
const row = [0, 100, 200, 5, 10, 0, 1, 2, 3]
const frameIndex = row[0] ?? 0
const animFrames = row.length > 5 ? row.slice(5) : undefined
const anim = animFrames && animFrames.length > 1
? animFrames.map(fIdx => ({
frame: dummyFrames[fIdx]!,
page: framePlacement[fIdx]?.[0] ?? 0,
}))
: undefined
const tile: AnimatableTile = {
frame: dummyFrames[frameIndex]!,
page: framePlacement[frameIndex]?.[0] ?? 0,
animatedFrames: anim,
currentFrameIndex: 0,
}
expect(tile.animatedFrames).toBeDefined()
expect(tile.animatedFrames!.length).toBe(4)
// Verify updateAnimatableTiles advances frames
const tiles = [tile]
// At t=0ms
updateAnimatableTiles(tiles, 0, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS)
expect(tile.currentFrameIndex).toBe(0)
// At t=150ms (> 128ms)
updateAnimatableTiles(tiles, 150, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS)
expect(tile.currentFrameIndex).toBe(1)
expect(tile.frame).toEqual(dummyFrames[1])
// At t=550ms (> 4 * 128 = 512ms, looped to frame 0)
updateAnimatableTiles(tiles, 550, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS)
expect(tile.currentFrameIndex).toBe(0)
expect(tile.frame).toEqual(dummyFrames[0])
})
})
describe('Baked Map Scene Inspection (Wilderness & Dungeons)', () => {
const packsDir = join(process.cwd(), 'samples/d2-packs')
const hasPacks = existsSync(join(packsDir, 'index.json')) && existsSync(join(packsDir, 'act1'))
beforeAll(() => {
if (!hasPacks) {
throw new Error(
'[Preflight] Missing pre-baked map pack assets at samples/d2-packs/. ' +
'When running in a git worktree, symlink samples/d2-packs from the main repository:\n' +
' ln -s <main-repo>/samples/d2-packs samples/d2-packs\n' +
'Or generate them with: npm run pack:data'
)
}
})
it('verifies town scene (towne1) preserves all animated objects', () => {
const townPath = join(packsDir, 'act1/1-act-1-town-towne1/scene.json')
expect(existsSync(townPath)).toBe(true)
const scene = JSON.parse(readFileSync(townPath, 'utf8'))
const objects = scene.objects as any[]
// Campfire (RB)
const rbs = objects.filter(o => o.token === 'RB')
expect(rbs.length).toBeGreaterThan(0)
for (const rb of rbs) {
expect(rb.animatedFrames?.length).toBe(20)
expect(rb.cycleAnim).toBe(true)
}
// Torches (TO)
const tos = objects.filter(o => o.token === 'TO')
expect(tos.length).toBeGreaterThan(0)
for (const to of tos) {
expect(to.animatedFrames?.length).toBe(20)
expect(to.cycleAnim).toBe(true)
}
})
it('verifies Cave (Level 10) packs have animated illumination objects', () => {
const act1Dir = join(packsDir, 'act1')
expect(existsSync(act1Dir)).toBe(true)
const caveDirs = readdirSync(act1Dir).filter(name => name.startsWith('10-act-1-cave'))
expect(caveDirs.length).toBeGreaterThan(0)
for (const d of caveDirs) {
const scenePath = join(act1Dir, d, 'scene.json')
expect(existsSync(scenePath)).toBe(true)
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
const illumination = (scene.objects as any[]).filter(o =>
o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase())
)
expect(illumination.length).toBeGreaterThan(0)
for (const item of illumination) {
if (item.animatedFrames) {
expect(item.animatedFrames.length).toBeGreaterThan(1)
expect(item.cycleAnim).toBe(true)
expect(item.frameDelta).toBeGreaterThan(0)
}
}
}
})
it('verifies Catacombs (Level 34) packs have animated candles (A1, A2) and fires (BF, BR)', () => {
const act1Dir = join(packsDir, 'act1')
expect(existsSync(act1Dir)).toBe(true)
const catacombsDirs = readdirSync(act1Dir).filter(name => name.startsWith('34-act-1-catacombs'))
expect(catacombsDirs.length).toBeGreaterThan(0)
let totalCandlesOrFires = 0
for (const d of catacombsDirs) {
const scenePath = join(act1Dir, d, 'scene.json')
expect(existsSync(scenePath)).toBe(true)
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
const items = (scene.objects as any[]).filter(o =>
o.token && ['A1', 'A2', 'BF', 'BR', 'TO'].includes(o.token.trim().toUpperCase())
)
for (const item of items) {
totalCandlesOrFires += 1
if (item.animatedFrames) {
expect(item.animatedFrames.length).toBeGreaterThan(1)
expect(item.cycleAnim).toBe(true)
expect(item.frameDurationMs).toBeGreaterThan(0)
}
}
}
expect(totalCandlesOrFires).toBeGreaterThan(0)
})
it('verifies Wilderness and Crypt maps contain cycleAnim=true on illumination objects', () => {
const act1Dir = join(packsDir, 'act1')
expect(existsSync(act1Dir)).toBe(true)
const cryptDirs = readdirSync(act1Dir).filter(name => name.startsWith('18-act-1-crypt') || name.startsWith('19-act-1-crypt'))
expect(cryptDirs.length).toBeGreaterThan(0)
for (const d of cryptDirs) {
const scenePath = join(act1Dir, d, 'scene.json')
expect(existsSync(scenePath)).toBe(true)
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
const items = (scene.objects as any[]).filter(o =>
o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase())
)
for (const item of items) {
if (item.animatedFrames) {
expect(item.cycleAnim).toBe(true)
}
}
}
})
it('verifies DT1 animated tile sequences in scenes that contain animated tiles', () => {
const act4Dir = join(packsDir, 'act4')
expect(existsSync(act4Dir)).toBe(true)
// River of flame (Level 107) and Chaos Sanctuary (Level 108) have animated lava tiles
const chaosDirs = readdirSync(act4Dir).filter(name => name.startsWith('108-act-4-diablo') || name.startsWith('107-act-4-lava'))
expect(chaosDirs.length).toBeGreaterThan(0)
let totalAnimatedTiles = 0
for (const d of chaosDirs) {
const scenePath = join(act4Dir, d, 'scene.json')
expect(existsSync(scenePath)).toBe(true)
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
const draws = [
...(scene.floors ?? []),
...(scene.walls ?? []),
...(scene.shadows ?? []),
...(scene.roofs ?? []),
]
const animatedTileDraws = draws.filter((row: number[]) => row.length > 5)
totalAnimatedTiles += animatedTileDraws.length
if (animatedTileDraws.length > 0) {
for (const row of animatedTileDraws) {
const animFrames = row.slice(5)
expect(animFrames.length).toBeGreaterThan(1)
// Each frame index should be valid in scene.frames
for (const fIdx of animFrames) {
expect(fIdx).toBeGreaterThanOrEqual(0)
expect(fIdx).toBeLessThan(scene.frames.length)
}
}
}
}
expect(totalAnimatedTiles).toBeGreaterThan(0)
})
})
})