diablo2-web/src/game/level-names-zh.ts

479 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}