1084 lines
41 KiB
TypeScript
1084 lines
41 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, 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'
|
||
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
|
||
import { planLevelMonsters } from '../src/game/monsters.ts'
|
||
import type { IsoMapScene } from '../src/game/d2map.ts'
|
||
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } 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'
|
||
|
||
/**
|
||
* 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\\'
|
||
/**
|
||
* The player's walking speed in scene pixels, matching `act-scene.ts`.
|
||
*
|
||
* Monster speeds are expressed relative to it, so the baker has to know the
|
||
* same number the browser does. Kept as a literal on both sides rather than
|
||
* shared through a module because `act-scene.ts` is a browser entry point and
|
||
* this script must not pull it in.
|
||
*/
|
||
const PLAYER_WALK_SPEED_PX = 170
|
||
|
||
/** Where monster and town-NPC art lives. */
|
||
/**
|
||
* 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)
|
||
|
||
/**
|
||
* `MonPreset.txt` `Place` values grouped by act.
|
||
*
|
||
* The position within an act **is** the DS1 type-1 `id`, so this array must stay
|
||
* dense: dropping a row would renumber every entry after it and silently hand
|
||
* each NPC its neighbour's identity. Measured against the 1.13c drop there are
|
||
* zero empty `Place` cells in all five acts, so the guard below never fires —
|
||
* it exists to fail loudly rather than quietly misnumber if that ever changes.
|
||
*/
|
||
const presetPlaceByAct = new Map<string, string[]>()
|
||
for (const row of tables.monpreset.rows) {
|
||
const act = cell(tables.monpreset, row, 'Act')
|
||
const place = cell(tables.monpreset, row, 'Place')
|
||
if (!place) {
|
||
throw new Error(
|
||
`MonPreset.txt act ${act} has an empty Place cell; the row index is the DS1 id, `
|
||
+ 'so skipping it would shift every later NPC onto the wrong monster',
|
||
)
|
||
}
|
||
let list = presetPlaceByAct.get(act)
|
||
if (list === undefined) {
|
||
list = []
|
||
presetPlaceByAct.set(act, list)
|
||
}
|
||
list.push(place)
|
||
}
|
||
|
||
/** `monstats.txt` rows indexed by `Id` (the column `MonPreset.Place` points at). */
|
||
const statsById = new Map<string, readonly string[]>()
|
||
for (const row of tables.monstats.rows) {
|
||
const id = cell(tables.monstats, row, 'Id')
|
||
if (id) statsById.set(id, row)
|
||
}
|
||
|
||
const monstersTable = {
|
||
preset: tables.monpreset,
|
||
stats: tables.monstats,
|
||
presetPlaceByAct,
|
||
statsById,
|
||
}
|
||
|
||
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[]>>()
|
||
const monsterMembers = new Map<string, Map<string, string[]>>()
|
||
for (const name of allNames) {
|
||
const isObj = name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase())
|
||
const isMon = name.toLowerCase().startsWith(MONSTER_ROOT.toLowerCase())
|
||
if (!isObj && !isMon) continue
|
||
// Derive the length; a hand-counted magic number here was off by one (22 vs
|
||
// the real 21), which sliced the first letter off every monster token and
|
||
// silently broke all NPC art lookups.
|
||
const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_ROOT.length
|
||
const rest = name.slice(prefixLen).split('\\')
|
||
if (rest.length < 3) continue
|
||
const token = (rest[0] ?? '').toUpperCase()
|
||
const mode = (rest[1] ?? '').toLowerCase()
|
||
const map = isObj ? objectMembers : monsterMembers
|
||
if (!map.has(token)) map.set(token, new Map())
|
||
const modes = map.get(token)!
|
||
if (!modes.has(mode)) modes.set(mode, [])
|
||
modes.get(mode)!.push(name)
|
||
}
|
||
|
||
const COMPONENT_PREFERENCE = ['tr', 'hd', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 'lg', 'ra', 'la', 'rh', 'lh', 'sh'] as const
|
||
|
||
function compRank(path: string): number {
|
||
const parts = path.toLowerCase().split('\\')
|
||
const comp = parts[4] ?? ''
|
||
const idx = COMPONENT_PREFERENCE.indexOf(comp as (typeof COMPONENT_PREFERENCE)[number])
|
||
return idx >= 0 ? idx : 99
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* component preference (body layer 'tr' first), then file format (DCC before DC6)
|
||
* — and COF definition files are filtered out since they are compositing metadata,
|
||
* not sprites.
|
||
*
|
||
* @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, baseIsMonsters = false): { member: string; candidates: number } | null {
|
||
const map = baseIsMonsters ? monsterMembers : objectMembers
|
||
const dirs = map.get(token.toUpperCase())
|
||
if (dirs === undefined) return null
|
||
const all = [...dirs.values()].flat().filter(name => {
|
||
const lower = name.toLowerCase()
|
||
return lower.endsWith('.dcc') || lower.endsWith('.dc6')
|
||
})
|
||
const candidates = all.length
|
||
if (candidates === 0) return null
|
||
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) => {
|
||
const aDcc = a.toLowerCase().endsWith('.dcc') ? 0 : 1
|
||
const bDcc = b.toLowerCase().endsWith('.dcc') ? 0 : 1
|
||
if (aDcc !== bDcc) return aDcc - bDcc
|
||
const aRank = compRank(a)
|
||
const bRank = compRank(b)
|
||
if (aRank !== bRank) return aRank - bRank
|
||
return 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((a, b) => {
|
||
const aDcc = a.toLowerCase().endsWith('.dcc') ? 0 : 1
|
||
const bDcc = b.toLowerCase().endsWith('.dcc') ? 0 : 1
|
||
if (aDcc !== bDcc) return aDcc - bDcc
|
||
const aRank = compRank(a)
|
||
const bRank = compRank(b)
|
||
if (aRank !== bRank) return aRank - bRank
|
||
return a.localeCompare(b)
|
||
})[0]
|
||
return fallback === undefined ? null : { member: fallback, candidates }
|
||
}
|
||
|
||
/** 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 1 - Corral Fill',
|
||
'Act 1 - Fence Fill',
|
||
'Act 1 - River',
|
||
'Act 1 - Bridge',
|
||
'Act 1 - Bivouac',
|
||
'Act 1 - Pond',
|
||
'Act 1 - Swamp Fill',
|
||
'Act 1 - Stone Fill',
|
||
'Act 1 - Cottages',
|
||
'Act 1 - Fallen Camp',
|
||
'Act 1 - Camp',
|
||
'Act 1 - Cairn Stones',
|
||
'Act 1 - Inifus',
|
||
'Act 1 - Tower',
|
||
'Act 1 - Ruin',
|
||
'Act 1 - Tree Fill',
|
||
'Act 1 - Graveyard',
|
||
],
|
||
'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(),
|
||
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]
|
||
}
|
||
|
||
let totalPngBytes = 0
|
||
let totalLevels = 0
|
||
let skippedArtlessSpawns = 0
|
||
let skippedMissingArtSpawns = 0
|
||
|
||
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 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
|
||
}
|
||
|
||
const objectPages = new PageBuilder(palette)
|
||
const objects: unknown[] = []
|
||
const npcs: unknown[] = []
|
||
const missingObjects: string[] = []
|
||
let objectsWithArt = 0
|
||
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
|
||
|
||
for (const object of level.objects) {
|
||
let resolved
|
||
try {
|
||
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
|
||
} catch (err) {
|
||
missingObjects.push((err as Error).message)
|
||
continue
|
||
}
|
||
if (resolved.kind === 'monster') continue
|
||
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
|
||
skippedArtlessSpawns += 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
|
||
continue
|
||
}
|
||
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 (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 (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,
|
||
}
|
||
}
|
||
}
|
||
|
||
const list = resolved.kind === 'npc' ? npcs : objects
|
||
list.push({
|
||
id: object.id,
|
||
type: object.type,
|
||
name: resolved.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) {
|
||
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])
|
||
|
||
// The level's population, planned here so the browser never needs
|
||
// `Levels.txt` or `MonStats.txt` on the pack path. The same seed the scene
|
||
// geometry used drives it, so a pack and a live mount of the same level agree
|
||
// on what lives there.
|
||
const population = planLevelMonsters(
|
||
tables,
|
||
entry.levelId,
|
||
scene.cellsX * scene.cellsY,
|
||
seed,
|
||
PLAYER_WALK_SPEED_PX,
|
||
)
|
||
|
||
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,
|
||
npcs,
|
||
monsters: {
|
||
types: population.types,
|
||
budget: population.budget,
|
||
packs: population.packs,
|
||
},
|
||
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,
|
||
npcs: npcs.length,
|
||
monsters: population.packs.reduce((sum, pack) => sum + pack.members.length, 0),
|
||
monsterPacks: population.packs.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,
|
||
npcs: npcs.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))
|
||
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
||
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)
|