643 lines
26 KiB
TypeScript
643 lines
26 KiB
TypeScript
/**
|
||
* 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}`)
|