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 { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { cell, loadActTables, parseTable, resolveLevel } from '../src/game/acts.ts'
|
||||
import type { ActTables, LevelInfo } from '../src/game/acts.ts'
|
||||
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts'
|
||||
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
|
||||
import { decodeDs1 } from '../src/formats/ds1.ts'
|
||||
import type { Ds1 } from '../src/formats/ds1.ts'
|
||||
import { decodeDt1 } from '../src/formats/dt1.ts'
|
||||
import type { Dt1 } from '../src/formats/dt1.ts'
|
||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||
|
|
@ -42,6 +43,10 @@ import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
|
|||
import { decodeDcc } from '../src/formats/dcc.ts'
|
||||
import { decodeDc6 } from '../src/formats/dc6.ts'
|
||||
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
||||
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
|
||||
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
||||
import { generateWilderness } from '../src/game/wilderness.ts'
|
||||
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
|
||||
import { encodeIndexedPng } from './png.ts'
|
||||
|
||||
/**
|
||||
|
|
@ -62,7 +67,7 @@ const OBJECT_PREFIX = 'data\\global\\objects\\'
|
|||
/**
|
||||
* Mode directories tried in order when picking an object's art.
|
||||
*
|
||||
* Note what the object art actually *is*: under `data\global\objects\` the
|
||||
* 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
|
||||
|
|
@ -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. */
|
||||
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> = {
|
||||
version: 1,
|
||||
generated: new Date().toISOString(),
|
||||
|
|
@ -426,318 +566,401 @@ const index: Record<string, 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 totalLevels = 0
|
||||
|
||||
const pending: string[] = []
|
||||
for (const entry of LEVELS) {
|
||||
if (entry.kind !== 'preset') {
|
||||
// Maze and wilderness levels come from the generators; until those land they
|
||||
// are reported rather than silently missing from the pack.
|
||||
pending.push(`${entry.kind} ${String(entry.levelId)} ${entry.name}`)
|
||||
continue
|
||||
async function bakeDs1Variant(
|
||||
entry: LevelJob,
|
||||
levelName: string,
|
||||
palette: Uint8Array,
|
||||
paletteName: string,
|
||||
dt1Names: readonly string[],
|
||||
libraries: Dt1[],
|
||||
level: Ds1,
|
||||
ds1Name: string,
|
||||
label: string,
|
||||
seed: number,
|
||||
): Promise<void> {
|
||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
||||
const spawn = findIsoSpawn(scene)
|
||||
const dir = join(outDir, `act${String(entry.act)}`, label)
|
||||
|
||||
// Frames used near the spawn load first; everything else follows.
|
||||
const hot = new Set<number>()
|
||||
if (spawn !== null) {
|
||||
const spawnCell = cellAt(scene, spawn.x, spawn.y)
|
||||
for (const draw of scene.floors) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
for (const draw of scene.walls) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
}
|
||||
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
|
||||
const pl2 = decodePl2(await archives.read(info.paletteName))
|
||||
const palette = pl2.rgb
|
||||
;(index.palettes as Record<string, number[]>)[`act${String(entry.act)}`] = [...palette]
|
||||
|
||||
const libraries = []
|
||||
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
||||
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
|
||||
}
|
||||
|
||||
for (const ds1Name of info.ds1Names) {
|
||||
const level = decodeDs1(await archives.read(ds1Name))
|
||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, levelSeed(ds1Name))
|
||||
const spawn = findIsoSpawn(scene)
|
||||
const label = `${entry.slug}-${slugOf(ds1Name)}`
|
||||
const dir = join(outDir, `act${String(entry.act)}`, label)
|
||||
const objectPages = new PageBuilder(palette)
|
||||
const objects: unknown[] = []
|
||||
const missingObjects: string[] = []
|
||||
let objectsWithArt = 0
|
||||
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
|
||||
|
||||
// Frames used near the spawn load first; everything else follows.
|
||||
const hot = new Set<number>()
|
||||
if (spawn !== null) {
|
||||
const spawnCell = cellAt(scene, spawn.x, spawn.y)
|
||||
for (const draw of scene.floors) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
for (const draw of scene.walls) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
}
|
||||
// Page order decides how much a first paint costs. Hot frames (the spawn
|
||||
// area) come first, then frames are ordered by *where on the map they are
|
||||
// first drawn*, in coarse screen bands (roughly a viewport each): a viewport then needs one band's page
|
||||
// instead of an arbitrary handful. Sorting by size instead — the obvious
|
||||
// choice for packing efficiency — scatters a viewport's frames across every
|
||||
// page, which is exactly what lazy loading cannot afford.
|
||||
const firstDrawAt = new Map<number, { x: number; y: number }>()
|
||||
for (const draw of [...scene.floors, ...scene.walls]) {
|
||||
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
|
||||
}
|
||||
const bandOf = (frameIndex: number): number => {
|
||||
const at = firstDrawAt.get(frameIndex)
|
||||
if (at === undefined) return Number.MAX_SAFE_INTEGER
|
||||
return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768)
|
||||
}
|
||||
const order = scene.frames.map((_, at) => at).sort((a, b) => {
|
||||
const ha = hot.has(a) ? 0 : 1
|
||||
const hb = hot.has(b) ? 0 : 1
|
||||
if (ha !== hb) return ha - hb
|
||||
const ba = bandOf(a)
|
||||
const bb = bandOf(b)
|
||||
if (ba !== bb) return ba - bb
|
||||
const fa = scene.frames[a]!
|
||||
const fb = scene.frames[b]!
|
||||
return fb.width * fb.height - fa.width * fa.height
|
||||
})
|
||||
const pages = new PageBuilder(palette)
|
||||
const placementOf = new Map<number, Placement>()
|
||||
for (const frameIndex of order) {
|
||||
const frame = scene.frames[frameIndex]!
|
||||
placementOf.set(frameIndex, pages.add(frame))
|
||||
}
|
||||
const pageFiles = pages.encode(0)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const sha = createHash('sha256')
|
||||
for (const page of pageFiles) {
|
||||
await writeFile(join(dir, page.name), page.png)
|
||||
sha.update(page.png)
|
||||
totalPngBytes += page.png.byteLength
|
||||
for (const object of level.objects) {
|
||||
let resolved
|
||||
try {
|
||||
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
|
||||
} catch (err) {
|
||||
missingObjects.push((err as Error).message)
|
||||
continue
|
||||
}
|
||||
if (resolved.kind === 'monster') continue
|
||||
const row = resolved.row
|
||||
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
|
||||
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
|
||||
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
|
||||
|
||||
// Object placements and their art members. The art itself is decoded from DCC/DC6
|
||||
// and shelf-packed into objectPages so the client can draw objects at depth.
|
||||
const objectPages = new PageBuilder(palette)
|
||||
const objects: unknown[] = []
|
||||
const missingObjects: string[] = []
|
||||
let objectsWithArt = 0
|
||||
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
|
||||
let objectFrame: {
|
||||
page: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
} | null = null
|
||||
|
||||
for (const object of level.objects) {
|
||||
// The DS1 `id` is an index into the hardcoded per-act object table, not an
|
||||
// `Objects.txt` row: act 1 id 0 is the rogue fountain (`Objects.txt` 12), not
|
||||
// row 0 ("Expansion"). `resolveDs1Object` walks that table and returns the
|
||||
// token/mode the engine actually uses.
|
||||
let resolved
|
||||
try {
|
||||
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id)
|
||||
} catch (err) {
|
||||
missingObjects.push((err as Error).message)
|
||||
continue
|
||||
}
|
||||
if (resolved.kind === 'monster') continue
|
||||
const row = resolved.row
|
||||
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
|
||||
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
|
||||
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
|
||||
|
||||
let objectFrame: {
|
||||
page: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
} | null = null
|
||||
|
||||
if (pick !== null) {
|
||||
let placed = placementByMember.get(pick.member)
|
||||
if (placed === undefined) {
|
||||
let art = decodedMemberCache.get(pick.member)
|
||||
if (art === undefined) {
|
||||
try {
|
||||
const bytes = await archives.read(pick.member)
|
||||
if (pick.member.toLowerCase().endsWith('.dc6')) {
|
||||
const sheet = decodeDc6(bytes)
|
||||
const group = sheet.groups[0]
|
||||
const f = group?.frames[0]
|
||||
if (f) {
|
||||
art = {
|
||||
frame: f,
|
||||
offsetX: -16 + f.offsetX,
|
||||
offsetY: 16 + (f.offsetY - f.height + 1),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const dcc = decodeDcc(bytes)
|
||||
const dir = dcc.directions[0]
|
||||
const f = dir?.frames[0]
|
||||
if (f && dir) {
|
||||
art = {
|
||||
frame: f.frame,
|
||||
offsetX: -16 + dir.box.left,
|
||||
offsetY: 16 + dir.box.top,
|
||||
}
|
||||
if (pick !== null) {
|
||||
let placed = placementByMember.get(pick.member)
|
||||
if (placed === undefined) {
|
||||
let art = decodedMemberCache.get(pick.member)
|
||||
if (art === undefined) {
|
||||
try {
|
||||
const bytes = await archives.read(pick.member)
|
||||
if (pick.member.toLowerCase().endsWith('.dc6')) {
|
||||
const sheet = decodeDc6(bytes)
|
||||
const group = sheet.groups[0]
|
||||
const f = group?.frames[0]
|
||||
if (f) {
|
||||
art = {
|
||||
frame: f,
|
||||
offsetX: -16 + f.offsetX,
|
||||
offsetY: 16 + (f.offsetY - f.height + 1),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const dcc = decodeDcc(bytes)
|
||||
const dir = dcc.directions[0]
|
||||
const f = dir?.frames[0]
|
||||
if (f && dir) {
|
||||
art = {
|
||||
frame: f.frame,
|
||||
offsetX: -16 + dir.box.left,
|
||||
offsetY: 16 + dir.box.top,
|
||||
}
|
||||
}
|
||||
if (art) decodedMemberCache.set(pick.member, art)
|
||||
} catch (err) {
|
||||
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
if (art) {
|
||||
const placement = objectPages.add(art.frame)
|
||||
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
|
||||
placementByMember.set(pick.member, placed)
|
||||
if (art) decodedMemberCache.set(pick.member, art)
|
||||
} catch (err) {
|
||||
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
if (placed) {
|
||||
objectFrame = {
|
||||
page: placed.placement.page,
|
||||
x: placed.placement.x,
|
||||
y: placed.placement.y,
|
||||
width: placed.placement.width,
|
||||
height: placed.placement.height,
|
||||
offsetX: placed.offsetX,
|
||||
offsetY: placed.offsetY,
|
||||
}
|
||||
if (art) {
|
||||
const placement = objectPages.add(art.frame)
|
||||
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
|
||||
placementByMember.set(pick.member, placed)
|
||||
}
|
||||
}
|
||||
if (placed) {
|
||||
objectFrame = {
|
||||
page: placed.placement.page,
|
||||
x: placed.placement.x,
|
||||
y: placed.placement.y,
|
||||
width: placed.placement.width,
|
||||
height: placed.placement.height,
|
||||
offsetX: placed.offsetX,
|
||||
offsetY: placed.offsetY,
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
const runs: number[][] = []
|
||||
let current = scene.blocked[0] ?? 0
|
||||
let count = 0
|
||||
for (const value of scene.blocked) {
|
||||
if (value === current) { count += 1; continue }
|
||||
runs.push([current, count])
|
||||
current = value
|
||||
count = 1
|
||||
}
|
||||
runs.push([current, count])
|
||||
|
||||
const sceneJson = {
|
||||
version: 1,
|
||||
fidelity: 'exact',
|
||||
act: entry.act,
|
||||
levelId: entry.levelId,
|
||||
levelName: info.levelName,
|
||||
ds1: ds1Name,
|
||||
cellsX: scene.cellsX,
|
||||
cellsY: scene.cellsY,
|
||||
originX: scene.originX,
|
||||
originY: scene.originY,
|
||||
widthPx: scene.widthPx,
|
||||
heightPx: scene.heightPx,
|
||||
pageSize: PAGE_SIZE,
|
||||
pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })),
|
||||
objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })),
|
||||
frames: scene.frames.map(frame => [frame.width, frame.height]),
|
||||
frameHash: scene.frames.map(frame => frameHash(frame.indices)),
|
||||
framePlacement: scene.frames.map((_, at) => {
|
||||
const place = placementOf.get(at)!
|
||||
return [place.page, place.x, place.y, place.width, place.height]
|
||||
}),
|
||||
floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
// Roofs stay in their own list because the engine paints them last of all.
|
||||
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
objects,
|
||||
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
|
||||
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
|
||||
stats: {
|
||||
floors: scene.floors.length,
|
||||
walls: scene.walls.length,
|
||||
roofs: scene.roofs.length,
|
||||
frames: scene.frames.length,
|
||||
missingTiles: scene.missingTiles,
|
||||
missingRefs: scene.missingRefs,
|
||||
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
|
||||
objects: objects.length,
|
||||
objectsWithArt,
|
||||
objectsUnresolved: missingObjects,
|
||||
objectsArtPending: objects.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,
|
||||
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,
|
||||
})
|
||||
totalLevels += 1
|
||||
const bakedFramesCount = objects.filter((o: any) => o.frame !== null).length
|
||||
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)} `
|
||||
+ `${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)}`,
|
||||
)
|
||||
if (pick !== null) objectsWithArt += 1
|
||||
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
|
||||
}
|
||||
|
||||
const objectFiles = objectPages.encode(0)
|
||||
const shaObjects = createHash('sha256')
|
||||
for (const page of objectFiles) {
|
||||
const name = page.name.replace('tiles-', 'objects-')
|
||||
await writeFile(join(dir, name), page.png)
|
||||
shaObjects.update(page.png)
|
||||
totalPngBytes += page.png.byteLength
|
||||
}
|
||||
|
||||
// Collision grid as runs, so the JSON stays small without a second format.
|
||||
const runs: number[][] = []
|
||||
let current = scene.blocked[0] ?? 0
|
||||
let count = 0
|
||||
for (const value of scene.blocked) {
|
||||
if (value === current) { count += 1; continue }
|
||||
runs.push([current, count])
|
||||
current = value
|
||||
count = 1
|
||||
}
|
||||
runs.push([current, count])
|
||||
|
||||
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 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}`)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ for (const name of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
|||
}
|
||||
const tables = await loadActTables(archives)
|
||||
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
|
||||
|
|
@ -122,6 +122,7 @@ let unknownObjectIds = 0
|
|||
const objectsTable = await loadObjectsTable(archives)
|
||||
|
||||
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 info = resolveLevel(tables, packed.levelId, packed.act)
|
||||
const libraries = []
|
||||
|
|
|
|||
|
|
@ -21,25 +21,103 @@ export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
|
|||
|
||||
/** `Levels.txt` 的 `Id` → 中文场景名。 */
|
||||
export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||
// Act 1
|
||||
1: '罗格营地',
|
||||
2: '鲜血荒地',
|
||||
3: '冰冷之原',
|
||||
4: '石块旷野',
|
||||
5: '黑暗森林',
|
||||
6: '黑色荒地',
|
||||
7: '泰摩高地',
|
||||
8: '邪恶洞窟',
|
||||
9: '洞穴一层',
|
||||
10: '地下通道一层',
|
||||
11: '窟窿一层',
|
||||
12: '深坑一层',
|
||||
13: '洞穴二层',
|
||||
14: '地下通道二层',
|
||||
15: '窟窿二层',
|
||||
16: '深坑二层',
|
||||
17: '埋骨之地',
|
||||
18: '大陵寝',
|
||||
19: '灵庙',
|
||||
20: '遗忘之塔',
|
||||
21: '塔地窖一层',
|
||||
22: '塔地窖二层',
|
||||
23: '塔地窖三层',
|
||||
24: '塔地窖四层',
|
||||
25: '塔地窖五层',
|
||||
26: '修道院大门',
|
||||
27: '外回廊',
|
||||
28: '军营',
|
||||
29: '监牢一层',
|
||||
30: '监牢二层',
|
||||
31: '监牢三层',
|
||||
32: '内回廊',
|
||||
33: '大教堂',
|
||||
34: '地下墓穴一层',
|
||||
35: '地下墓穴二层',
|
||||
36: '地下墓穴三层',
|
||||
37: '地下墓穴四层',
|
||||
38: '崔斯特瑞姆',
|
||||
39: '奶牛关',
|
||||
|
||||
// Act 2
|
||||
40: '鲁高因',
|
||||
41: '碎石荒地',
|
||||
42: '干燥高地',
|
||||
43: '遥远绿洲',
|
||||
44: '遗失的城市',
|
||||
45: '群蛇峡谷',
|
||||
46: '术士峡谷',
|
||||
47: '下水道一层',
|
||||
48: '下水道二层',
|
||||
49: '下水道三层',
|
||||
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: '都瑞尔的巢穴',
|
||||
74: '秘法圣所',
|
||||
|
||||
// Act 3
|
||||
75: '库拉斯特码头',
|
||||
76: '蜘蛛森林',
|
||||
77: '庞大湿地',
|
||||
78: '剥皮丛林',
|
||||
79: '库拉斯特下层',
|
||||
80: '库拉斯特商场',
|
||||
81: '库拉斯特上层',
|
||||
82: '库拉斯特堤道',
|
||||
83: '崔凡克',
|
||||
84: '蜘蛛巢穴',
|
||||
85: '蜘蛛洞窟',
|
||||
86: '沼泽地坑一层',
|
||||
87: '沼泽地坑二层',
|
||||
88: '剥皮地牢一层',
|
||||
89: '剥皮地牢二层',
|
||||
90: '沼泽地坑三层',
|
||||
91: '剥皮地牢三层',
|
||||
92: '下水道一层',
|
||||
93: '下水道二层',
|
||||
94: '废弃神殿',
|
||||
95: '荒废神庙',
|
||||
|
|
@ -47,15 +125,47 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
|||
97: '遗忘神殿',
|
||||
98: '废弃神庙',
|
||||
99: '荒废圣物室',
|
||||
100: '憎恨囚牢一层',
|
||||
101: '憎恨囚牢二层',
|
||||
102: '憎恨囚牢三层',
|
||||
103: '火焰之河要塞',
|
||||
|
||||
// Act 4
|
||||
103: '群魔堡垒',
|
||||
104: '郊外大草原',
|
||||
105: '绝望平原',
|
||||
106: '神罚之城',
|
||||
107: '火焰之河',
|
||||
108: '混沌避难所',
|
||||
|
||||
// Act 5
|
||||
109: '哈洛加斯',
|
||||
110: '血腥丘陵',
|
||||
111: '冰冻高地',
|
||||
112: '亚瑞特高原',
|
||||
113: '水晶通道',
|
||||
114: '冰河',
|
||||
115: '冰河路径',
|
||||
116: '漂泊者洞穴',
|
||||
117: '冰冻苔原',
|
||||
118: '冰窖',
|
||||
119: '痛苦之厅',
|
||||
120: '岩石之巅',
|
||||
121: '尼拉塞克神殿',
|
||||
122: '悲痛之厅',
|
||||
123: '死者呼唤之厅',
|
||||
124: '沃特厅',
|
||||
125: '艾巴当',
|
||||
126: '阿克隆地道',
|
||||
127: '炼狱地坑',
|
||||
128: '世界之石要塞一层',
|
||||
129: '世界之石要塞二层',
|
||||
130: '世界之石要塞三层',
|
||||
131: '毁灭王座',
|
||||
132: '世界之石大殿',
|
||||
136: '崔斯特瑞姆',
|
||||
133: '遗忘之沙',
|
||||
134: '巨窟',
|
||||
135: '苦痛熔炉',
|
||||
136: '混沌崔斯特瑞姆',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,10 +190,12 @@ const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
|||
/**
|
||||
* 把一个变体后缀变成显示用的中文(拿不准就原样返回 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.
|
||||
*/
|
||||
export function variantLabelZh(suffix: string): string {
|
||||
const match = /^var(\d+)$/i.exec(suffix)
|
||||
if (match) return `变体 ${match[1]}`
|
||||
return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue