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]
|
||||
*/
|
||||
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<string, Dt1>()
|
||||
/** 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<Uint8Array> {
|
|||
return pl2.rgb
|
||||
}
|
||||
|
||||
const index: Record<string, unknown> = {
|
||||
version: 1,
|
||||
generated: new Date().toISOString(),
|
||||
archiveDir,
|
||||
pageSize: PAGE_SIZE,
|
||||
palettes: {} as Record<string, number[]>,
|
||||
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<string, number[]>)[`act${String(act)}`] = [...pal]
|
||||
interface SceneResult {
|
||||
readonly jobId: number
|
||||
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(
|
||||
jobId: number,
|
||||
entry: LevelJob,
|
||||
levelName: string,
|
||||
palette: Uint8Array,
|
||||
|
|
@ -1215,7 +1235,9 @@ async function bakeDs1Variant(
|
|||
readonly animSpeed?: number | undefined
|
||||
} = { entrances: [], mazeWarps: [] },
|
||||
animSpeedOverride?: number,
|
||||
): Promise<void> {
|
||||
): Promise<SceneResult> {
|
||||
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<number>()
|
||||
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)
|
||||
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 }>()
|
||||
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,51 +1656,62 @@ 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<SceneResult> {
|
||||
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)
|
||||
|
|
@ -1682,42 +1727,29 @@ for (const entry of LEVELS) {
|
|||
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,
|
||||
levelId: job.entry.levelId,
|
||||
levelName: info.levelName,
|
||||
sizeX: info.sizeX > 0 ? info.sizeX : 120,
|
||||
sizeY: info.sizeY > 0 ? info.sizeY : 120,
|
||||
seed,
|
||||
seed: job.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,
|
||||
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,
|
||||
)
|
||||
}
|
||||
} 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (entry.kind === 'maze') {
|
||||
|
||||
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,33 +1759,29 @@ 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,
|
||||
levelId: job.entry.levelId,
|
||||
levelName: job.entry.name,
|
||||
levelTypeName: type.name,
|
||||
sectionSize: sectionX,
|
||||
sectionHeight: sectionY,
|
||||
minRooms,
|
||||
merge,
|
||||
seed,
|
||||
seed: job.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,
|
||||
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[],
|
||||
|
|
@ -1761,54 +1789,45 @@ for (const entry of LEVELS) {
|
|||
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 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 gates = gatesFor(job.entry.levelId)
|
||||
const result = generateWilderness({
|
||||
levelId: entry.levelId,
|
||||
levelName: entry.name,
|
||||
levelId: job.entry.levelId,
|
||||
levelName: job.entry.name,
|
||||
levelTypeName: type.name,
|
||||
sizeX,
|
||||
sizeY,
|
||||
subType,
|
||||
subTheme: Math.max(0, subTheme),
|
||||
seed,
|
||||
seed: job.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,
|
||||
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: [],
|
||||
|
|
@ -1819,14 +1838,291 @@ for (const entry of LEVELS) {
|
|||
...(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 })
|
||||
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 {
|
||||
|
|
@ -1857,13 +2153,11 @@ if (filterLevel !== undefined || filterAct !== null) {
|
|||
// 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({
|
||||
// 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 => ({
|
||||
|
|
@ -1884,12 +2178,60 @@ await writeFile(join(outDir, 'world-graph.json'), JSON.stringify({
|
|||
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)
|
||||
}
|
||||
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)} 个传送点`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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