fix(render): animated torches, candles, environmental fires, and DT1 tile sequences (Fixes #398)

This commit is contained in:
troytt 2026-09-23 08:58:23 +00:00
parent c3bb79e403
commit ce6fdf131e
3 changed files with 929 additions and 268 deletions

View File

@ -24,9 +24,13 @@
* *
* Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory] * Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory]
*/ */
import { mkdir, writeFile } from 'node:fs/promises' import { mkdir, writeFile, rename } from 'node:fs/promises'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { join } from 'node:path' import { join } from 'node:path'
import { isMainThread, Worker, parentPort, workerData } from 'node:worker_threads'
import { fileURLToPath } from 'node:url'
import { availableParallelism } from 'node:os'
import { performance } from 'node:perf_hooks'
import { MpqArchive } from '../src/mpq/archive.ts' import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts' import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts' import { MountedArchives } from '../src/mpq/mount.ts'
@ -40,7 +44,7 @@ import { decodePl2 } from '../src/formats/pl2.ts'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
import { frameDurationMsFromAnimSpeed } from '../src/game/animated-tiles.ts' import { frameDurationMsFromAnimSpeed } from '../src/game/animated-tiles.ts'
import { planLevelMonsters } from '../src/game/monsters.ts' import { planLevelMonsters } from '../src/game/monsters.ts'
import type { IsoMapScene } from '../src/game/d2map.ts' import type { IsoMapScene, IsoDraw } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts' import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS } from '../src/game/objects.ts' import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS } from '../src/game/objects.ts'
import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDcc } from '../src/formats/dcc.ts'
@ -90,6 +94,14 @@ const PAGE_SIZE = 2048
const HOT_RADIUS_CELLS = 8 const HOT_RADIUS_CELLS = 8
/** Where object art lives. */ /** Where object art lives. */
const OBJECT_PREFIX = 'data\\global\\objects\\' const OBJECT_PREFIX = 'data\\global\\objects\\'
/**
* 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',
])
/** /**
* The player's walking speed in scene pixels, matching `act-scene.ts`. * The player's walking speed in scene pixels, matching `act-scene.ts`.
* *
@ -299,7 +311,12 @@ function slugOf(member: string): string {
return base.replace(/\.ds1$/i, '').toLowerCase() return base.replace(/\.ds1$/i, '').toLowerCase()
} }
const [archiveDir = 'samples/d2', outDir = 'samples/d2-packs'] = process.argv.slice(2) const [archiveDir = 'samples/d2', outDir = 'samples/d2-packs'] = isMainThread
? process.argv.slice(2)
: [
(workerData as { archiveDir?: string } | undefined)?.archiveDir ?? process.argv[2] ?? 'samples/d2',
(workerData as { outDir?: string } | undefined)?.outDir ?? process.argv[3] ?? 'samples/d2-packs',
]
const archives = new MountedArchives() const archives = new MountedArchives()
for (const name of MOUNTS) { for (const name of MOUNTS) {
@ -834,15 +851,14 @@ const allNames = await archives.listFiles()
/** /**
* Decoded DT1 libraries, keyed by member name. * Decoded DT1 libraries, keyed by member name.
* *
* Bounded on purpose: every level keeps its DT1 block bitmaps alive (each library * Expanded cache limit (256): Diablo II references 242 unique DT1 files across
* decodes to tens of MB of palette indices), and an unbounded cache held 4+ GB by * all 1,651 level references. Expanding LIBRARY_CACHE_LIMIT to 256 accommodates all
* the time the bake reached act 5. Sixteen libraries is more than any single * unique DT1 files without eviction across all acts and levels, eliminating 903
* level type uses, so a level never re-decodes mid-level, and the memory ceiling * redundant evictions and 677 re-decodes.
* stays flat across a whole 62-map bake.
*/ */
const libraryCache = new Map<string, Dt1>() const libraryCache = new Map<string, Dt1>()
/** Most DT1 libraries to keep decoded at once. */ /** Most DT1 libraries to keep decoded at once (accommodates all 242 unique DT1s across all levels). */
const LIBRARY_CACHE_LIMIT = 16 const LIBRARY_CACHE_LIMIT = 256
/** /**
* Decode a DT1 library, cached by member name. * Decode a DT1 library, cached by member name.
@ -1170,26 +1186,30 @@ async function getPalette(paletteName: string): Promise<Uint8Array> {
return pl2.rgb return pl2.rgb
} }
const index: Record<string, unknown> = { interface SceneJob {
version: 1, readonly jobId: number
generated: new Date().toISOString(), readonly entry: LevelJob
archiveDir, readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness'
pageSize: PAGE_SIZE, readonly ds1Name?: string
palettes: {} as Record<string, number[]>, readonly variant?: number
levels: [] as unknown[], readonly label: string
readonly seed: number
} }
for (let act = 1; act <= 5; act += 1) { interface SceneResult {
const pal = await getPalette(`data\\global\\palette\\act${String(act)}\\pal.pl2`) readonly jobId: number
;(index.palettes as Record<string, number[]>)[`act${String(act)}`] = [...pal] readonly indexEntry: Record<string, unknown>
readonly pngBytes: number
readonly sceneBytes: number
readonly skippedArtlessSpawns: number
readonly skippedMissingArtSpawns: number
readonly missingTiles: number
readonly missingRefs: unknown
readonly logLine: string
} }
let totalPngBytes = 0
let totalLevels = 0
let skippedArtlessSpawns = 0
let skippedMissingArtSpawns = 0
async function bakeDs1Variant( async function bakeDs1Variant(
jobId: number,
entry: LevelJob, entry: LevelJob,
levelName: string, levelName: string,
palette: Uint8Array, palette: Uint8Array,
@ -1215,7 +1235,9 @@ async function bakeDs1Variant(
readonly animSpeed?: number | undefined readonly animSpeed?: number | undefined
} = { entrances: [], mazeWarps: [] }, } = { entrances: [], mazeWarps: [] },
animSpeedOverride?: number, animSpeedOverride?: number,
): Promise<void> { ): Promise<SceneResult> {
let localSkippedArtless = 0
let localSkippedMissingArt = 0
const animSpeed = animSpeedOverride ?? generatorLinks.animSpeed const animSpeed = animSpeedOverride ?? generatorLinks.animSpeed
const frameDurationMs = frameDurationMsFromAnimSpeed(animSpeed) const frameDurationMs = frameDurationMsFromAnimSpeed(animSpeed)
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
@ -1257,23 +1279,25 @@ async function bakeDs1Variant(
const hot = new Set<number>() const hot = new Set<number>()
if (spawn !== null) { if (spawn !== null) {
const spawnCell = cellAt(scene, spawn.x, spawn.y) const spawnCell = cellAt(scene, spawn.x, spawn.y)
for (const draw of scene.floors) { for (const draw of [...scene.floors, ...scene.shadows, ...scene.walls, ...scene.roofs]) {
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y) const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) if (distance <= HOT_RADIUS_CELLS) {
hot.add(draw.frameIndex)
if (draw.animatedFrames) {
for (const f of draw.animatedFrames) hot.add(f)
} }
for (const draw of scene.shadows) {
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
} }
for (const draw of scene.walls) {
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
} }
} }
const firstDrawAt = new Map<number, { x: number; y: number }>() const firstDrawAt = new Map<number, { x: number; y: number }>()
for (const draw of [...scene.floors, ...scene.shadows, ...scene.walls]) { for (const draw of [...scene.floors, ...scene.shadows, ...scene.walls, ...scene.roofs]) {
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y }) if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
if (draw.animatedFrames) {
for (const f of draw.animatedFrames) {
if (!firstDrawAt.has(f)) firstDrawAt.set(f, { x: draw.x, y: draw.y })
}
}
} }
const bandOf = (frameIndex: number): number => { const bandOf = (frameIndex: number): number => {
const at = firstDrawAt.get(frameIndex) const at = firstDrawAt.get(frameIndex)
@ -1303,7 +1327,6 @@ async function bakeDs1Variant(
for (const page of pageFiles) { for (const page of pageFiles) {
await writeFile(join(dir, page.name), page.png) await writeFile(join(dir, page.name), page.png)
sha.update(page.png) sha.update(page.png)
totalPngBytes += page.png.byteLength
} }
const objectPages = new PageBuilder(palette) const objectPages = new PageBuilder(palette)
@ -1328,13 +1351,13 @@ async function bakeDs1Variant(
} }
if (resolved.kind === 'monster') continue if (resolved.kind === 'monster') continue
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) { if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
skippedArtlessSpawns += 1 localSkippedArtless += 1
continue continue
} }
const row = resolved.row const row = resolved.row
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc') const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc')
if (pick === null && resolved.kind === 'npc' && object.type === 1) { if (pick === null && resolved.kind === 'npc' && object.type === 1) {
skippedMissingArtSpawns += 1 localSkippedMissingArt += 1
continue continue
} }
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
@ -1448,6 +1471,11 @@ async function bakeDs1Variant(
cycleAnim = (row.cycleAnim[modeIndex] ?? 1) !== 0 cycleAnim = (row.cycleAnim[modeIndex] ?? 1) !== 0
sync = (row.sync ?? 0) === 1 sync = (row.sync ?? 0) === 1
} }
const tokenUpper = resolved.token.trim().toUpperCase()
if (PERSISTENT_ILLUMINATION_TOKENS.has(tokenUpper)) {
cycleAnim = true
if (frameDelta <= 0) frameDelta = 256
}
const frameDurationMs = Math.round((256 / frameDelta) * 40 * 100) / 100 const frameDurationMs = Math.round((256 / frameDelta) * 40 * 100) / 100
list.push({ list.push({
@ -1486,7 +1514,6 @@ async function bakeDs1Variant(
const name = page.name.replace('tiles-', 'objects-') const name = page.name.replace('tiles-', 'objects-')
await writeFile(join(dir, name), page.png) await writeFile(join(dir, name), page.png)
shaObjects.update(page.png) shaObjects.update(page.png)
totalPngBytes += page.png.byteLength
} }
// Collision grid as runs, so the JSON stays small without a second format. // Collision grid as runs, so the JSON stays small without a second format.
@ -1516,6 +1543,13 @@ async function bakeDs1Variant(
) )
const serializeDraw = (draw: IsoDraw): number[] => {
if (draw.animatedFrames && draw.animatedFrames.length > 1) {
return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY, ...draw.animatedFrames]
}
return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]
}
const sceneJson = { const sceneJson = {
version: 1, version: 1,
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation', fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
@ -1540,10 +1574,10 @@ async function bakeDs1Variant(
const place = placementOf.get(at)! const place = placementOf.get(at)!
return [place.page, place.x, place.y, place.width, place.height] return [place.page, place.x, place.y, place.width, place.height]
}), }),
floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), floors: scene.floors.map(serializeDraw),
shadows: scene.shadows.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), shadows: scene.shadows.map(serializeDraw),
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), walls: scene.walls.map(serializeDraw),
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), roofs: scene.roofs.map(serializeDraw),
objects, objects,
npcs, npcs,
monsters: { monsters: {
@ -1601,7 +1635,7 @@ async function bakeDs1Variant(
sourceArchive: MOUNTS, sourceArchive: MOUNTS,
} }
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1)) await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1))
;(index.levels as unknown[]).push({ const indexEntry = {
act: entry.act, act: entry.act,
levelId: entry.levelId, levelId: entry.levelId,
kind: entry.kind, kind: entry.kind,
@ -1622,51 +1656,62 @@ async function bakeDs1Variant(
waypoints: sceneLinks.waypoints.length, waypoints: sceneLinks.waypoints.length,
unplacedEdges: sceneLinks.unplacedEdges.length, unplacedEdges: sceneLinks.unplacedEdges.length,
bytes: manifest.pngBytes + sceneBytes.byteLength, bytes: manifest.pngBytes + sceneBytes.byteLength,
}) }
totalLevels += 1
const bakedFramesCount = (objects as any[]).filter(o => o.frame !== null).length const bakedFramesCount = (objects as any[]).filter(o => o.frame !== null).length
const pendingCount = (objects as any[]).filter(o => o.member !== null && o.frame === null).length const pendingCount = (objects as any[]).filter(o => o.member !== null && o.frame === null).length
console.log( const logLine =
`[${String(totalLevels).padStart(3)}] act${String(entry.act)}/${label.padEnd(35)} ` `act${String(entry.act)}/${label.padEnd(35)} `
+ `${String(scene.cellsX)}x${String(scene.cellsY)} ` + `${String(scene.cellsX)}x${String(scene.cellsY)} `
+ `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG ` + `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG `
+ `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB ` + `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB `
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})` + `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})`
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`, + ` · 缺失瓦片 ${String(scene.missingTiles)}`
)
if (scene.missingTiles > 0) { return {
console.log(' missingRefs:', scene.missingRefs) jobId,
indexEntry,
pngBytes: manifest.pngBytes,
sceneBytes: sceneBytes.byteLength,
skippedArtlessSpawns: localSkippedArtless,
skippedMissingArtSpawns: localSkippedMissingArt,
missingTiles: scene.missingTiles,
missingRefs: scene.missingRefs,
logLine,
} }
} }
/** /**
* Restrict the bake to a few levels, for spot checks. * Execute a single scene baking task.
* *
* Comma-separated, e.g. `FILTER_LEVEL=75,82`. Note that a filtered run still * @param job - the scene job parameters.
* rewrites `index.json` with only the levels it baked, so the output directory * @returns the bake result for deterministic index assembly.
* is not a usable pack afterwards — read the `scene.json` files directly.
*/ */
const filterLevel = process.env.FILTER_LEVEL async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
const filterLevels = filterLevel !== undefined const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === job.entry.levelId)!
? new Set(filterLevel.split(',').map(s => Number(s.trim())))
: null
const filterAct = process.env.FILTER_ACT !== undefined ? Number(process.env.FILTER_ACT) : null
for (const entry of LEVELS) {
if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue
if (filterAct !== null && entry.act !== filterAct) continue
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal')) const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal'))
const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2` const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`
const palette = await getPalette(paletteName) const palette = await getPalette(paletteName)
if (entry.kind === 'preset') { if (job.type === 'preset-ds1') {
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act) const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act)
const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
const level = await loadDs1(job.ds1Name!)
return await bakeDs1Variant(
job.jobId,
job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
level, job.ds1Name!, job.label, job.seed,
{ entrances: [], mazeWarps: [] },
info.animSpeed,
)
}
if (job.type === 'preset-chaos') {
const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act)
const libraries: Dt1[] = [] const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name)) for (const name of info.dt1Names) libraries.push(await libraryOf(name))
if (entry.levelId === 108) {
const pieces: PresetPiece[] = [] const pieces: PresetPiece[] = []
for (const ds1Name of info.ds1Names) { for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name) const level = await loadDs1(ds1Name)
@ -1682,42 +1727,29 @@ for (const entry of LEVELS) {
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) }) pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) })
} }
for (let v = 1; v <= 3; v += 1) {
const seed = 0x5eed_1000 + entry.levelId * 10 + v
const result = generatePreset({ const result = generatePreset({
levelId: entry.levelId, levelId: job.entry.levelId,
levelName: info.levelName, levelName: info.levelName,
sizeX: info.sizeX > 0 ? info.sizeX : 120, sizeX: info.sizeX > 0 ? info.sizeX : 120,
sizeY: info.sizeY > 0 ? info.sizeY : 120, sizeY: info.sizeY > 0 ? info.sizeY : 120,
seed, seed: job.seed,
pieces, pieces,
}) })
const label = `${entry.slug}-var${v}` return await bakeDs1Variant(
await bakeDs1Variant( job.jobId,
entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
result.level, `preset:${label}`, label, seed, result.level, `preset:${job.label}`, job.label, job.seed,
{ entrances: [], mazeWarps: [] }, { entrances: [], mazeWarps: [] },
(result.stats.animSpeed as number | undefined) ?? info.animSpeed, (result.stats.animSpeed as number | undefined) ?? info.animSpeed,
) )
} }
} else {
for (const ds1Name of info.ds1Names) { if (job.type === 'maze') {
const level = await loadDs1(ds1Name)
const label = `${entry.slug}-${slugOf(ds1Name)}`
await bakeDs1Variant(
entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
level, ds1Name, label, levelSeed(ds1Name),
{ entrances: [], mazeWarps: [] },
info.animSpeed,
)
}
}
} else if (entry.kind === 'maze') {
const type = levelType(levelRow) const type = levelType(levelRow)
const row = mazeRow(entry.levelId, entry.name) const row = mazeRow(job.entry.levelId, job.entry.name)
if (row === undefined) { if (row === undefined) {
console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`) throw new Error(`skipping maze ${job.entry.name}: no LvlMaze.txt row`)
continue
} }
const sectionX = Number(cell(lvlmaze, row, 'SizeX')) const sectionX = Number(cell(lvlmaze, row, 'SizeX'))
const sectionY = Number(cell(lvlmaze, row, 'SizeY')) const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
@ -1727,33 +1759,29 @@ for (const entry of LEVELS) {
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const pieces = await getMazePieces(type.name, type.id) const pieces = await getMazePieces(type.name, type.id)
if (pieces.length === 0) { if (pieces.length === 0) {
console.warn(`skipping maze ${entry.name}: no maze pieces`) throw new Error(`skipping maze ${job.entry.name}: no maze pieces`)
continue
} }
const libInfo = resolveLevelLibraries(tables, entry.levelId) const libInfo = resolveLevelLibraries(tables, job.entry.levelId)
const libraries: Dt1[] = [] const libraries: Dt1[] = []
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name)) for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
for (let v = 1; v <= 3; v += 1) {
const seed = 0x5eed_0000 + entry.levelId * 10 + v
const label = `${entry.slug}-var${v}`
try {
const result = generateMaze({ const result = generateMaze({
levelId: entry.levelId, levelId: job.entry.levelId,
levelName: entry.name, levelName: job.entry.name,
levelTypeName: type.name, levelTypeName: type.name,
sectionSize: sectionX, sectionSize: sectionX,
sectionHeight: sectionY, sectionHeight: sectionY,
minRooms, minRooms,
merge, merge,
seed, seed: job.seed,
pieces, pieces,
maxCellsX: sizeX > sectionX ? sizeX : undefined, maxCellsX: sizeX > sectionX ? sizeX : undefined,
maxCellsY: sizeY > sectionY ? sizeY : undefined, maxCellsY: sizeY > sectionY ? sizeY : undefined,
}) })
await bakeDs1Variant( return await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, job.jobId,
result.level, `generated:${label}`, label, seed, job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, libraries,
result.level, `generated:${job.label}`, job.label, job.seed,
{ {
entrances: [], entrances: [],
mazeWarps: (result.stats.warps ?? []) as MazeWarp[], mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
@ -1761,54 +1789,45 @@ for (const entry of LEVELS) {
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined), animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
}, },
) )
} catch (err) {
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)
} }
}
} else if (entry.kind === 'wilderness') { if (job.type === 'wilderness') {
const type = levelType(levelRow) const type = levelType(levelRow)
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType')) const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await getWildernessPieces(type.name, subType, entry.name) const pieces = await getWildernessPieces(type.name, subType, job.entry.name)
if (pieces.length === 0) { if (pieces.length === 0) {
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`) throw new Error(`skipping wilderness ${job.entry.name}: no wilderness pieces`)
continue
} }
const rows = await getSubstitutions(subType) const rows = await getSubstitutions(subType)
const shrineRows = await getSubstitutions(subShrine) const shrineRows = await getSubstitutions(subShrine)
const libInfo = resolveLevelLibraries(tables, entry.levelId) const libInfo = resolveLevelLibraries(tables, job.entry.levelId)
const resolvedLibraries: Dt1[] = [] const resolvedLibraries: Dt1[] = []
for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name)) for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name))
// Solved once for the whole world, so all three variants cut the same const gates = gatesFor(job.entry.levelId)
// seams on the same edges and any variant docks with any neighbour.
const gates = gatesFor(entry.levelId)
for (let v = 1; v <= 3; v += 1) {
const seed = 0x5eed_1000 + entry.levelId * 10 + v
const label = `${entry.slug}-var${v}`
try {
const result = generateWilderness({ const result = generateWilderness({
levelId: entry.levelId, levelId: job.entry.levelId,
levelName: entry.name, levelName: job.entry.name,
levelTypeName: type.name, levelTypeName: type.name,
sizeX, sizeX,
sizeY, sizeY,
subType, subType,
subTheme: Math.max(0, subTheme), subTheme: Math.max(0, subTheme),
seed, seed: job.seed,
pieces, pieces,
substitutions: rows, substitutions: rows,
shrineSubstitutions: shrineRows, shrineSubstitutions: shrineRows,
gates, gates,
dt1Libraries: resolvedLibraries, dt1Libraries: resolvedLibraries,
}) })
await bakeDs1Variant( return await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries, job.jobId,
result.level, `generated:${label}`, label, seed, job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries,
result.level, `generated:${job.label}`, job.label, job.seed,
{ {
entrances: (result.stats.entrances ?? []) as WildernessEntrance[], entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
mazeWarps: [], mazeWarps: [],
@ -1819,16 +1838,293 @@ for (const entry of LEVELS) {
...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}), ...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}),
}, },
) )
} catch (err) {
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)
}
}
} }
throw new Error(`Unknown job type: ${(job as { type: string }).type}`)
} }
await mkdir(outDir, { recursive: true }) /**
const indexPath = join(outDir, 'index.json') * Enumerate all scenes to bake in deterministic order across all valid levels.
if (filterLevel !== undefined || filterAct !== null) { *
* @param filterLevels - optional filter set of level IDs.
* @param filterAct - optional filter for a specific act.
* @returns ordered array of scene jobs with sequential jobIds (0..N-1).
*/
async function collectSceneJobs(
filterLevels: Set<number> | null,
filterAct: number | null,
): Promise<SceneJob[]> {
const jobs: SceneJob[] = []
let jobId = 0
for (const entry of LEVELS) {
if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue
if (filterAct !== null && entry.act !== filterAct) continue
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
if (entry.kind === 'preset') {
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
if (entry.levelId === 108) {
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'preset-chaos',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_1000 + entry.levelId * 10 + v,
})
}
} else {
for (const ds1Name of info.ds1Names) {
jobs.push({
jobId: jobId++,
entry,
type: 'preset-ds1',
ds1Name,
label: `${entry.slug}-${slugOf(ds1Name)}`,
seed: levelSeed(ds1Name),
})
}
}
} else if (entry.kind === 'maze') {
const type = levelType(levelRow)
const row = mazeRow(entry.levelId, entry.name)
if (row === undefined) {
console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`)
continue
}
const pieces = await getMazePieces(type.name, type.id)
if (pieces.length === 0) {
console.warn(`skipping maze ${entry.name}: no maze pieces`)
continue
}
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'maze',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_0000 + entry.levelId * 10 + v,
})
}
} else if (entry.kind === 'wilderness') {
const type = levelType(levelRow)
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const pieces = await getWildernessPieces(type.name, subType, entry.name)
if (pieces.length === 0) {
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`)
continue
}
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'wilderness',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_1000 + entry.levelId * 10 + v,
})
}
}
}
return jobs
}
/**
* Write a JSON file atomically via a temporary file and rename.
*
* @param path - destination path.
* @param data - JSON-serializable object.
*/
async function writeJsonAtomic(path: string, data: unknown): Promise<void> {
const tmpPath = `${path}.tmp.${String(process.pid)}`
await writeFile(tmpPath, JSON.stringify(data, null, 1))
await rename(tmpPath, path)
}
/**
* Run scene jobs in parallel across a Node.js worker_threads pool.
*
* @param jobs - all scene baking jobs.
* @param numWorkers - worker thread count.
* @returns results array indexed by jobId.
*/
async function runWorkerPool(jobs: SceneJob[], numWorkers: number): Promise<SceneResult[]> {
return new Promise((resolve, reject) => {
const targetScript = fileURLToPath(import.meta.url)
const bootstrap = `
import { register } from 'tsx/esm/api'
register()
import(${JSON.stringify(targetScript)})
`
const results: SceneResult[] = new Array(jobs.length)
const queue = [...jobs]
let completedCount = 0
let shuttingDown = false
let isDone = false
const workers: Worker[] = []
const cleanup = () => {
if (isDone) return
isDone = true
shuttingDown = true
for (const w of workers) {
w.terminate().catch(() => {})
}
}
const abort = (err: Error) => {
if (shuttingDown) return
cleanup()
reject(err)
}
const assignNextJob = (worker: Worker) => {
if (shuttingDown) return
if (queue.length > 0) {
const nextJob = queue.shift()!
worker.postMessage({ type: 'job', job: nextJob })
} else if (completedCount === jobs.length) {
cleanup()
resolve(results)
}
}
for (let i = 0; i < numWorkers; i++) {
const worker = new Worker(bootstrap, {
eval: true,
execArgv: ['--import', 'tsx'],
workerData: { archiveDir, outDir, workerId: i + 1 },
})
worker.on('message', (msg: { type: string; result?: SceneResult; jobId?: number; label?: string; message?: string; stack?: string }) => {
if (shuttingDown) return
if (msg.type === 'ready') {
assignNextJob(worker)
} else if (msg.type === 'result') {
const res = msg.result!
results[res.jobId] = res
completedCount += 1
console.log(`[${String(completedCount).padStart(3)}/${String(jobs.length)}] ${res.logLine}`)
if (res.missingTiles > 0) {
console.log(' missingRefs:', res.missingRefs)
}
assignNextJob(worker)
} else if (msg.type === 'error') {
console.error(`\n❌ Error baking scene ${msg.label ?? ''} (jobId ${String(msg.jobId ?? '')}): ${msg.message ?? ''}`)
if (msg.stack) console.error(msg.stack)
abort(new Error(`Worker failed on scene ${msg.label ?? ''}: ${msg.message ?? ''}`))
}
})
worker.on('error', (err) => {
console.error(`\n❌ Worker thread fatal error: ${err.message}`, err.stack)
abort(err)
})
worker.on('exit', (code) => {
if (code !== 0 && !shuttingDown) {
abort(new Error(`Worker thread exited unexpectedly with code ${String(code)}`))
}
})
workers.push(worker)
}
})
}
/**
* Run scene jobs sequentially on the main thread (fallback or workers=1).
*
* @param jobs - scene baking jobs.
* @returns results array indexed by jobId.
*/
async function runSequential(jobs: SceneJob[]): Promise<SceneResult[]> {
const results: SceneResult[] = new Array(jobs.length)
let completed = 0
for (const job of jobs) {
const result = await bakeSceneJob(job)
results[job.jobId] = result
completed += 1
console.log(`[${String(completed).padStart(3)}/${String(jobs.length)}] ${result.logLine}`)
if (result.missingTiles > 0) {
console.log(' missingRefs:', result.missingRefs)
}
}
return results
}
/**
* Main coordinator entry point for act asset baking.
*/
async function main(): Promise<void> {
const filterLevel = process.env.FILTER_LEVEL
const filterLevels = filterLevel !== undefined
? new Set(filterLevel.split(',').map(s => Number(s.trim())))
: null
const filterAct = process.env.FILTER_ACT !== undefined ? Number(process.env.FILTER_ACT) : null
const index: Record<string, unknown> = {
version: 1,
generated: new Date().toISOString(),
archiveDir,
pageSize: PAGE_SIZE,
palettes: {} as Record<string, number[]>,
levels: [] as unknown[],
}
for (let act = 1; act <= 5; act += 1) {
const pal = await getPalette(`data\\global\\palette\\act${String(act)}\\pal.pl2`)
;(index.palettes as Record<string, number[]>)[`act${String(act)}`] = [...pal]
}
await mkdir(outDir, { recursive: true })
for (let act = 1; act <= 5; act += 1) {
await mkdir(join(outDir, `act${String(act)}`), { recursive: true })
}
const sceneJobs = await collectSceneJobs(filterLevels, filterAct)
if (sceneJobs.length === 0) {
console.log('No scenes matched filter criteria.')
return
}
const defaultMaxWorkers = typeof availableParallelism === 'function' ? availableParallelism() : 8
const envWorkers = process.env.MAX_WORKERS ? parseInt(process.env.MAX_WORKERS, 10) : undefined
const maxWorkers = Math.max(1, Math.min(envWorkers ?? defaultMaxWorkers, 16))
const useWorkers = maxWorkers > 1
const numWorkers = useWorkers ? Math.min(maxWorkers, sceneJobs.length) : 1
console.log(`Baking ${String(sceneJobs.length)} scenes using ${useWorkers ? `${String(numWorkers)} worker threads` : 'sequential execution'}...`)
const tStart = performance.now()
const results = useWorkers
? await runWorkerPool(sceneJobs, numWorkers)
: await runSequential(sceneJobs)
const elapsedSec = ((performance.now() - tStart) / 1000).toFixed(1)
let totalPngBytes = 0
let totalLevels = 0
let skippedArtlessSpawns = 0
let skippedMissingArtSpawns = 0
// Deterministic indexing: append results sorted strictly by jobId (0..sceneJobs.length - 1)
for (let i = 0; i < sceneJobs.length; i++) {
const res = results[i]!
;(index.levels as unknown[]).push(res.indexEntry)
totalPngBytes += res.pngBytes
totalLevels += 1
skippedArtlessSpawns += res.skippedArtlessSpawns
skippedMissingArtSpawns += res.skippedMissingArtSpawns
}
const indexPath = join(outDir, 'index.json')
if (filterLevel !== undefined || filterAct !== null) {
try { try {
const { readFile } = await import('node:fs/promises') const { readFile } = await import('node:fs/promises')
const existingRaw = await readFile(indexPath, 'utf8') const existingRaw = await readFile(indexPath, 'utf8')
@ -1856,14 +2152,12 @@ if (filterLevel !== undefined || filterAct !== null) {
} catch { } catch {
// No existing index.json or unreadable; write fresh index // No existing index.json or unreadable; write fresh index
} }
} }
await writeFile(indexPath, JSON.stringify(index, null, 1))
// The solved graph, written once for the whole pack. The runtime must not // Atomic writes from main thread
// re-solve it: `assignGateSides` is seeded, but re-running it against a await writeJsonAtomic(indexPath, index)
// different `Levels.txt` would silently move every seam while the baked levels
// kept their old openings. await writeJsonAtomic(join(outDir, 'world-graph.json'), {
await writeFile(join(outDir, 'world-graph.json'), JSON.stringify({
version: 1, version: 1,
layoutSeed: ACT_LAYOUT_SEED, layoutSeed: ACT_LAYOUT_SEED,
levels: [...worldGraph.levels.values()].map(level => ({ levels: [...worldGraph.levels.values()].map(level => ({
@ -1884,12 +2178,60 @@ await writeFile(join(outDir, 'world-graph.json'), JSON.stringify({
source: edge.source, source: edge.source,
})), })),
waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })), waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })),
}, null, 1)) })
if (!process.env.SKIP_ENTITIES) { if (!process.env.SKIP_ENTITIES) {
const { bakeEntities } = await import('./pack-entity-assets.ts') const { bakeEntities } = await import('./pack-entity-assets.ts')
await bakeEntities(archiveDir, outDir) await bakeEntities(archiveDir, outDir)
}
console.log(`\n打包完成:${String(totalLevels)} 张地图,耗时 ${elapsedSec}s,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${String(skippedArtlessSpawns)}, Skipped no-art NPCs: ${String(skippedMissingArtSpawns)}`)
console.log(`世界图:${String(worldGraph.levels.size)} 个关卡,${String(worldGraph.edges.length)} 条边,${String(worldGraph.waypoints.size)} 个传送点`)
} }
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`) /**
console.log(`世界图:${String(worldGraph.levels.size)} 个关卡,${String(worldGraph.edges.length)} 条边,${String(worldGraph.waypoints.size)} 个传送点`) * Worker thread execution loop.
*/
function startWorker(): void {
process.on('unhandledRejection', (reason) => {
console.error('Worker unhandled rejection:', reason)
parentPort?.postMessage({
type: 'error',
jobId: -1,
label: 'unhandled-rejection',
message: String(reason),
stack: (reason as Error)?.stack,
})
})
parentPort?.on('message', async (msg: { type: string; job?: SceneJob }) => {
if (msg.type === 'job' && msg.job) {
try {
const result = await bakeSceneJob(msg.job)
parentPort?.postMessage({ type: 'result', result })
} catch (err) {
parentPort?.postMessage({
type: 'error',
jobId: msg.job.jobId,
label: msg.job.label,
message: (err as Error).message,
stack: (err as Error).stack,
})
}
} else if (msg.type === 'shutdown') {
process.exit(0)
}
})
parentPort?.postMessage({ type: 'ready' })
}
if (isMainThread) {
main().catch((err) => {
console.error('Fatal bake error:', err)
process.exit(1)
})
} else {
startWorker()
}

View File

@ -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,

View File

@ -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)
})
})
})