feat(packer): bake all 101 generated levels into static packs with 3 variants each
- Update scripts/pack-act-assets.ts to bake 3 deterministic variants for each maze (DrlgType 1) and wilderness (DrlgType 3) level - Cache mazePieces, wildernessPieces, substitutions, and palettes for fast one-pass baking - Populate LEVEL_NAMES_ZH with all 136 standard Chinese level names - Update variantLabelZh to format var1..var3 as 变体 1..3 - Guard scripts/verify-packs.ts to verify preset levels cleanly while supporting non-preset entries
This commit is contained in:
parent
1cfef30878
commit
2a0dd4ce25
|
|
@ -30,9 +30,10 @@ import { join } from 'node:path'
|
||||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
import { fileSource } from '../src/mpq/file-source.ts'
|
import { fileSource } from '../src/mpq/file-source.ts'
|
||||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
import { cell, loadActTables, parseTable, resolveLevel } from '../src/game/acts.ts'
|
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts'
|
||||||
import type { ActTables, LevelInfo } from '../src/game/acts.ts'
|
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
|
||||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||||
|
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||||
import type { Dt1 } from '../src/formats/dt1.ts'
|
import type { Dt1 } from '../src/formats/dt1.ts'
|
||||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||||
|
|
@ -42,6 +43,10 @@ import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
|
||||||
import { decodeDcc } from '../src/formats/dcc.ts'
|
import { decodeDcc } from '../src/formats/dcc.ts'
|
||||||
import { decodeDc6 } from '../src/formats/dc6.ts'
|
import { decodeDc6 } from '../src/formats/dc6.ts'
|
||||||
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
||||||
|
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
|
||||||
|
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
||||||
|
import { generateWilderness } from '../src/game/wilderness.ts'
|
||||||
|
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
|
||||||
import { encodeIndexedPng } from './png.ts'
|
import { encodeIndexedPng } from './png.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -62,7 +67,7 @@ const OBJECT_PREFIX = 'data\\global\\objects\\'
|
||||||
/**
|
/**
|
||||||
* Mode directories tried in order when picking an object's art.
|
* Mode directories tried in order when picking an object's art.
|
||||||
*
|
*
|
||||||
* Note what the object art actually *is*: under `data\global\objects\` the
|
* Note what the object art actually *is*: under `data\global\objects` the
|
||||||
* archives hold **1748 DCC and 1461 COF files against just 13 DC6**. Diablo II
|
* 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
|
* 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
|
* lists layers and frames plus the DCC files behind them — so this packer
|
||||||
|
|
@ -417,6 +422,141 @@ function pickObjectMember(token: string, modeToken: string): { member: string; c
|
||||||
/** Cache decoded member frames across levels so each unique member is decoded once. */
|
/** Cache decoded member frames across levels so each unique member is decoded once. */
|
||||||
const decodedMemberCache = new Map<string, { frame: SpriteFrame; offsetX: number; offsetY: number }>()
|
const decodedMemberCache = new Map<string, { frame: SpriteFrame; offsetX: number; offsetY: number }>()
|
||||||
|
|
||||||
|
const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt'))
|
||||||
|
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
||||||
|
|
||||||
|
/** The level type row for a level row. */
|
||||||
|
function levelType(levelRow: readonly string[]): { id: string; name: string } {
|
||||||
|
const id = cell(tables.levels, levelRow, 'LevelType')
|
||||||
|
const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id)
|
||||||
|
return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `LvlMaze` row for a level, by id then by name. */
|
||||||
|
function mazeRow(levelId: number, levelName: string): readonly string[] | undefined {
|
||||||
|
return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId)
|
||||||
|
?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ds1Cache = new Map<string, Ds1>()
|
||||||
|
|
||||||
|
async function loadDs1(member: string): Promise<Ds1> {
|
||||||
|
const cached = ds1Cache.get(member)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const decoded = decodeDs1(await archives.read(member))
|
||||||
|
ds1Cache.set(member, decoded)
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
||||||
|
const levels: Ds1[] = []
|
||||||
|
for (let slot = 1; slot <= 6; slot += 1) {
|
||||||
|
const value = cell(table, row, `File${String(slot)}`)
|
||||||
|
if (value === '' || value === '0') continue
|
||||||
|
levels.push(await loadDs1(tileMemberPath(value)))
|
||||||
|
}
|
||||||
|
return levels
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
|
||||||
|
const values: number[] = []
|
||||||
|
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
'Act 1 - Wilderness': ['Act 1 - Wild'],
|
||||||
|
'Act 2 - Desert': ['Act 2 - Desert'],
|
||||||
|
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
||||||
|
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||||
|
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
|
||||||
|
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
|
||||||
|
'Act 5 - Siege': ['Act 5 - Siege'],
|
||||||
|
'Act 5 - Barricade': ['Act 5 - Barricade'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mazePiecesCache = new Map<string, Promise<MazePiece[]>>()
|
||||||
|
function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<MazePiece[]> {
|
||||||
|
void levelTypeId
|
||||||
|
const cached = mazePiecesCache.get(levelTypeName)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const promise = (async () => {
|
||||||
|
const pieces: MazePiece[] = []
|
||||||
|
for (const row of tables.lvlprest.rows) {
|
||||||
|
if (cell(tables.lvlprest, row, 'LevelId') !== '0' && cell(tables.lvlprest, row, 'LevelId') !== '') continue
|
||||||
|
const name = cell(tables.lvlprest, row, 'Name')
|
||||||
|
const classified = classifyMazePieceName(name, levelTypeName)
|
||||||
|
if (classified === null) continue
|
||||||
|
const levels = await rowDs1s(tables.lvlprest, row)
|
||||||
|
if (levels.length === 0) continue
|
||||||
|
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
|
||||||
|
}
|
||||||
|
return pieces
|
||||||
|
})()
|
||||||
|
mazePiecesCache.set(levelTypeName, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildernessPiecesCache = new Map<string, Promise<WildernessPiece[]>>()
|
||||||
|
function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
|
||||||
|
const cached = wildernessPiecesCache.get(levelTypeName)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const promise = (async () => {
|
||||||
|
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
|
||||||
|
const pieces: WildernessPiece[] = []
|
||||||
|
for (const row of tables.lvlprest.rows) {
|
||||||
|
const name = cell(tables.lvlprest, row, 'Name')
|
||||||
|
if (!families.some(family => name.startsWith(family))) continue
|
||||||
|
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
||||||
|
const levels = await rowDs1s(tables.lvlprest, row)
|
||||||
|
if (levels.length === 0) continue
|
||||||
|
pieces.push({ name, levels, border: /border|cliff/i.test(name) })
|
||||||
|
}
|
||||||
|
return pieces
|
||||||
|
})()
|
||||||
|
wildernessPiecesCache.set(levelTypeName, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const substitutionsCache = new Map<number, Promise<WildernessSubstitution[]>>()
|
||||||
|
function getSubstitutions(type: number): Promise<WildernessSubstitution[]> {
|
||||||
|
if (type < 0) return Promise.resolve([])
|
||||||
|
const cached = substitutionsCache.get(type)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const promise = (async () => {
|
||||||
|
const rows: WildernessSubstitution[] = []
|
||||||
|
for (const row of lvlsub.rows) {
|
||||||
|
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
|
||||||
|
const file = cell(lvlsub, row, 'File')
|
||||||
|
if (file === '' || file === '0') continue
|
||||||
|
const levels = [await loadDs1(tileMemberPath(file))]
|
||||||
|
rows.push({
|
||||||
|
name: cell(lvlsub, row, 'Name'),
|
||||||
|
type,
|
||||||
|
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
|
||||||
|
bordType: Number(cell(lvlsub, row, 'BordType')),
|
||||||
|
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
|
||||||
|
prob: themeValues(lvlsub, row, 'Prob'),
|
||||||
|
trials: themeValues(lvlsub, row, 'Trials'),
|
||||||
|
max: themeValues(lvlsub, row, 'Max'),
|
||||||
|
levels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})()
|
||||||
|
substitutionsCache.set(type, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const paletteCache = new Map<string, Uint8Array>()
|
||||||
|
async function getPalette(paletteName: string): Promise<Uint8Array> {
|
||||||
|
const cached = paletteCache.get(paletteName)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const pl2 = decodePl2(await archives.read(paletteName))
|
||||||
|
paletteCache.set(paletteName, pl2.rgb)
|
||||||
|
return pl2.rgb
|
||||||
|
}
|
||||||
|
|
||||||
const index: Record<string, unknown> = {
|
const index: Record<string, unknown> = {
|
||||||
version: 1,
|
version: 1,
|
||||||
generated: new Date().toISOString(),
|
generated: new Date().toISOString(),
|
||||||
|
|
@ -426,318 +566,401 @@ const index: Record<string, unknown> = {
|
||||||
levels: [] as unknown[],
|
levels: [] as unknown[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let act = 1; act <= 5; act += 1) {
|
||||||
|
const pal = await getPalette(`data\\global\\palette\\act${String(act)}\\pal.pl2`)
|
||||||
|
;(index.palettes as Record<string, number[]>)[`act${String(act)}`] = [...pal]
|
||||||
|
}
|
||||||
|
|
||||||
let totalPngBytes = 0
|
let totalPngBytes = 0
|
||||||
let totalLevels = 0
|
let totalLevels = 0
|
||||||
|
|
||||||
const pending: string[] = []
|
async function bakeDs1Variant(
|
||||||
for (const entry of LEVELS) {
|
entry: LevelJob,
|
||||||
if (entry.kind !== 'preset') {
|
levelName: string,
|
||||||
// Maze and wilderness levels come from the generators; until those land they
|
palette: Uint8Array,
|
||||||
// are reported rather than silently missing from the pack.
|
paletteName: string,
|
||||||
pending.push(`${entry.kind} ${String(entry.levelId)} ${entry.name}`)
|
dt1Names: readonly string[],
|
||||||
continue
|
libraries: Dt1[],
|
||||||
|
level: Ds1,
|
||||||
|
ds1Name: string,
|
||||||
|
label: string,
|
||||||
|
seed: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
||||||
|
const spawn = findIsoSpawn(scene)
|
||||||
|
const dir = join(outDir, `act${String(entry.act)}`, label)
|
||||||
|
|
||||||
|
// Frames used near the spawn load first; everything else follows.
|
||||||
|
const hot = new Set<number>()
|
||||||
|
if (spawn !== null) {
|
||||||
|
const spawnCell = cellAt(scene, spawn.x, spawn.y)
|
||||||
|
for (const draw of scene.floors) {
|
||||||
|
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||||
|
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||||
|
}
|
||||||
|
for (const draw of scene.walls) {
|
||||||
|
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||||
|
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
|
|
||||||
const pl2 = decodePl2(await archives.read(info.paletteName))
|
|
||||||
const palette = pl2.rgb
|
|
||||||
;(index.palettes as Record<string, number[]>)[`act${String(entry.act)}`] = [...palette]
|
|
||||||
|
|
||||||
const libraries = []
|
const firstDrawAt = new Map<number, { x: number; y: number }>()
|
||||||
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
for (const draw of [...scene.floors, ...scene.walls]) {
|
||||||
|
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
|
||||||
|
}
|
||||||
|
const bandOf = (frameIndex: number): number => {
|
||||||
|
const at = firstDrawAt.get(frameIndex)
|
||||||
|
if (at === undefined) return Number.MAX_SAFE_INTEGER
|
||||||
|
return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768)
|
||||||
|
}
|
||||||
|
const order = scene.frames.map((_, at) => at).sort((a, b) => {
|
||||||
|
const ha = hot.has(a) ? 0 : 1
|
||||||
|
const hb = hot.has(b) ? 0 : 1
|
||||||
|
if (ha !== hb) return ha - hb
|
||||||
|
const ba = bandOf(a)
|
||||||
|
const bb = bandOf(b)
|
||||||
|
if (ba !== bb) return ba - bb
|
||||||
|
const fa = scene.frames[a]!
|
||||||
|
const fb = scene.frames[b]!
|
||||||
|
return fb.width * fb.height - fa.width * fa.height
|
||||||
|
})
|
||||||
|
const pages = new PageBuilder(palette)
|
||||||
|
const placementOf = new Map<number, Placement>()
|
||||||
|
for (const frameIndex of order) {
|
||||||
|
const frame = scene.frames[frameIndex]!
|
||||||
|
placementOf.set(frameIndex, pages.add(frame))
|
||||||
|
}
|
||||||
|
const pageFiles = pages.encode(0)
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
const sha = createHash('sha256')
|
||||||
|
for (const page of pageFiles) {
|
||||||
|
await writeFile(join(dir, page.name), page.png)
|
||||||
|
sha.update(page.png)
|
||||||
|
totalPngBytes += page.png.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
for (const ds1Name of info.ds1Names) {
|
const objectPages = new PageBuilder(palette)
|
||||||
const level = decodeDs1(await archives.read(ds1Name))
|
const objects: unknown[] = []
|
||||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, levelSeed(ds1Name))
|
const missingObjects: string[] = []
|
||||||
const spawn = findIsoSpawn(scene)
|
let objectsWithArt = 0
|
||||||
const label = `${entry.slug}-${slugOf(ds1Name)}`
|
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
|
||||||
const dir = join(outDir, `act${String(entry.act)}`, label)
|
|
||||||
|
|
||||||
// Frames used near the spawn load first; everything else follows.
|
for (const object of level.objects) {
|
||||||
const hot = new Set<number>()
|
let resolved
|
||||||
if (spawn !== null) {
|
try {
|
||||||
const spawnCell = cellAt(scene, spawn.x, spawn.y)
|
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
|
||||||
for (const draw of scene.floors) {
|
} catch (err) {
|
||||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
missingObjects.push((err as Error).message)
|
||||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
continue
|
||||||
}
|
|
||||||
for (const draw of scene.walls) {
|
|
||||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
|
||||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Page order decides how much a first paint costs. Hot frames (the spawn
|
|
||||||
// area) come first, then frames are ordered by *where on the map they are
|
|
||||||
// first drawn*, in coarse screen bands (roughly a viewport each): a viewport then needs one band's page
|
|
||||||
// instead of an arbitrary handful. Sorting by size instead — the obvious
|
|
||||||
// choice for packing efficiency — scatters a viewport's frames across every
|
|
||||||
// page, which is exactly what lazy loading cannot afford.
|
|
||||||
const firstDrawAt = new Map<number, { x: number; y: number }>()
|
|
||||||
for (const draw of [...scene.floors, ...scene.walls]) {
|
|
||||||
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
|
|
||||||
}
|
|
||||||
const bandOf = (frameIndex: number): number => {
|
|
||||||
const at = firstDrawAt.get(frameIndex)
|
|
||||||
if (at === undefined) return Number.MAX_SAFE_INTEGER
|
|
||||||
return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768)
|
|
||||||
}
|
|
||||||
const order = scene.frames.map((_, at) => at).sort((a, b) => {
|
|
||||||
const ha = hot.has(a) ? 0 : 1
|
|
||||||
const hb = hot.has(b) ? 0 : 1
|
|
||||||
if (ha !== hb) return ha - hb
|
|
||||||
const ba = bandOf(a)
|
|
||||||
const bb = bandOf(b)
|
|
||||||
if (ba !== bb) return ba - bb
|
|
||||||
const fa = scene.frames[a]!
|
|
||||||
const fb = scene.frames[b]!
|
|
||||||
return fb.width * fb.height - fa.width * fa.height
|
|
||||||
})
|
|
||||||
const pages = new PageBuilder(palette)
|
|
||||||
const placementOf = new Map<number, Placement>()
|
|
||||||
for (const frameIndex of order) {
|
|
||||||
const frame = scene.frames[frameIndex]!
|
|
||||||
placementOf.set(frameIndex, pages.add(frame))
|
|
||||||
}
|
|
||||||
const pageFiles = pages.encode(0)
|
|
||||||
await mkdir(dir, { recursive: true })
|
|
||||||
const sha = createHash('sha256')
|
|
||||||
for (const page of pageFiles) {
|
|
||||||
await writeFile(join(dir, page.name), page.png)
|
|
||||||
sha.update(page.png)
|
|
||||||
totalPngBytes += page.png.byteLength
|
|
||||||
}
|
}
|
||||||
|
if (resolved.kind === 'monster') continue
|
||||||
|
const row = resolved.row
|
||||||
|
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
|
||||||
|
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
|
||||||
|
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
|
||||||
|
|
||||||
// Object placements and their art members. The art itself is decoded from DCC/DC6
|
let objectFrame: {
|
||||||
// and shelf-packed into objectPages so the client can draw objects at depth.
|
page: number
|
||||||
const objectPages = new PageBuilder(palette)
|
x: number
|
||||||
const objects: unknown[] = []
|
y: number
|
||||||
const missingObjects: string[] = []
|
width: number
|
||||||
let objectsWithArt = 0
|
height: number
|
||||||
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
|
offsetX: number
|
||||||
|
offsetY: number
|
||||||
|
} | null = null
|
||||||
|
|
||||||
for (const object of level.objects) {
|
if (pick !== null) {
|
||||||
// The DS1 `id` is an index into the hardcoded per-act object table, not an
|
let placed = placementByMember.get(pick.member)
|
||||||
// `Objects.txt` row: act 1 id 0 is the rogue fountain (`Objects.txt` 12), not
|
if (placed === undefined) {
|
||||||
// row 0 ("Expansion"). `resolveDs1Object` walks that table and returns the
|
let art = decodedMemberCache.get(pick.member)
|
||||||
// token/mode the engine actually uses.
|
if (art === undefined) {
|
||||||
let resolved
|
try {
|
||||||
try {
|
const bytes = await archives.read(pick.member)
|
||||||
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
|
if (pick.member.toLowerCase().endsWith('.dc6')) {
|
||||||
} catch (err) {
|
const sheet = decodeDc6(bytes)
|
||||||
missingObjects.push((err as Error).message)
|
const group = sheet.groups[0]
|
||||||
continue
|
const f = group?.frames[0]
|
||||||
}
|
if (f) {
|
||||||
if (resolved.kind === 'monster') continue
|
art = {
|
||||||
const row = resolved.row
|
frame: f,
|
||||||
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
|
offsetX: -16 + f.offsetX,
|
||||||
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
|
offsetY: 16 + (f.offsetY - f.height + 1),
|
||||||
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
|
}
|
||||||
|
}
|
||||||
let objectFrame: {
|
} else {
|
||||||
page: number
|
const dcc = decodeDcc(bytes)
|
||||||
x: number
|
const dir = dcc.directions[0]
|
||||||
y: number
|
const f = dir?.frames[0]
|
||||||
width: number
|
if (f && dir) {
|
||||||
height: number
|
art = {
|
||||||
offsetX: number
|
frame: f.frame,
|
||||||
offsetY: number
|
offsetX: -16 + dir.box.left,
|
||||||
} | null = null
|
offsetY: 16 + dir.box.top,
|
||||||
|
|
||||||
if (pick !== null) {
|
|
||||||
let placed = placementByMember.get(pick.member)
|
|
||||||
if (placed === undefined) {
|
|
||||||
let art = decodedMemberCache.get(pick.member)
|
|
||||||
if (art === undefined) {
|
|
||||||
try {
|
|
||||||
const bytes = await archives.read(pick.member)
|
|
||||||
if (pick.member.toLowerCase().endsWith('.dc6')) {
|
|
||||||
const sheet = decodeDc6(bytes)
|
|
||||||
const group = sheet.groups[0]
|
|
||||||
const f = group?.frames[0]
|
|
||||||
if (f) {
|
|
||||||
art = {
|
|
||||||
frame: f,
|
|
||||||
offsetX: -16 + f.offsetX,
|
|
||||||
offsetY: 16 + (f.offsetY - f.height + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const dcc = decodeDcc(bytes)
|
|
||||||
const dir = dcc.directions[0]
|
|
||||||
const f = dir?.frames[0]
|
|
||||||
if (f && dir) {
|
|
||||||
art = {
|
|
||||||
frame: f.frame,
|
|
||||||
offsetX: -16 + dir.box.left,
|
|
||||||
offsetY: 16 + dir.box.top,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (art) decodedMemberCache.set(pick.member, art)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
|
|
||||||
}
|
}
|
||||||
}
|
if (art) decodedMemberCache.set(pick.member, art)
|
||||||
if (art) {
|
} catch (err) {
|
||||||
const placement = objectPages.add(art.frame)
|
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
|
||||||
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
|
|
||||||
placementByMember.set(pick.member, placed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (placed) {
|
if (art) {
|
||||||
objectFrame = {
|
const placement = objectPages.add(art.frame)
|
||||||
page: placed.placement.page,
|
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
|
||||||
x: placed.placement.x,
|
placementByMember.set(pick.member, placed)
|
||||||
y: placed.placement.y,
|
}
|
||||||
width: placed.placement.width,
|
}
|
||||||
height: placed.placement.height,
|
if (placed) {
|
||||||
offsetX: placed.offsetX,
|
objectFrame = {
|
||||||
offsetY: placed.offsetY,
|
page: placed.placement.page,
|
||||||
}
|
x: placed.placement.x,
|
||||||
|
y: placed.placement.y,
|
||||||
|
width: placed.placement.width,
|
||||||
|
height: placed.placement.height,
|
||||||
|
offsetX: placed.offsetX,
|
||||||
|
offsetY: placed.offsetY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
objects.push({
|
|
||||||
id: object.id,
|
|
||||||
type: object.type,
|
|
||||||
name: row?.name ?? resolved.token,
|
|
||||||
token: resolved.token,
|
|
||||||
mode: resolved.mode === '' ? 'NU' : resolved.mode,
|
|
||||||
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
|
|
||||||
// engine picks the art straight from the table's token, with no row.
|
|
||||||
objectsTxtId: resolved.entry?.objectsTxtId ?? -1,
|
|
||||||
hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0),
|
|
||||||
member: pick === null ? null : pick.member,
|
|
||||||
alternatives: pick === null ? 0 : pick.candidates,
|
|
||||||
// Screen point of the object's sub-tile; the sprite anchor is applied
|
|
||||||
// when a frame exists.
|
|
||||||
x: Math.round(orthoX),
|
|
||||||
y: Math.round(orthoY),
|
|
||||||
depth: (object.x + object.y) / 5,
|
|
||||||
frame: objectFrame,
|
|
||||||
})
|
|
||||||
if (pick !== null) objectsWithArt += 1
|
|
||||||
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
|
|
||||||
}
|
|
||||||
const objectFiles = objectPages.encode(0)
|
|
||||||
const shaObjects = createHash('sha256')
|
|
||||||
for (const page of objectFiles) {
|
|
||||||
await writeFile(join(dir, page.name.replace('tiles-', 'objects-')), page.png)
|
|
||||||
shaObjects.update(page.png)
|
|
||||||
totalPngBytes += page.png.byteLength
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collision grid as runs, so the JSON stays small without a second format.
|
objects.push({
|
||||||
const runs: number[][] = []
|
id: object.id,
|
||||||
let current = scene.blocked[0] ?? 0
|
type: object.type,
|
||||||
let count = 0
|
name: row?.name ?? resolved.token,
|
||||||
for (const value of scene.blocked) {
|
token: resolved.token,
|
||||||
if (value === current) { count += 1; continue }
|
mode: resolved.mode === '' ? 'NU' : resolved.mode,
|
||||||
runs.push([current, count])
|
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
|
||||||
current = value
|
// engine picks the art straight from the table's token, with no row.
|
||||||
count = 1
|
objectsTxtId: resolved.entry?.objectsTxtId ?? -1,
|
||||||
}
|
hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0),
|
||||||
runs.push([current, count])
|
member: pick === null ? null : pick.member,
|
||||||
|
alternatives: pick === null ? 0 : pick.candidates,
|
||||||
const sceneJson = {
|
// Screen point of the object's sub-tile; the sprite anchor is applied
|
||||||
version: 1,
|
// when a frame exists.
|
||||||
fidelity: 'exact',
|
x: Math.round(orthoX),
|
||||||
act: entry.act,
|
y: Math.round(orthoY),
|
||||||
levelId: entry.levelId,
|
depth: (object.x + object.y) / 5,
|
||||||
levelName: info.levelName,
|
frame: objectFrame,
|
||||||
ds1: ds1Name,
|
|
||||||
cellsX: scene.cellsX,
|
|
||||||
cellsY: scene.cellsY,
|
|
||||||
originX: scene.originX,
|
|
||||||
originY: scene.originY,
|
|
||||||
widthPx: scene.widthPx,
|
|
||||||
heightPx: scene.heightPx,
|
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })),
|
|
||||||
objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })),
|
|
||||||
frames: scene.frames.map(frame => [frame.width, frame.height]),
|
|
||||||
frameHash: scene.frames.map(frame => frameHash(frame.indices)),
|
|
||||||
framePlacement: scene.frames.map((_, at) => {
|
|
||||||
const place = placementOf.get(at)!
|
|
||||||
return [place.page, place.x, place.y, place.width, place.height]
|
|
||||||
}),
|
|
||||||
floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
|
||||||
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
|
||||||
// Roofs stay in their own list because the engine paints them last of all.
|
|
||||||
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
|
||||||
objects,
|
|
||||||
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
|
|
||||||
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
|
|
||||||
stats: {
|
|
||||||
floors: scene.floors.length,
|
|
||||||
walls: scene.walls.length,
|
|
||||||
roofs: scene.roofs.length,
|
|
||||||
frames: scene.frames.length,
|
|
||||||
missingTiles: scene.missingTiles,
|
|
||||||
missingRefs: scene.missingRefs,
|
|
||||||
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
|
|
||||||
objects: objects.length,
|
|
||||||
objectsWithArt,
|
|
||||||
objectsUnresolved: missingObjects,
|
|
||||||
objectsArtPending: objects.filter((o: any) => o.member !== null && o.frame === null).length,
|
|
||||||
dt1Libraries: info.dt1Names.length,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
|
|
||||||
await writeFile(join(dir, 'scene.json'), sceneBytes)
|
|
||||||
const manifest = {
|
|
||||||
level: info.levelName,
|
|
||||||
act: entry.act,
|
|
||||||
levelId: entry.levelId,
|
|
||||||
ds1: ds1Name,
|
|
||||||
dt1: info.dt1Names,
|
|
||||||
palette: info.paletteName,
|
|
||||||
sceneBytes: sceneBytes.byteLength,
|
|
||||||
sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'),
|
|
||||||
tilePagesSha256: sha.digest('hex'),
|
|
||||||
objectPagesSha256: shaObjects.digest('hex'),
|
|
||||||
pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0)
|
|
||||||
+ objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0),
|
|
||||||
sourceArchive: MOUNTS,
|
|
||||||
}
|
|
||||||
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1))
|
|
||||||
;(index.levels as unknown[]).push({
|
|
||||||
act: entry.act,
|
|
||||||
levelId: entry.levelId,
|
|
||||||
kind: entry.kind,
|
|
||||||
slug: entry.slug,
|
|
||||||
label,
|
|
||||||
levelName: info.levelName,
|
|
||||||
ds1: ds1Name,
|
|
||||||
path: `act${String(entry.act)}/${label}`,
|
|
||||||
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
|
|
||||||
frames: scene.frames.length,
|
|
||||||
objects: objects.length,
|
|
||||||
pages: pageFiles.length,
|
|
||||||
objectPages: objectFiles.length,
|
|
||||||
missingTiles: scene.missingTiles,
|
|
||||||
bytes: manifest.pngBytes + sceneBytes.byteLength,
|
|
||||||
})
|
})
|
||||||
totalLevels += 1
|
if (pick !== null) objectsWithArt += 1
|
||||||
const bakedFramesCount = objects.filter((o: any) => o.frame !== null).length
|
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
|
||||||
const pendingCount = objects.filter((o: any) => o.member !== null && o.frame === null).length
|
}
|
||||||
console.log(
|
|
||||||
`act${String(entry.act)}/${label.padEnd(22)} ${String(scene.cellsX)}x${String(scene.cellsY)} `
|
const objectFiles = objectPages.encode(0)
|
||||||
+ `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG `
|
const shaObjects = createHash('sha256')
|
||||||
+ `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB `
|
for (const page of objectFiles) {
|
||||||
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})`
|
const name = page.name.replace('tiles-', 'objects-')
|
||||||
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`,
|
await writeFile(join(dir, name), page.png)
|
||||||
)
|
shaObjects.update(page.png)
|
||||||
|
totalPngBytes += page.png.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision grid as runs, so the JSON stays small without a second format.
|
||||||
|
const runs: number[][] = []
|
||||||
|
let current = scene.blocked[0] ?? 0
|
||||||
|
let count = 0
|
||||||
|
for (const value of scene.blocked) {
|
||||||
|
if (value === current) { count += 1; continue }
|
||||||
|
runs.push([current, count])
|
||||||
|
current = value
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
runs.push([current, count])
|
||||||
|
|
||||||
|
const sceneJson = {
|
||||||
|
version: 1,
|
||||||
|
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
|
||||||
|
act: entry.act,
|
||||||
|
levelId: entry.levelId,
|
||||||
|
levelName,
|
||||||
|
ds1: ds1Name,
|
||||||
|
cellsX: scene.cellsX,
|
||||||
|
cellsY: scene.cellsY,
|
||||||
|
originX: scene.originX,
|
||||||
|
originY: scene.originY,
|
||||||
|
widthPx: scene.widthPx,
|
||||||
|
heightPx: scene.heightPx,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })),
|
||||||
|
objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })),
|
||||||
|
frames: scene.frames.map(frame => [frame.width, frame.height]),
|
||||||
|
frameHash: scene.frames.map(frame => frameHash(frame.indices)),
|
||||||
|
framePlacement: scene.frames.map((_, at) => {
|
||||||
|
const place = placementOf.get(at)!
|
||||||
|
return [place.page, place.x, place.y, place.width, place.height]
|
||||||
|
}),
|
||||||
|
floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||||
|
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||||
|
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||||
|
objects,
|
||||||
|
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
|
||||||
|
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
|
||||||
|
stats: {
|
||||||
|
floors: scene.floors.length,
|
||||||
|
walls: scene.walls.length,
|
||||||
|
roofs: scene.roofs.length,
|
||||||
|
frames: scene.frames.length,
|
||||||
|
missingTiles: scene.missingTiles,
|
||||||
|
missingRefs: scene.missingRefs,
|
||||||
|
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
|
||||||
|
objects: objects.length,
|
||||||
|
objectsWithArt,
|
||||||
|
objectsUnresolved: missingObjects,
|
||||||
|
objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length,
|
||||||
|
dt1Libraries: dt1Names.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
|
||||||
|
await writeFile(join(dir, 'scene.json'), sceneBytes)
|
||||||
|
const manifest = {
|
||||||
|
level: levelName,
|
||||||
|
act: entry.act,
|
||||||
|
levelId: entry.levelId,
|
||||||
|
ds1: ds1Name,
|
||||||
|
dt1: dt1Names,
|
||||||
|
palette: paletteName,
|
||||||
|
sceneBytes: sceneBytes.byteLength,
|
||||||
|
sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'),
|
||||||
|
tilePagesSha256: sha.digest('hex'),
|
||||||
|
objectPagesSha256: shaObjects.digest('hex'),
|
||||||
|
pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0)
|
||||||
|
+ objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0),
|
||||||
|
sourceArchive: MOUNTS,
|
||||||
|
}
|
||||||
|
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1))
|
||||||
|
;(index.levels as unknown[]).push({
|
||||||
|
act: entry.act,
|
||||||
|
levelId: entry.levelId,
|
||||||
|
kind: entry.kind,
|
||||||
|
slug: entry.slug,
|
||||||
|
label,
|
||||||
|
levelName,
|
||||||
|
ds1: ds1Name,
|
||||||
|
path: `act${String(entry.act)}/${label}`,
|
||||||
|
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
|
||||||
|
frames: scene.frames.length,
|
||||||
|
objects: objects.length,
|
||||||
|
pages: pageFiles.length,
|
||||||
|
objectPages: objectFiles.length,
|
||||||
|
missingTiles: scene.missingTiles,
|
||||||
|
bytes: manifest.pngBytes + sceneBytes.byteLength,
|
||||||
|
})
|
||||||
|
totalLevels += 1
|
||||||
|
const bakedFramesCount = (objects as any[]).filter(o => o.frame !== null).length
|
||||||
|
const pendingCount = (objects as any[]).filter(o => o.member !== null && o.frame === null).length
|
||||||
|
console.log(
|
||||||
|
`[${String(totalLevels).padStart(3)}] act${String(entry.act)}/${label.padEnd(35)} `
|
||||||
|
+ `${String(scene.cellsX)}x${String(scene.cellsY)} `
|
||||||
|
+ `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG `
|
||||||
|
+ `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB `
|
||||||
|
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})`
|
||||||
|
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLevel = process.env.FILTER_LEVEL
|
||||||
|
|
||||||
|
for (const entry of LEVELS) {
|
||||||
|
if (filterLevel !== undefined && String(entry.levelId) !== filterLevel) continue
|
||||||
|
|
||||||
|
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
|
||||||
|
const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal'))
|
||||||
|
const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`
|
||||||
|
const palette = await getPalette(paletteName)
|
||||||
|
|
||||||
|
if (entry.kind === 'preset') {
|
||||||
|
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
|
||||||
|
const libraries: Dt1[] = []
|
||||||
|
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
||||||
|
|
||||||
|
for (const ds1Name of info.ds1Names) {
|
||||||
|
const level = await loadDs1(ds1Name)
|
||||||
|
const label = `${entry.slug}-${slugOf(ds1Name)}`
|
||||||
|
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
|
||||||
|
}
|
||||||
|
} else if (entry.kind === 'maze') {
|
||||||
|
const type = levelType(levelRow)
|
||||||
|
const row = mazeRow(entry.levelId, entry.name)
|
||||||
|
if (row === undefined) {
|
||||||
|
console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const sectionX = Number(cell(lvlmaze, row, 'SizeX'))
|
||||||
|
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
|
||||||
|
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
|
||||||
|
const merge = Number(cell(lvlmaze, row, 'Merge'))
|
||||||
|
const pieces = await getMazePieces(type.name, type.id)
|
||||||
|
if (pieces.length === 0) {
|
||||||
|
console.warn(`skipping maze ${entry.name}: no maze pieces`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const libInfo = resolveLevelLibraries(tables, entry.levelId)
|
||||||
|
const libraries: Dt1[] = []
|
||||||
|
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
|
||||||
|
|
||||||
|
for (let v = 1; v <= 3; v += 1) {
|
||||||
|
const seed = 0x5eed_0000 + entry.levelId * 10 + v
|
||||||
|
const label = `${entry.slug}-var${v}`
|
||||||
|
try {
|
||||||
|
const result = generateMaze({
|
||||||
|
levelId: entry.levelId,
|
||||||
|
levelName: entry.name,
|
||||||
|
levelTypeName: type.name,
|
||||||
|
sectionSize: sectionX,
|
||||||
|
sectionHeight: sectionY,
|
||||||
|
minRooms,
|
||||||
|
merge,
|
||||||
|
seed,
|
||||||
|
pieces,
|
||||||
|
})
|
||||||
|
await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (entry.kind === 'wilderness') {
|
||||||
|
const type = levelType(levelRow)
|
||||||
|
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
|
||||||
|
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
|
||||||
|
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
|
||||||
|
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
|
||||||
|
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
|
||||||
|
const pieces = await getWildernessPieces(type.name)
|
||||||
|
if (pieces.length === 0) {
|
||||||
|
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rows = await getSubstitutions(subType)
|
||||||
|
const shrineRows = await getSubstitutions(subShrine)
|
||||||
|
const libInfo = resolveLevelLibraries(tables, entry.levelId)
|
||||||
|
const libraries: Dt1[] = []
|
||||||
|
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
|
||||||
|
|
||||||
|
for (let v = 1; v <= 3; v += 1) {
|
||||||
|
const seed = 0x5eed_1000 + entry.levelId * 10 + v
|
||||||
|
const label = `${entry.slug}-var${v}`
|
||||||
|
try {
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: entry.levelId,
|
||||||
|
levelName: entry.name,
|
||||||
|
levelTypeName: type.name,
|
||||||
|
sizeX,
|
||||||
|
sizeY,
|
||||||
|
subType,
|
||||||
|
subTheme: Math.max(0, subTheme),
|
||||||
|
seed,
|
||||||
|
pieces,
|
||||||
|
substitutions: rows,
|
||||||
|
shrineSubstitutions: shrineRows,
|
||||||
|
})
|
||||||
|
await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(outDir, { recursive: true })
|
await mkdir(outDir, { recursive: true })
|
||||||
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
|
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
|
||||||
if (pending.length > 0) {
|
|
||||||
const byKind = new Map<string, number>()
|
|
||||||
for (const item of pending) {
|
|
||||||
const kind = item.split(' ')[0] ?? '?'
|
|
||||||
byKind.set(kind, (byKind.get(kind) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
console.log(`尚未生成(需要生成器):${[...byKind].map(([k, v]) => `${k} ${String(v)} 个`).join(',')}`)
|
|
||||||
}
|
|
||||||
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ for (const name of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||||
}
|
}
|
||||||
const tables = await loadActTables(archives)
|
const tables = await loadActTables(archives)
|
||||||
const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as {
|
const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as {
|
||||||
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string }[]
|
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string; kind?: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let checks = 0
|
let checks = 0
|
||||||
|
|
@ -122,6 +122,7 @@ let unknownObjectIds = 0
|
||||||
const objectsTable = await loadObjectsTable(archives)
|
const objectsTable = await loadObjectsTable(archives)
|
||||||
|
|
||||||
for (const entry of index.levels) {
|
for (const entry of index.levels) {
|
||||||
|
if (entry.kind !== undefined && entry.kind !== 'preset') continue
|
||||||
const packed = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedScene
|
const packed = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedScene
|
||||||
const info = resolveLevel(tables, packed.levelId, packed.act)
|
const info = resolveLevel(tables, packed.levelId, packed.act)
|
||||||
const libraries = []
|
const libraries = []
|
||||||
|
|
|
||||||
|
|
@ -21,25 +21,103 @@ export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||||
|
|
||||||
/** `Levels.txt` 的 `Id` → 中文场景名。 */
|
/** `Levels.txt` 的 `Id` → 中文场景名。 */
|
||||||
export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||||
|
// Act 1
|
||||||
1: '罗格营地',
|
1: '罗格营地',
|
||||||
|
2: '鲜血荒地',
|
||||||
|
3: '冰冷之原',
|
||||||
|
4: '石块旷野',
|
||||||
|
5: '黑暗森林',
|
||||||
|
6: '黑色荒地',
|
||||||
|
7: '泰摩高地',
|
||||||
|
8: '邪恶洞窟',
|
||||||
|
9: '洞穴一层',
|
||||||
|
10: '地下通道一层',
|
||||||
|
11: '窟窿一层',
|
||||||
|
12: '深坑一层',
|
||||||
13: '洞穴二层',
|
13: '洞穴二层',
|
||||||
14: '地下通道二层',
|
14: '地下通道二层',
|
||||||
15: '窟窿二层',
|
15: '窟窿二层',
|
||||||
16: '深坑二层',
|
16: '深坑二层',
|
||||||
|
17: '埋骨之地',
|
||||||
|
18: '大陵寝',
|
||||||
|
19: '灵庙',
|
||||||
20: '遗忘之塔',
|
20: '遗忘之塔',
|
||||||
|
21: '塔地窖一层',
|
||||||
|
22: '塔地窖二层',
|
||||||
|
23: '塔地窖三层',
|
||||||
|
24: '塔地窖四层',
|
||||||
25: '塔地窖五层',
|
25: '塔地窖五层',
|
||||||
26: '修道院大门',
|
26: '修道院大门',
|
||||||
27: '外回廊',
|
27: '外回廊',
|
||||||
|
28: '军营',
|
||||||
|
29: '监牢一层',
|
||||||
|
30: '监牢二层',
|
||||||
|
31: '监牢三层',
|
||||||
32: '内回廊',
|
32: '内回廊',
|
||||||
33: '大教堂',
|
33: '大教堂',
|
||||||
|
34: '地下墓穴一层',
|
||||||
|
35: '地下墓穴二层',
|
||||||
|
36: '地下墓穴三层',
|
||||||
37: '地下墓穴四层',
|
37: '地下墓穴四层',
|
||||||
38: '崔斯特瑞姆',
|
38: '崔斯特瑞姆',
|
||||||
|
39: '奶牛关',
|
||||||
|
|
||||||
|
// Act 2
|
||||||
40: '鲁高因',
|
40: '鲁高因',
|
||||||
|
41: '碎石荒地',
|
||||||
|
42: '干燥高地',
|
||||||
|
43: '遥远绿洲',
|
||||||
|
44: '遗失的城市',
|
||||||
|
45: '群蛇峡谷',
|
||||||
|
46: '术士峡谷',
|
||||||
|
47: '下水道一层',
|
||||||
|
48: '下水道二层',
|
||||||
|
49: '下水道三层',
|
||||||
50: '后宫一层',
|
50: '后宫一层',
|
||||||
|
51: '后宫二层',
|
||||||
|
52: '皇宫地牢一层',
|
||||||
|
53: '皇宫地牢二层',
|
||||||
|
54: '皇宫地牢三层',
|
||||||
|
55: '石制古墓一层',
|
||||||
|
56: '死亡之殿一层',
|
||||||
|
57: '死亡之殿二层',
|
||||||
|
58: '利爪蝮蛇神殿一层',
|
||||||
|
59: '石制古墓二层',
|
||||||
|
60: '死亡之殿三层',
|
||||||
|
61: '利爪蝮蛇神殿二层',
|
||||||
|
62: '蛆虫巢穴一层',
|
||||||
|
63: '蛆虫巢穴二层',
|
||||||
|
64: '蛆虫巢穴三层',
|
||||||
|
65: '古代通道',
|
||||||
|
66: '塔·拉夏古墓 (1)',
|
||||||
|
67: '塔·拉夏古墓 (2)',
|
||||||
|
68: '塔·拉夏古墓 (3)',
|
||||||
|
69: '塔·拉夏古墓 (4)',
|
||||||
|
70: '塔·拉夏古墓 (5)',
|
||||||
|
71: '塔·拉夏古墓 (6)',
|
||||||
|
72: '塔·拉夏古墓 (7)',
|
||||||
73: '都瑞尔的巢穴',
|
73: '都瑞尔的巢穴',
|
||||||
|
74: '秘法圣所',
|
||||||
|
|
||||||
|
// Act 3
|
||||||
75: '库拉斯特码头',
|
75: '库拉斯特码头',
|
||||||
|
76: '蜘蛛森林',
|
||||||
|
77: '庞大湿地',
|
||||||
|
78: '剥皮丛林',
|
||||||
|
79: '库拉斯特下层',
|
||||||
|
80: '库拉斯特商场',
|
||||||
|
81: '库拉斯特上层',
|
||||||
|
82: '库拉斯特堤道',
|
||||||
|
83: '崔凡克',
|
||||||
|
84: '蜘蛛巢穴',
|
||||||
|
85: '蜘蛛洞窟',
|
||||||
|
86: '沼泽地坑一层',
|
||||||
|
87: '沼泽地坑二层',
|
||||||
|
88: '剥皮地牢一层',
|
||||||
|
89: '剥皮地牢二层',
|
||||||
90: '沼泽地坑三层',
|
90: '沼泽地坑三层',
|
||||||
91: '剥皮地牢三层',
|
91: '剥皮地牢三层',
|
||||||
|
92: '下水道一层',
|
||||||
93: '下水道二层',
|
93: '下水道二层',
|
||||||
94: '废弃神殿',
|
94: '废弃神殿',
|
||||||
95: '荒废神庙',
|
95: '荒废神庙',
|
||||||
|
|
@ -47,15 +125,47 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||||
97: '遗忘神殿',
|
97: '遗忘神殿',
|
||||||
98: '废弃神庙',
|
98: '废弃神庙',
|
||||||
99: '荒废圣物室',
|
99: '荒废圣物室',
|
||||||
|
100: '憎恨囚牢一层',
|
||||||
|
101: '憎恨囚牢二层',
|
||||||
102: '憎恨囚牢三层',
|
102: '憎恨囚牢三层',
|
||||||
103: '火焰之河要塞',
|
|
||||||
|
// Act 4
|
||||||
|
103: '群魔堡垒',
|
||||||
|
104: '郊外大草原',
|
||||||
|
105: '绝望平原',
|
||||||
|
106: '神罚之城',
|
||||||
|
107: '火焰之河',
|
||||||
|
108: '混沌避难所',
|
||||||
|
|
||||||
|
// Act 5
|
||||||
109: '哈洛加斯',
|
109: '哈洛加斯',
|
||||||
|
110: '血腥丘陵',
|
||||||
|
111: '冰冻高地',
|
||||||
|
112: '亚瑞特高原',
|
||||||
|
113: '水晶通道',
|
||||||
|
114: '冰河',
|
||||||
|
115: '冰河路径',
|
||||||
|
116: '漂泊者洞穴',
|
||||||
|
117: '冰冻苔原',
|
||||||
|
118: '冰窖',
|
||||||
|
119: '痛苦之厅',
|
||||||
120: '岩石之巅',
|
120: '岩石之巅',
|
||||||
121: '尼拉塞克神殿',
|
121: '尼拉塞克神殿',
|
||||||
|
122: '悲痛之厅',
|
||||||
|
123: '死者呼唤之厅',
|
||||||
124: '沃特厅',
|
124: '沃特厅',
|
||||||
|
125: '艾巴当',
|
||||||
|
126: '阿克隆地道',
|
||||||
|
127: '炼狱地坑',
|
||||||
|
128: '世界之石要塞一层',
|
||||||
|
129: '世界之石要塞二层',
|
||||||
|
130: '世界之石要塞三层',
|
||||||
131: '毁灭王座',
|
131: '毁灭王座',
|
||||||
132: '世界之石大殿',
|
132: '世界之石大殿',
|
||||||
136: '崔斯特瑞姆',
|
133: '遗忘之沙',
|
||||||
|
134: '巨窟',
|
||||||
|
135: '苦痛熔炉',
|
||||||
|
136: '混沌崔斯特瑞姆',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,10 +190,12 @@ const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
||||||
/**
|
/**
|
||||||
* 把一个变体后缀变成显示用的中文(拿不准就原样返回 DS1 名)。
|
* 把一个变体后缀变成显示用的中文(拿不准就原样返回 DS1 名)。
|
||||||
*
|
*
|
||||||
* @param suffix - the part of the pack label after the level slug, e.g. `towns1`.
|
* @param suffix - the part of the pack label after the level slug, e.g. `towns1` or `var1`.
|
||||||
* @returns the label to show in the third selector.
|
* @returns the label to show in the third selector.
|
||||||
*/
|
*/
|
||||||
export function variantLabelZh(suffix: string): string {
|
export function variantLabelZh(suffix: string): string {
|
||||||
|
const match = /^var(\d+)$/i.exec(suffix)
|
||||||
|
if (match) return `变体 ${match[1]}`
|
||||||
return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix
|
return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue