feat(packer): bake all 101 generated levels into static packs with 3 variants each

- Update scripts/pack-act-assets.ts to bake 3 deterministic variants for each maze (DrlgType 1) and wilderness (DrlgType 3) level
- Cache mazePieces, wildernessPieces, substitutions, and palettes for fast one-pass baking
- Populate LEVEL_NAMES_ZH with all 136 standard Chinese level names
- Update variantLabelZh to format var1..var3 as 变体 1..3
- Guard scripts/verify-packs.ts to verify preset levels cleanly while supporting non-preset entries
This commit is contained in:
troytt 2026-09-14 11:15:13 +00:00
parent 1cfef30878
commit 2a0dd4ce25
3 changed files with 632 additions and 296 deletions

View File

@ -30,9 +30,10 @@ import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, loadActTables, parseTable, resolveLevel } from '../src/game/acts.ts'
import type { ActTables, LevelInfo } from '../src/game/acts.ts'
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts'
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
@ -42,6 +43,10 @@ import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts'
import type { SpriteFrame } from '../src/formats/sprite.ts'
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
import { generateWilderness } from '../src/game/wilderness.ts'
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
import { encodeIndexedPng } from './png.ts'
/**
@ -62,7 +67,7 @@ const OBJECT_PREFIX = 'data\\global\\objects\\'
/**
* Mode directories tried in order when picking an object's art.
*
* Note what the object art actually *is*: under `data\global\objects\` the
* Note what the object art actually *is*: under `data\global\objects` the
* archives hold **1748 DCC and 1461 COF files against just 13 DC6**. Diablo II
* draws objects with the same composite pipeline as characters — a COF that
* lists layers and frames plus the DCC files behind them — so this packer
@ -417,6 +422,141 @@ function pickObjectMember(token: string, modeToken: string): { member: string; c
/** Cache decoded member frames across levels so each unique member is decoded once. */
const decodedMemberCache = new Map<string, { frame: SpriteFrame; offsetX: number; offsetY: number }>()
const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt'))
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
/** The level type row for a level row. */
function levelType(levelRow: readonly string[]): { id: string; name: string } {
const id = cell(tables.levels, levelRow, 'LevelType')
const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id)
return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') }
}
/** The `LvlMaze` row for a level, by id then by name. */
function mazeRow(levelId: number, levelName: string): readonly string[] | undefined {
return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId)
?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName)
}
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(member: string): Promise<Ds1> {
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await archives.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const value = cell(table, row, `File${String(slot)}`)
if (value === '' || value === '0') continue
levels.push(await loadDs1(tileMemberPath(value)))
}
return levels
}
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
const values: number[] = []
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
return values
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': ['Act 1 - Wild'],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
const mazePiecesCache = new Map<string, Promise<MazePiece[]>>()
function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<MazePiece[]> {
void levelTypeId
const cached = mazePiecesCache.get(levelTypeName)
if (cached !== undefined) return cached
const promise = (async () => {
const pieces: MazePiece[] = []
for (const row of tables.lvlprest.rows) {
if (cell(tables.lvlprest, row, 'LevelId') !== '0' && cell(tables.lvlprest, row, 'LevelId') !== '') continue
const name = cell(tables.lvlprest, row, 'Name')
const classified = classifyMazePieceName(name, levelTypeName)
if (classified === null) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
}
return pieces
})()
mazePiecesCache.set(levelTypeName, promise)
return promise
}
const wildernessPiecesCache = new Map<string, Promise<WildernessPiece[]>>()
function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const cached = wildernessPiecesCache.get(levelTypeName)
if (cached !== undefined) return cached
const promise = (async () => {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, levels, border: /border|cliff/i.test(name) })
}
return pieces
})()
wildernessPiecesCache.set(levelTypeName, promise)
return promise
}
const substitutionsCache = new Map<number, Promise<WildernessSubstitution[]>>()
function getSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return Promise.resolve([])
const cached = substitutionsCache.get(type)
if (cached !== undefined) return cached
const promise = (async () => {
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(tileMemberPath(file))]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: themeValues(lvlsub, row, 'Prob'),
trials: themeValues(lvlsub, row, 'Trials'),
max: themeValues(lvlsub, row, 'Max'),
levels,
})
}
return rows
})()
substitutionsCache.set(type, promise)
return promise
}
const paletteCache = new Map<string, Uint8Array>()
async function getPalette(paletteName: string): Promise<Uint8Array> {
const cached = paletteCache.get(paletteName)
if (cached !== undefined) return cached
const pl2 = decodePl2(await archives.read(paletteName))
paletteCache.set(paletteName, pl2.rgb)
return pl2.rgb
}
const index: Record<string, unknown> = {
version: 1,
generated: new Date().toISOString(),
@ -426,318 +566,401 @@ const index: Record<string, unknown> = {
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]
}
let totalPngBytes = 0
let totalLevels = 0
const pending: string[] = []
for (const entry of LEVELS) {
if (entry.kind !== 'preset') {
// Maze and wilderness levels come from the generators; until those land they
// are reported rather than silently missing from the pack.
pending.push(`${entry.kind} ${String(entry.levelId)} ${entry.name}`)
continue
async function bakeDs1Variant(
entry: LevelJob,
levelName: string,
palette: Uint8Array,
paletteName: string,
dt1Names: readonly string[],
libraries: Dt1[],
level: Ds1,
ds1Name: string,
label: string,
seed: number,
): Promise<void> {
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
const spawn = findIsoSpawn(scene)
const dir = join(outDir, `act${String(entry.act)}`, label)
// Frames used near the spawn load first; everything else follows.
const hot = new Set<number>()
if (spawn !== null) {
const spawnCell = cellAt(scene, spawn.x, spawn.y)
for (const draw of scene.floors) {
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 info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
const pl2 = decodePl2(await archives.read(info.paletteName))
const palette = pl2.rgb
;(index.palettes as Record<string, number[]>)[`act${String(entry.act)}`] = [...palette]
const libraries = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
const firstDrawAt = new Map<number, { x: number; y: number }>()
for (const draw of [...scene.floors, ...scene.walls]) {
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
}
const bandOf = (frameIndex: number): number => {
const at = firstDrawAt.get(frameIndex)
if (at === undefined) return Number.MAX_SAFE_INTEGER
return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768)
}
const order = scene.frames.map((_, at) => at).sort((a, b) => {
const ha = hot.has(a) ? 0 : 1
const hb = hot.has(b) ? 0 : 1
if (ha !== hb) return ha - hb
const ba = bandOf(a)
const bb = bandOf(b)
if (ba !== bb) return ba - bb
const fa = scene.frames[a]!
const fb = scene.frames[b]!
return fb.width * fb.height - fa.width * fa.height
})
const pages = new PageBuilder(palette)
const placementOf = new Map<number, Placement>()
for (const frameIndex of order) {
const frame = scene.frames[frameIndex]!
placementOf.set(frameIndex, pages.add(frame))
}
const pageFiles = pages.encode(0)
await mkdir(dir, { recursive: true })
const sha = createHash('sha256')
for (const page of pageFiles) {
await writeFile(join(dir, page.name), page.png)
sha.update(page.png)
totalPngBytes += page.png.byteLength
}
for (const ds1Name of info.ds1Names) {
const level = decodeDs1(await archives.read(ds1Name))
const scene: IsoMapScene = buildIsoMapScene(level, libraries, levelSeed(ds1Name))
const spawn = findIsoSpawn(scene)
const label = `${entry.slug}-${slugOf(ds1Name)}`
const dir = join(outDir, `act${String(entry.act)}`, label)
const objectPages = new PageBuilder(palette)
const objects: unknown[] = []
const missingObjects: string[] = []
let objectsWithArt = 0
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
// Frames used near the spawn load first; everything else follows.
const hot = new Set<number>()
if (spawn !== null) {
const spawnCell = cellAt(scene, spawn.x, spawn.y)
for (const draw of scene.floors) {
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)
}
}
// Page order decides how much a first paint costs. Hot frames (the spawn
// area) come first, then frames are ordered by *where on the map they are
// first drawn*, in coarse screen bands (roughly a viewport each): a viewport then needs one band's page
// instead of an arbitrary handful. Sorting by size instead — the obvious
// choice for packing efficiency — scatters a viewport's frames across every
// page, which is exactly what lazy loading cannot afford.
const firstDrawAt = new Map<number, { x: number; y: number }>()
for (const draw of [...scene.floors, ...scene.walls]) {
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
}
const bandOf = (frameIndex: number): number => {
const at = firstDrawAt.get(frameIndex)
if (at === undefined) return Number.MAX_SAFE_INTEGER
return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768)
}
const order = scene.frames.map((_, at) => at).sort((a, b) => {
const ha = hot.has(a) ? 0 : 1
const hb = hot.has(b) ? 0 : 1
if (ha !== hb) return ha - hb
const ba = bandOf(a)
const bb = bandOf(b)
if (ba !== bb) return ba - bb
const fa = scene.frames[a]!
const fb = scene.frames[b]!
return fb.width * fb.height - fa.width * fa.height
})
const pages = new PageBuilder(palette)
const placementOf = new Map<number, Placement>()
for (const frameIndex of order) {
const frame = scene.frames[frameIndex]!
placementOf.set(frameIndex, pages.add(frame))
}
const pageFiles = pages.encode(0)
await mkdir(dir, { recursive: true })
const sha = createHash('sha256')
for (const page of pageFiles) {
await writeFile(join(dir, page.name), page.png)
sha.update(page.png)
totalPngBytes += page.png.byteLength
for (const object of level.objects) {
let resolved
try {
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
} catch (err) {
missingObjects.push((err as Error).message)
continue
}
if (resolved.kind === 'monster') continue
const row = resolved.row
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
// Object placements and their art members. The art itself is decoded from DCC/DC6
// and shelf-packed into objectPages so the client can draw objects at depth.
const objectPages = new PageBuilder(palette)
const objects: unknown[] = []
const missingObjects: string[] = []
let objectsWithArt = 0
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
let objectFrame: {
page: number
x: number
y: number
width: number
height: number
offsetX: number
offsetY: number
} | null = null
for (const object of level.objects) {
// The DS1 `id` is an index into the hardcoded per-act object table, not an
// `Objects.txt` row: act 1 id 0 is the rogue fountain (`Objects.txt` 12), not
// row 0 ("Expansion"). `resolveDs1Object` walks that table and returns the
// token/mode the engine actually uses.
let resolved
try {
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
} catch (err) {
missingObjects.push((err as Error).message)
continue
}
if (resolved.kind === 'monster') continue
const row = resolved.row
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
let objectFrame: {
page: number
x: number
y: number
width: number
height: number
offsetX: number
offsetY: number
} | null = null
if (pick !== null) {
let placed = placementByMember.get(pick.member)
if (placed === undefined) {
let art = decodedMemberCache.get(pick.member)
if (art === undefined) {
try {
const bytes = await archives.read(pick.member)
if (pick.member.toLowerCase().endsWith('.dc6')) {
const sheet = decodeDc6(bytes)
const group = sheet.groups[0]
const f = group?.frames[0]
if (f) {
art = {
frame: f,
offsetX: -16 + f.offsetX,
offsetY: 16 + (f.offsetY - f.height + 1),
}
}
} else {
const dcc = decodeDcc(bytes)
const dir = dcc.directions[0]
const f = dir?.frames[0]
if (f && dir) {
art = {
frame: f.frame,
offsetX: -16 + dir.box.left,
offsetY: 16 + dir.box.top,
}
if (pick !== null) {
let placed = placementByMember.get(pick.member)
if (placed === undefined) {
let art = decodedMemberCache.get(pick.member)
if (art === undefined) {
try {
const bytes = await archives.read(pick.member)
if (pick.member.toLowerCase().endsWith('.dc6')) {
const sheet = decodeDc6(bytes)
const group = sheet.groups[0]
const f = group?.frames[0]
if (f) {
art = {
frame: f,
offsetX: -16 + f.offsetX,
offsetY: 16 + (f.offsetY - f.height + 1),
}
}
} else {
const dcc = decodeDcc(bytes)
const dir = dcc.directions[0]
const f = dir?.frames[0]
if (f && dir) {
art = {
frame: f.frame,
offsetX: -16 + dir.box.left,
offsetY: 16 + dir.box.top,
}
}
if (art) decodedMemberCache.set(pick.member, art)
} catch (err) {
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
}
}
if (art) {
const placement = objectPages.add(art.frame)
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
placementByMember.set(pick.member, placed)
if (art) decodedMemberCache.set(pick.member, art)
} catch (err) {
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
}
}
if (placed) {
objectFrame = {
page: placed.placement.page,
x: placed.placement.x,
y: placed.placement.y,
width: placed.placement.width,
height: placed.placement.height,
offsetX: placed.offsetX,
offsetY: placed.offsetY,
}
if (art) {
const placement = objectPages.add(art.frame)
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
placementByMember.set(pick.member, placed)
}
}
if (placed) {
objectFrame = {
page: placed.placement.page,
x: placed.placement.x,
y: placed.placement.y,
width: placed.placement.width,
height: placed.placement.height,
offsetX: placed.offsetX,
offsetY: placed.offsetY,
}
}
objects.push({
id: object.id,
type: object.type,
name: row?.name ?? resolved.token,
token: resolved.token,
mode: resolved.mode === '' ? 'NU' : resolved.mode,
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
// engine picks the art straight from the table's token, with no row.
objectsTxtId: resolved.entry?.objectsTxtId ?? -1,
hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0),
member: pick === null ? null : pick.member,
alternatives: pick === null ? 0 : pick.candidates,
// Screen point of the object's sub-tile; the sprite anchor is applied
// when a frame exists.
x: Math.round(orthoX),
y: Math.round(orthoY),
depth: (object.x + object.y) / 5,
frame: objectFrame,
})
if (pick !== null) objectsWithArt += 1
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
}
const objectFiles = objectPages.encode(0)
const shaObjects = createHash('sha256')
for (const page of objectFiles) {
await writeFile(join(dir, page.name.replace('tiles-', 'objects-')), page.png)
shaObjects.update(page.png)
totalPngBytes += page.png.byteLength
}
// Collision grid as runs, so the JSON stays small without a second format.
const runs: number[][] = []
let current = scene.blocked[0] ?? 0
let count = 0
for (const value of scene.blocked) {
if (value === current) { count += 1; continue }
runs.push([current, count])
current = value
count = 1
}
runs.push([current, count])
const sceneJson = {
version: 1,
fidelity: 'exact',
act: entry.act,
levelId: entry.levelId,
levelName: info.levelName,
ds1: ds1Name,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
originX: scene.originX,
originY: scene.originY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
pageSize: PAGE_SIZE,
pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })),
objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })),
frames: scene.frames.map(frame => [frame.width, frame.height]),
frameHash: scene.frames.map(frame => frameHash(frame.indices)),
framePlacement: scene.frames.map((_, at) => {
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]),
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
// Roofs stay in their own list because the engine paints them last of all.
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
objects,
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
stats: {
floors: scene.floors.length,
walls: scene.walls.length,
roofs: scene.roofs.length,
frames: scene.frames.length,
missingTiles: scene.missingTiles,
missingRefs: scene.missingRefs,
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
objects: objects.length,
objectsWithArt,
objectsUnresolved: missingObjects,
objectsArtPending: objects.filter((o: any) => o.member !== null && o.frame === null).length,
dt1Libraries: info.dt1Names.length,
},
}
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
await writeFile(join(dir, 'scene.json'), sceneBytes)
const manifest = {
level: info.levelName,
act: entry.act,
levelId: entry.levelId,
ds1: ds1Name,
dt1: info.dt1Names,
palette: info.paletteName,
sceneBytes: sceneBytes.byteLength,
sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'),
tilePagesSha256: sha.digest('hex'),
objectPagesSha256: shaObjects.digest('hex'),
pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0)
+ objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0),
sourceArchive: MOUNTS,
}
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1))
;(index.levels as unknown[]).push({
act: entry.act,
levelId: entry.levelId,
kind: entry.kind,
slug: entry.slug,
label,
levelName: info.levelName,
ds1: ds1Name,
path: `act${String(entry.act)}/${label}`,
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
frames: scene.frames.length,
objects: objects.length,
pages: pageFiles.length,
objectPages: objectFiles.length,
missingTiles: scene.missingTiles,
bytes: manifest.pngBytes + sceneBytes.byteLength,
objects.push({
id: object.id,
type: object.type,
name: row?.name ?? resolved.token,
token: resolved.token,
mode: resolved.mode === '' ? 'NU' : resolved.mode,
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
// engine picks the art straight from the table's token, with no row.
objectsTxtId: resolved.entry?.objectsTxtId ?? -1,
hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0),
member: pick === null ? null : pick.member,
alternatives: pick === null ? 0 : pick.candidates,
// Screen point of the object's sub-tile; the sprite anchor is applied
// when a frame exists.
x: Math.round(orthoX),
y: Math.round(orthoY),
depth: (object.x + object.y) / 5,
frame: objectFrame,
})
totalLevels += 1
const bakedFramesCount = objects.filter((o: any) => o.frame !== null).length
const pendingCount = objects.filter((o: any) => o.member !== null && o.frame === null).length
console.log(
`act${String(entry.act)}/${label.padEnd(22)} ${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 (pick !== null) objectsWithArt += 1
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
}
const objectFiles = objectPages.encode(0)
const shaObjects = createHash('sha256')
for (const page of objectFiles) {
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.
const runs: number[][] = []
let current = scene.blocked[0] ?? 0
let count = 0
for (const value of scene.blocked) {
if (value === current) { count += 1; continue }
runs.push([current, count])
current = value
count = 1
}
runs.push([current, count])
const sceneJson = {
version: 1,
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
act: entry.act,
levelId: entry.levelId,
levelName,
ds1: ds1Name,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
originX: scene.originX,
originY: scene.originY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
pageSize: PAGE_SIZE,
pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })),
objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })),
frames: scene.frames.map(frame => [frame.width, frame.height]),
frameHash: scene.frames.map(frame => frameHash(frame.indices)),
framePlacement: scene.frames.map((_, at) => {
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]),
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]),
objects,
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
stats: {
floors: scene.floors.length,
walls: scene.walls.length,
roofs: scene.roofs.length,
frames: scene.frames.length,
missingTiles: scene.missingTiles,
missingRefs: scene.missingRefs,
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
objects: objects.length,
objectsWithArt,
objectsUnresolved: missingObjects,
objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length,
dt1Libraries: dt1Names.length,
},
}
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
await writeFile(join(dir, 'scene.json'), sceneBytes)
const manifest = {
level: levelName,
act: entry.act,
levelId: entry.levelId,
ds1: ds1Name,
dt1: dt1Names,
palette: paletteName,
sceneBytes: sceneBytes.byteLength,
sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'),
tilePagesSha256: sha.digest('hex'),
objectPagesSha256: shaObjects.digest('hex'),
pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0)
+ objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0),
sourceArchive: MOUNTS,
}
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1))
;(index.levels as unknown[]).push({
act: entry.act,
levelId: entry.levelId,
kind: entry.kind,
slug: entry.slug,
label,
levelName,
ds1: ds1Name,
path: `act${String(entry.act)}/${label}`,
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
frames: scene.frames.length,
objects: objects.length,
pages: pageFiles.length,
objectPages: objectFiles.length,
missingTiles: scene.missingTiles,
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)} `
+ `${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)}`,
)
}
const filterLevel = process.env.FILTER_LEVEL
for (const entry of LEVELS) {
if (filterLevel !== undefined && String(entry.levelId) !== filterLevel) 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 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)
const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
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))
}
} 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 sectionX = Number(cell(lvlmaze, row, 'SizeX'))
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
const merge = Number(cell(lvlmaze, row, 'Merge'))
const pieces = await getMazePieces(type.name, type.id)
if (pieces.length === 0) {
console.warn(`skipping maze ${entry.name}: no maze pieces`)
continue
}
const libInfo = resolveLevelLibraries(tables, entry.levelId)
const libraries: Dt1[] = []
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
for (let v = 1; v <= 3; v += 1) {
const seed = 0x5eed_0000 + entry.levelId * 10 + v
const label = `${entry.slug}-var${v}`
try {
const result = generateMaze({
levelId: entry.levelId,
levelName: entry.name,
levelTypeName: type.name,
sectionSize: sectionX,
sectionHeight: sectionY,
minRooms,
merge,
seed,
pieces,
})
await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed)
} catch (err) {
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)
}
}
} else if (entry.kind === '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)
if (pieces.length === 0) {
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`)
continue
}
const rows = await getSubstitutions(subType)
const shrineRows = await getSubstitutions(subShrine)
const libInfo = resolveLevelLibraries(tables, 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_1000 + entry.levelId * 10 + v
const label = `${entry.slug}-var${v}`
try {
const result = generateWilderness({
levelId: entry.levelId,
levelName: entry.name,
levelTypeName: type.name,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: rows,
shrineSubstitutions: shrineRows,
})
await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed)
} catch (err) {
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)
}
}
}
}
await mkdir(outDir, { recursive: true })
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
if (pending.length > 0) {
const byKind = new Map<string, number>()
for (const item of pending) {
const kind = item.split(' ')[0] ?? '?'
byKind.set(kind, (byKind.get(kind) ?? 0) + 1)
}
console.log(`尚未生成(需要生成器):${[...byKind].map(([k, v]) => `${k} ${String(v)} 个`).join(',')}`)
}
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)

View File

@ -97,7 +97,7 @@ for (const name of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
}
const tables = await loadActTables(archives)
const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as {
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string }[]
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string; kind?: string }[]
}
let checks = 0
@ -122,6 +122,7 @@ let unknownObjectIds = 0
const objectsTable = await loadObjectsTable(archives)
for (const entry of index.levels) {
if (entry.kind !== undefined && entry.kind !== 'preset') continue
const packed = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedScene
const info = resolveLevel(tables, packed.levelId, packed.act)
const libraries = []

View File

@ -21,25 +21,103 @@ export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
/** `Levels.txt` 的 `Id` → 中文场景名。 */
export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
// Act 1
1: '罗格营地',
2: '鲜血荒地',
3: '冰冷之原',
4: '石块旷野',
5: '黑暗森林',
6: '黑色荒地',
7: '泰摩高地',
8: '邪恶洞窟',
9: '洞穴一层',
10: '地下通道一层',
11: '窟窿一层',
12: '深坑一层',
13: '洞穴二层',
14: '地下通道二层',
15: '窟窿二层',
16: '深坑二层',
17: '埋骨之地',
18: '大陵寝',
19: '灵庙',
20: '遗忘之塔',
21: '塔地窖一层',
22: '塔地窖二层',
23: '塔地窖三层',
24: '塔地窖四层',
25: '塔地窖五层',
26: '修道院大门',
27: '外回廊',
28: '军营',
29: '监牢一层',
30: '监牢二层',
31: '监牢三层',
32: '内回廊',
33: '大教堂',
34: '地下墓穴一层',
35: '地下墓穴二层',
36: '地下墓穴三层',
37: '地下墓穴四层',
38: '崔斯特瑞姆',
39: '奶牛关',
// Act 2
40: '鲁高因',
41: '碎石荒地',
42: '干燥高地',
43: '遥远绿洲',
44: '遗失的城市',
45: '群蛇峡谷',
46: '术士峡谷',
47: '下水道一层',
48: '下水道二层',
49: '下水道三层',
50: '后宫一层',
51: '后宫二层',
52: '皇宫地牢一层',
53: '皇宫地牢二层',
54: '皇宫地牢三层',
55: '石制古墓一层',
56: '死亡之殿一层',
57: '死亡之殿二层',
58: '利爪蝮蛇神殿一层',
59: '石制古墓二层',
60: '死亡之殿三层',
61: '利爪蝮蛇神殿二层',
62: '蛆虫巢穴一层',
63: '蛆虫巢穴二层',
64: '蛆虫巢穴三层',
65: '古代通道',
66: '塔·拉夏古墓 (1)',
67: '塔·拉夏古墓 (2)',
68: '塔·拉夏古墓 (3)',
69: '塔·拉夏古墓 (4)',
70: '塔·拉夏古墓 (5)',
71: '塔·拉夏古墓 (6)',
72: '塔·拉夏古墓 (7)',
73: '都瑞尔的巢穴',
74: '秘法圣所',
// Act 3
75: '库拉斯特码头',
76: '蜘蛛森林',
77: '庞大湿地',
78: '剥皮丛林',
79: '库拉斯特下层',
80: '库拉斯特商场',
81: '库拉斯特上层',
82: '库拉斯特堤道',
83: '崔凡克',
84: '蜘蛛巢穴',
85: '蜘蛛洞窟',
86: '沼泽地坑一层',
87: '沼泽地坑二层',
88: '剥皮地牢一层',
89: '剥皮地牢二层',
90: '沼泽地坑三层',
91: '剥皮地牢三层',
92: '下水道一层',
93: '下水道二层',
94: '废弃神殿',
95: '荒废神庙',
@ -47,15 +125,47 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
97: '遗忘神殿',
98: '废弃神庙',
99: '荒废圣物室',
100: '憎恨囚牢一层',
101: '憎恨囚牢二层',
102: '憎恨囚牢三层',
103: '火焰之河要塞',
// Act 4
103: '群魔堡垒',
104: '郊外大草原',
105: '绝望平原',
106: '神罚之城',
107: '火焰之河',
108: '混沌避难所',
// Act 5
109: '哈洛加斯',
110: '血腥丘陵',
111: '冰冻高地',
112: '亚瑞特高原',
113: '水晶通道',
114: '冰河',
115: '冰河路径',
116: '漂泊者洞穴',
117: '冰冻苔原',
118: '冰窖',
119: '痛苦之厅',
120: '岩石之巅',
121: '尼拉塞克神殿',
122: '悲痛之厅',
123: '死者呼唤之厅',
124: '沃特厅',
125: '艾巴当',
126: '阿克隆地道',
127: '炼狱地坑',
128: '世界之石要塞一层',
129: '世界之石要塞二层',
130: '世界之石要塞三层',
131: '毁灭王座',
132: '世界之石大殿',
136: '崔斯特瑞姆',
133: '遗忘之沙',
134: '巨窟',
135: '苦痛熔炉',
136: '混沌崔斯特瑞姆',
}
/**
@ -80,10 +190,12 @@ const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
/**
* 把一个变体后缀变成显示用的中文(拿不准就原样返回 DS1 名)。
*
* @param suffix - the part of the pack label after the level slug, e.g. `towns1`.
* @param suffix - the part of the pack label after the level slug, e.g. `towns1` or `var1`.
* @returns the label to show in the third selector.
*/
export function variantLabelZh(suffix: string): string {
const match = /^var(\d+)$/i.exec(suffix)
if (match) return `变体 ${match[1]}`
return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix
}