479 lines
13 KiB
TypeScript
479 lines
13 KiB
TypeScript
import type { DecodedTbl } from '../formats/tbl.ts'
|
||
import type { D2Table } from './acts.ts'
|
||
import { cell } from './acts.ts'
|
||
|
||
/** Decoded TBL structure or string-to-string Map. */
|
||
export type D2Tbl = DecodedTbl | Map<string, string>
|
||
|
||
/** 五个 act 的中文名,下标即 act 号。 */
|
||
export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
|
||
1: '第一章',
|
||
2: '第二章',
|
||
3: '第三章',
|
||
4: '第四章',
|
||
5: '第五章',
|
||
}
|
||
|
||
/** `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: '荒废神庙',
|
||
96: '遗忘圣物室',
|
||
97: '遗忘神殿',
|
||
98: '废弃神庙',
|
||
99: '荒废圣物室',
|
||
100: '憎恨囚牢一层',
|
||
101: '憎恨囚牢二层',
|
||
102: '憎恨囚牢三层',
|
||
|
||
// 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: '世界之石大殿',
|
||
133: '遗忘之沙',
|
||
134: '巨窟',
|
||
135: '苦痛熔炉',
|
||
136: '混沌崔斯特瑞姆',
|
||
}
|
||
|
||
/**
|
||
* DS1 变体后缀 → 方位名。
|
||
*
|
||
* 这些后缀是 D2 自己的命名习惯:城镇/回廊/鲁高因的 DS1 以 `N`/`E`/`S`/`W` 结尾
|
||
* (`TownN1`、`CourtW`、`LutN`…)。**只白名单这几个**,不做"末尾字母是方位"的猜测:
|
||
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
|
||
*/
|
||
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
||
townn1: '北门',
|
||
towne1: '东门',
|
||
towns1: '南门',
|
||
townw1: '西门',
|
||
courtn: '北向',
|
||
courte: '东向',
|
||
courtw: '西向',
|
||
lutn: '北门',
|
||
lutw: '西门',
|
||
}
|
||
|
||
/**
|
||
* 把一个变体后缀变成显示用的中文(拿不准就原样返回 DS1 名)。
|
||
*
|
||
* @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
|
||
}
|
||
|
||
/**
|
||
* 从 pack 索引条目的 `slug` 里取 `Levels.txt` 的关卡 Id。
|
||
*
|
||
* @param slug - e.g. `33-act-1-cathedral`.
|
||
* @returns the id, or null when the slug does not start with one.
|
||
*/
|
||
export function levelIdOfSlug(slug: string): number | null {
|
||
const match = /^(\d+)-/.exec(slug)
|
||
return match?.[1] === undefined ? null : Number(match[1])
|
||
}
|
||
|
||
/**
|
||
* 一个场景的中文名,取不到就回退调用方给的英文名。
|
||
* 支持传入自定义映射表或自动使用全局由 string.tbl 解码的名称,无匹配时回退到 LEVEL_NAMES_ZH 或 fallback。
|
||
*
|
||
* @param slug - the scene slug from the pack index.
|
||
* @param fallback - the English name from the pack index.
|
||
* @param customMap - optional custom map or record of levelId -> name.
|
||
* @returns the display name.
|
||
*/
|
||
export function sceneNameZh(
|
||
slug: string,
|
||
fallback: string,
|
||
customMap?: Map<number, string> | Record<number, string>,
|
||
): string {
|
||
const id = levelIdOfSlug(slug)
|
||
if (id === null) return fallback
|
||
if (customMap) {
|
||
const val = customMap instanceof Map ? customMap.get(id) : customMap[id]
|
||
if (val) return val
|
||
}
|
||
if (activeTblLevelNames?.has(id)) {
|
||
return activeTblLevelNames.get(id)!
|
||
}
|
||
return LEVEL_NAMES_ZH[id] ?? fallback
|
||
}
|
||
|
||
/** Canonical English level name keys corresponding to Levels.txt Id. */
|
||
export const CANONICAL_LEVEL_NAME_KEYS: Readonly<Record<number, string>> = {
|
||
1: 'Rogue Encampment',
|
||
2: 'Blood Moor',
|
||
3: 'Cold Plains',
|
||
4: 'Stony Field',
|
||
5: 'Dark Wood',
|
||
6: 'Black Marsh',
|
||
7: 'Tamoe Highland',
|
||
8: 'Den of Evil',
|
||
9: 'Cave Level 1',
|
||
10: 'Underground Passage Level 1',
|
||
11: 'Hole Level 1',
|
||
12: 'Pit Level 1',
|
||
13: 'Cave Level 2',
|
||
14: 'Underground Passage Level 2',
|
||
15: 'Hole Level 2',
|
||
16: 'Pit Level 2',
|
||
17: 'Burial Grounds',
|
||
18: 'Crypt',
|
||
19: 'Mausoleum',
|
||
20: 'Forgotten Tower',
|
||
21: 'Tower Cellar Level 1',
|
||
22: 'Tower Cellar Level 2',
|
||
23: 'Tower Cellar Level 3',
|
||
24: 'Tower Cellar Level 4',
|
||
25: 'Tower Cellar Level 5',
|
||
26: 'Monastery Gate',
|
||
27: 'Outer Cloister',
|
||
28: 'Barracks',
|
||
29: 'Jail Level 1',
|
||
30: 'Jail Level 2',
|
||
31: 'Jail Level 3',
|
||
32: 'Inner Cloister',
|
||
33: 'Cathedral',
|
||
34: 'Catacombs Level 1',
|
||
35: 'Catacombs Level 2',
|
||
36: 'Catacombs Level 3',
|
||
37: 'Catacombs Level 4',
|
||
38: 'Tristram',
|
||
39: 'Moo Moo Farm',
|
||
|
||
40: 'Lut Gholein',
|
||
41: 'Rocky Waste',
|
||
42: 'Dry Hills',
|
||
43: 'Far Oasis',
|
||
44: 'Lost City',
|
||
45: 'Valley of Snakes',
|
||
46: 'Canyon of the Magi',
|
||
47: 'Sewers Level 1',
|
||
48: 'Sewers Level 2',
|
||
49: 'Sewers Level 3',
|
||
50: 'Harem Level 1',
|
||
51: 'Harem Level 2',
|
||
52: 'Palace Cellar Level 1',
|
||
53: 'Palace Cellar Level 2',
|
||
54: 'Palace Cellar Level 3',
|
||
55: 'Stony Tomb Level 1',
|
||
56: 'Halls of the Dead Level 1',
|
||
57: 'Halls of the Dead Level 2',
|
||
58: 'Claw Viper Temple Level 1',
|
||
59: 'Stony Tomb Level 2',
|
||
60: 'Halls of the Dead Level 3',
|
||
61: 'Claw Viper Temple Level 2',
|
||
62: 'Maggot Lair Level 1',
|
||
63: 'Maggot Lair Level 2',
|
||
64: 'Maggot Lair Level 3',
|
||
65: 'Ancient Tunnels',
|
||
66: "Tal Rasha's Tomb 1",
|
||
67: "Tal Rasha's Tomb 2",
|
||
68: "Tal Rasha's Tomb 3",
|
||
69: "Tal Rasha's Tomb 4",
|
||
70: "Tal Rasha's Tomb 5",
|
||
71: "Tal Rasha's Tomb 6",
|
||
72: "Tal Rasha's Tomb 7",
|
||
73: "Duriel's Lair",
|
||
74: 'Arcane Sanctuary',
|
||
|
||
75: 'Kurast Docks',
|
||
76: 'Spider Forest',
|
||
77: 'Great Marsh',
|
||
78: 'Flayer Jungle',
|
||
79: 'Lower Kurast',
|
||
80: 'Kurast Bazaar',
|
||
81: 'Upper Kurast',
|
||
82: 'Kurast Causeway',
|
||
83: 'Travincal',
|
||
84: 'Spider Cave',
|
||
85: 'Spider Cavern',
|
||
86: 'Swampy Pit Level 1',
|
||
87: 'Swampy Pit Level 2',
|
||
88: 'Flayer Dungeon Level 1',
|
||
89: 'Flayer Dungeon Level 2',
|
||
90: 'Swampy Pit Level 3',
|
||
91: 'Flayer Dungeon Level 3',
|
||
92: 'Sewers Level 1',
|
||
93: 'Sewers Level 2',
|
||
94: 'Ruined Temple',
|
||
95: 'Disused Fane',
|
||
96: 'Forgotten Reliquary',
|
||
97: 'Forgotten Temple',
|
||
98: 'Ruined Fane',
|
||
99: 'Disused Reliquary',
|
||
100: 'Durance of Hate Level 1',
|
||
101: 'Durance of Hate Level 2',
|
||
102: 'Durance of Hate Level 3',
|
||
|
||
103: 'Pandemonium Fortress',
|
||
104: 'Outer Steppes',
|
||
105: 'Plains of Despair',
|
||
106: 'City of the Damned',
|
||
107: 'River of Flame',
|
||
108: 'Chaos Sanctuary',
|
||
|
||
109: 'Harrogath',
|
||
110: 'Bloody Foothills',
|
||
111: 'Frigid Highlands',
|
||
112: 'Arreat Plateau',
|
||
113: 'Crystalline Passage',
|
||
114: 'Frozen River',
|
||
115: 'Glacial Trail',
|
||
116: 'Drifter Cavern',
|
||
117: 'Frozen Tundra',
|
||
118: "The Ancients' Way",
|
||
119: 'Arreat Summit',
|
||
120: "Nihlathak's Temple",
|
||
121: 'Halls of Anguish',
|
||
122: 'Halls of Pain',
|
||
123: 'Halls of Vaught',
|
||
124: 'Abaddon',
|
||
125: 'Pit of Acheron',
|
||
126: 'Infernal Pit',
|
||
127: 'Worldstone Keep Level 1',
|
||
128: 'Worldstone Keep Level 2',
|
||
129: 'Worldstone Keep Level 3',
|
||
130: 'Throne of Destruction',
|
||
131: 'Worldstone Chamber',
|
||
132: "Matron's Den",
|
||
133: 'Forgotten Sands',
|
||
134: 'Furnace of Pain',
|
||
135: 'Tristram',
|
||
136: 'Chaos Tristram',
|
||
}
|
||
|
||
function lookupTbl(tbl: D2Tbl | Map<string, string>, key: string): string | undefined {
|
||
if (tbl instanceof Map) {
|
||
return tbl.get(key)
|
||
}
|
||
if (tbl && typeof tbl === 'object' && 'dict' in tbl && tbl.dict instanceof Map) {
|
||
return tbl.dict.get(key)
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/** Currently active decoded level names from string.tbl. */
|
||
let activeTblLevelNames: Map<number, string> | null = null
|
||
|
||
/** Set or clear the globally active level names decoded from string.tbl. */
|
||
export function setActiveLevelNames(names: Map<number, string> | Record<number, string> | null): void {
|
||
if (names === null) {
|
||
activeTblLevelNames = null
|
||
} else if (names instanceof Map) {
|
||
activeTblLevelNames = names
|
||
} else {
|
||
activeTblLevelNames = new Map(Object.entries(names).map(([k, v]) => [Number(k), v]))
|
||
}
|
||
}
|
||
|
||
/** Get the currently active level names decoded from string.tbl, if any. */
|
||
export function getActiveLevelNames(): ReadonlyMap<number, string> | null {
|
||
return activeTblLevelNames
|
||
}
|
||
|
||
/**
|
||
* Decodes localized level names from a string.tbl dictionary (or Map<string, string>).
|
||
* Supports reading from string.tbl and optional Levels.txt table (or loadActTables).
|
||
* Falls back gracefully to LEVEL_NAMES_ZH or English nameKey if strings are missing or table is not loaded.
|
||
*
|
||
* @param tbl - decoded string.tbl object or Map of string keys to localized names.
|
||
* @param levelsTable - optional Levels.txt D2Table for dynamic Id->LevelName mapping.
|
||
* @returns Map of levelId -> localized display name.
|
||
*/
|
||
export function decodeLevelNamesFromTbl(
|
||
tbl?: D2Tbl | Map<string, string> | null,
|
||
levelsTable?: D2Table,
|
||
): Map<number, string> {
|
||
const result = new Map<number, string>()
|
||
|
||
if (levelsTable) {
|
||
for (const row of levelsTable.rows) {
|
||
const idStr = cell(levelsTable, row, 'Id')
|
||
if (!idStr) continue
|
||
const id = Number(idStr)
|
||
if (isNaN(id) || id <= 0) continue
|
||
|
||
const levelNameKey = cell(levelsTable, row, 'LevelName')
|
||
const nameKey = cell(levelsTable, row, 'Name')
|
||
const canonicalKey = CANONICAL_LEVEL_NAME_KEYS[id]
|
||
|
||
let match: string | undefined
|
||
if (tbl) {
|
||
match =
|
||
(levelNameKey ? lookupTbl(tbl, levelNameKey) : undefined) ??
|
||
(nameKey ? lookupTbl(tbl, nameKey) : undefined) ??
|
||
(canonicalKey ? lookupTbl(tbl, canonicalKey) : undefined) ??
|
||
lookupTbl(tbl, String(id))
|
||
}
|
||
|
||
if (match) {
|
||
result.set(id, match)
|
||
} else {
|
||
const fallback = LEVEL_NAMES_ZH[id] ?? levelNameKey ?? canonicalKey ?? nameKey
|
||
if (fallback) {
|
||
result.set(id, fallback)
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// Without Levels.txt, iterate all canonical IDs
|
||
const allIds = new Set<number>([
|
||
...Object.keys(LEVEL_NAMES_ZH).map(Number),
|
||
...Object.keys(CANONICAL_LEVEL_NAME_KEYS).map(Number),
|
||
])
|
||
|
||
for (const id of allIds) {
|
||
const canonicalKey = CANONICAL_LEVEL_NAME_KEYS[id]
|
||
let match: string | undefined
|
||
if (tbl) {
|
||
match =
|
||
(canonicalKey ? lookupTbl(tbl, canonicalKey) : undefined) ??
|
||
lookupTbl(tbl, String(id))
|
||
}
|
||
|
||
if (match) {
|
||
result.set(id, match)
|
||
} else {
|
||
const fallback = LEVEL_NAMES_ZH[id] ?? canonicalKey ?? String(id)
|
||
result.set(id, fallback)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|