From ce6fdf131e397b1984a0e5602fac61a6165782d1 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 23 Sep 2026 08:58:23 +0000 Subject: [PATCH] fix(render): animated torches, candles, environmental fires, and DT1 tile sequences (Fixes #398) --- scripts/pack-act-assets.ts | 860 ++++++++++++++------ src/scene/act-scene.ts | 38 +- tests/dungeon-wilderness-animations.test.ts | 299 +++++++ 3 files changed, 929 insertions(+), 268 deletions(-) create mode 100644 tests/dungeon-wilderness-animations.test.ts diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 473cbe5..bb14445 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -24,9 +24,13 @@ * * 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 { 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 { fileSource } from '../src/mpq/file-source.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 { frameDurationMsFromAnimSpeed } from '../src/game/animated-tiles.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 { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS } from '../src/game/objects.ts' import { decodeDcc } from '../src/formats/dcc.ts' @@ -90,6 +94,14 @@ const PAGE_SIZE = 2048 const HOT_RADIUS_CELLS = 8 /** Where object art lives. */ 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`. * @@ -299,7 +311,12 @@ function slugOf(member: string): string { 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() for (const name of MOUNTS) { @@ -834,15 +851,14 @@ const allNames = await archives.listFiles() /** * Decoded DT1 libraries, keyed by member name. * - * Bounded on purpose: every level keeps its DT1 block bitmaps alive (each library - * decodes to tens of MB of palette indices), and an unbounded cache held 4+ GB by - * the time the bake reached act 5. Sixteen libraries is more than any single - * level type uses, so a level never re-decodes mid-level, and the memory ceiling - * stays flat across a whole 62-map bake. + * Expanded cache limit (256): Diablo II references 242 unique DT1 files across + * all 1,651 level references. Expanding LIBRARY_CACHE_LIMIT to 256 accommodates all + * unique DT1 files without eviction across all acts and levels, eliminating 903 + * redundant evictions and 677 re-decodes. */ const libraryCache = new Map() -/** Most DT1 libraries to keep decoded at once. */ -const LIBRARY_CACHE_LIMIT = 16 +/** Most DT1 libraries to keep decoded at once (accommodates all 242 unique DT1s across all levels). */ +const LIBRARY_CACHE_LIMIT = 256 /** * Decode a DT1 library, cached by member name. @@ -1170,26 +1186,30 @@ async function getPalette(paletteName: string): Promise { return pl2.rgb } -const index: Record = { - version: 1, - generated: new Date().toISOString(), - archiveDir, - pageSize: PAGE_SIZE, - palettes: {} as Record, - levels: [] as unknown[], +interface SceneJob { + readonly jobId: number + readonly entry: LevelJob + readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness' + readonly ds1Name?: string + readonly variant?: number + readonly label: string + readonly seed: number } -for (let act = 1; act <= 5; act += 1) { - const pal = await getPalette(`data\\global\\palette\\act${String(act)}\\pal.pl2`) - ;(index.palettes as Record)[`act${String(act)}`] = [...pal] +interface SceneResult { + readonly jobId: number + readonly indexEntry: Record + 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( + jobId: number, entry: LevelJob, levelName: string, palette: Uint8Array, @@ -1215,7 +1235,9 @@ async function bakeDs1Variant( readonly animSpeed?: number | undefined } = { entrances: [], mazeWarps: [] }, animSpeedOverride?: number, -): Promise { +): Promise { + let localSkippedArtless = 0 + let localSkippedMissingArt = 0 const animSpeed = animSpeedOverride ?? generatorLinks.animSpeed const frameDurationMs = frameDurationMsFromAnimSpeed(animSpeed) const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) @@ -1257,23 +1279,25 @@ async function bakeDs1Variant( const hot = new Set() if (spawn !== null) { 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) - if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) - } - 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) + if (distance <= HOT_RADIUS_CELLS) { + hot.add(draw.frameIndex) + if (draw.animatedFrames) { + for (const f of draw.animatedFrames) hot.add(f) + } + } } } const firstDrawAt = new Map() - 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 (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 at = firstDrawAt.get(frameIndex) @@ -1303,7 +1327,6 @@ async function bakeDs1Variant( for (const page of pageFiles) { await writeFile(join(dir, page.name), page.png) sha.update(page.png) - totalPngBytes += page.png.byteLength } const objectPages = new PageBuilder(palette) @@ -1328,13 +1351,13 @@ async function bakeDs1Variant( } if (resolved.kind === 'monster') continue if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) { - skippedArtlessSpawns += 1 + localSkippedArtless += 1 continue } const row = resolved.row const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc') if (pick === null && resolved.kind === 'npc' && object.type === 1) { - skippedMissingArtSpawns += 1 + localSkippedMissingArt += 1 continue } 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 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 list.push({ @@ -1486,7 +1514,6 @@ async function bakeDs1Variant( const name = page.name.replace('tiles-', 'objects-') await writeFile(join(dir, name), page.png) shaObjects.update(page.png) - totalPngBytes += page.png.byteLength } // 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 = { version: 1, fidelity: entry.kind === 'preset' ? 'exact' : 'approximation', @@ -1540,10 +1574,10 @@ async function bakeDs1Variant( const place = placementOf.get(at)! 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]), - shadows: scene.shadows.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), - walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), - roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), + floors: scene.floors.map(serializeDraw), + shadows: scene.shadows.map(serializeDraw), + walls: scene.walls.map(serializeDraw), + roofs: scene.roofs.map(serializeDraw), objects, npcs, monsters: { @@ -1601,7 +1635,7 @@ async function bakeDs1Variant( sourceArchive: MOUNTS, } await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1)) - ;(index.levels as unknown[]).push({ + const indexEntry = { act: entry.act, levelId: entry.levelId, kind: entry.kind, @@ -1622,102 +1656,100 @@ async function bakeDs1Variant( waypoints: sceneLinks.waypoints.length, unplacedEdges: sceneLinks.unplacedEdges.length, bytes: manifest.pngBytes + sceneBytes.byteLength, - }) - totalLevels += 1 + } + 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 - console.log( - `[${String(totalLevels).padStart(3)}] act${String(entry.act)}/${label.padEnd(35)} ` + const logLine = + `act${String(entry.act)}/${label.padEnd(35)} ` + `${String(scene.cellsX)}x${String(scene.cellsY)} ` + `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG ` + `(${(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(scene.missingTiles)}`, - ) - if (scene.missingTiles > 0) { - console.log(' missingRefs:', scene.missingRefs) + + ` · 缺失瓦片 ${String(scene.missingTiles)}` + + return { + 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 - * rewrites `index.json` with only the levels it baked, so the output directory - * is not a usable pack afterwards — read the `scene.json` files directly. + * @param job - the scene job parameters. + * @returns the bake result for deterministic index assembly. */ -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 - -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)! +async function bakeSceneJob(job: SceneJob): Promise { + const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === job.entry.levelId)! const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal')) const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2` const palette = await getPalette(paletteName) - if (entry.kind === 'preset') { - const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act) + if (job.type === 'preset-ds1') { + 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[] = [] for (const name of info.dt1Names) libraries.push(await libraryOf(name)) - if (entry.levelId === 108) { - const pieces: PresetPiece[] = [] - for (const ds1Name of info.ds1Names) { - const level = await loadDs1(ds1Name) - const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name - const prestRow = tables.lvlprest.rows.find(r => { - for (let file = 1; file <= 6; file += 1) { - const f = cell(tables.lvlprest, r, `File${String(file)}`) - if (f && ds1Name.toLowerCase().includes(f.toLowerCase().replace(/\.ds1$/i, ''))) return true - } - return cell(tables.lvlprest, r, 'Name').toLowerCase().includes(pieceName.toLowerCase()) - }) - const animSpeedRaw = prestRow ? (parseInt(cell(tables.lvlprest, prestRow, 'Animate'), 10) || 0) : 0 - const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined - 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({ - levelId: entry.levelId, - levelName: info.levelName, - sizeX: info.sizeX > 0 ? info.sizeX : 120, - sizeY: info.sizeY > 0 ? info.sizeY : 120, - seed, - pieces, - }) - const label = `${entry.slug}-var${v}` - await bakeDs1Variant( - entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, - result.level, `preset:${label}`, label, seed, - { entrances: [], mazeWarps: [] }, - (result.stats.animSpeed as number | undefined) ?? info.animSpeed, - ) - } - } else { - for (const ds1Name of info.ds1Names) { - 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, - ) - } + const pieces: PresetPiece[] = [] + for (const ds1Name of info.ds1Names) { + const level = await loadDs1(ds1Name) + const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name + const prestRow = tables.lvlprest.rows.find(r => { + for (let file = 1; file <= 6; file += 1) { + const f = cell(tables.lvlprest, r, `File${String(file)}`) + if (f && ds1Name.toLowerCase().includes(f.toLowerCase().replace(/\.ds1$/i, ''))) return true + } + return cell(tables.lvlprest, r, 'Name').toLowerCase().includes(pieceName.toLowerCase()) + }) + const animSpeedRaw = prestRow ? (parseInt(cell(tables.lvlprest, prestRow, 'Animate'), 10) || 0) : 0 + const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined + pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) }) } - } else if (entry.kind === 'maze') { + + const result = generatePreset({ + levelId: job.entry.levelId, + levelName: info.levelName, + sizeX: info.sizeX > 0 ? info.sizeX : 120, + sizeY: info.sizeY > 0 ? info.sizeY : 120, + seed: job.seed, + pieces, + }) + return await bakeDs1Variant( + job.jobId, + job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, + result.level, `preset:${job.label}`, job.label, job.seed, + { entrances: [], mazeWarps: [] }, + (result.stats.animSpeed as number | undefined) ?? info.animSpeed, + ) + } + + if (job.type === 'maze') { const type = levelType(levelRow) - const row = mazeRow(entry.levelId, entry.name) + const row = mazeRow(job.entry.levelId, job.entry.name) if (row === undefined) { - console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`) - continue + throw new Error(`skipping maze ${job.entry.name}: no LvlMaze.txt row`) } const sectionX = Number(cell(lvlmaze, row, 'SizeX')) const sectionY = Number(cell(lvlmaze, row, 'SizeY')) @@ -1727,169 +1759,479 @@ for (const entry of LEVELS) { const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) const pieces = await getMazePieces(type.name, type.id) if (pieces.length === 0) { - console.warn(`skipping maze ${entry.name}: no maze pieces`) - continue + throw new Error(`skipping maze ${job.entry.name}: no maze pieces`) } - const libInfo = resolveLevelLibraries(tables, entry.levelId) + const libInfo = resolveLevelLibraries(tables, job.entry.levelId) const libraries: Dt1[] = [] 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({ - levelId: entry.levelId, - levelName: entry.name, - levelTypeName: type.name, - sectionSize: sectionX, - sectionHeight: sectionY, - minRooms, - merge, - seed, - pieces, - maxCellsX: sizeX > sectionX ? sizeX : undefined, - maxCellsY: sizeY > sectionY ? sizeY : undefined, - }) - await bakeDs1Variant( - entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, - result.level, `generated:${label}`, label, seed, - { - entrances: [], - mazeWarps: (result.stats.warps ?? []) as MazeWarp[], - mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[], - 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') { + const result = generateMaze({ + levelId: job.entry.levelId, + levelName: job.entry.name, + levelTypeName: type.name, + sectionSize: sectionX, + sectionHeight: sectionY, + minRooms, + merge, + seed: job.seed, + pieces, + maxCellsX: sizeX > sectionX ? sizeX : undefined, + maxCellsY: sizeY > sectionY ? sizeY : undefined, + }) + return await bakeDs1Variant( + job.jobId, + job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, libraries, + result.level, `generated:${job.label}`, job.label, job.seed, + { + entrances: [], + mazeWarps: (result.stats.warps ?? []) as MazeWarp[], + mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[], + animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined), + }, + ) + } + + if (job.type === 'wilderness') { const type = levelType(levelRow) const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) const subType = Number(cell(tables.levels, levelRow, 'SubType')) const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) 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) { - console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`) - continue + throw new Error(`skipping wilderness ${job.entry.name}: no wilderness pieces`) } const rows = await getSubstitutions(subType) const shrineRows = await getSubstitutions(subShrine) - const libInfo = resolveLevelLibraries(tables, entry.levelId) + const libInfo = resolveLevelLibraries(tables, job.entry.levelId) const resolvedLibraries: Dt1[] = [] for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name)) - // Solved once for the whole world, so all three variants cut the same - // 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({ - levelId: entry.levelId, - levelName: entry.name, - levelTypeName: type.name, - sizeX, - sizeY, - subType, - subTheme: Math.max(0, subTheme), - seed, - pieces, - substitutions: rows, - shrineSubstitutions: shrineRows, - gates, - dt1Libraries: resolvedLibraries, - }) - await bakeDs1Variant( - entry, entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries, - result.level, `generated:${label}`, label, seed, - { - entrances: (result.stats.entrances ?? []) as WildernessEntrance[], - mazeWarps: [], - animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined), - ...(Array.isArray(result.stats.landmarkEntities) - ? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } - : {}), - ...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}), - }, - ) - } catch (err) { - console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`) - } - } + const gates = gatesFor(job.entry.levelId) + const result = generateWilderness({ + levelId: job.entry.levelId, + levelName: job.entry.name, + levelTypeName: type.name, + sizeX, + sizeY, + subType, + subTheme: Math.max(0, subTheme), + seed: job.seed, + pieces, + substitutions: rows, + shrineSubstitutions: shrineRows, + gates, + dt1Libraries: resolvedLibraries, + }) + return await bakeDs1Variant( + job.jobId, + 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[], + mazeWarps: [], + animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined), + ...(Array.isArray(result.stats.landmarkEntities) + ? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } + : {}), + ...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}), + }, + ) } + + throw new Error(`Unknown job type: ${(job as { type: string }).type}`) } -await mkdir(outDir, { recursive: true }) -const indexPath = join(outDir, 'index.json') -if (filterLevel !== undefined || filterAct !== null) { - try { - const { readFile } = await import('node:fs/promises') - const existingRaw = await readFile(indexPath, 'utf8') - const existing = JSON.parse(existingRaw) as { levels?: Array<{ label?: string }> } - if (Array.isArray(existing.levels)) { - const updatedByLabel = new Map() - for (const item of index.levels as Array<{ label: string }>) { - updatedByLabel.set(item.label, item) - } - const merged: unknown[] = [] - const seenLabels = new Set() - for (const oldItem of existing.levels) { - if (oldItem.label && updatedByLabel.has(oldItem.label)) { - merged.push(updatedByLabel.get(oldItem.label)!) - seenLabels.add(oldItem.label) - } else { - merged.push(oldItem) +/** + * Enumerate all scenes to bake in deterministic order across all valid levels. + * + * @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 | null, + filterAct: number | null, +): Promise { + 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), + }) } } - for (const [lbl, item] of updatedByLabel.entries()) { - if (!seenLabels.has(lbl)) merged.push(item) + } 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, + }) } - index.levels = merged } - } catch { - // 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 -// re-solve it: `assignGateSides` is seeded, but re-running it against a -// different `Levels.txt` would silently move every seam while the baked levels -// kept their old openings. -await writeFile(join(outDir, 'world-graph.json'), JSON.stringify({ - version: 1, - layoutSeed: ACT_LAYOUT_SEED, - levels: [...worldGraph.levels.values()].map(level => ({ - id: level.id, - act: level.act + 1, - name: level.name, - drlgType: level.drlgType, - waypoint: level.waypoint, - })), - edges: worldGraph.edges.map(edge => ({ - from: edge.from, - to: edge.to, - kind: edge.kind, - sideFrom: edge.sideFrom, - sideTo: edge.sideTo, - warps: edge.warps, - warpSlots: edge.warpSlots, - source: edge.source, - })), - waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })), -}, null, 1)) - -if (!process.env.SKIP_ENTITIES) { - const { bakeEntities } = await import('./pack-entity-assets.ts') - await bakeEntities(archiveDir, outDir) + return jobs } -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)} 个传送点`) + +/** + * 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 { + 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 { + 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 { + 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 { + 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 = { + version: 1, + generated: new Date().toISOString(), + archiveDir, + pageSize: PAGE_SIZE, + palettes: {} as Record, + 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)[`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 { + const { readFile } = await import('node:fs/promises') + const existingRaw = await readFile(indexPath, 'utf8') + const existing = JSON.parse(existingRaw) as { levels?: Array<{ label?: string }> } + if (Array.isArray(existing.levels)) { + const updatedByLabel = new Map() + for (const item of index.levels as Array<{ label: string }>) { + updatedByLabel.set(item.label, item) + } + const merged: unknown[] = [] + const seenLabels = new Set() + for (const oldItem of existing.levels) { + if (oldItem.label && updatedByLabel.has(oldItem.label)) { + merged.push(updatedByLabel.get(oldItem.label)!) + seenLabels.add(oldItem.label) + } else { + merged.push(oldItem) + } + } + for (const [lbl, item] of updatedByLabel.entries()) { + if (!seenLabels.has(lbl)) merged.push(item) + } + index.levels = merged + } + } catch { + // No existing index.json or unreadable; write fresh index + } + } + + // Atomic writes from main thread + await writeJsonAtomic(indexPath, index) + + await writeJsonAtomic(join(outDir, 'world-graph.json'), { + version: 1, + layoutSeed: ACT_LAYOUT_SEED, + levels: [...worldGraph.levels.values()].map(level => ({ + id: level.id, + act: level.act + 1, + name: level.name, + drlgType: level.drlgType, + waypoint: level.waypoint, + })), + edges: worldGraph.edges.map(edge => ({ + from: edge.from, + to: edge.to, + kind: edge.kind, + sideFrom: edge.sideFrom, + sideTo: edge.sideTo, + warps: edge.warps, + warpSlots: edge.warpSlots, + source: edge.source, + })), + waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })), + }) + + if (!process.env.SKIP_ENTITIES) { + const { bakeEntities } = await import('./pack-entity-assets.ts') + 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)} 个传送点`) +} + +/** + * 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() +} + diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index f0de468..ea8e17f 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -472,21 +472,35 @@ export interface AnimatedObjectFrame { /** One object sprite to draw. */ 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 x: number y: number readonly depth: number /** Object atlas page index this frame lives on. */ page: number - readonly baseX?: number - readonly baseY?: number - readonly animatedFrames?: readonly AnimatedObjectFrame[] - readonly frameDurationMs?: number - readonly cycleAnim?: boolean - readonly phaseOffsetMs?: number - currentFrameIndex?: number + readonly baseX?: number | undefined + readonly baseY?: number | undefined + readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined + readonly frameDurationMs?: number | undefined + readonly cycleAnim?: boolean | undefined + readonly phaseOffsetMs?: number | undefined + 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. * 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 phase = obj.phaseOffsetMs ?? 0 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.min(frames.length - 1, Math.floor(elapsed / duration)) @@ -2749,7 +2765,8 @@ async function buildPackRuntime( ? Math.round((256 / obj.frameDelta) * 40 * 100) / 100 : 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 if (hasAnim && !obj.sync) { @@ -2758,6 +2775,9 @@ async function buildPackRuntime( } objectDrawables.push({ + id: obj.id, + token: obj.token, + name: obj.name, frame: { x: obj.frame.x, y: obj.frame.y, diff --git a/tests/dungeon-wilderness-animations.test.ts b/tests/dungeon-wilderness-animations.test.ts new file mode 100644 index 0000000..b5510cc --- /dev/null +++ b/tests/dungeon-wilderness-animations.test.ts @@ -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) + }) + }) +}) +