fix(render): animated torches, candles, environmental fires, and DT1 tile sequences (Fixes #398)
This commit is contained in:
parent
c3bb79e403
commit
ce6fdf131e
File diff suppressed because it is too large
Load Diff
|
|
@ -472,21 +472,35 @@ export interface AnimatedObjectFrame {
|
||||||
|
|
||||||
/** One object sprite to draw. */
|
/** One object sprite to draw. */
|
||||||
export interface ObjectDrawable {
|
export interface ObjectDrawable {
|
||||||
|
readonly id?: number | undefined
|
||||||
|
readonly objectsTxtId?: number | undefined
|
||||||
|
readonly token?: string | undefined
|
||||||
|
readonly name?: string | null | undefined
|
||||||
|
readonly kind?: 'object' | 'npc' | undefined
|
||||||
frame: AtlasFrame
|
frame: AtlasFrame
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
readonly depth: number
|
readonly depth: number
|
||||||
/** Object atlas page index this frame lives on. */
|
/** Object atlas page index this frame lives on. */
|
||||||
page: number
|
page: number
|
||||||
readonly baseX?: number
|
readonly baseX?: number | undefined
|
||||||
readonly baseY?: number
|
readonly baseY?: number | undefined
|
||||||
readonly animatedFrames?: readonly AnimatedObjectFrame[]
|
readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined
|
||||||
readonly frameDurationMs?: number
|
readonly frameDurationMs?: number | undefined
|
||||||
readonly cycleAnim?: boolean
|
readonly cycleAnim?: boolean | undefined
|
||||||
readonly phaseOffsetMs?: number
|
readonly phaseOffsetMs?: number | undefined
|
||||||
currentFrameIndex?: number
|
currentFrameIndex?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens that represent persistent environmental fire and illumination objects.
|
||||||
|
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
|
||||||
|
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
|
||||||
|
*/
|
||||||
|
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
|
||||||
|
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
|
||||||
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
|
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
|
||||||
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
|
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
|
||||||
|
|
@ -518,7 +532,9 @@ export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): bool
|
||||||
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
|
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
|
||||||
const phase = obj.phaseOffsetMs ?? 0
|
const phase = obj.phaseOffsetMs ?? 0
|
||||||
const elapsed = Math.max(0, clockMs + phase)
|
const elapsed = Math.max(0, clockMs + phase)
|
||||||
const targetIndex = obj.cycleAnim !== false
|
const isPersistent = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
|
||||||
|
const cycleAnim = isPersistent || obj.cycleAnim !== false
|
||||||
|
const targetIndex = cycleAnim
|
||||||
? Math.floor(elapsed / duration) % frames.length
|
? Math.floor(elapsed / duration) % frames.length
|
||||||
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
|
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
|
||||||
|
|
||||||
|
|
@ -2749,7 +2765,8 @@ async function buildPackRuntime(
|
||||||
? Math.round((256 / obj.frameDelta) * 40 * 100) / 100
|
? Math.round((256 / obj.frameDelta) * 40 * 100) / 100
|
||||||
: 40
|
: 40
|
||||||
)
|
)
|
||||||
const cycleAnim = obj.cycleAnim ?? true
|
const isPersistentFire = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
|
||||||
|
const cycleAnim = isPersistentFire ? true : (obj.cycleAnim ?? true)
|
||||||
|
|
||||||
let phaseOffsetMs = 0
|
let phaseOffsetMs = 0
|
||||||
if (hasAnim && !obj.sync) {
|
if (hasAnim && !obj.sync) {
|
||||||
|
|
@ -2758,6 +2775,9 @@ async function buildPackRuntime(
|
||||||
}
|
}
|
||||||
|
|
||||||
objectDrawables.push({
|
objectDrawables.push({
|
||||||
|
id: obj.id,
|
||||||
|
token: obj.token,
|
||||||
|
name: obj.name,
|
||||||
frame: {
|
frame: {
|
||||||
x: obj.frame.x,
|
x: obj.frame.x,
|
||||||
y: obj.frame.y,
|
y: obj.frame.y,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
import { describe, it, expect } 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
it('verifies town scene (towne1) preserves all animated objects', () => {
|
||||||
|
const townPath = join(packsDir, 'act1/1-act-1-town-towne1/scene.json')
|
||||||
|
if (!existsSync(townPath)) return
|
||||||
|
|
||||||
|
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')
|
||||||
|
if (!existsSync(act1Dir)) return
|
||||||
|
|
||||||
|
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')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
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')
|
||||||
|
if (!existsSync(act1Dir)) return
|
||||||
|
|
||||||
|
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')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
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')
|
||||||
|
if (!existsSync(act1Dir)) return
|
||||||
|
|
||||||
|
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')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
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')
|
||||||
|
if (!existsSync(act4Dir)) return
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Reference in New Issue