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:
troytt 2026-09-14 11:15:13 +00:00
parent 1cfef30878
commit 2a0dd4ce25
3 changed files with 632 additions and 296 deletions

View File

@ -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,30 +566,28 @@ 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
}
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
const pl2 = decodePl2(await archives.read(info.paletteName))
const palette = pl2.rgb
;(index.palettes as Record<string, number[]>)[`act${String(entry.act)}`] = [...palette]
const libraries = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
for (const ds1Name of info.ds1Names) {
const level = decodeDs1(await archives.read(ds1Name))
const scene: IsoMapScene = buildIsoMapScene(level, libraries, levelSeed(ds1Name))
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 label = `${entry.slug}-${slugOf(ds1Name)}`
const dir = join(outDir, `act${String(entry.act)}`, label)
// Frames used near the spawn load first; everything else follows.
@ -465,12 +603,7 @@ for (const entry of LEVELS) {
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 })
@ -506,8 +639,6 @@ for (const entry of LEVELS) {
totalPngBytes += page.png.byteLength
}
// 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[] = []
@ -515,10 +646,6 @@ for (const entry of LEVELS) {
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
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)
@ -618,10 +745,12 @@ for (const entry of LEVELS) {
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)
const name = page.name.replace('tiles-', 'objects-')
await writeFile(join(dir, name), page.png)
shaObjects.update(page.png)
totalPngBytes += page.png.byteLength
}
@ -640,10 +769,10 @@ for (const entry of LEVELS) {
const sceneJson = {
version: 1,
fidelity: 'exact',
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
act: entry.act,
levelId: entry.levelId,
levelName: info.levelName,
levelName,
ds1: ds1Name,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
@ -662,7 +791,6 @@ for (const entry of LEVELS) {
}),
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 },
@ -678,19 +806,19 @@ for (const entry of LEVELS) {
objects: objects.length,
objectsWithArt,
objectsUnresolved: missingObjects,
objectsArtPending: objects.filter((o: any) => o.member !== null && o.frame === null).length,
dt1Libraries: info.dt1Names.length,
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: info.levelName,
level: levelName,
act: entry.act,
levelId: entry.levelId,
ds1: ds1Name,
dt1: info.dt1Names,
palette: info.paletteName,
dt1: dt1Names,
palette: paletteName,
sceneBytes: sceneBytes.byteLength,
sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'),
tilePagesSha256: sha.digest('hex'),
@ -706,7 +834,7 @@ for (const entry of LEVELS) {
kind: entry.kind,
slug: entry.slug,
label,
levelName: info.levelName,
levelName,
ds1: ds1Name,
path: `act${String(entry.act)}/${label}`,
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
@ -718,26 +846,121 @@ for (const entry of LEVELS) {
bytes: manifest.pngBytes + sceneBytes.byteLength,
})
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
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(
`act${String(entry.act)}/${label.padEnd(22)} ${String(scene.cellsX)}x${String(scene.cellsY)} `
`[${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}`)

View File

@ -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 = []

View File

@ -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
}