diablo2-web/scripts/pack-act-assets.ts

643 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Bake map assets out of the archives into web-native packs.
*
* The page can already read the real archives, but doing so costs it 38 MB and
* ~11k range requests per visit (measured), because it decodes DS1/DT1/PL2 and
* builds an atlas in the browser every time. This script moves all of that
* offline: it walks the same code the page walks — `resolveLevel`, `decodeDs1`,
* `decodeDt1`, `buildIsoMapScene`, `decodeDc6` — and writes the *result* as
* indexed PNG tile pages plus one JSON scene per map. The page then decodes
* nothing but PNG.
*
* Design decisions worth knowing:
*
* - **Indexed PNG, one palette per act.** Diablo II art is palette-indexed and
* each act ships its own `pal.pl2`; PNG colour type 3 carries both, so the
* browser's own decoder does the palette expansion on the GPU upload path.
* - **Pages of at most 2048², hot frames first.** Frames used near the spawn go
* into the first page(s), so the page can paint after loading one or two PNGs
* instead of the whole map.
* - **The scene JSON is the authority, not a re-derivation.** Draw lists are
* already in painter's order and the collision grid is exactly the one the
* live path produces; `scripts/verify-packs.ts` re-derives both from the MPQs
* and fails if they differ by a byte.
*
* Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory]
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
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 { decodeDs1 } 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'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
import type { IsoMapScene } from '../src/game/d2map.ts'
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
import { encodeIndexedPng } from './png.ts'
/**
* Archive stack, in load order (later overrides earlier).
*
* `d2char.mpq` is mounted *first* so it sits at the bottom: it holds the object
* and character art (`data\global\objects\…`) but no map data, and this order
* keeps the map lookups resolving through `d2data` → `d2exp` → `Patch_D2` as the
* game does.
*/
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
/** Atlas page side limit (both dimensions). */
const PAGE_SIZE = 2048
/** Cells around the spawn whose frames go into the first page. */
const HOT_RADIUS_CELLS = 8
/** Where object art lives. */
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
* 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
* records each object's placement and its exact art members, and leaves the
* pixels to the `COF`/`DCC` decoder that the character art also needs. Baking a
* wrong file as if it were a DC6 would have produced silent garbage instead.
*/
const OBJECT_MODES = ['tr', 'nu', 's1', 's2', 's3'] as const
/**
* The maps to bake, derived from `Levels.txt` rather than listed by hand.
*
* `DrlgType` picks the generator, exactly as the game does (documented in the
* knowledge base and mirrored by OpenDiablo2):
*
* - **2 = preset area**: `LvlPrest` rows name fixed DS1 files, so the map is
* baked byte-for-byte — what `verify-packs.ts` diffs against the live decoder.
* - **1 = random maze** / **3 = wilderness**: no fixed layout exists. Those come
* from the generators (`src/game/maze.ts`, `src/game/wilderness.ts`) driven by
* `LvlMaze`, `LvlSub` and the `LvlPrest` room pieces, and their packs are
* marked `approximation` because the engine's own placement is hardcoded.
*/
type LevelKind = 'preset' | 'maze' | 'wilderness'
/** One map to bake. */
interface LevelJob {
readonly act: number
readonly levelId: number
readonly name: string
readonly kind: LevelKind
readonly slug: string
}
/** Filled in from `Levels.txt` once the tables are loaded. */
let LEVELS: readonly LevelJob[] = []
/**
* Turn a level name into a stable, file-friendly slug.
*
* @param name - `Levels.txt` name, e.g. `Act 1 - Tristram`.
* @returns the slug.
*/
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
}
/** One frame's placement inside a page. */
interface Placement {
readonly page: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
/** One atlas page being packed. */
interface PackedPage {
readonly pixels: Uint8Array
frames: number
}
/** Frames, shelf-packed into pages of at most {@link PAGE_SIZE} squared. */
class PageBuilder {
private readonly pages: PackedPage[] = []
/** Palette the pages are encoded against. */
private readonly palette: Uint8Array
private cursorX = 0
private shelfY = 0
private shelfHeight = 0
constructor(palette: Uint8Array) {
this.palette = palette
}
/** Frames placed so far. */
get placed(): number {
return this.pages.reduce((sum, page) => sum + page.frames, 0)
}
/** The pages, with their used height. */
get pageCount(): number {
return this.pages.length
}
/**
* Place one indexed frame, opening pages as needed.
*
* Shelves keep placement trivial and predictable; frames wider than a page are
* clamped rather than rejected, because a tile bigger than 2048 px would be a
* broken library, not a reason to lose the whole map.
*
* @param frame - width, height and palette indices.
* @returns where the frame landed.
*/
add(frame: { width: number; height: number; indices: Uint8Array }): Placement {
const width = Math.max(1, Math.min(frame.width, PAGE_SIZE))
const height = Math.max(1, Math.min(frame.height, PAGE_SIZE))
if (this.cursorX + width > PAGE_SIZE) {
this.shelfY += this.shelfHeight
this.shelfHeight = 0
this.cursorX = 0
}
if (this.pages.length === 0 || this.shelfY + height > PAGE_SIZE) {
this.pages.push({ pixels: new Uint8Array(PAGE_SIZE * PAGE_SIZE), frames: 0 })
this.shelfY = 0
this.shelfHeight = 0
this.cursorX = 0
}
const page = this.pages[this.pages.length - 1]!
const placement: Placement = {
page: this.pages.length - 1, x: this.cursorX, y: this.shelfY, width, height,
}
for (let row = 0; row < height; row += 1) {
const from = row * frame.width
const to = (this.shelfY + row) * PAGE_SIZE + this.cursorX
page.pixels.set(frame.indices.subarray(from, from + width), to)
}
this.cursorX += width
this.shelfHeight = Math.max(this.shelfHeight, height)
page.frames += 1
return placement
}
/**
* Encode every page, trimming each to the rows it actually used.
*
* @param transparent - palette index to mark transparent.
* @returns file-ready PNGs, in page order.
*/
encode(transparent: number): { name: string; png: Uint8Array; width: number; height: number; frames: number }[] {
return this.pages.map((page, index) => {
const used = Math.max(1, this.usedHeight(page))
const trimmed = new Uint8Array(PAGE_SIZE * used)
for (let row = 0; row < used; row += 1) {
trimmed.set(page.pixels.subarray(row * PAGE_SIZE, (row + 1) * PAGE_SIZE), row * PAGE_SIZE)
}
return {
name: `tiles-${String(index)}.png`,
png: encodeIndexedPng({
width: PAGE_SIZE,
height: used,
pixels: trimmed,
palette: this.palette,
transparentIndex: transparent,
}),
width: PAGE_SIZE,
height: used,
frames: page.frames,
}
})
}
/**
* Height of the last written row, for trimming.
*
* @param page - the page.
* @returns the used height in rows.
*/
private usedHeight(page: PackedPage): number {
for (let row = PAGE_SIZE - 1; row >= 0; row -= 1) {
const from = row * PAGE_SIZE
for (let x = 0; x < PAGE_SIZE; x += 1) if (page.pixels[from + x] !== 0) return row + 1
}
return 1
}
}
/**
* FNV-1a over a frame's indexed pixels, so verification can compare content
* without shipping the pixels twice.
*
* @param indices - palette indices.
* @returns an 8-character hex digest.
*/
function frameHash(indices: Uint8Array): string {
let hash = 0x811c9dc5
for (const byte of indices) {
hash ^= byte
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash.toString(16).padStart(8, '0')
}
/**
* Turn a DS1 member name into a file-friendly slug.
*
* @param member - full member path.
* @returns the slug.
*/
function slugOf(member: string): string {
const base = member.split('\\').pop() ?? member
return base.replace(/\.ds1$/i, '').toLowerCase()
}
const [archiveDir = 'samples/d2', outDir = 'samples/d2-packs'] = process.argv.slice(2)
const archives = new MountedArchives()
for (const name of MOUNTS) {
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
}
const tables: ActTables = await loadActTables(archives)
const objectsTable = parseTable(await archives.read('data\\global\\excel\\objects.txt'))
/** Same table, through the typed loader the object resolution expects. */
const objectsTableTyped = await loadObjectsTable(archives)
const allNames = await archives.listFiles()
{
const jobs: LevelJob[] = []
for (const row of tables.levels.rows) {
const levelId = Number(cell(tables.levels, row, 'Id'))
if (!Number.isFinite(levelId) || levelId === 0) continue
const name = cell(tables.levels, row, 'Name')
const drlg = Number(cell(tables.levels, row, 'DrlgType'))
const act = Number(cell(tables.levels, row, 'Act')) + 1
let kind: LevelKind | null = null
if (drlg === 2) kind = 'preset'
else if (drlg === 1) kind = 'maze'
else if (drlg === 3) kind = 'wilderness'
if (kind === null) continue
jobs.push({ act, levelId, name, kind, slug: `${String(levelId)}-${slugify(name)}` })
}
LEVELS = jobs
}
/**
* Decoded DT1 libraries, kept across levels.
*
* A hundred-plus levels share a few dozen level types, so decoding each library
* once instead of once per level turns the bake from repeated work into one pass.
*/
/**
* 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.
*/
const libraryCache = new Map<string, Dt1>()
/** Most DT1 libraries to keep decoded at once. */
const LIBRARY_CACHE_LIMIT = 16
/**
* Decode a DT1 library, cached by member name.
*
* @param member - archive member name.
* @returns the decoded library.
*/
async function libraryOf(member: string): Promise<Dt1> {
const cached = libraryCache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDt1(await archives.read(member))
libraryCache.set(member, decoded)
if (libraryCache.size > LIBRARY_CACHE_LIMIT) {
// `Map` iterates in insertion order, so the first key is the oldest entry.
const oldest = libraryCache.keys().next().value
if (oldest !== undefined && oldest !== member) libraryCache.delete(oldest)
}
return decoded
}
/** Objects.txt lookup by Id, built once. */
const objectById = new Map<number, { name: string; token: string; hp: number }>()
for (const row of objectsTable.rows) {
const id = Number(cell(objectsTable, row, 'Id'))
if (!Number.isFinite(id)) continue
objectById.set(id, {
name: cell(objectsTable, row, 'Name'),
token: cell(objectsTable, row, 'Token').toUpperCase(),
hp: Number(cell(objectsTable, row, 'HitPoints') || '0'),
})
}
/** Object members grouped by token and mode, built once. */
const objectMembers = new Map<string, Map<string, string[]>>()
for (const name of allNames) {
if (!name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase())) continue
const rest = name.slice(OBJECT_PREFIX.length).split('\\')
if (rest.length < 3) continue
const token = (rest[0] ?? '').toUpperCase()
const mode = (rest[1] ?? '').toLowerCase()
if (!objectMembers.has(token)) objectMembers.set(token, new Map())
const modes = objectMembers.get(token)!
if (!modes.has(mode)) modes.set(mode, [])
modes.get(mode)!.push(name)
}
/**
* Pick the member that stands in for an object's art.
*
* A token ships many variants (lit/unlit, per-mode, per-weapon-class); the pack
* is static art, so one deterministic pick is recorded — mode order first, then
* file name — and the number of alternatives goes in the manifest so the choice
* is visible rather than implied.
*
* The mode token lives in the *file name* (`<token><component>lit<mode>hth.dcc`), not in
* the directory, so the mode the lookup table gives is matched against file names.
*
* @param token - object token from the lookup table.
* @param modeToken - animation mode token the engine places the object in (`NU`/`OP`/…).
* @returns the member name and how many candidates there were.
*/
function pickObjectMember(token: string, modeToken: string): { member: string; candidates: number } | null {
const dirs = objectMembers.get(token)
if (dirs === undefined) return null
const all = [...dirs.values()].flat()
const candidates = all.length
const wanted = modeToken.trim().toLowerCase() === '' ? 'nu' : modeToken.trim().toLowerCase()
const order = [wanted, ...OBJECT_MODES.map(mode => mode.toLowerCase()).filter(mode => mode !== wanted)]
for (const mode of order) {
const files = all
.filter(name => {
const base = (name.split('\\').pop() ?? '').toLowerCase()
return base.includes(mode) || (mode !== 'nu' && base.includes(`lit${mode}`))
})
.sort((a, b) => (a.toLowerCase().endsWith('.dcc') ? 0 : 1) - (b.toLowerCase().endsWith('.dcc') ? 0 : 1) || a.localeCompare(b))
if (files.length > 0) return { member: files[0]!, candidates }
}
// No file carries the mode token: fall back to any member, deterministically.
const fallback = [...all].sort()[0]
return fallback === undefined ? null : { member: fallback, candidates }
}
const index: Record<string, unknown> = {
version: 1,
generated: new Date().toISOString(),
archiveDir,
pageSize: PAGE_SIZE,
palettes: {} as Record<string, number[]>,
levels: [] as unknown[],
}
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
}
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))
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)
// 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
}
// Object placements and their art members. The art itself is COF+DCC (see
// OBJECT_MODES), so this records what each object *is* and exactly which
// members would draw it, and reports how many are waiting on that decoder.
const objectPages = new PageBuilder(palette)
const objects: unknown[] = []
const missingObjects: string[] = []
let objectsWithArt = 0
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
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: null,
})
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.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,
})
totalLevels += 1
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)}(美术待 DCC/COF:${String(objectsWithArt)})`
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`,
)
}
}
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}`)