2585 lines
102 KiB
TypeScript
2585 lines
102 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, rename } from 'node:fs/promises'
|
||
import { createHash } from 'node:crypto'
|
||
import { join } from 'node:path'
|
||
import { isMainThread, Worker, parentPort, workerData } from 'node:worker_threads'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { availableParallelism } from 'node:os'
|
||
import { performance } from 'node:perf_hooks'
|
||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||
import { fileSource } from '../src/mpq/file-source.ts'
|
||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, townLevelForAct, ACT_TOWNS } 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 { frameDurationMsFromAnimSpeed } from '../src/game/animated-tiles.ts'
|
||
import { planLevelMonsters } from '../src/game/monsters.ts'
|
||
import type { IsoMapScene, IsoDraw } from '../src/game/d2map.ts'
|
||
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
||
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS, ADDITIVE_OBJECT_TOKENS, objectCofMember } from '../src/game/objects.ts'
|
||
import { isWaypointObjectsTxtId, WAYPOINT_TOKENS } from '../src/game/object-lookup.ts'
|
||
import { decodeCof } from '../src/formats/cof.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, MazeWaypoint } from '../src/game/maze.ts'
|
||
import { generateWilderness } from '../src/game/wilderness.ts'
|
||
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
|
||
import { generatePreset } from '../src/game/preset.ts'
|
||
import type { PresetPiece } from '../src/game/preset.ts'
|
||
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
|
||
import type { DrlgEntrance, DrlgLevelInput } from '../src/game/drlg/drlg-map.ts'
|
||
import { dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
|
||
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
|
||
import type { DrlgTables } from '../src/game/drlg/drlg-tables.ts'
|
||
import { WorldVariants } from '../src/game/act-variants.ts'
|
||
import type { ActLayout, VariantEntry, VariantIndex } from '../src/game/act-variants.ts'
|
||
import { drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables } from './lib/drlg-mpq-source.ts'
|
||
|
||
/**
|
||
* An entrance a generator reports: the wilderness generator's, or the DRLG adapter's (seams carry a
|
||
* span) with the display label the bake gives it.
|
||
*/
|
||
type GeneratorEntrance = WildernessEntrance | (DrlgEntrance & { readonly label: string })
|
||
export interface MazeWarp {
|
||
readonly room: number
|
||
readonly direction: 'up' | 'down'
|
||
readonly kind?: string
|
||
readonly pieceName?: string
|
||
readonly centreX: number
|
||
readonly centreY: number
|
||
}
|
||
import {
|
||
assignGateSides, buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp,
|
||
parseLevelRows, parseWarpGeometry, SIDES,
|
||
} from '../src/game/world-graph.ts'
|
||
import type { Side, WorldEdge, WorldGraph } from '../src/game/world-graph.ts'
|
||
import {
|
||
cellToSubTile, findBorderOpening, findWaypointSpot, largestWalkableRegion, nearestWalkable,
|
||
seamArrivalSpot, triggerableFrom, WARP_TRIGGER_SUBTILES,
|
||
} from '../src/game/level-links.ts'
|
||
import type {
|
||
BorderOpening, LinkGrid, SceneEntrance, SceneLinks, SceneWarp, SceneWaypoint, WalkableRegion,
|
||
} from '../src/game/level-links.ts'
|
||
import { findWarpTiles } from '../src/game/warp-tiles.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\\'
|
||
/**
|
||
* Tokens that represent persistent environmental fire and illumination objects.
|
||
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
|
||
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
|
||
*/
|
||
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
|
||
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
|
||
])
|
||
/**
|
||
* The player's walking speed in scene pixels, matching `act-scene.ts`.
|
||
*
|
||
* 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'] = isMainThread
|
||
? process.argv.slice(2)
|
||
: [
|
||
(workerData as { archiveDir?: string } | undefined)?.archiveDir ?? process.argv[2] ?? 'samples/d2',
|
||
(workerData as { outDir?: string } | undefined)?.outDir ?? process.argv[3] ?? 'samples/d2-packs',
|
||
]
|
||
|
||
const archives = new MountedArchives()
|
||
for (const name of MOUNTS) {
|
||
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,
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* World connectivity
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* The seed the act layout is solved with.
|
||
*
|
||
* Deliberately **not** derived from a level's seed. Which edge a seam sits on
|
||
* is a property of the pair, not of either level: if the Cold Plains rolled its
|
||
* Stony Field exit per variant, `3-var1` would put it north while `4-var2` was
|
||
* still expecting to be entered from the south, and the two copies could not be
|
||
* docked. One seed for the whole bake means all three variants of every level
|
||
* agree, and any variant can be swapped for any other at runtime.
|
||
*/
|
||
const ACT_LAYOUT_SEED = 0x5eed_2000
|
||
|
||
const worldGraph: WorldGraph = assignGateSides(
|
||
buildWorldGraph(parseLevelRows(tables.levels)),
|
||
ACT_LAYOUT_SEED,
|
||
)
|
||
const warpGeometry = parseWarpGeometry(tables.lvlwarp)
|
||
|
||
/**
|
||
* Distance from the nearest town, in level hops.
|
||
*
|
||
* Used for one thing: telling a dungeon's up staircase from its down one. The
|
||
* data does not say. `Vis`/`Warp` slot order is not depth order — level 20, the
|
||
* Forgotten Tower's entrance building, has its way out in slot 0 and its way
|
||
* down in slot 1, while level 9's descent is in slot 4 — so the only reliable
|
||
* signal is that the way out is the neighbour closer to town.
|
||
*/
|
||
const townDistance = ((): Map<number, number> => {
|
||
const outgoing = new Map<number, number[]>()
|
||
for (const edge of worldGraph.edges) {
|
||
const list = outgoing.get(edge.from)
|
||
if (list === undefined) outgoing.set(edge.from, [edge.to])
|
||
else list.push(edge.to)
|
||
}
|
||
const distance = new Map<number, number>()
|
||
const queue: number[] = []
|
||
for (const town of ACT_TOWNS) {
|
||
distance.set(town, 0)
|
||
queue.push(town)
|
||
}
|
||
while (queue.length > 0) {
|
||
const at = queue.shift()
|
||
if (at === undefined) break
|
||
const here = distance.get(at) ?? 0
|
||
for (const next of outgoing.get(at) ?? []) {
|
||
if (distance.has(next)) continue
|
||
distance.set(next, here + 1)
|
||
queue.push(next)
|
||
}
|
||
}
|
||
return distance
|
||
})()
|
||
|
||
/**
|
||
* A level's display name, for labelling the openings that lead to it.
|
||
*
|
||
* @param levelId - the level.
|
||
* @returns its `Levels.txt` name, or the bare id when there is no such level.
|
||
*/
|
||
function levelNameOf(levelId: number): string {
|
||
return worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}`
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* DRLG port (Act I outdoor levels)
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** Copies the bake makes of every act the DRLG port generates, one game seed each. */
|
||
const DRLG_ACT_VARIANTS = 3
|
||
|
||
/**
|
||
* The game seed copy `variant` of `act` is generated from.
|
||
*
|
||
* D2 generates a whole act from one seed (DRLG_AllocDrlg -> DRLGOUTPLACE_CreateLevelConnections ->
|
||
* DRLG_InitLevel), so the seed belongs to the (act, copy) pair, never to a level. Act I's three are the
|
||
* seeds of the committed oracle fixtures (tests/fixtures/d2moo-oracle/act1-seed-5eed010{0,1,2}), so every
|
||
* baked copy is one the native D2MOO oracle generates identically.
|
||
*
|
||
* @param act - act number (1..5).
|
||
* @param variant - copy number (0-based, the index entries' `actVariant`).
|
||
* @returns the game seed.
|
||
*/
|
||
function actGameSeed(act: number, variant: number): number {
|
||
return (0x5eed_0000 + act * 0x100 + variant) >>> 0
|
||
}
|
||
|
||
/**
|
||
* The levels of an act the DRLG port generates: Act I's outdoor levels (Levels.txt DrlgType 3). The
|
||
* other acts still bake through the legacy generators.
|
||
*
|
||
* @param t - the DRLG tables.
|
||
* @param act - act number (1..5).
|
||
* @returns level ids, ascending.
|
||
*/
|
||
function drlgLevelsOf(t: DrlgTables, act: number): readonly number[] {
|
||
return act === 1 ? act1OutdoorLevelIds(t) : []
|
||
}
|
||
|
||
/** One DRLG-generated copy of an act: its index record and the adapter input of each generated level. */
|
||
interface DrlgActCopy {
|
||
readonly layout: ActLayout
|
||
readonly inputs: ReadonlyMap<number, DrlgLevelInput>
|
||
}
|
||
|
||
let drlgTablesPromise: Promise<DrlgTables> | undefined
|
||
/**
|
||
* The DRLG's compiled tables, loaded once per thread (the adapter maps a level with them).
|
||
*
|
||
* @returns the tables.
|
||
*/
|
||
function drlgTables(): Promise<DrlgTables> {
|
||
drlgTablesPromise ??= loadDrlgMpqTables(member => archives.read(member))
|
||
return drlgTablesPromise
|
||
}
|
||
|
||
/**
|
||
* Generate every copy of every DRLG act that has a level to bake, on the main thread.
|
||
*
|
||
* One DRLG run per copy, as in D2: the act's outdoor levels, their seams and the town's gate come out
|
||
* of the same run, so the copy is recorded as a whole (`index.json` `actLayouts`) and the runtime uses
|
||
* one copy per act (src/game/act-variants.ts). The whole act is generated even when a filter asks for a
|
||
* single level, because no level of it exists without the others.
|
||
*
|
||
* @param wanted - whether the bake's filters select a level.
|
||
* @returns the copies by act; acts without a selected DRLG level are absent.
|
||
*/
|
||
async function generateDrlgActs(wanted: (act: number, levelId: number) => boolean): Promise<Map<number, DrlgActCopy[]>> {
|
||
const out = new Map<number, DrlgActCopy[]>()
|
||
const t = await drlgTables()
|
||
for (let act = 1; act <= 5; act += 1) {
|
||
const levels = drlgLevelsOf(t, act)
|
||
if (!levels.some(levelId => wanted(act, levelId))) continue
|
||
const data = await loadDrlgMpqData(member => archives.read(member), act)
|
||
const town = townLevelForAct(act)
|
||
const copies: DrlgActCopy[] = []
|
||
for (let variant = 0; variant < DRLG_ACT_VARIANTS; variant += 1) {
|
||
const gameSeed = actGameSeed(act, variant)
|
||
const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels)
|
||
const townFile = dump.act.find(level => level.id === town)?.preset?.map?.file
|
||
if (townFile === undefined || townFile === '') {
|
||
throw new Error(`act ${String(act)} seed 0x${gameSeed.toString(16)}: the generated act picked no DS1 for its town (level ${String(town)})`)
|
||
}
|
||
copies.push({
|
||
layout: { variant, gameSeed, levels: [...levels], presets: [{ levelId: town, ds1: townFile.replace(/\//g, '\\') }] },
|
||
inputs: new Map(levels.map(levelId => [levelId, drlgLevelInputFromDump(dump, levelId, data.tables)])),
|
||
})
|
||
console.log(`DRLG act ${String(act)} copy ${String(variant)} (seed 0x${gameSeed.toString(16)}): town ${townFile}`)
|
||
}
|
||
out.set(act, copies)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Check that every DRLG act copy in the (merged) index resolves the way the runtime resolves it: each
|
||
* generated level has exactly one copy per variant, the town file the copy fixed is baked, and no
|
||
* other copy of a generated level is left over (e.g. from a legacy bake merged in by a filtered run).
|
||
*
|
||
* @param index - the index about to be written.
|
||
*/
|
||
function validateActLayouts(index: VariantIndex<VariantEntry>): void {
|
||
for (const [actKey, layouts] of Object.entries(index.actLayouts ?? {})) {
|
||
const act = Number(actKey)
|
||
const generated = new Set(layouts.flatMap(layout => layout.levels))
|
||
for (const entry of index.levels) {
|
||
if (entry.act === act && entry.levelId !== undefined && generated.has(entry.levelId) && entry.actVariant === undefined) {
|
||
throw new Error(`index: ${entry.label} is a copy of DRLG level ${String(entry.levelId)} without an actVariant; bake the whole act`)
|
||
}
|
||
}
|
||
for (const layout of layouts) {
|
||
// A fresh selection per copy, pinned to it the way opening one of its levels pins it.
|
||
const variants = new WorldVariants(index)
|
||
const member = index.levels.find(entry => entry.act === act && entry.actVariant === layout.variant)
|
||
if (member === undefined) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no baked level belongs to it`)
|
||
variants.pin(member)
|
||
for (const levelId of [...layout.levels, ...layout.presets.map(preset => preset.levelId)]) {
|
||
if (variants.entryForLevel(levelId) === null) {
|
||
throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: level ${String(levelId)} is not baked`)
|
||
}
|
||
}
|
||
if (variants.startEntry(act) === null) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no start level`)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The border seams an outdoor level must cut, as the generator wants them.
|
||
*
|
||
* @param levelId - the level being generated.
|
||
* @returns one gate per seamless edge leaving it.
|
||
*/
|
||
function gatesFor(levelId: number): PlannedGate[] {
|
||
const gates: PlannedGate[] = []
|
||
for (const edge of edgesFrom(worldGraph, levelId)) {
|
||
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
|
||
gates.push({
|
||
side: edge.sideFrom,
|
||
toLevelId: edge.to,
|
||
label: `${levelNameOf(edge.to)} Seam`,
|
||
// The Burial Grounds seam is the one place the border art has a
|
||
// dedicated opening: variant 4 is the graveyard gate, 3 the plain road.
|
||
variant: (levelId === 3 && edge.to === 17) || (levelId === 17 && edge.to === 3) ? 4 : 3,
|
||
})
|
||
}
|
||
return gates
|
||
}
|
||
|
||
/**
|
||
* Turn a graph edge and a place into a baked warp.
|
||
*
|
||
* @param edge - the edge being placed.
|
||
* @param direction - which way it goes.
|
||
* @param cellX - the anchor cell.
|
||
* @param cellY - the anchor cell.
|
||
* @param grid - the collision map, for nudging the arrival point off a wall.
|
||
* @param source - how the position was found.
|
||
* @param region - the level's main walkable region when there is a meaningful
|
||
* one, keeping the arrival point out of sealed pockets; undefined for
|
||
* generated levels, where the unstamped void would be the largest region.
|
||
* @param subTile - the warp's exact sub-tile when the generator knows it (the
|
||
* DRLG's UNIT_TILE preset unit); the anchor cell's centre otherwise.
|
||
* @returns the warp.
|
||
*/
|
||
function makeWarp(
|
||
edge: WorldEdge,
|
||
direction: SceneWarp['direction'],
|
||
cellX: number,
|
||
cellY: number,
|
||
grid: IsoMapScene,
|
||
source: SceneWarp['source'],
|
||
region: WalkableRegion | undefined,
|
||
subTile?: { readonly x: number; readonly y: number },
|
||
): SceneWarp {
|
||
const warpId = edge.warps[0] ?? -1
|
||
const geometry = warpId < 0 ? undefined : findWarpGeometry(warpGeometry, warpId)
|
||
let x = subTile?.x ?? cellToSubTile(cellX)
|
||
let y = subTile?.y ?? cellToSubTile(cellY)
|
||
const fullSpan = Math.max(grid.gridWidth, grid.gridHeight)
|
||
if (region !== undefined && !triggerableFrom(grid, region, x, y, WARP_TRIGGER_SUBTILES)) {
|
||
const snapped = nearestWalkable(grid, x, y, fullSpan, region)
|
||
if (snapped !== null) {
|
||
x = snapped.x
|
||
y = snapped.y
|
||
}
|
||
}
|
||
// `OffsetX/Y` is where the engine materialises the player, relative to the
|
||
// anchor tile and usually negative so that they do not land on the trigger.
|
||
const wanted = {
|
||
x: x + (geometry?.offsetX ?? -2),
|
||
y: y + (geometry?.offsetY ?? -2),
|
||
}
|
||
const arrive = nearestWalkable(grid, wanted.x, wanted.y, fullSpan, region) ?? { x, y }
|
||
return {
|
||
toLevelId: edge.to,
|
||
warpId,
|
||
direction,
|
||
label: `${levelNameOf(edge.to)}`,
|
||
x,
|
||
y,
|
||
arriveX: arrive.x,
|
||
arriveY: arrive.y,
|
||
selectX: geometry?.selectX ?? 0,
|
||
selectY: geometry?.selectY ?? 0,
|
||
selectDX: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDX,
|
||
selectDY: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDY,
|
||
exitWalkX: geometry?.exitWalkX ?? 0,
|
||
exitWalkY: geometry?.exitWalkY ?? 0,
|
||
source,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Work out every way out of one baked level.
|
||
*
|
||
* **Warps come from the artwork.** Every map in the game, shipped or generated,
|
||
* marks its staircases with DS1 special tiles whose `style` is the `Warp0..7`
|
||
* slot — see {@link findWarpTiles} — and the slot says which edge of the graph
|
||
* the staircase is. That single rule covers presets, mazes and outdoor levels
|
||
* alike, it gives the position the original engine would have used, and it
|
||
* removes the need to guess which of a dungeon's two staircases goes up.
|
||
*
|
||
* **Seams come from the generator or the collision map.** A seamless edge has
|
||
* no marker, because walking off the edge of the Blood Moor is not a staircase.
|
||
* Outdoor levels report the openings they cut; presets have theirs recovered
|
||
* from the collision map, since their artwork is fixed and nobody wrote down
|
||
* where the gate is.
|
||
*
|
||
* The maze generator's staircase rooms are kept as a fallback for the handful
|
||
* of pieces that carry no marker.
|
||
*
|
||
* @param levelId - the level.
|
||
* @param kind - how it was built.
|
||
* @param ds1 - the assembled map, for its warp markers.
|
||
* @param grid - the baked collision map.
|
||
* @param entrances - `stats.entrances` from the wilderness generator, or the
|
||
* DRLG adapter's entrances, if any.
|
||
* @param mazeWarps - `stats.warps` from the maze generator, if any.
|
||
* @param waypointCells - waypoint objects found in the artwork, in sub-tiles.
|
||
* @param mazeWaypoints - `stats.waypoints` from the maze generator, if any.
|
||
* @param options - `exactLinks`: the generator reports every seam and warp of
|
||
* the level exactly (the DRLG port). Nothing is then inferred: warp markers,
|
||
* staircase rooms, border scans and fallback warps are not used, every seam
|
||
* must carry the stretch of edge it opens on, and any disagreement with the
|
||
* world graph throws.
|
||
* @returns the links, plus anything the graph wanted that could not be placed.
|
||
*/
|
||
function buildSceneLinks(
|
||
levelId: number,
|
||
kind: LevelKind,
|
||
ds1: Ds1,
|
||
grid: IsoMapScene,
|
||
entrances: readonly GeneratorEntrance[],
|
||
mazeWarps: readonly MazeWarp[],
|
||
waypointCells: readonly { x: number; y: number }[],
|
||
mazeWaypoints?: readonly MazeWaypoint[],
|
||
options: { readonly exactLinks?: boolean } = {},
|
||
): SceneLinks {
|
||
const exactLinks = options.exactLinks === true
|
||
const outEntrances: SceneEntrance[] = []
|
||
const outWarps: SceneWarp[] = []
|
||
const unplaced: { toLevelId: number; reason: string }[] = []
|
||
const notes: string[] = []
|
||
const placedSeams = new Set<number>()
|
||
const placedWarps = new Set<number>()
|
||
|
||
// The part of the map the player can actually stand on.
|
||
//
|
||
// Presets are finished artwork and outdoor levels are stamped edge to edge,
|
||
// so in both the biggest connected patch of open ground is the play area.
|
||
//
|
||
// For mazes, `buildIsoMapScene` leaves unstamped void cells around the rooms
|
||
// as `blocked = 0` (see Issue #38), so the void outside the dungeon walls
|
||
// would be larger than the dungeon itself. Masking out cells that carry no
|
||
// active floor tile before running `largestWalkableRegion` isolates the true
|
||
// dungeon interior, ensuring every staircase and fallback warp lands inside
|
||
// connected rooms rather than outside the walls.
|
||
let regionGrid: LinkGrid = grid
|
||
if (kind === 'maze') {
|
||
const maskedBlocked = new Uint8Array(grid.blocked)
|
||
for (let cy = 0; cy < grid.cellsY; cy += 1) {
|
||
for (let cx = 0; cx < grid.cellsX; cx += 1) {
|
||
const cell = ds1.cells[cy]?.[cx]
|
||
const hasFloor = cell !== undefined && cell.floors.some(f => !f.hidden && f.prop1 !== 0)
|
||
if (!hasFloor) {
|
||
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
||
const row = (cy * SUB_TILES_PER_TILE + sy) * grid.gridWidth + cx * SUB_TILES_PER_TILE
|
||
maskedBlocked.fill(1, row, row + SUB_TILES_PER_TILE)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
regionGrid = {
|
||
cellsX: grid.cellsX,
|
||
cellsY: grid.cellsY,
|
||
gridWidth: grid.gridWidth,
|
||
gridHeight: grid.gridHeight,
|
||
blocked: maskedBlocked,
|
||
}
|
||
}
|
||
const region = largestWalkableRegion(regionGrid)
|
||
|
||
|
||
const warpEdgeTo = new Map<number, WorldEdge>()
|
||
/** `Warp0..7` slot -> the edge that slot crosses. */
|
||
const warpEdgeBySlot = new Map<number, WorldEdge>()
|
||
for (const edge of edgesFrom(worldGraph, levelId)) {
|
||
if (edge.kind !== 'warp') continue
|
||
warpEdgeTo.set(edge.to, edge)
|
||
for (const slot of edge.warpSlots) warpEdgeBySlot.set(slot, edge)
|
||
}
|
||
|
||
/** Which way a crossing leads, for the runtime's benefit. */
|
||
const here = townDistance.get(levelId) ?? Number.MAX_SAFE_INTEGER
|
||
const directionTo = (toLevelId: number): SceneWarp['direction'] =>
|
||
(townDistance.get(toLevelId) ?? Number.MAX_SAFE_INTEGER) < here ? 'up' : 'down'
|
||
|
||
// 1. The staircases the artwork declares. Authoritative for every kind of
|
||
// level, because the slot identifies the destination outright. Skipped
|
||
// when the generator reports its warps exactly: the DRLG port hands over
|
||
// the UNIT_TILE warp units D2 itself creates for them.
|
||
for (const tile of exactLinks ? [] : findWarpTiles(ds1)) {
|
||
const edge = warpEdgeBySlot.get(tile.slot)
|
||
// A marker for a slot this level does not use: the piece was drawn for a
|
||
// level that connects there and reused here. Ignore it rather than invent
|
||
// a destination.
|
||
if (edge === undefined) continue
|
||
// A generator can stamp the same stair piece twice; the first wins.
|
||
if (placedWarps.has(edge.to)) continue
|
||
outWarps.push(makeWarp(edge, directionTo(edge.to), tile.cellX, tile.cellY, grid, 'tile', region))
|
||
placedWarps.add(edge.to)
|
||
}
|
||
|
||
// 2. Outdoor levels: the generator already cut the seams, and knows where the
|
||
// interior presets it stamped sit.
|
||
for (const anchor of entrances) {
|
||
const span = 'span' in anchor ? anchor.span : undefined
|
||
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0 && span !== undefined) {
|
||
// A DRLG seam: the level link names the stretch of this edge that opens
|
||
// onto the neighbour (the open border block, or the whole shared edge
|
||
// for the town and the Monastery approach), so the walkable gap is
|
||
// searched there and nowhere else.
|
||
const range = { from: span.from * SUB_TILES_PER_TILE, to: span.to * SUB_TILES_PER_TILE }
|
||
const opening = findBorderOpening(grid, anchor.side, region, undefined, range)
|
||
if (opening === null) {
|
||
throw new Error(`level ${String(levelId)}: no reachable opening on the ${anchor.side} edge in tiles `
|
||
+ `${String(span.from)}..${String(span.to)} for the seam to level ${String(anchor.toLevelId)}`)
|
||
}
|
||
if (placedSeams.has(anchor.toLevelId)) {
|
||
throw new Error(`level ${String(levelId)}: two seams lead to level ${String(anchor.toLevelId)}`)
|
||
}
|
||
outEntrances.push({
|
||
toLevelId: anchor.toLevelId,
|
||
side: anchor.side,
|
||
label: anchor.label,
|
||
x: opening.x,
|
||
y: opening.y,
|
||
arriveX: opening.arriveX,
|
||
arriveY: opening.arriveY,
|
||
})
|
||
placedSeams.add(anchor.toLevelId)
|
||
continue
|
||
}
|
||
if (exactLinks && anchor.kind === 'gate') {
|
||
throw new Error(`level ${String(levelId)}: seam to level ${String(anchor.toLevelId)} has no side and span`)
|
||
}
|
||
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) {
|
||
// The anchor the generator hands over is the border *piece*, and the
|
||
// centre of that cell is wall; on some outdoor variants the border
|
||
// block's gap is separated from the main play area by up to ~40
|
||
// sub-tiles of rock. Snapping to `region` with a 64-sub-tile radius
|
||
// places the seam trigger on the outer boundary of the main play area,
|
||
// and `seamArrivalSpot` steps inward outside the trigger radius.
|
||
const gate = nearestWalkable(grid, cellToSubTile(anchor.x), cellToSubTile(anchor.y), 64, region)
|
||
?? { x: cellToSubTile(anchor.x), y: cellToSubTile(anchor.y) }
|
||
const arrive = seamArrivalSpot(grid, gate.x, gate.y, anchor.side, region)
|
||
outEntrances.push({
|
||
toLevelId: anchor.toLevelId,
|
||
side: anchor.side,
|
||
label: anchor.label,
|
||
x: gate.x,
|
||
y: gate.y,
|
||
arriveX: arrive.x,
|
||
arriveY: arrive.y,
|
||
})
|
||
placedSeams.add(anchor.toLevelId)
|
||
continue
|
||
}
|
||
// A preset's mouth: the cave, tower or crypt that opens off this level.
|
||
const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId)
|
||
const warp = 'warp' in anchor ? anchor.warp : undefined
|
||
if (exactLinks) {
|
||
if (edge === undefined) {
|
||
throw new Error(`level ${String(levelId)}: the generator has a warp to level ${String(anchor.toLevelId)}, the world graph does not`)
|
||
}
|
||
if (placedWarps.has(edge.to)) {
|
||
throw new Error(`level ${String(levelId)}: two warps lead to level ${String(anchor.toLevelId)}`)
|
||
}
|
||
if (warp === undefined) {
|
||
throw new Error(`level ${String(levelId)}: warp to level ${String(anchor.toLevelId)} has no warp unit position`)
|
||
}
|
||
}
|
||
if (edge === undefined || placedWarps.has(edge.to)) continue
|
||
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region,
|
||
warp === undefined ? undefined : { x: warp.subX, y: warp.subY }))
|
||
placedWarps.add(anchor.toLevelId)
|
||
}
|
||
|
||
if (exactLinks) {
|
||
// The generator's links and the world graph are two readings of the same
|
||
// Levels.txt rows (Vis0..7 / Warp0..7); any difference is a bug in one of
|
||
// them, never something to paper over with a guessed opening.
|
||
for (const edge of edgesFrom(worldGraph, levelId)) {
|
||
if (edge.kind === 'seamless' && !placedSeams.has(edge.to)) {
|
||
throw new Error(`level ${String(levelId)}: the world graph has a seam to level ${String(edge.to)}, the generator does not`)
|
||
}
|
||
if (edge.kind === 'warp' && !placedWarps.has(edge.to)) {
|
||
throw new Error(`level ${String(levelId)}: the world graph has a warp to level ${String(edge.to)}, the generator does not`)
|
||
}
|
||
}
|
||
for (const to of placedSeams) {
|
||
if (!edgesFrom(worldGraph, levelId).some(edge => edge.kind === 'seamless' && edge.to === to)) {
|
||
throw new Error(`level ${String(levelId)}: the generator has a seam to level ${String(to)}, the world graph does not`)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Mazes: staircase rooms, for pieces whose artwork carries no marker.
|
||
if (kind === 'maze') {
|
||
const leftover = [...edgesFrom(worldGraph, levelId)]
|
||
.filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to))
|
||
const queues = {
|
||
up: leftover.filter(edge => directionTo(edge.to) === 'up'),
|
||
down: leftover.filter(edge => directionTo(edge.to) === 'down'),
|
||
}
|
||
for (const room of mazeWarps) {
|
||
const edge = queues[room.direction].shift()
|
||
if (edge === undefined) continue
|
||
outWarps.push(makeWarp(edge, room.direction, room.centreX, room.centreY, grid, 'room', region))
|
||
placedWarps.add(edge.to)
|
||
}
|
||
}
|
||
|
||
// 4. Seams nobody cut: read them off the artwork's walkable border.
|
||
//
|
||
// Presets always land here, because their artwork is fixed and nobody
|
||
// wrote down where the gate is. So do the two mazes with a seamless edge
|
||
// (the Barracks opens onto the Courtyard, Lava 1 onto the Chaos
|
||
// Sanctuary), and outdoor levels whose grid is too small for the planner
|
||
// to run, like the 6x2 Kurast docks.
|
||
//
|
||
// The graph's side is usually right, but not always. The Rogue Encampment
|
||
// ships as four presets — `TownN1`, `TownE1`, `TownS1`, `TownW1` — which
|
||
// are the same camp slid to four different corners of a 57x41 canvas, so
|
||
// that the palisade opens off a different edge of the map in each. The
|
||
// graph has one side per *level*, so three variants in four would be told
|
||
// to put the gate where this particular camp has none.
|
||
//
|
||
// What distinguishes the two cases is `inset`: an opening at inset 0 is
|
||
// walkable ground running off the edge of the map, which is what a gate
|
||
// looks like, while a large inset means the scan gave up on the border and
|
||
// settled for the far end of the play area. So when the level has exactly
|
||
// one seam and some side reaches the border while the graph's side does
|
||
// not, the artwork wins and the widest such contact is the gate. With more
|
||
// than one seam there is no way to tell which border contact belongs to
|
||
// which neighbour, so the graph's sides stand.
|
||
const loneSeams = [...edgesFrom(worldGraph, levelId)]
|
||
.filter(edge => edge.kind === 'seamless' && edge.sideFrom !== null && !placedSeams.has(edge.to))
|
||
for (const edge of loneSeams) {
|
||
const wanted = edge.sideFrom!
|
||
const measured = new Map<Side, BorderOpening>()
|
||
for (const side of SIDES) {
|
||
const found = findBorderOpening(grid, side, region)
|
||
if (found !== null) measured.set(side, found)
|
||
}
|
||
|
||
const asked = measured.get(wanted)
|
||
let side: Side | null = asked === undefined ? null : wanted
|
||
let opening = asked
|
||
|
||
const atBorder = [...measured].filter(([, found]) => found.inset === 0)
|
||
if (loneSeams.length === 1 && atBorder.length > 0 && (asked === undefined || asked.inset > 0)) {
|
||
// Widest contact wins: `TownE1` grazes the north border with a one-cell
|
||
// path as well as opening its whole east flank, and the flank is the gate.
|
||
const [bestSide, bestOpening] = atBorder
|
||
.reduce((best, next) => (next[1].width > best[1].width ? next : best))
|
||
side = bestSide
|
||
opening = bestOpening
|
||
}
|
||
|
||
if (opening === undefined || side === null) {
|
||
// Nothing on the asked-for side and nothing at any border either.
|
||
const any = [...measured].sort((a, b) => a[1].inset - b[1].inset)[0]
|
||
if (any === undefined) {
|
||
unplaced.push({ toLevelId: edge.to, reason: 'every edge is solid' })
|
||
continue
|
||
}
|
||
side = any[0]
|
||
opening = any[1]
|
||
}
|
||
|
||
outEntrances.push({
|
||
toLevelId: edge.to,
|
||
side,
|
||
label: `${levelNameOf(edge.to)} Gate`,
|
||
x: opening.x,
|
||
y: opening.y,
|
||
arriveX: opening.arriveX,
|
||
arriveY: opening.arriveY,
|
||
})
|
||
placedSeams.add(edge.to)
|
||
}
|
||
|
||
// 5. Last resort. Several level types — the Act 2 Harem and Basement, the
|
||
// Act 3 Spider Cavern, the Act 5 Infernal Pit — have an empty `specials`
|
||
// table in `MAZE_LEVEL_TYPE_PROFILES` because their DRLG staircase pass
|
||
// has not been transcribed (`unimplementedPassesFor` names them), so they
|
||
// stamp no stair room, and their pieces carry no marker either. Leaving
|
||
// those edges unplaced makes a level you can enter and never leave, which
|
||
// is a worse lie than an approximate staircase. Each one is spread to a
|
||
// different quarter of the map so two of them never coincide, and each is
|
||
// tagged `fallback` so nothing mistakes it for the real position.
|
||
const needFallback = [...edgesFrom(worldGraph, levelId)]
|
||
.filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to))
|
||
needFallback.forEach((edge, index) => {
|
||
// Quarter centres, in reading order, so the choice is deterministic.
|
||
const quarters = [[1, 1], [3, 1], [1, 3], [3, 3], [2, 2]] as const
|
||
const [qx, qy] = quarters[index % quarters.length]!
|
||
const wanted = {
|
||
x: Math.floor(grid.gridWidth * qx / 4),
|
||
y: Math.floor(grid.cellsY * SUB_TILES_PER_TILE * qy / 4),
|
||
}
|
||
const spot = nearestWalkable(grid, wanted.x, wanted.y, Math.max(grid.gridWidth, grid.gridHeight), region)
|
||
if (spot === null) {
|
||
unplaced.push({ toLevelId: edge.to, reason: 'no walkable ground for a fallback warp' })
|
||
return
|
||
}
|
||
const fromName = worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}`
|
||
const toName = worldGraph.levels.get(edge.to)?.name ?? `level ${String(edge.to)}`
|
||
notes.push(`fallback warp placed for edge ${fromName} -> ${toName}`)
|
||
outWarps.push(makeWarp(
|
||
edge,
|
||
directionTo(edge.to),
|
||
Math.floor(spot.x / SUB_TILES_PER_TILE),
|
||
Math.floor(spot.y / SUB_TILES_PER_TILE),
|
||
grid,
|
||
'fallback',
|
||
region,
|
||
))
|
||
placedWarps.add(edge.to)
|
||
})
|
||
|
||
// Anything the graph wanted and nothing produced. Portals are excluded on
|
||
// purpose: a town portal or a quest portal is conjured at run time by the
|
||
// thing that opens it, so there is nothing for the bake to cut. Seams pass 4
|
||
// already complained about are skipped, or every solid edge would be counted
|
||
// twice: once with the real reason and once with this generic one.
|
||
const reported = new Set(unplaced.map(hole => hole.toLevelId))
|
||
for (const edge of edgesFrom(worldGraph, levelId)) {
|
||
if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to)) {
|
||
unplaced.push({ toLevelId: edge.to, reason: 'seam not cut by the generator' })
|
||
}
|
||
}
|
||
|
||
// Waypoints. The artwork wins when it has one. The DRLG port's levels always
|
||
// do (DRLGOUTDOORS_SpawnAct12Waypoint puts the object in the level), so for
|
||
// them a missing or unreachable waypoint is a bug and throws. Legacy
|
||
// generators do not run that pass, so their levels get a stand-in: a
|
||
// waypoint the network knows about but the map does not show is worse than
|
||
// an approximate position.
|
||
const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255
|
||
const waypoints: SceneWaypoint[] = []
|
||
if (exactLinks && waypointId === 255 && waypointCells.length > 0) {
|
||
throw new Error(`level ${String(levelId)}: the generator placed a waypoint, Levels.txt has none`)
|
||
}
|
||
if (waypointId !== 255) {
|
||
if (exactLinks && waypointCells.length !== 1) {
|
||
throw new Error(`level ${String(levelId)}: the generator placed ${String(waypointCells.length)} waypoints for waypoint ${String(waypointId)}`)
|
||
}
|
||
const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined)
|
||
const spot = fromArt ?? findWaypointSpot(grid, region)
|
||
if (spot !== null && spot !== undefined) {
|
||
const reached = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region)
|
||
if (exactLinks && reached === null) {
|
||
throw new Error(`level ${String(levelId)}: waypoint at ${String(spot.x)},${String(spot.y)} is not next to the main walkable region`)
|
||
}
|
||
const arrive = reached ?? spot
|
||
waypoints.push({
|
||
waypointId,
|
||
x: spot.x,
|
||
y: spot.y,
|
||
arriveX: arrive.x,
|
||
arriveY: arrive.y,
|
||
source: fromArt === undefined ? 'placed' : 'object',
|
||
})
|
||
}
|
||
}
|
||
|
||
return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced, notes }
|
||
}
|
||
|
||
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 || levelId === 108) 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.
|
||
*
|
||
* Expanded cache limit (256): Diablo II references 242 unique DT1 files across
|
||
* all 1,651 level references. Expanding LIBRARY_CACHE_LIMIT to 256 accommodates all
|
||
* unique DT1 files without eviction across all acts and levels, eliminating 903
|
||
* redundant evictions and 677 re-decodes.
|
||
*/
|
||
const libraryCache = new Map<string, Dt1>()
|
||
/** Most DT1 libraries to keep decoded at once (accommodates all 242 unique DT1s across all levels). */
|
||
const LIBRARY_CACHE_LIMIT = 256
|
||
|
||
/**
|
||
* 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 }
|
||
}
|
||
|
||
interface DecodedMemberFrame {
|
||
frame: SpriteFrame
|
||
offsetX: number
|
||
offsetY: number
|
||
}
|
||
|
||
interface DecodedMemberArt {
|
||
frames: DecodedMemberFrame[]
|
||
}
|
||
|
||
/** Cache decoded member frames across levels so each unique member is decoded once. */
|
||
const decodedMemberCache = new Map<string, DecodedMemberArt>()
|
||
|
||
/** Cache additive blend determination for object COFs. */
|
||
const cofAdditiveCache = new Map<string, boolean>()
|
||
|
||
async function isObjectCofAdditive(token: string, modeIndex: number, baseIsMonsters: boolean): Promise<boolean> {
|
||
if (token === '') return false
|
||
const member = objectCofMember(token, modeIndex >= 0 ? modeIndex : 0, baseIsMonsters)
|
||
const cached = cofAdditiveCache.get(member)
|
||
if (cached !== undefined) return cached
|
||
try {
|
||
const bytes = await archives.read(member)
|
||
const cof = decodeCof(bytes)
|
||
const additive = cof.layers.some(l => l.drawEffect === 3 || l.transparent)
|
||
cofAdditiveCache.set(member, additive)
|
||
return additive
|
||
} catch {
|
||
cofAdditiveCache.set(member, false)
|
||
return false
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* The LvlPrest families the legacy outdoor generator draws its pieces from, by level type. Act I is not
|
||
* here: its outdoor levels bake only through the DRLG port ('drlg' jobs, see generateDrlgActs).
|
||
*/
|
||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||
'Act 2 - Desert': ['Act 2 - Desert'],
|
||
'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'],
|
||
'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 4 - Bridge'],
|
||
'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
|
||
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
|
||
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
|
||
pieces.push({
|
||
name,
|
||
kind: classified.kind satisfies MazePieceKind,
|
||
sides: classified.sides,
|
||
levels,
|
||
// `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set.
|
||
killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1',
|
||
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||
})
|
||
}
|
||
return pieces
|
||
})()
|
||
mazePiecesCache.set(levelTypeName, promise)
|
||
return promise
|
||
}
|
||
|
||
const BROKEN_ACT5_BARRICADE_PRESETS = new Set([
|
||
'Act 5 - Barricade Cliff Border 1',
|
||
'Act 5 - Barricade Cliff Border 4',
|
||
'Act 5 - Barricade Cliff Border 5',
|
||
'Act 5 - Barricade Ravine Border 3',
|
||
'Act 5 - Barricade Ravine Border 12',
|
||
'Act 5 - Barricade Cliff Border 4 Snow',
|
||
'Act 5 - Barricade Cliff Border 5 Snow',
|
||
'Act 5 - Barricade Cliff Border 9 Snow',
|
||
'Act 5 - Barricade Cliff Border 10 Snow',
|
||
'Act 5 - Barricade Cliff Border 12 Snow',
|
||
'Act 5 - Barricade Ravine Border 2 Snow',
|
||
'Act 5 - Barricade Ravine Border 3 Snow',
|
||
'Act 5 - Barricade Ravine Border 6 Snow',
|
||
'Act 5 - Barricade Ravine Border 7 Snow',
|
||
'Act 5 - Barricade Ravine Border 9 Snow',
|
||
'Act 5 - Barricade Ravine Border 10 Snow',
|
||
'Act 5 - Barricade Ravine Border 12 Snow',
|
||
])
|
||
|
||
const wildernessPiecesCache = new Map<string, Promise<WildernessPiece[]>>()
|
||
function getWildernessPieces(levelTypeName: string, subType?: number, levelName?: string): Promise<WildernessPiece[]> {
|
||
const isSnow = subType === 11 || (levelName !== undefined && /snow/i.test(levelName))
|
||
const cacheKey = `${levelTypeName}:${isSnow ? 'snow' : 'dirt'}`
|
||
const cached = wildernessPiecesCache.get(cacheKey)
|
||
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') {
|
||
if (BROKEN_ACT5_BARRICADE_PRESETS.has(name)) continue
|
||
if (isSnow && !/snow/i.test(name)) continue
|
||
if (!isSnow && /snow/i.test(name)) continue
|
||
}
|
||
const levels = await rowDs1s(tables.lvlprest, row)
|
||
if (levels.length === 0) continue
|
||
const isBorder = /border|cliff|ravine/i.test(name)
|
||
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
|
||
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
|
||
pieces.push({ name, levels, border: isBorder, ...(animSpeed !== undefined ? { animSpeed } : {}) })
|
||
}
|
||
return pieces
|
||
})()
|
||
wildernessPiecesCache.set(cacheKey, 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
|
||
}
|
||
|
||
interface SceneJob {
|
||
readonly jobId: number
|
||
readonly entry: LevelJob
|
||
readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness' | 'drlg'
|
||
readonly ds1Name?: string
|
||
/** Copy number: 1-based for the legacy generators, the 0-based act copy (`actVariant`) for 'drlg'. */
|
||
readonly variant?: number
|
||
readonly label: string
|
||
readonly seed: number
|
||
/** 'drlg' jobs: the level as the main thread's DRLG run of the act copy generated it. */
|
||
readonly drlg?: { readonly gameSeed: number; readonly input: DrlgLevelInput }
|
||
}
|
||
|
||
interface SceneResult {
|
||
readonly jobId: number
|
||
readonly indexEntry: Record<string, unknown>
|
||
readonly pngBytes: number
|
||
readonly sceneBytes: number
|
||
readonly skippedArtlessSpawns: number
|
||
readonly skippedMissingArtSpawns: number
|
||
readonly missingTiles: number
|
||
readonly missingRefs: unknown
|
||
readonly logLine: string
|
||
}
|
||
|
||
async function bakeDs1Variant(
|
||
jobId: number,
|
||
entry: LevelJob,
|
||
levelName: string,
|
||
palette: Uint8Array,
|
||
paletteName: string,
|
||
dt1Names: readonly string[],
|
||
libraries: Dt1[],
|
||
level: Ds1,
|
||
ds1Name: string,
|
||
label: string,
|
||
seed: number,
|
||
/**
|
||
* What the generator learned while building this level.
|
||
*
|
||
* Empty for presets, which are not generated: their openings are recovered
|
||
* from the collision map instead.
|
||
*/
|
||
generatorLinks: {
|
||
readonly entrances: readonly GeneratorEntrance[]
|
||
readonly mazeWarps: readonly MazeWarp[]
|
||
readonly mazeWaypoints?: readonly MazeWaypoint[]
|
||
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
|
||
readonly notes?: readonly string[] | undefined
|
||
readonly animSpeed?: number | undefined
|
||
/** The generator reports every seam and warp exactly (see buildSceneLinks). */
|
||
readonly exactLinks?: boolean
|
||
/**
|
||
* `'layout'`: every room, tile (style, sequence, type, DT1 library) and preset unit is D2's own
|
||
* (the DRLG port); only the variant within a tile's (style, sequence) is the renderer's pick.
|
||
* Unset: the generator approximates the level.
|
||
*/
|
||
readonly fidelity?: 'layout'
|
||
} = { entrances: [], mazeWarps: [] },
|
||
animSpeedOverride?: number,
|
||
): Promise<SceneResult> {
|
||
let localSkippedArtless = 0
|
||
let localSkippedMissingArt = 0
|
||
const animSpeed = animSpeedOverride ?? generatorLinks.animSpeed
|
||
const frameDurationMs = frameDurationMsFromAnimSpeed(animSpeed)
|
||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
||
|
||
/**
|
||
* Waypoint pedestals found in the artwork, in sub-tiles.
|
||
*
|
||
* Only presets have one: the outdoor and maze generators do not run the pass
|
||
* that spawns waypoints, so their levels fall back to a placed position.
|
||
*/
|
||
const waypointCells: { x: number; y: number }[] = []
|
||
for (const object of level.objects) {
|
||
try {
|
||
const resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
|
||
const resolvedName = resolved.name ?? resolved.row?.name ?? resolved.token
|
||
const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? resolved.row?.id)
|
||
|| WAYPOINT_TOKENS.has(resolved.token.toUpperCase())
|
||
|| resolved.token.toLowerCase() === 'wp'
|
||
|| /waypoint/i.test(resolvedName)
|
||
if (isWp) {
|
||
waypointCells.push({ x: object.x, y: object.y })
|
||
}
|
||
} catch {
|
||
// Unresolved objects handled in main loop below
|
||
}
|
||
}
|
||
|
||
const sceneLinks = buildSceneLinks(
|
||
entry.levelId,
|
||
entry.kind,
|
||
level,
|
||
scene,
|
||
generatorLinks.entrances,
|
||
generatorLinks.mazeWarps,
|
||
waypointCells,
|
||
generatorLinks.mazeWaypoints,
|
||
{ exactLinks: generatorLinks.exactLinks === true },
|
||
)
|
||
|
||
const spawn = findIsoSpawn(scene, { warps: sceneLinks.warps, entrances: sceneLinks.entrances })
|
||
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, ...scene.shadows, ...scene.walls, ...scene.roofs]) {
|
||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||
if (distance <= HOT_RADIUS_CELLS) {
|
||
hot.add(draw.frameIndex)
|
||
if (draw.animatedFrames) {
|
||
for (const f of draw.animatedFrames) hot.add(f)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const firstDrawAt = new Map<number, { x: number; y: number }>()
|
||
for (const draw of [...scene.floors, ...scene.shadows, ...scene.walls, ...scene.roofs]) {
|
||
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
|
||
if (draw.animatedFrames) {
|
||
for (const f of draw.animatedFrames) {
|
||
if (!firstDrawAt.has(f)) firstDrawAt.set(f, { x: draw.x, y: draw.y })
|
||
}
|
||
}
|
||
}
|
||
const bandOf = (frameIndex: number): number => {
|
||
const at = firstDrawAt.get(frameIndex)
|
||
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)
|
||
}
|
||
|
||
const objectPages = new PageBuilder(palette)
|
||
const objects: unknown[] = []
|
||
const npcs: unknown[] = []
|
||
const missingObjects: string[] = []
|
||
let objectsWithArt = 0
|
||
interface PlacedMemberFrame {
|
||
placement: Placement
|
||
offsetX: number
|
||
offsetY: number
|
||
}
|
||
const placementByMember = new Map<string, { frames: PlacedMemberFrame[] }>()
|
||
|
||
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) {
|
||
localSkippedArtless += 1
|
||
continue
|
||
}
|
||
const row = resolved.row
|
||
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc')
|
||
if (pick === null && resolved.kind === 'npc' && object.type === 1) {
|
||
localSkippedMissingArt += 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
|
||
let animatedFrames: {
|
||
page: number
|
||
x: number
|
||
y: number
|
||
width: number
|
||
height: number
|
||
offsetX: number
|
||
offsetY: number
|
||
}[] | undefined = undefined
|
||
|
||
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]
|
||
if (group && group.frames.length > 0) {
|
||
const frames: DecodedMemberFrame[] = group.frames.map(f => ({
|
||
frame: f,
|
||
offsetX: -16 + f.offsetX,
|
||
offsetY: 16 + (f.offsetY - f.height + 1),
|
||
}))
|
||
art = { frames }
|
||
}
|
||
} else {
|
||
const dcc = decodeDcc(bytes)
|
||
const dir = dcc.directions[0]
|
||
if (dir && dir.frames.length > 0) {
|
||
const frames: DecodedMemberFrame[] = dir.frames.map(f => ({
|
||
frame: f.frame,
|
||
offsetX: -16 + dir.box.left,
|
||
offsetY: 16 + dir.box.top,
|
||
}))
|
||
art = { frames }
|
||
}
|
||
}
|
||
if (art) decodedMemberCache.set(pick.member, art)
|
||
} catch (err) {
|
||
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
|
||
}
|
||
}
|
||
if (art && art.frames.length > 0) {
|
||
const placedFrames: PlacedMemberFrame[] = art.frames.map(item => ({
|
||
placement: objectPages.add(item.frame),
|
||
offsetX: item.offsetX,
|
||
offsetY: item.offsetY,
|
||
}))
|
||
placed = { frames: placedFrames }
|
||
placementByMember.set(pick.member, placed)
|
||
}
|
||
}
|
||
if (placed && placed.frames.length > 0) {
|
||
const f0 = placed.frames[0]!
|
||
objectFrame = {
|
||
page: f0.placement.page,
|
||
x: f0.placement.x,
|
||
y: f0.placement.y,
|
||
width: f0.placement.width,
|
||
height: f0.placement.height,
|
||
offsetX: f0.offsetX,
|
||
offsetY: f0.offsetY,
|
||
}
|
||
if (placed.frames.length > 1) {
|
||
animatedFrames = placed.frames.map(f => ({
|
||
page: f.placement.page,
|
||
x: f.placement.x,
|
||
y: f.placement.y,
|
||
width: f.placement.width,
|
||
height: f.placement.height,
|
||
offsetX: f.offsetX,
|
||
offsetY: f.offsetY,
|
||
}))
|
||
}
|
||
}
|
||
}
|
||
|
||
const list = resolved.kind === 'npc' ? npcs : objects
|
||
let resolvedName = resolved.name ?? row?.name ?? resolved.token
|
||
// The pedestal's own sub-tile, taken here because everything downstream is
|
||
// in screen pixels and cannot be converted back.
|
||
const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? row?.id)
|
||
|| WAYPOINT_TOKENS.has(resolved.token.toUpperCase())
|
||
|| resolved.token.toLowerCase() === 'wp'
|
||
|| /waypoint/i.test(resolvedName)
|
||
if (isWp) {
|
||
waypointCells.push({ x: object.x, y: object.y })
|
||
if (resolvedName === 'Dummy') {
|
||
resolvedName = 'Waypoint'
|
||
}
|
||
}
|
||
|
||
const modeToken = resolved.mode === '' ? 'NU' : resolved.mode
|
||
const modeIndex = OBJECT_MODE_TOKENS.indexOf(modeToken as any)
|
||
let frameDelta = 256
|
||
let cycleAnim = true
|
||
let sync = false
|
||
if (row !== null && modeIndex >= 0) {
|
||
const delta = row.frameDelta[modeIndex] ?? 0
|
||
if (delta > 0) frameDelta = delta
|
||
cycleAnim = (row.cycleAnim[modeIndex] ?? 1) !== 0
|
||
sync = (row.sync ?? 0) === 1
|
||
}
|
||
const tokenUpper = resolved.token.trim().toUpperCase()
|
||
if (PERSISTENT_ILLUMINATION_TOKENS.has(tokenUpper)) {
|
||
cycleAnim = true
|
||
if (frameDelta <= 0) frameDelta = 256
|
||
}
|
||
const frameDurationMs = Math.round((256 / frameDelta) * 40 * 100) / 100
|
||
|
||
const isCofAdditive = await isObjectCofAdditive(resolved.token, modeIndex, resolved.kind === 'npc')
|
||
const isAdditive = (row !== null && (row.trans === 7 || row.trans === 1)) ||
|
||
isCofAdditive ||
|
||
ADDITIVE_OBJECT_TOKENS.has(tokenUpper) ||
|
||
resolved.blendMode === 'additive'
|
||
|
||
list.push({
|
||
id: object.id,
|
||
type: object.type,
|
||
name: resolvedName,
|
||
token: resolved.token,
|
||
mode: modeToken,
|
||
// `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,
|
||
trans: row?.trans ?? 0,
|
||
blendMode: isAdditive ? 'additive' : 'normal',
|
||
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,
|
||
...(animatedFrames ? {
|
||
animatedFrames,
|
||
frameDelta,
|
||
frameDurationMs,
|
||
cycleAnim,
|
||
sync,
|
||
} : {}),
|
||
})
|
||
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)
|
||
}
|
||
|
||
// 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,
|
||
'normal',
|
||
generatorLinks.landmarks ? { landmarks: generatorLinks.landmarks } : undefined,
|
||
)
|
||
|
||
|
||
const serializeDraw = (draw: IsoDraw): number[] => {
|
||
if (draw.animatedFrames && draw.animatedFrames.length > 1) {
|
||
return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY, ...draw.animatedFrames]
|
||
}
|
||
return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]
|
||
}
|
||
|
||
const sceneJson = {
|
||
version: 1,
|
||
fidelity: entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation',
|
||
act: entry.act,
|
||
levelId: entry.levelId,
|
||
levelName,
|
||
ds1: ds1Name,
|
||
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||
frameDurationMs,
|
||
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(serializeDraw),
|
||
shadows: scene.shadows.map(serializeDraw),
|
||
walls: scene.walls.map(serializeDraw),
|
||
roofs: scene.roofs.map(serializeDraw),
|
||
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)],
|
||
// Everything the runtime needs to leave this level, in sub-tiles.
|
||
entrances: sceneLinks.entrances,
|
||
warps: sceneLinks.warps,
|
||
waypoints: sceneLinks.waypoints,
|
||
stats: {
|
||
floors: scene.floors.length,
|
||
shadows: scene.shadows.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,
|
||
// Edges the world graph has that this copy of the level has nowhere to
|
||
// put. Reported rather than dropped: each one is a hole in the world.
|
||
unplacedEdges: sceneLinks.unplacedEdges,
|
||
notes: [
|
||
...(generatorLinks.notes ?? []),
|
||
...(sceneLinks.notes ?? []),
|
||
],
|
||
},
|
||
}
|
||
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))
|
||
const indexEntry = {
|
||
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,
|
||
entrances: sceneLinks.entrances.length,
|
||
warps: sceneLinks.warps.length,
|
||
waypoints: sceneLinks.waypoints.length,
|
||
unplacedEdges: sceneLinks.unplacedEdges.length,
|
||
bytes: manifest.pngBytes + sceneBytes.byteLength,
|
||
}
|
||
|
||
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
|
||
const logLine =
|
||
`act${String(entry.act)}/${label.padEnd(35)} `
|
||
+ `${String(scene.cellsX)}x${String(scene.cellsY)} `
|
||
+ `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG `
|
||
+ `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB `
|
||
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})`
|
||
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`
|
||
|
||
return {
|
||
jobId,
|
||
indexEntry,
|
||
pngBytes: manifest.pngBytes,
|
||
sceneBytes: sceneBytes.byteLength,
|
||
skippedArtlessSpawns: localSkippedArtless,
|
||
skippedMissingArtSpawns: localSkippedMissingArt,
|
||
missingTiles: scene.missingTiles,
|
||
missingRefs: scene.missingRefs,
|
||
logLine,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute a single scene baking task.
|
||
*
|
||
* @param job - the scene job parameters.
|
||
* @returns the bake result for deterministic index assembly.
|
||
*/
|
||
async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
|
||
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === job.entry.levelId)!
|
||
const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal'))
|
||
const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`
|
||
const palette = await getPalette(paletteName)
|
||
|
||
if (job.type === 'preset-ds1') {
|
||
const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act)
|
||
const libraries: Dt1[] = []
|
||
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
||
const level = await loadDs1(job.ds1Name!)
|
||
return await bakeDs1Variant(
|
||
job.jobId,
|
||
job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
|
||
level, job.ds1Name!, job.label, job.seed,
|
||
{ entrances: [], mazeWarps: [] },
|
||
info.animSpeed,
|
||
)
|
||
}
|
||
|
||
if (job.type === 'preset-chaos') {
|
||
const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act)
|
||
const libraries: Dt1[] = []
|
||
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
||
|
||
const pieces: PresetPiece[] = []
|
||
for (const ds1Name of info.ds1Names) {
|
||
const level = await loadDs1(ds1Name)
|
||
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name
|
||
const prestRow = tables.lvlprest.rows.find(r => {
|
||
for (let file = 1; file <= 6; file += 1) {
|
||
const f = cell(tables.lvlprest, r, `File${String(file)}`)
|
||
if (f && ds1Name.toLowerCase().includes(f.toLowerCase().replace(/\.ds1$/i, ''))) return true
|
||
}
|
||
return cell(tables.lvlprest, r, 'Name').toLowerCase().includes(pieceName.toLowerCase())
|
||
})
|
||
const animSpeedRaw = prestRow ? (parseInt(cell(tables.lvlprest, prestRow, 'Animate'), 10) || 0) : 0
|
||
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
|
||
pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) })
|
||
}
|
||
|
||
const result = generatePreset({
|
||
levelId: job.entry.levelId,
|
||
levelName: info.levelName,
|
||
sizeX: info.sizeX > 0 ? info.sizeX : 120,
|
||
sizeY: info.sizeY > 0 ? info.sizeY : 120,
|
||
seed: job.seed,
|
||
pieces,
|
||
})
|
||
return await bakeDs1Variant(
|
||
job.jobId,
|
||
job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
|
||
result.level, `preset:${job.label}`, job.label, job.seed,
|
||
{ entrances: [], mazeWarps: [] },
|
||
(result.stats.animSpeed as number | undefined) ?? info.animSpeed,
|
||
)
|
||
}
|
||
|
||
if (job.type === 'maze') {
|
||
const type = levelType(levelRow)
|
||
const row = mazeRow(job.entry.levelId, job.entry.name)
|
||
if (row === undefined) {
|
||
throw new Error(`skipping maze ${job.entry.name}: no LvlMaze.txt row`)
|
||
}
|
||
const sectionX = Number(cell(lvlmaze, row, 'SizeX'))
|
||
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
|
||
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
|
||
const merge = Number(cell(lvlmaze, row, 'Merge'))
|
||
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
|
||
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
|
||
const pieces = await getMazePieces(type.name, type.id)
|
||
if (pieces.length === 0) {
|
||
throw new Error(`skipping maze ${job.entry.name}: no maze pieces`)
|
||
}
|
||
const libInfo = resolveLevelLibraries(tables, job.entry.levelId)
|
||
const libraries: Dt1[] = []
|
||
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
|
||
|
||
const result = generateMaze({
|
||
levelId: job.entry.levelId,
|
||
levelName: job.entry.name,
|
||
levelTypeName: type.name,
|
||
sectionSize: sectionX,
|
||
sectionHeight: sectionY,
|
||
minRooms,
|
||
merge,
|
||
seed: job.seed,
|
||
pieces,
|
||
maxCellsX: sizeX > sectionX ? sizeX : undefined,
|
||
maxCellsY: sizeY > sectionY ? sizeY : undefined,
|
||
})
|
||
return await bakeDs1Variant(
|
||
job.jobId,
|
||
job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, libraries,
|
||
result.level, `generated:${job.label}`, job.label, job.seed,
|
||
{
|
||
entrances: [],
|
||
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
|
||
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
|
||
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
|
||
},
|
||
)
|
||
}
|
||
|
||
if (job.type === 'drlg') {
|
||
const drlg = job.drlg
|
||
if (drlg === undefined || job.variant === undefined) {
|
||
throw new Error(`${job.label}: a DRLG job must carry its generated level and act copy`)
|
||
}
|
||
const t = await drlgTables()
|
||
// The libraries of the tiles D2 picked, for their (style, sequence, type) headers.
|
||
const dt1 = new Map<string, Dt1>()
|
||
for (const tile of drlg.input.tiles) {
|
||
if (tile.tile !== null && !dt1.has(tile.tile.library)) dt1.set(tile.tile.library, await libraryOf(tile.tile.library))
|
||
}
|
||
const map = buildDrlgLevelMap(drlg.input, { tables: t, dt1, superUniqueIds: drlgSuperUniqueIds(tables.superuniques, t) })
|
||
const libraries: Dt1[] = []
|
||
for (const name of map.dt1Names) libraries.push(await libraryOf(name))
|
||
const entrances: GeneratorEntrance[] = map.entrances.map(entrance => ({
|
||
...entrance,
|
||
label: entrance.kind === 'gate' ? `${levelNameOf(entrance.toLevelId)} Seam` : levelNameOf(entrance.toLevelId),
|
||
}))
|
||
const s = map.stats
|
||
const result = await bakeDs1Variant(
|
||
job.jobId,
|
||
job.entry, job.entry.name, palette, paletteName, map.dt1Names, libraries,
|
||
map.ds1, `generated:${job.label}`, job.label, job.seed,
|
||
{
|
||
entrances,
|
||
mazeWarps: [],
|
||
landmarks: map.landmarks,
|
||
exactLinks: true,
|
||
fidelity: 'layout',
|
||
notes: [
|
||
`DRLG port: act ${String(job.entry.act)} copy ${String(job.variant)}, game seed 0x${drlg.gameSeed.toString(16)}`,
|
||
`DRLG tiles: ${String(s.walls)} walls, ${String(s.floors)} floors, ${String(s.shadows)} shadows, `
|
||
+ `${String(s.withoutTile)} without a DT1 tile; units: ${String(s.objects)} objects, ${String(s.monsters)} monsters`,
|
||
],
|
||
},
|
||
)
|
||
return { ...result, indexEntry: { ...result.indexEntry, actVariant: job.variant } }
|
||
}
|
||
|
||
if (job.type === 'wilderness') {
|
||
const type = levelType(levelRow)
|
||
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
|
||
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
|
||
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
|
||
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
|
||
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
|
||
const pieces = await getWildernessPieces(type.name, subType, job.entry.name)
|
||
if (pieces.length === 0) {
|
||
throw new Error(`skipping wilderness ${job.entry.name}: no wilderness pieces`)
|
||
}
|
||
const rows = await getSubstitutions(subType)
|
||
const shrineRows = await getSubstitutions(subShrine)
|
||
const libInfo = resolveLevelLibraries(tables, job.entry.levelId)
|
||
const resolvedLibraries: Dt1[] = []
|
||
for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name))
|
||
|
||
const gates = gatesFor(job.entry.levelId)
|
||
const result = generateWilderness({
|
||
levelId: job.entry.levelId,
|
||
levelName: job.entry.name,
|
||
levelTypeName: type.name,
|
||
sizeX,
|
||
sizeY,
|
||
subType,
|
||
subTheme: Math.max(0, subTheme),
|
||
seed: job.seed,
|
||
pieces,
|
||
substitutions: rows,
|
||
shrineSubstitutions: shrineRows,
|
||
gates,
|
||
dt1Libraries: resolvedLibraries,
|
||
})
|
||
return await bakeDs1Variant(
|
||
job.jobId,
|
||
job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries,
|
||
result.level, `generated:${job.label}`, job.label, job.seed,
|
||
{
|
||
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
|
||
mazeWarps: [],
|
||
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
|
||
...(Array.isArray(result.stats.landmarkEntities)
|
||
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
|
||
: {}),
|
||
...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}),
|
||
},
|
||
)
|
||
}
|
||
|
||
throw new Error(`Unknown job type: ${(job as { type: string }).type}`)
|
||
}
|
||
|
||
/**
|
||
* Enumerate all scenes to bake in deterministic order across all valid levels.
|
||
*
|
||
* @param filterLevels - optional filter set of level IDs.
|
||
* @param filterAct - optional filter for a specific act.
|
||
* @param drlgActs - the DRLG-generated act copies ({@link generateDrlgActs}); their levels bake one
|
||
* 'drlg' job per copy instead of going through the legacy wilderness generator.
|
||
* @returns ordered array of scene jobs with sequential jobIds (0..N-1).
|
||
*/
|
||
async function collectSceneJobs(
|
||
filterLevels: Set<number> | null,
|
||
filterAct: number | null,
|
||
drlgActs: ReadonlyMap<number, readonly DrlgActCopy[]>,
|
||
): Promise<SceneJob[]> {
|
||
const jobs: SceneJob[] = []
|
||
let jobId = 0
|
||
|
||
for (const entry of LEVELS) {
|
||
if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue
|
||
if (filterAct !== null && entry.act !== filterAct) continue
|
||
|
||
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
|
||
|
||
if (entry.kind === 'preset') {
|
||
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
|
||
if (entry.levelId === 108) {
|
||
for (let v = 1; v <= 3; v += 1) {
|
||
jobs.push({
|
||
jobId: jobId++,
|
||
entry,
|
||
type: 'preset-chaos',
|
||
variant: v,
|
||
label: `${entry.slug}-var${v}`,
|
||
seed: 0x5eed_1000 + entry.levelId * 10 + v,
|
||
})
|
||
}
|
||
} else {
|
||
for (const ds1Name of info.ds1Names) {
|
||
jobs.push({
|
||
jobId: jobId++,
|
||
entry,
|
||
type: 'preset-ds1',
|
||
ds1Name,
|
||
label: `${entry.slug}-${slugOf(ds1Name)}`,
|
||
seed: levelSeed(ds1Name),
|
||
})
|
||
}
|
||
}
|
||
} else if (entry.kind === 'maze') {
|
||
const type = levelType(levelRow)
|
||
const row = mazeRow(entry.levelId, entry.name)
|
||
if (row === undefined) {
|
||
console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`)
|
||
continue
|
||
}
|
||
const pieces = await getMazePieces(type.name, type.id)
|
||
if (pieces.length === 0) {
|
||
console.warn(`skipping maze ${entry.name}: no maze pieces`)
|
||
continue
|
||
}
|
||
for (let v = 1; v <= 3; v += 1) {
|
||
jobs.push({
|
||
jobId: jobId++,
|
||
entry,
|
||
type: 'maze',
|
||
variant: v,
|
||
label: `${entry.slug}-var${v}`,
|
||
seed: 0x5eed_0000 + entry.levelId * 10 + v,
|
||
})
|
||
}
|
||
} else if (entry.kind === 'wilderness') {
|
||
const copies = drlgActs.get(entry.act)
|
||
if (copies !== undefined && copies.some(copy => copy.layout.levels.includes(entry.levelId))) {
|
||
for (const copy of copies) {
|
||
const input = copy.inputs.get(entry.levelId)
|
||
if (input === undefined) {
|
||
throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`)
|
||
}
|
||
jobs.push({
|
||
jobId: jobId++,
|
||
entry,
|
||
type: 'drlg',
|
||
variant: copy.layout.variant,
|
||
label: `${entry.slug}-var${String(copy.layout.variant + 1)}`,
|
||
seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1,
|
||
drlg: { gameSeed: copy.layout.gameSeed, input },
|
||
})
|
||
}
|
||
continue
|
||
}
|
||
const type = levelType(levelRow)
|
||
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
|
||
const pieces = await getWildernessPieces(type.name, subType, entry.name)
|
||
if (pieces.length === 0) {
|
||
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`)
|
||
continue
|
||
}
|
||
for (let v = 1; v <= 3; v += 1) {
|
||
jobs.push({
|
||
jobId: jobId++,
|
||
entry,
|
||
type: 'wilderness',
|
||
variant: v,
|
||
label: `${entry.slug}-var${v}`,
|
||
seed: 0x5eed_1000 + entry.levelId * 10 + v,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Every selected level of a generated act must bake from the DRLG copies: a level whose Levels.txt
|
||
// kind disagreed with the DRLG's DrlgType would otherwise vanish from the act without a word.
|
||
for (const [act, copies] of drlgActs) {
|
||
for (const levelId of copies[0]?.layout.levels ?? []) {
|
||
if (filterLevels !== null && !filterLevels.has(levelId)) continue
|
||
if (filterAct !== null && act !== filterAct) continue
|
||
const baked = jobs.filter(job => job.type === 'drlg' && job.entry.levelId === levelId).length
|
||
if (baked !== copies.length) {
|
||
throw new Error(`DRLG level ${String(levelId)} of act ${String(act)} has ${String(baked)} bake jobs, expected ${String(copies.length)}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
return jobs
|
||
}
|
||
|
||
/**
|
||
* Write a JSON file atomically via a temporary file and rename.
|
||
*
|
||
* @param path - destination path.
|
||
* @param data - JSON-serializable object.
|
||
*/
|
||
async function writeJsonAtomic(path: string, data: unknown): Promise<void> {
|
||
const tmpPath = `${path}.tmp.${String(process.pid)}`
|
||
await writeFile(tmpPath, JSON.stringify(data, null, 1))
|
||
await rename(tmpPath, path)
|
||
}
|
||
|
||
/**
|
||
* Run scene jobs in parallel across a Node.js worker_threads pool.
|
||
*
|
||
* @param jobs - all scene baking jobs.
|
||
* @param numWorkers - worker thread count.
|
||
* @returns results array indexed by jobId.
|
||
*/
|
||
async function runWorkerPool(jobs: SceneJob[], numWorkers: number): Promise<SceneResult[]> {
|
||
return new Promise((resolve, reject) => {
|
||
const targetScript = fileURLToPath(import.meta.url)
|
||
const bootstrap = `
|
||
import { register } from 'tsx/esm/api'
|
||
register()
|
||
import(${JSON.stringify(targetScript)})
|
||
`
|
||
const results: SceneResult[] = new Array(jobs.length)
|
||
const queue = [...jobs]
|
||
let completedCount = 0
|
||
let shuttingDown = false
|
||
let isDone = false
|
||
|
||
const workers: Worker[] = []
|
||
|
||
const cleanup = () => {
|
||
if (isDone) return
|
||
isDone = true
|
||
shuttingDown = true
|
||
for (const w of workers) {
|
||
w.terminate().catch(() => {})
|
||
}
|
||
}
|
||
|
||
const abort = (err: Error) => {
|
||
if (shuttingDown) return
|
||
cleanup()
|
||
reject(err)
|
||
}
|
||
|
||
const assignNextJob = (worker: Worker) => {
|
||
if (shuttingDown) return
|
||
if (queue.length > 0) {
|
||
const nextJob = queue.shift()!
|
||
worker.postMessage({ type: 'job', job: nextJob })
|
||
} else if (completedCount === jobs.length) {
|
||
cleanup()
|
||
resolve(results)
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < numWorkers; i++) {
|
||
const worker = new Worker(bootstrap, {
|
||
eval: true,
|
||
execArgv: ['--import', 'tsx'],
|
||
workerData: { archiveDir, outDir, workerId: i + 1 },
|
||
})
|
||
|
||
worker.on('message', (msg: { type: string; result?: SceneResult; jobId?: number; label?: string; message?: string; stack?: string }) => {
|
||
if (shuttingDown) return
|
||
if (msg.type === 'ready') {
|
||
assignNextJob(worker)
|
||
} else if (msg.type === 'result') {
|
||
const res = msg.result!
|
||
results[res.jobId] = res
|
||
completedCount += 1
|
||
console.log(`[${String(completedCount).padStart(3)}/${String(jobs.length)}] ${res.logLine}`)
|
||
if (res.missingTiles > 0) {
|
||
console.log(' missingRefs:', res.missingRefs)
|
||
}
|
||
assignNextJob(worker)
|
||
} else if (msg.type === 'error') {
|
||
console.error(`\n❌ Error baking scene ${msg.label ?? ''} (jobId ${String(msg.jobId ?? '')}): ${msg.message ?? ''}`)
|
||
if (msg.stack) console.error(msg.stack)
|
||
abort(new Error(`Worker failed on scene ${msg.label ?? ''}: ${msg.message ?? ''}`))
|
||
}
|
||
})
|
||
|
||
worker.on('error', (err) => {
|
||
console.error(`\n❌ Worker thread fatal error: ${err.message}`, err.stack)
|
||
abort(err)
|
||
})
|
||
|
||
worker.on('exit', (code) => {
|
||
if (code !== 0 && !shuttingDown) {
|
||
abort(new Error(`Worker thread exited unexpectedly with code ${String(code)}`))
|
||
}
|
||
})
|
||
|
||
workers.push(worker)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Run scene jobs sequentially on the main thread (fallback or workers=1).
|
||
*
|
||
* @param jobs - scene baking jobs.
|
||
* @returns results array indexed by jobId.
|
||
*/
|
||
async function runSequential(jobs: SceneJob[]): Promise<SceneResult[]> {
|
||
const results: SceneResult[] = new Array(jobs.length)
|
||
let completed = 0
|
||
for (const job of jobs) {
|
||
const result = await bakeSceneJob(job)
|
||
results[job.jobId] = result
|
||
completed += 1
|
||
console.log(`[${String(completed).padStart(3)}/${String(jobs.length)}] ${result.logLine}`)
|
||
if (result.missingTiles > 0) {
|
||
console.log(' missingRefs:', result.missingRefs)
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* Main coordinator entry point for act asset baking.
|
||
*/
|
||
async function main(): Promise<void> {
|
||
const filterLevel = process.env.FILTER_LEVEL
|
||
const filterLevels = filterLevel !== undefined
|
||
? new Set(filterLevel.split(',').map(s => Number(s.trim())))
|
||
: null
|
||
const filterAct = process.env.FILTER_ACT !== undefined ? Number(process.env.FILTER_ACT) : null
|
||
|
||
const index: Record<string, unknown> = {
|
||
version: 1,
|
||
generated: new Date().toISOString(),
|
||
archiveDir,
|
||
pageSize: PAGE_SIZE,
|
||
palettes: {} as Record<string, number[]>,
|
||
levels: [] as unknown[],
|
||
}
|
||
|
||
for (let act = 1; act <= 5; act += 1) {
|
||
const pal = await getPalette(`data\\global\\palette\\act${String(act)}\\pal.pl2`)
|
||
;(index.palettes as Record<string, number[]>)[`act${String(act)}`] = [...pal]
|
||
}
|
||
|
||
await mkdir(outDir, { recursive: true })
|
||
for (let act = 1; act <= 5; act += 1) {
|
||
await mkdir(join(outDir, `act${String(act)}`), { recursive: true })
|
||
}
|
||
|
||
const drlgActs = await generateDrlgActs((act, levelId) =>
|
||
(filterLevels === null || filterLevels.has(levelId)) && (filterAct === null || act === filterAct))
|
||
const sceneJobs = await collectSceneJobs(filterLevels, filterAct, drlgActs)
|
||
if (sceneJobs.length === 0) {
|
||
console.log('No scenes matched filter criteria.')
|
||
return
|
||
}
|
||
|
||
const defaultMaxWorkers = typeof availableParallelism === 'function' ? availableParallelism() : 8
|
||
const envWorkers = process.env.MAX_WORKERS ? parseInt(process.env.MAX_WORKERS, 10) : undefined
|
||
const maxWorkers = Math.max(1, Math.min(envWorkers ?? defaultMaxWorkers, 16))
|
||
const useWorkers = maxWorkers > 1
|
||
const numWorkers = useWorkers ? Math.min(maxWorkers, sceneJobs.length) : 1
|
||
|
||
console.log(`Baking ${String(sceneJobs.length)} scenes using ${useWorkers ? `${String(numWorkers)} worker threads` : 'sequential execution'}...`)
|
||
|
||
const tStart = performance.now()
|
||
const results = useWorkers
|
||
? await runWorkerPool(sceneJobs, numWorkers)
|
||
: await runSequential(sceneJobs)
|
||
const elapsedSec = ((performance.now() - tStart) / 1000).toFixed(1)
|
||
|
||
let totalPngBytes = 0
|
||
let totalLevels = 0
|
||
let skippedArtlessSpawns = 0
|
||
let skippedMissingArtSpawns = 0
|
||
|
||
// Deterministic indexing: append results sorted strictly by jobId (0..sceneJobs.length - 1)
|
||
for (let i = 0; i < sceneJobs.length; i++) {
|
||
const res = results[i]!
|
||
;(index.levels as unknown[]).push(res.indexEntry)
|
||
totalPngBytes += res.pngBytes
|
||
totalLevels += 1
|
||
skippedArtlessSpawns += res.skippedArtlessSpawns
|
||
skippedMissingArtSpawns += res.skippedMissingArtSpawns
|
||
}
|
||
|
||
// The DRLG act copies the generated levels belong to (src/game/act-variants.ts reads them).
|
||
const actLayouts: Record<string, ActLayout[]> = {}
|
||
for (const [act, copies] of drlgActs) actLayouts[String(act)] = copies.map(copy => copy.layout)
|
||
if (Object.keys(actLayouts).length > 0) index.actLayouts = actLayouts
|
||
|
||
const indexPath = join(outDir, 'index.json')
|
||
if (filterLevel !== undefined || filterAct !== null) {
|
||
try {
|
||
const { readFile } = await import('node:fs/promises')
|
||
const existingRaw = await readFile(indexPath, 'utf8')
|
||
const existing = JSON.parse(existingRaw) as {
|
||
levels?: Array<{ label?: string }>
|
||
actLayouts?: Record<string, ActLayout[]>
|
||
}
|
||
if (Array.isArray(existing.levels)) {
|
||
const updatedByLabel = new Map<string, unknown>()
|
||
for (const item of index.levels as Array<{ label: string }>) {
|
||
updatedByLabel.set(item.label, item)
|
||
}
|
||
const merged: unknown[] = []
|
||
const seenLabels = new Set<string>()
|
||
for (const oldItem of existing.levels) {
|
||
if (oldItem.label && updatedByLabel.has(oldItem.label)) {
|
||
merged.push(updatedByLabel.get(oldItem.label)!)
|
||
seenLabels.add(oldItem.label)
|
||
} else {
|
||
merged.push(oldItem)
|
||
}
|
||
}
|
||
for (const [lbl, item] of updatedByLabel.entries()) {
|
||
if (!seenLabels.has(lbl)) merged.push(item)
|
||
}
|
||
index.levels = merged
|
||
}
|
||
// An act this run generated replaces its old copies whole; other acts keep theirs.
|
||
if (existing.actLayouts !== undefined) index.actLayouts = { ...existing.actLayouts, ...actLayouts }
|
||
} catch {
|
||
// No existing index.json or unreadable; write fresh index
|
||
}
|
||
}
|
||
|
||
// Outside the merge's catch-all: an act copy the runtime could not resolve must fail the bake.
|
||
validateActLayouts(index as unknown as VariantIndex<VariantEntry>)
|
||
|
||
// Atomic writes from main thread
|
||
await writeJsonAtomic(indexPath, index)
|
||
|
||
await writeJsonAtomic(join(outDir, 'world-graph.json'), {
|
||
version: 1,
|
||
layoutSeed: ACT_LAYOUT_SEED,
|
||
levels: [...worldGraph.levels.values()].map(level => ({
|
||
id: level.id,
|
||
act: level.act + 1,
|
||
name: level.name,
|
||
drlgType: level.drlgType,
|
||
waypoint: level.waypoint,
|
||
})),
|
||
edges: worldGraph.edges.map(edge => ({
|
||
from: edge.from,
|
||
to: edge.to,
|
||
kind: edge.kind,
|
||
sideFrom: edge.sideFrom,
|
||
sideTo: edge.sideTo,
|
||
warps: edge.warps,
|
||
warpSlots: edge.warpSlots,
|
||
source: edge.source,
|
||
})),
|
||
waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })),
|
||
})
|
||
|
||
if (!process.env.SKIP_ENTITIES) {
|
||
const { bakeEntities } = await import('./pack-entity-assets.ts')
|
||
await bakeEntities(archiveDir, outDir)
|
||
}
|
||
|
||
console.log(`\n打包完成:${String(totalLevels)} 张地图,耗时 ${elapsedSec}s,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
||
console.log(`Skipped artless spawns: ${String(skippedArtlessSpawns)}, Skipped no-art NPCs: ${String(skippedMissingArtSpawns)}`)
|
||
console.log(`世界图:${String(worldGraph.levels.size)} 个关卡,${String(worldGraph.edges.length)} 条边,${String(worldGraph.waypoints.size)} 个传送点`)
|
||
}
|
||
|
||
/**
|
||
* Worker thread execution loop.
|
||
*/
|
||
function startWorker(): void {
|
||
process.on('unhandledRejection', (reason) => {
|
||
console.error('Worker unhandled rejection:', reason)
|
||
parentPort?.postMessage({
|
||
type: 'error',
|
||
jobId: -1,
|
||
label: 'unhandled-rejection',
|
||
message: String(reason),
|
||
stack: (reason as Error)?.stack,
|
||
})
|
||
})
|
||
|
||
parentPort?.on('message', async (msg: { type: string; job?: SceneJob }) => {
|
||
if (msg.type === 'job' && msg.job) {
|
||
try {
|
||
const result = await bakeSceneJob(msg.job)
|
||
parentPort?.postMessage({ type: 'result', result })
|
||
} catch (err) {
|
||
parentPort?.postMessage({
|
||
type: 'error',
|
||
jobId: msg.job.jobId,
|
||
label: msg.job.label,
|
||
message: (err as Error).message,
|
||
stack: (err as Error).stack,
|
||
})
|
||
}
|
||
} else if (msg.type === 'shutdown') {
|
||
process.exit(0)
|
||
}
|
||
})
|
||
parentPort?.postMessage({ type: 'ready' })
|
||
}
|
||
|
||
if (isMainThread) {
|
||
main().catch((err) => {
|
||
console.error('Fatal bake error:', err)
|
||
process.exit(1)
|
||
})
|
||
} else {
|
||
startWorker()
|
||
}
|
||
|