fix(render): animated torches, candles, environmental fires, and DT1 tile sequences (Fixes #398)
This commit is contained in:
parent
c3bb79e403
commit
ce6fdf131e
|
|
@ -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,14 +1838,291 @@ 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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<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 })
|
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')
|
const indexPath = join(outDir, 'index.json')
|
||||||
if (filterLevel !== undefined || filterAct !== null) {
|
if (filterLevel !== undefined || filterAct !== null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1857,13 +2153,11 @@ if (filterLevel !== undefined || filterAct !== null) {
|
||||||
// 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)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
|
||||||
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)
|
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(`世界图:${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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -472,21 +472,35 @@ export interface AnimatedObjectFrame {
|
||||||
|
|
||||||
/** One object sprite to draw. */
|
/** One object sprite to draw. */
|
||||||
export interface ObjectDrawable {
|
export interface ObjectDrawable {
|
||||||
|
readonly id?: number | undefined
|
||||||
|
readonly objectsTxtId?: number | undefined
|
||||||
|
readonly token?: string | undefined
|
||||||
|
readonly name?: string | null | undefined
|
||||||
|
readonly kind?: 'object' | 'npc' | undefined
|
||||||
frame: AtlasFrame
|
frame: AtlasFrame
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
readonly depth: number
|
readonly depth: number
|
||||||
/** Object atlas page index this frame lives on. */
|
/** Object atlas page index this frame lives on. */
|
||||||
page: number
|
page: number
|
||||||
readonly baseX?: number
|
readonly baseX?: number | undefined
|
||||||
readonly baseY?: number
|
readonly baseY?: number | undefined
|
||||||
readonly animatedFrames?: readonly AnimatedObjectFrame[]
|
readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined
|
||||||
readonly frameDurationMs?: number
|
readonly frameDurationMs?: number | undefined
|
||||||
readonly cycleAnim?: boolean
|
readonly cycleAnim?: boolean | undefined
|
||||||
readonly phaseOffsetMs?: number
|
readonly phaseOffsetMs?: number | undefined
|
||||||
currentFrameIndex?: number
|
currentFrameIndex?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens that represent persistent environmental fire and illumination objects.
|
||||||
|
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
|
||||||
|
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
|
||||||
|
*/
|
||||||
|
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
|
||||||
|
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
|
||||||
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
|
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
|
||||||
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
|
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
|
||||||
|
|
@ -518,7 +532,9 @@ export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): bool
|
||||||
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
|
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
|
||||||
const phase = obj.phaseOffsetMs ?? 0
|
const phase = obj.phaseOffsetMs ?? 0
|
||||||
const elapsed = Math.max(0, clockMs + phase)
|
const elapsed = Math.max(0, clockMs + phase)
|
||||||
const targetIndex = obj.cycleAnim !== false
|
const isPersistent = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
|
||||||
|
const cycleAnim = isPersistent || obj.cycleAnim !== false
|
||||||
|
const targetIndex = cycleAnim
|
||||||
? Math.floor(elapsed / duration) % frames.length
|
? Math.floor(elapsed / duration) % frames.length
|
||||||
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
|
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
|
||||||
|
|
||||||
|
|
@ -2749,7 +2765,8 @@ async function buildPackRuntime(
|
||||||
? Math.round((256 / obj.frameDelta) * 40 * 100) / 100
|
? Math.round((256 / obj.frameDelta) * 40 * 100) / 100
|
||||||
: 40
|
: 40
|
||||||
)
|
)
|
||||||
const cycleAnim = obj.cycleAnim ?? true
|
const isPersistentFire = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
|
||||||
|
const cycleAnim = isPersistentFire ? true : (obj.cycleAnim ?? true)
|
||||||
|
|
||||||
let phaseOffsetMs = 0
|
let phaseOffsetMs = 0
|
||||||
if (hasAnim && !obj.sync) {
|
if (hasAnim && !obj.sync) {
|
||||||
|
|
@ -2758,6 +2775,9 @@ async function buildPackRuntime(
|
||||||
}
|
}
|
||||||
|
|
||||||
objectDrawables.push({
|
objectDrawables.push({
|
||||||
|
id: obj.id,
|
||||||
|
token: obj.token,
|
||||||
|
name: obj.name,
|
||||||
frame: {
|
frame: {
|
||||||
x: obj.frame.x,
|
x: obj.frame.x,
|
||||||
y: obj.frame.y,
|
y: obj.frame.y,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
computeObjectPhaseOffsetMs,
|
||||||
|
updateAnimatedObject,
|
||||||
|
updateAnimatedObjects,
|
||||||
|
PERSISTENT_ILLUMINATION_TOKENS,
|
||||||
|
type ObjectDrawable,
|
||||||
|
type AnimatedObjectFrame,
|
||||||
|
} from '../src/scene/act-scene.ts'
|
||||||
|
import {
|
||||||
|
updateAnimatableTiles,
|
||||||
|
type AnimatableTile,
|
||||||
|
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
|
||||||
|
} from '../src/game/animated-tiles.ts'
|
||||||
|
|
||||||
|
describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #398)', () => {
|
||||||
|
describe('PERSISTENT_ILLUMINATION_TOKENS definitions', () => {
|
||||||
|
it('includes all canonical illumination and environmental fire tokens', () => {
|
||||||
|
const requiredTokens = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL']
|
||||||
|
for (const token of requiredTokens) {
|
||||||
|
expect(PERSISTENT_ILLUMINATION_TOKENS.has(token)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Persistent illumination looping parity (updateAnimatedObject)', () => {
|
||||||
|
const dummyAtlasFrame = (i: number) => ({
|
||||||
|
x: i * 32,
|
||||||
|
y: 0,
|
||||||
|
width: 32,
|
||||||
|
height: 64,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTestIlluminationDrawable = (
|
||||||
|
token: string,
|
||||||
|
frameCount = 10,
|
||||||
|
frameDurationMs = 80,
|
||||||
|
forceCycleAnim?: boolean,
|
||||||
|
): ObjectDrawable => {
|
||||||
|
const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({
|
||||||
|
frame: dummyAtlasFrame(i),
|
||||||
|
page: 0,
|
||||||
|
offsetX: -16,
|
||||||
|
offsetY: -32,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
frame: frames[0]!.frame,
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
depth: 60,
|
||||||
|
page: 0,
|
||||||
|
baseX: 100,
|
||||||
|
baseY: 200,
|
||||||
|
animatedFrames: frames,
|
||||||
|
frameDurationMs,
|
||||||
|
cycleAnim: forceCycleAnim,
|
||||||
|
phaseOffsetMs: 0,
|
||||||
|
currentFrameIndex: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('cycles continuously even if cycleAnim was false for persistent illumination tokens', () => {
|
||||||
|
// Tokens like FX, FY, FZ (environmental fires) or BR (braziers) might have cycleAnim=0 in Objects.txt mode 0
|
||||||
|
const tokensToTest = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL']
|
||||||
|
|
||||||
|
for (const token of tokensToTest) {
|
||||||
|
const obj = createTestIlluminationDrawable(token, 10, 80, false)
|
||||||
|
const totalDuration = 10 * 80 // 800ms
|
||||||
|
|
||||||
|
// At t=0
|
||||||
|
updateAnimatedObject(obj, 0)
|
||||||
|
expect(obj.currentFrameIndex).toBe(0)
|
||||||
|
|
||||||
|
// At t=160ms (frame 2)
|
||||||
|
updateAnimatedObject(obj, 160)
|
||||||
|
expect(obj.currentFrameIndex).toBe(2)
|
||||||
|
|
||||||
|
// Beyond cycle: at t=800ms, should loop back to frame 0 (NOT clamped to frame 9)
|
||||||
|
updateAnimatedObject(obj, 800)
|
||||||
|
expect(obj.currentFrameIndex).toBe(0)
|
||||||
|
|
||||||
|
// At t=1040ms (800 + 3 * 80), should be frame 3
|
||||||
|
updateAnimatedObject(obj, 1040)
|
||||||
|
expect(obj.currentFrameIndex).toBe(3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps non-persistent objects when cycleAnim is false', () => {
|
||||||
|
const nonFireObj = createTestIlluminationDrawable('XX', 10, 80, false)
|
||||||
|
// At t=1000ms (> 800ms total duration)
|
||||||
|
updateAnimatedObject(nonFireObj, 1000)
|
||||||
|
expect(nonFireObj.currentFrameIndex).toBe(9) // clamped to last frame
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maintains independent spatial phase offsets across torches and candles', () => {
|
||||||
|
const duration = 20 * 51.2
|
||||||
|
const p1 = computeObjectPhaseOffsetMs(100, 200, duration)
|
||||||
|
const p2 = computeObjectPhaseOffsetMs(104, 208, duration)
|
||||||
|
expect(p1).not.toBe(p2)
|
||||||
|
expect(p1).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(p1).toBeLessThan(duration)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DT1 animated tile serialization and unpacking parity', () => {
|
||||||
|
it('correctly unpacks extended animated tile rows [frameIndex, x, y, cx, cy, ...animFrames]', () => {
|
||||||
|
// Simulate scene frames
|
||||||
|
const dummyFrames = [
|
||||||
|
{ x: 0, y: 0, width: 160, height: 80 },
|
||||||
|
{ x: 160, y: 0, width: 160, height: 80 },
|
||||||
|
{ x: 320, y: 0, width: 160, height: 80 },
|
||||||
|
{ x: 480, y: 0, width: 160, height: 80 },
|
||||||
|
]
|
||||||
|
const framePlacement = [
|
||||||
|
[0, 0, 0, 160, 80],
|
||||||
|
[0, 160, 0, 160, 80],
|
||||||
|
[0, 320, 0, 160, 80],
|
||||||
|
[0, 480, 0, 160, 80],
|
||||||
|
]
|
||||||
|
|
||||||
|
// Row with 4 animated frames: indices 0, 1, 2, 3
|
||||||
|
const row = [0, 100, 200, 5, 10, 0, 1, 2, 3]
|
||||||
|
const frameIndex = row[0] ?? 0
|
||||||
|
const animFrames = row.length > 5 ? row.slice(5) : undefined
|
||||||
|
const anim = animFrames && animFrames.length > 1
|
||||||
|
? animFrames.map(fIdx => ({
|
||||||
|
frame: dummyFrames[fIdx]!,
|
||||||
|
page: framePlacement[fIdx]?.[0] ?? 0,
|
||||||
|
}))
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const tile: AnimatableTile = {
|
||||||
|
frame: dummyFrames[frameIndex]!,
|
||||||
|
page: framePlacement[frameIndex]?.[0] ?? 0,
|
||||||
|
animatedFrames: anim,
|
||||||
|
currentFrameIndex: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(tile.animatedFrames).toBeDefined()
|
||||||
|
expect(tile.animatedFrames!.length).toBe(4)
|
||||||
|
|
||||||
|
// Verify updateAnimatableTiles advances frames
|
||||||
|
const tiles = [tile]
|
||||||
|
// At t=0ms
|
||||||
|
updateAnimatableTiles(tiles, 0, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS)
|
||||||
|
expect(tile.currentFrameIndex).toBe(0)
|
||||||
|
|
||||||
|
// At t=150ms (> 128ms)
|
||||||
|
updateAnimatableTiles(tiles, 150, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS)
|
||||||
|
expect(tile.currentFrameIndex).toBe(1)
|
||||||
|
expect(tile.frame).toEqual(dummyFrames[1])
|
||||||
|
|
||||||
|
// At t=550ms (> 4 * 128 = 512ms, looped to frame 0)
|
||||||
|
updateAnimatableTiles(tiles, 550, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS)
|
||||||
|
expect(tile.currentFrameIndex).toBe(0)
|
||||||
|
expect(tile.frame).toEqual(dummyFrames[0])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Baked Map Scene Inspection (Wilderness & Dungeons)', () => {
|
||||||
|
const packsDir = join(process.cwd(), 'samples/d2-packs')
|
||||||
|
|
||||||
|
it('verifies town scene (towne1) preserves all animated objects', () => {
|
||||||
|
const townPath = join(packsDir, 'act1/1-act-1-town-towne1/scene.json')
|
||||||
|
if (!existsSync(townPath)) return
|
||||||
|
|
||||||
|
const scene = JSON.parse(readFileSync(townPath, 'utf8'))
|
||||||
|
const objects = scene.objects as any[]
|
||||||
|
|
||||||
|
// Campfire (RB)
|
||||||
|
const rbs = objects.filter(o => o.token === 'RB')
|
||||||
|
expect(rbs.length).toBeGreaterThan(0)
|
||||||
|
for (const rb of rbs) {
|
||||||
|
expect(rb.animatedFrames?.length).toBe(20)
|
||||||
|
expect(rb.cycleAnim).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torches (TO)
|
||||||
|
const tos = objects.filter(o => o.token === 'TO')
|
||||||
|
expect(tos.length).toBeGreaterThan(0)
|
||||||
|
for (const to of tos) {
|
||||||
|
expect(to.animatedFrames?.length).toBe(20)
|
||||||
|
expect(to.cycleAnim).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies Cave (Level 10) packs have animated illumination objects', () => {
|
||||||
|
const act1Dir = join(packsDir, 'act1')
|
||||||
|
if (!existsSync(act1Dir)) return
|
||||||
|
|
||||||
|
const caveDirs = readdirSync(act1Dir).filter(name => name.startsWith('10-act-1-cave'))
|
||||||
|
expect(caveDirs.length).toBeGreaterThan(0)
|
||||||
|
for (const d of caveDirs) {
|
||||||
|
const scenePath = join(act1Dir, d, 'scene.json')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
|
||||||
|
const illumination = (scene.objects as any[]).filter(o =>
|
||||||
|
o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase())
|
||||||
|
)
|
||||||
|
expect(illumination.length).toBeGreaterThan(0)
|
||||||
|
for (const item of illumination) {
|
||||||
|
if (item.animatedFrames) {
|
||||||
|
expect(item.animatedFrames.length).toBeGreaterThan(1)
|
||||||
|
expect(item.cycleAnim).toBe(true)
|
||||||
|
expect(item.frameDelta).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies Catacombs (Level 34) packs have animated candles (A1, A2) and fires (BF, BR)', () => {
|
||||||
|
const act1Dir = join(packsDir, 'act1')
|
||||||
|
if (!existsSync(act1Dir)) return
|
||||||
|
|
||||||
|
const catacombsDirs = readdirSync(act1Dir).filter(name => name.startsWith('34-act-1-catacombs'))
|
||||||
|
expect(catacombsDirs.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
let totalCandlesOrFires = 0
|
||||||
|
for (const d of catacombsDirs) {
|
||||||
|
const scenePath = join(act1Dir, d, 'scene.json')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
|
||||||
|
const items = (scene.objects as any[]).filter(o =>
|
||||||
|
o.token && ['A1', 'A2', 'BF', 'BR', 'TO'].includes(o.token.trim().toUpperCase())
|
||||||
|
)
|
||||||
|
for (const item of items) {
|
||||||
|
totalCandlesOrFires += 1
|
||||||
|
if (item.animatedFrames) {
|
||||||
|
expect(item.animatedFrames.length).toBeGreaterThan(1)
|
||||||
|
expect(item.cycleAnim).toBe(true)
|
||||||
|
expect(item.frameDurationMs).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(totalCandlesOrFires).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies Wilderness and Crypt maps contain cycleAnim=true on illumination objects', () => {
|
||||||
|
const act1Dir = join(packsDir, 'act1')
|
||||||
|
if (!existsSync(act1Dir)) return
|
||||||
|
|
||||||
|
const cryptDirs = readdirSync(act1Dir).filter(name => name.startsWith('18-act-1-crypt') || name.startsWith('19-act-1-crypt'))
|
||||||
|
expect(cryptDirs.length).toBeGreaterThan(0)
|
||||||
|
for (const d of cryptDirs) {
|
||||||
|
const scenePath = join(act1Dir, d, 'scene.json')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
|
||||||
|
const items = (scene.objects as any[]).filter(o =>
|
||||||
|
o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase())
|
||||||
|
)
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.animatedFrames) {
|
||||||
|
expect(item.cycleAnim).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies DT1 animated tile sequences in scenes that contain animated tiles', () => {
|
||||||
|
const act4Dir = join(packsDir, 'act4')
|
||||||
|
if (!existsSync(act4Dir)) return
|
||||||
|
|
||||||
|
// River of flame (Level 107) and Chaos Sanctuary (Level 108) have animated lava tiles
|
||||||
|
const chaosDirs = readdirSync(act4Dir).filter(name => name.startsWith('108-act-4-diablo') || name.startsWith('107-act-4-lava'))
|
||||||
|
expect(chaosDirs.length).toBeGreaterThan(0)
|
||||||
|
let totalAnimatedTiles = 0
|
||||||
|
for (const d of chaosDirs) {
|
||||||
|
const scenePath = join(act4Dir, d, 'scene.json')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
|
||||||
|
const draws = [
|
||||||
|
...(scene.floors ?? []),
|
||||||
|
...(scene.walls ?? []),
|
||||||
|
...(scene.shadows ?? []),
|
||||||
|
...(scene.roofs ?? []),
|
||||||
|
]
|
||||||
|
const animatedTileDraws = draws.filter((row: number[]) => row.length > 5)
|
||||||
|
totalAnimatedTiles += animatedTileDraws.length
|
||||||
|
if (animatedTileDraws.length > 0) {
|
||||||
|
for (const row of animatedTileDraws) {
|
||||||
|
const animFrames = row.slice(5)
|
||||||
|
expect(animFrames.length).toBeGreaterThan(1)
|
||||||
|
// Each frame index should be valid in scene.frames
|
||||||
|
for (const fIdx of animFrames) {
|
||||||
|
expect(fIdx).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(fIdx).toBeLessThan(scene.frames.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(totalAnimatedTiles).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Reference in New Issue