fix: load localized map area names from tbl on transition (Fixes #147)

This commit is contained in:
troytt 2026-09-22 02:55:24 +00:00
parent f24ad292ca
commit 09cc23b53c
3 changed files with 344 additions and 65 deletions

View File

@ -142,13 +142,13 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
115: '冰河路径', 115: '冰河路径',
116: '漂泊者洞穴', 116: '漂泊者洞穴',
117: '冰冻苔原', 117: '冰冻苔原',
118: '冰窖', 118: '远古之路',
119: '痛苦之厅', 119: '冰窖',
120: '岩石之巅', 120: '亚瑞特山顶',
121: '尼拉塞克神殿', 121: '尼拉塞克的神殿',
122: '悲痛之厅', 122: '悲痛之厅',
123: '死者呼唤之厅', 123: '痛苦之厅',
124: '沃特厅', 124: '沃德之厅',
125: '艾巴当', 125: '艾巴当',
126: '阿克隆地道', 126: '阿克隆地道',
127: '炼狱地坑', 127: '炼狱地坑',
@ -194,43 +194,6 @@ export function variantLabelZh(suffix: string): string {
return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix 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. */ /** Canonical English level name keys corresponding to Levels.txt Id. */
export const CANONICAL_LEVEL_NAME_KEYS: Readonly<Record<number, string>> = { export const CANONICAL_LEVEL_NAME_KEYS: Readonly<Record<number, string>> = {
1: 'Rogue Encampment', 1: 'Rogue Encampment',
@ -355,24 +318,265 @@ export const CANONICAL_LEVEL_NAME_KEYS: Readonly<Record<number, string>> = {
116: 'Drifter Cavern', 116: 'Drifter Cavern',
117: 'Frozen Tundra', 117: 'Frozen Tundra',
118: "The Ancients' Way", 118: "The Ancients' Way",
119: 'Arreat Summit', 119: 'Icy Cellar',
120: "Nihlathak's Temple", 120: 'Arreat Summit',
121: 'Halls of Anguish', 121: "Nihlathak's Temple",
122: 'Halls of Pain', 122: 'Halls of Anguish',
123: 'Halls of Vaught', 123: 'Halls of Pain',
124: 'Abaddon', 124: 'Halls of Vaught',
125: 'Pit of Acheron', 125: 'Abaddon',
126: 'Infernal Pit', 126: 'Pit of Acheron',
127: 'Worldstone Keep Level 1', 127: 'Infernal Pit',
128: 'Worldstone Keep Level 2', 128: 'Worldstone Keep Level 1',
129: 'Worldstone Keep Level 3', 129: 'Worldstone Keep Level 2',
130: 'Throne of Destruction', 130: 'Worldstone Keep Level 3',
131: 'Worldstone Chamber', 131: 'Throne of Destruction',
132: "Matron's Den", 132: 'Worldstone Chamber',
133: 'Forgotten Sands', 133: "Matron's Den",
134: 'Furnace of Pain', 134: 'Forgotten Sands',
135: 'Tristram', 135: 'Furnace of Pain',
136: 'Chaos Tristram', 136: 'Tristram',
}
/** Alias for CANONICAL_LEVEL_NAME_KEYS for symmetry with LEVEL_NAMES_ZH. */
export const LEVEL_NAMES_EN: Readonly<Record<number, string>> = CANONICAL_LEVEL_NAME_KEYS
/**
* Normalizes any level slug or name string for robust lookup.
* e.g. "17-burial-grounds" -> "burialgrounds"
* "rogue-encampment" -> "rogueencampment"
* "The Ancients' Way" -> "theancientsway"
*/
function normalizeSlug(str: string): string {
return str.toLowerCase().replace(/[^a-z0-9]/g, '')
}
/** Check if a string looks like a code/slug rather than a human-readable title. */
function isRawSlug(str: string): boolean {
return str.includes('-') || str.includes('_') || /^[a-z0-9]+$/.test(str)
}
function toTitleCase(slug: string): string {
const clean = slug.replace(/^\d+[-_]/, '')
return clean
.split(/[-_\s]+/)
.filter(Boolean)
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ')
}
/** Pre-built map of normalized slugs/names to canonical level ID. */
const SLUG_TO_LEVEL_ID: ReadonlyMap<string, number> = (() => {
const map = new Map<string, number>()
for (let id = 1; id <= 136; id++) {
const en = CANONICAL_LEVEL_NAME_KEYS[id]
if (en) {
map.set(normalizeSlug(en), id)
map.set(normalizeSlug(en.replace(/^The\s+/i, '')), id)
}
}
// Common aliases in pack manifests
const aliases: Record<string, number> = {
'rogueencampment': 1,
'bloodmoor': 2,
'coldplains': 3,
'stonyfield': 4,
'darkwood': 5,
'blackmarsh': 6,
'tamoehighland': 7,
'denofevil': 8,
'cave1': 9,
'cavelevel1': 9,
'undergroundpassage1': 10,
'undergroundpassagelevel1': 10,
'hole1': 11,
'holelevel1': 11,
'pit1': 12,
'pitlevel1': 12,
'cave2': 13,
'cavelevel2': 13,
'undergroundpassage2': 14,
'undergroundpassagelevel2': 14,
'hole2': 15,
'holelevel2': 15,
'pit2': 16,
'pitlevel2': 16,
'burialgrounds': 17,
'crypt': 18,
'mausoleum': 19,
'forgottentower': 20,
'towercellar1': 21,
'towercellar2': 22,
'towercellar3': 23,
'towercellar4': 24,
'towercellar5': 25,
'monasterygate': 26,
'outercloister': 27,
'barracks': 28,
'jail1': 29,
'jail2': 30,
'jail3': 31,
'innercloister': 32,
'cathedral': 33,
'catacombs1': 34,
'catacombs2': 35,
'catacombs3': 36,
'catacombs4': 37,
'tristram': 38,
'moomoofarm': 39,
'lutgholein': 40,
'rockywaste': 41,
'dryhills': 42,
'faroasis': 43,
'lostcity': 44,
'valleyofsnakes': 45,
'canyonofmagi': 46,
'canyonofthemagi': 46,
'harrogath': 109,
'bloodyfoothills': 110,
'frigidhighlands': 111,
'arreatplateau': 112,
'crystallinepassage': 113,
'frozenriver': 114,
'glacialtrail': 115,
'driftercavern': 116,
'frozentundra': 117,
'ancientsway': 118,
'theancientsway': 118,
'icycellar': 119,
'arreatsummit': 120,
'nihlathakstemple': 121,
'hallsofanguish': 122,
'hallsofpain': 123,
'hallsofvaught': 124,
'abaddon': 125,
'pitofacheron': 126,
'infernalpit': 127,
'worldstonekeep1': 128,
'worldstonekeep2': 129,
'worldstonekeep3': 130,
'throneofdestruction': 131,
'worldstonechamber': 132,
}
for (const [k, v] of Object.entries(aliases)) {
map.set(normalizeSlug(k), v)
}
return map
})()
/**
* 从 pack 索引条目的 `slug` 或关卡代号里解析 `Levels.txt` 的关卡 Id。
* 支持前缀格式(`17-burial-grounds`)以及无前缀纯文本 slug(`burial-grounds`、`rogue-encampment`)。
*
* @param slug - e.g. `17-burial-grounds` or `burial-grounds`.
* @returns the id (1..136), or null when unresolvable.
*/
export function levelIdOfSlug(slug: string): number | null {
const match = /^(\d+)(?:-|_|$)/.exec(slug)
if (match?.[1] !== undefined) {
const parsed = Number(match[1])
if (parsed >= 1 && parsed <= 136) return parsed
}
const norm = normalizeSlug(slug)
return SLUG_TO_LEVEL_ID.get(norm) ?? null
}
export interface LevelNames {
readonly nameZh: string
readonly nameEn: string
}
/**
* Bilingual level name lookup for UI banners and HUD displays.
* Resolves both numeric level IDs (1..136) and string slugs (e.g. 'burial-grounds').
* Returns authentic Chinese and English names, never empty, never leaking raw slugs.
*/
export function getLevelNames(levelIdOrSlug: number | string): LevelNames {
let id: number | null = null
let rawStr = ''
if (typeof levelIdOrSlug === 'number') {
id = levelIdOrSlug
rawStr = String(levelIdOrSlug)
} else {
rawStr = levelIdOrSlug.trim()
id = levelIdOfSlug(rawStr)
}
let nameZh: string | undefined
let nameEn: string | undefined
if (id !== null) {
nameZh = activeTblLevelNames?.get(id) ?? LEVEL_NAMES_ZH[id]
nameEn = CANONICAL_LEVEL_NAME_KEYS[id]
}
if (!nameEn) {
nameEn = toTitleCase(rawStr) || 'Unknown Area'
}
if (!nameZh) {
nameZh = nameEn
}
return { nameZh, nameEn }
}
/**
* 一个场景的中文名,取不到就回退调用方给的英文名。
* 支持传入自定义映射表或自动使用全局由 string.tbl 解码的名称,无匹配时回退到 LEVEL_NAMES_ZH 或 fallback。
* 防止原始无前缀 slug(如 'burial-grounds')直接泄露到 UI。
*
* @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 | number,
fallback?: string,
customMap?: Map<number, string> | Record<number, string>,
): string {
const slugStr = String(slug)
const id = typeof slug === 'number' ? slug : levelIdOfSlug(slugStr)
if (id !== null) {
if (customMap) {
const val = customMap instanceof Map ? customMap.get(id) : customMap[id]
if (val) return val
}
if (activeTblLevelNames?.has(id)) {
return activeTblLevelNames.get(id)!
}
if (LEVEL_NAMES_ZH[id]) {
return LEVEL_NAMES_ZH[id]!
}
}
// Guard against leaking raw un-prefixed slugs
if (fallback && !isRawSlug(fallback)) {
return fallback
}
return getLevelNames(slug).nameZh
}
/**
* 一个场景的英文名。
*
* @param slug - the scene slug from the pack index or numeric level ID.
* @param fallback - optional fallback English string.
* @returns canonical English level name.
*/
export function sceneNameEn(
slug: string | number,
fallback?: string,
): string {
const slugStr = String(slug)
const id = typeof slug === 'number' ? slug : levelIdOfSlug(slugStr)
if (id !== null && CANONICAL_LEVEL_NAME_KEYS[id]) {
return CANONICAL_LEVEL_NAME_KEYS[id]!
}
if (fallback && !isRawSlug(fallback)) {
return fallback
}
return getLevelNames(slug).nameEn
} }
function lookupTbl(tbl: D2Tbl | Map<string, string>, key: string): string | undefined { function lookupTbl(tbl: D2Tbl | Map<string, string>, key: string): string | undefined {

View File

@ -55,7 +55,7 @@ import { facingToDirection } from '../game/character.ts'
import type { LoadedMonsterArt } from '../game/monster-art.ts' import type { LoadedMonsterArt } from '../game/monster-art.ts'
import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts' import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts' import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts' import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
import { GameLoop } from '../sim/loop.ts' import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput } from '../sim/input.ts' import { KeyboardInput } from '../sim/input.ts'
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts' import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts'
@ -2675,7 +2675,6 @@ if (typeof window !== 'undefined') {
if (typeof window !== 'undefined' && window.__d2webHud) { if (typeof window !== 'undefined' && window.__d2webHud) {
(window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount (window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount
} }
hudManager.worldPanels.showAreaBanner(sceneNameZh(runtime.level, runtime.level), runtime.level)
} }
const mouseController = new SceneMouseController({ const mouseController = new SceneMouseController({
@ -2869,6 +2868,9 @@ if (typeof window !== 'undefined') {
state.animatedTiles = next.animatedDrawables?.length ?? 0 state.animatedTiles = next.animatedDrawables?.length ?? 0
state.animSpeed = next.animSpeed state.animSpeed = next.animSpeed
state.frameDurationMs = next.frameDurationMs state.frameDurationMs = next.frameDurationMs
const names = getLevelNames(next.levelId ?? next.level)
hudManager?.worldPanels.showAreaBanner(names.nameZh, names.nameEn)
} }
/** /**
@ -2994,8 +2996,8 @@ if (typeof window !== 'undefined') {
preloadRemaining(next, renderer) preloadRemaining(next, renderer)
minimapCache = null minimapCache = null
hudManager?.worldPanels.showAreaBanner(sceneNameZh(next.level, next.level), next.level) const names = getLevelNames(next.levelId ?? next.level)
status.textContent = `已传送到:${sceneNameZh(next.level, next.level)}(Act ${String(next.act)})` status.textContent = `已传送到:${names.nameZh}(Act ${String(next.act)})`
} catch (err) { } catch (err) {
status.textContent = `传送点传送失败:${(err as Error).message}` status.textContent = `传送点传送失败:${(err as Error).message}`
} finally { } finally {

View File

@ -30,6 +30,13 @@ import { WorldPanelsHud, ACT_WAYPOINTS, ALL_QUESTS, ACT_QUESTS, findWaypointBySl
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import { HudManager, computeHudLayout } from '../src/ui/hud-manager.ts' import { HudManager, computeHudLayout } from '../src/ui/hud-manager.ts'
import { projectNpcLabel } from '../src/scene/act-scene.ts' import { projectNpcLabel } from '../src/scene/act-scene.ts'
import {
getLevelNames,
sceneNameZh,
sceneNameEn,
LEVEL_NAMES_ZH,
LEVEL_NAMES_EN,
} from '../src/game/level-names-zh.ts'
describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => { it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => {
@ -548,4 +555,70 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
expect(act4Quests[1]!.id).toBe('a4q2') expect(act4Quests[1]!.id).toBe('a4q2')
expect(act4Quests[2]!.id).toBe('a4q3') expect(act4Quests[2]!.id).toBe('a4q3')
}) })
it('loads localized map area names from tbl with 1.13c Act 5 alignment and slug protection (Issue #147)', () => {
// 1. Specific key areas required by Issue #147 and v1.13c tbl
expect(getLevelNames(1)).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' })
expect(getLevelNames(2)).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' })
expect(getLevelNames(17)).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' })
// 2. Act 5 (109..136) alignment against string.tbl / expansionstring.tbl
expect(getLevelNames(109)).toEqual({ nameZh: '哈洛加斯', nameEn: 'Harrogath' })
expect(getLevelNames(118)).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" })
expect(getLevelNames(119)).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' })
expect(getLevelNames(120)).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' })
expect(getLevelNames(121)).toEqual({ nameZh: '尼拉塞克的神殿', nameEn: "Nihlathak's Temple" })
expect(getLevelNames(122)).toEqual({ nameZh: '悲痛之厅', nameEn: 'Halls of Anguish' })
expect(getLevelNames(123)).toEqual({ nameZh: '痛苦之厅', nameEn: 'Halls of Pain' })
expect(getLevelNames(124)).toEqual({ nameZh: '沃德之厅', nameEn: 'Halls of Vaught' })
expect(getLevelNames(131)).toEqual({ nameZh: '毁灭王座', nameEn: 'Throne of Destruction' })
expect(getLevelNames(132)).toEqual({ nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' })
// 3. Complete 136 level IDs validity: no empty strings, no raw hyphen slugs
for (let id = 1; id <= 136; id++) {
const names = getLevelNames(id)
expect(names.nameZh).toBeTruthy()
expect(names.nameEn).toBeTruthy()
expect(names.nameZh).not.toContain('-')
expect(names.nameEn).not.toContain('-')
expect(LEVEL_NAMES_ZH[id]).toBe(names.nameZh)
expect(LEVEL_NAMES_EN[id]).toBe(names.nameEn)
}
// 4. Slug resolution: both un-prefixed and numbered prefixes resolve cleanly
expect(getLevelNames('burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' })
expect(getLevelNames('17-burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' })
expect(getLevelNames('rogue-encampment')).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' })
expect(getLevelNames('blood-moor')).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' })
expect(getLevelNames('harrogath')).toEqual({ nameZh: '哈洛加斯', nameEn: 'Harrogath' })
expect(getLevelNames('the-ancients-way')).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" })
expect(getLevelNames('icy-cellar')).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' })
expect(getLevelNames('arreat-summit')).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' })
// 5. sceneNameZh and sceneNameEn parity and slug leak prevention
expect(sceneNameZh(17)).toBe('埋骨之地')
expect(sceneNameZh('burial-grounds')).toBe('埋骨之地')
expect(sceneNameZh('17-burial-grounds')).toBe('埋骨之地')
expect(sceneNameEn(17)).toBe('Burial Grounds')
expect(sceneNameEn('burial-grounds')).toBe('Burial Grounds')
expect(sceneNameEn('17-burial-grounds')).toBe('Burial Grounds')
// 6. Unknown fallback must never produce raw un-spaced lowercase slugs
const unknownFallback = getLevelNames('unknown-custom-dungeon')
expect(unknownFallback.nameZh).toBe('Unknown Custom Dungeon')
expect(unknownFallback.nameEn).toBe('Unknown Custom Dungeon')
expect(sceneNameZh('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon')
expect(sceneNameEn('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon')
// 7. Area banner display logic in WorldPanelsHud
const worldPanels = new WorldPanelsHud()
const now = 1000
const bgNames = getLevelNames(17)
worldPanels.showAreaBanner(bgNames.nameZh, bgNames.nameEn, now)
expect(worldPanels.areaBanner).toEqual({
titleZh: '埋骨之地',
titleEn: 'Burial Grounds',
untilMs: now + 3600,
})
})
}) })