From 6fa9c7784e245cb8c5a9c26c5195c0c6a47dde64 Mon Sep 17 00:00:00 2001
From: troytt <47798984@qq.com>
Date: Mon, 28 Sep 2026 05:07:27 +0000
Subject: [PATCH] feat(i18n): implement full Chinese (CHI) and English (ENG)
bilingual versions (#503)
- Add src/i18n/lang.ts for URL/query/storage language detection, runtime state, and language switcher URL generation preserving query parameters
- Expand scripts/pack-canonical-tbl.ts and regenerate src/data/canonical-tbl-strings.ts with 3,721 ENG and 3,374 CHI 1.13c .tbl entries (UI, monsters, SuperUniques, levels, skills, items)
- Add completedEn speech strings for Act II Quest 2 and Act IV Quest 2 in src/data/canonical-quest-data.ts
- Add index.html bilingual entry portal, acts-zh.html (CHI), acts-en.html (ENG), and language switcher in acts.html; configure multi-page rollup inputs in vite.config.ts
- Localize all 15 UI/game modules (level-names-zh, character-sheet, control-bar, globes, ground-items, ground-labels, item-bridge, inventory, skill-tree-panel, hotkeys, npc-table, world-panels, monsters, hud-manager, act-scene) for authentic CHI and zero-CJK ENG modes
- Add tests/issue-503-bilingual-parity.test.ts and scripts/verify-issue-503-browser.ts
TAG=agy
CONV=5016e6fd-4af8-421f-ae4b-6d443a0a102a
---
acts-en.html | 112 +
acts-zh.html | 112 +
acts.html | 42 +-
index.html | 183 ++
scripts/pack-canonical-tbl.ts | 314 ++-
scripts/verify-issue-503-browser.ts | 368 ++++
src/data/canonical-quest-data.ts | 8 +-
src/data/canonical-tbl-strings.ts | 2303 +++++++++++++++++++-
src/game/ground-items.ts | 23 +-
src/game/level-names-zh.ts | 68 +
src/game/monsters.ts | 81 +-
src/game/npc-table.ts | 223 ++
src/i18n/lang.ts | 151 ++
src/scene/act-scene.ts | 589 +++--
src/ui/character-sheet.ts | 43 +-
src/ui/control-bar.ts | 57 +-
src/ui/globes.ts | 7 +-
src/ui/ground-labels.ts | 27 +-
src/ui/hotkeys.ts | 243 +--
src/ui/hud-manager.ts | 35 +-
src/ui/inventory.ts | 53 +-
src/ui/item-bridge.ts | 69 +-
src/ui/skill-tree-panel.ts | 180 +-
src/ui/world-panels.ts | 599 ++++-
tests/challenger-m3-tooltip-stress.test.ts | 2 +-
tests/ground-drop-parity-issues.test.ts | 4 +-
tests/issue-503-bilingual-parity.test.ts | 465 ++++
tests/item-to-ui-bridge.test.ts | 19 +-
tests/item-tooltip-color-dye.test.ts | 8 +-
tests/quest-tbl.test.ts | 2 +-
tests/skill-quickbar-tooltip.test.ts | 29 +-
tests/ui-hud.test.ts | 4 +-
tests/unidentified-drops-identify.test.ts | 20 +-
vite.config.ts | 31 +-
34 files changed, 5816 insertions(+), 658 deletions(-)
create mode 100644 acts-en.html
create mode 100644 acts-zh.html
create mode 100644 index.html
create mode 100644 scripts/verify-issue-503-browser.ts
create mode 100644 src/i18n/lang.ts
create mode 100644 tests/issue-503-bilingual-parity.test.ts
diff --git a/acts-en.html b/acts-en.html
new file mode 100644
index 0000000..505a5e3
--- /dev/null
+++ b/acts-en.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+ d2web · Diablo II v1.13c
+
+
+
+
+ d2web · Diablo II
+ —
+
+
+
+
+
+
+
+
+
+
Lang: ZH
+
I Inventory · A Character · T Skills · Q Quests · V Waypoints · ~ Belt · R Run/Walk · W Swap
+
+ Loading map…
+
+
+
+
+
+
+
diff --git a/acts-zh.html b/acts-zh.html
new file mode 100644
index 0000000..d400d72
--- /dev/null
+++ b/acts-zh.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+ d2web · 暗黑破坏神 2 v1.13c
+
+
+
+
+
+
+
+
+
+
+
+
+
English
+
I背包 · A角色 · T技能 · Q任务 · V小站 · ~腰带 · R奔跑 · W换手
+
+ 正在加载地图…
+
+
+
+
+
+
+
diff --git a/acts.html b/acts.html
index 3ab2cc8..d400d72 100644
--- a/acts.html
+++ b/acts.html
@@ -3,13 +3,12 @@
- d2web · 暗黑破坏神 2 地图与法师
+ d2web · 暗黑破坏神 2 v1.13c
@@ -44,13 +57,13 @@
+
English
I背包 · A角色 · T技能 · Q任务 · V小站 · ~腰带 · R奔跑 · W换手
正在加载地图…
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..42fa984
--- /dev/null
+++ b/index.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+ Diablo II Web · 暗黑破坏神 II 网页版 (v1.13c)
+
+
+
+
+ Diablo II: Lord of Destruction · Patch 1.13c
+ DIABLO II WEB · 暗黑破坏神 II
+ Select Language Edition · 请选择游戏语言版本
+
+
+
+
+
+
+
+
+
diff --git a/scripts/pack-canonical-tbl.ts b/scripts/pack-canonical-tbl.ts
index 83379e2..b50bfe5 100644
--- a/scripts/pack-canonical-tbl.ts
+++ b/scripts/pack-canonical-tbl.ts
@@ -18,6 +18,212 @@ import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadStringTableChain } from '../src/game/item-names.ts'
import { parseTable } from '../src/game/tables.ts'
+import {
+ ITEM_NAMES_ZH,
+ MAGIC_PREFIX_ZH,
+ MAGIC_SUFFIX_ZH,
+ RARE_PREFIX_ZH,
+ RARE_SUFFIX_ZH,
+ SKILL_NAMES_ZH,
+ CLASS_SKILL_NAMES_ZH,
+ CLASS_RESTRICTION_SUFFIX_ZH,
+ RUNE_INFO_ZH,
+} from '../src/game/tooltip-i18n.ts'
+
+const EXPANSION_CHI_FALLBACKS: Readonly> = {
+ strethereal: '无形的 (无法修复)',
+ ModStre9s: '无法破坏',
+ ModStre9t: '每秒修复 %d 点耐久度',
+ ModStre9u: '每 %d 秒修复 1 点耐久度',
+ ModStre9v: '自动恢复数量',
+ ModStre9i: '增加堆叠数量',
+ ModStre9c: '(以角色等级决定)',
+ ModStre9d: '(在夜间增加)',
+ ModStre9e: '(在白天增加)',
+ ModStre9f: '(在黎明附近增加)',
+ ModStre9g: '(在黄昏附近增加)',
+ ModStre9h: ' 次聚气的',
+ ModStre9w: '施展等级 %d',
+ ModStre9x: '当你挥击时',
+ ModStre9y: '当你被击中时',
+ ModStre9z: '当你击中敌人时',
+ ModStre10d: '(%d/%d 次聚气)',
+ ModStre10L: '几率施展',
+ ModStre10a: '聚气',
+ ModStre10b: '等级',
+ ModStre10c: '每级',
+ ModStre10e: '(',
+ ModStre10f: ')',
+ ModStre10g: '隐秘',
+ ModStre10h: '毒素免疫',
+ ModStre10i: '诅咒',
+ ModStre10j: '每个队伍成员',
+ ModStre10k: '踢击伤害:',
+ ItemExpansiveChanc1: '击中时有 %d%% 几率施展等级 %d %s',
+ ItemExpansiveChanc2: '受击时有 %d%% 几率施展等级 %d %s',
+ ItemExpansiveChancX: '攻击时有 %d%% 几率施展等级 %d %s',
+ ExInsertSockets: '可镶嵌于带凹槽的物品中',
+ StrGemX1: '头盔:',
+ StrGemX2: '盾牌:',
+ StrGemX3: '武器:',
+ StrGemX4: '护甲:',
+ GemXp1: '头盔:',
+ GemXp2: '盾牌:',
+ GemXp3: '武器:',
+ GemXp4: '护甲:',
+ WeaponDescOrb: '法珠等级',
+ ModStre8a: ' 德鲁伊技能',
+ ModStre8c: ' 凹槽',
+ ModStre8d: ' 对恶魔的攻击准确率',
+ ModStre8e: ' 对不死生物的攻击准确率',
+ ModStre8f: ' 对恶魔的伤害',
+ ModStre8g: '% 攻击准确率',
+ ModStre8h: ' 标枪与长矛技能',
+ ModStre8i: ' 被动与魔法技能',
+ ModStre8j: ' 弓与十字弓技能',
+ ModStre8k: ' 防御灵气技能',
+ ModStre8l: ' 攻击灵气技能',
+ ModStre8m: ' 战斗技能',
+ ModStre8n: ' 召唤技能',
+ ModStre8o: ' 毒素与白骨技能',
+ ModStre8p: ' 诅咒',
+ ModStre8q: ' 战嚎技能',
+ ModStre8r: ' 战斗技能',
+ ModStre8s: ' 战斗专家技能',
+ ModStre8t: ' 冰冷技能',
+ ModStre8u: ' 闪电技能',
+ ModStre8v: ' 火焰技能',
+ ModStre8w: ' 召唤技能',
+ ModStre8x: ' 变形技能',
+ ModStre8y: ' 元素技能',
+ ModStre8z: ' 陷阱技能',
+ ModStre9a: ' 影子训练技能',
+ ModStre9b: ' 武学艺术技能',
+ ice: '解冻安雅的药剂\n玛拉的药剂',
+ '0sc': '知识卷轴',
+ tr2: '右键点击施放\n抗性卷轴',
+ Armor: '防具',
+ Rings: '戒指',
+ 'Class Specific': '职业专属',
+ // Expansion SuperUniques
+ 'Siege Boss': '督军山克',
+ 'Ancient Barbarian 1': '塔力克',
+ 'Ancient Barbarian 2': '马道克',
+ 'Ancient Barbarian 3': '科力克',
+ 'Axe Dweller': '持斧者',
+ 'Bonesaw Breaker': '碎骨破甲者',
+ 'Dac Farren': '达克法恩',
+ 'Megaflow Rectifier': '矫正者怪异',
+ 'Eyeback Unleashed': '狂暴者眼魔',
+ 'Threash Socket': '剥壳凹槽',
+ Pindleskin: '暴躁外皮',
+ 'Snapchip Shatter': '碎冰冲击者',
+ 'Anodized Elite': '地狱佳人',
+ 'Vinvear Molech': '温韦尔·摩洛克',
+ 'Sharp Tooth Sayer': '利齿杀手',
+ 'Magma Torquer': '岩浆扭曲者',
+ 'Blaze Ripper': '烈焰撕裂者',
+ Frozenstein: '弗罗森斯坦',
+ Nihlathak: '尼拉塞克',
+ 'Nihlathak Town': '尼拉塞克',
+ 'Baal Subject 1': '毁灭者卡兰索',
+ 'Baal Subject 2': '被诅咒的阿克姆',
+ 'Baal Subject 3': '血腥巴特克',
+ 'Baal Subject 4': '不洁者凡塔',
+ 'Baal Subject 5': '折磨者利斯特',
+ // Expansion Monsters & NPCs
+ 'Baal Subject Mummy': '解开者',
+ BaalColdMage: '死亡法师',
+ 'Wake of Destruction': '火焰之径',
+ 'Charged Bolt Sentry': '充能弹卫哨',
+ 'Lightning Sentry': '闪电卫哨',
+ 'Blade Creeper': '刀刃守卫',
+ 'Invis Pet': '仆从',
+ 'Inferno Sentry': '地狱火卫哨',
+ 'Death Sentry': '死亡卫哨',
+ 'Shadow Warrior': '影子战士',
+ 'Shadow Master': '影子大师',
+ 'Druid Hawk': '乌鸦',
+ 'Druid Spirit Wolf': '鬼狼',
+ 'Druid Fenris': '狂狼',
+ 'Spirit of Barbs': '荆棘之灵',
+ 'Heart of Wolverine': '狼獾之心',
+ 'Oak Sage': '橡木智者',
+ 'Druid Plague Poppy': '瘟疫藤',
+ 'Druid Cycle of Life': '太阳藤',
+ 'Vine Creature': '食尸藤',
+ 'Druid Bear': '灰熊',
+ Wolf: '狼',
+ Bear: '熊',
+ 'Barricade Door': '路障门',
+ 'Prison Door': '牢门',
+ 'Barricade Tower': '路障塔',
+ 'Siege Beast': '攻城兽',
+ SnowYeti1: '雪地流浪者',
+ SnowYeti2: '雪怪',
+ SnowYeti3: '寒冰怪',
+ SnowYeti4: '冰封深渊怪',
+ WolfRider1: '沉沦魔狼骑兵',
+ WolfRider2: '削肉者狼骑兵',
+ WolfRider3: '恶魔狼骑兵',
+ 'Hell Temptress': '地狱女妖',
+ 'Blood Temptress': '鲜血女妖',
+ 'Blood Witch': '鲜血女巫',
+ 'Hell Witch': '地狱女巫',
+ CatapultS: '投石车',
+ CatapultE: '投石车',
+ CatapultSiege: '投石车',
+ CatapultW: '投石车',
+ 'Catapult Spotter S': '投石车',
+ 'Catapult Spotter E': '投石车',
+ 'Catapult Spotter Siege': '投石车',
+ 'Catapult Spotter W': '投石车',
+ 'Frozen Horror1': '冰冻潜行者',
+ 'Frozen Horror2': '冰冻恐惧',
+ 'Frozen Horror3': '冰冻灾祸',
+ 'Frozen Horror4': '冰冻魔怪',
+ 'Frozen Horror5': '冰冻焦灼者',
+ 'Blood Lord1': '月亮之王',
+ 'Blood Lord2': '暗夜之王',
+ 'Blood Lord3': '鲜血之王',
+ 'Blood Lord4': '地狱之王',
+ 'Blood Lord5': '死亡之王',
+ Larzuk: '拉苏克',
+ Drehya: '安雅',
+ Malah: '玛拉',
+ 'Qual-Kehk': '夸尔凯克',
+ 'Act 5 Combatant': '野蛮人战士',
+ 'Barricade Wall Right': '路障',
+ 'Barricade Wall Left': '路障',
+ 'Evil hut': '邪恶魔屋',
+ 'Death Mauler1': '死亡重击者',
+ 'Death Mauler2': '死亡斗士',
+ 'Death Mauler3': '死亡斩击者',
+ 'Death Mauler4': '死亡狂战士',
+ 'Death Mauler5': '死亡统领',
+ POW: '被俘的野蛮人',
+ 'Act 5 Townguard': '守卫',
+ 'Ancient Statue 1': '守护者塔力克',
+ 'Ancient Statue 2': '守卫者马道克',
+ 'Ancient Statue 3': '保卫者科力克',
+ 'Baal Throne': '巴尔',
+ 'Baal Crab': '巴尔',
+ 'Baal Taunt': '巴尔',
+ 'Baal Crab to Stairs': '巴尔',
+ 'Baal Tentacle': '腐烂触须',
+ 'Baal Crab Clone': '巴尔',
+ 'Baals Minion': '毁灭的仆从',
+ 'Putrid Defiler1': '腐败玷污者',
+ 'Putrid Defiler2': '卑劣玷污者',
+ 'Putrid Defiler3': '恶臭玷污者',
+ 'Putrid Defiler4': '酸臭玷污者',
+ 'Putrid Defiler5': '腥臭玷污者',
+ 'Pain Worm1': '痛苦之虫',
+ 'Pain Worm2': '折磨之虫',
+ 'Pain Worm3': '苦难之虫',
+ 'Pain Worm4': '威胁之虫',
+ 'Pain Worm5': '剧痛之虫',
+}
async function main() {
const archives = new MountedArchives()
@@ -126,7 +332,7 @@ async function main() {
}
}
- // 5. CharStats.txt & SkillDesc.txt
+ // 5. CharStats.txt, SkillDesc.txt, MonStats.txt, SuperUniques.txt, Levels.txt
for (const r of (await readTxt('CharStats.txt')).rows) {
for (const col of ['StrAllSkills', 'StrSkillTab1', 'StrSkillTab2', 'StrSkillTab3', 'StrClassOnly', 'class']) {
addKey(r[col])
@@ -135,8 +341,69 @@ async function main() {
for (const r of (await readTxt('SkillDesc.txt')).rows) {
addKey(r['str name'])
}
+ for (const r of (await readTxt('MonStats.txt')).rows) {
+ addKey(r['NameStr'])
+ addKey(r['DescStr'])
+ }
+ for (const r of (await readTxt('SuperUniques.txt')).rows) {
+ addKey(r['Name'])
+ }
+ for (const r of (await readTxt('Levels.txt')).rows) {
+ addKey(r['LevelName'])
+ addKey(r['LevelWarp'])
+ addKey(r['EntryFile'])
+ }
+
+ // 6. All ModStr*, strMod*, ItemStats*, Weapon*, StrGem*, GemXp*, ExInsert*, UI/HUD/Monster/Elite/Immunity/Vendor keys
+ const explicitUiAndMonsterKeys = new Set([
+ 'Socketable',
+ 'gld',
+ 'Gold',
+ 'strGoldLabel',
+ 'Hiquality',
+ 'Cracked',
+ 'Crude',
+ 'Damaged',
+ 'Low Quality',
+ 'Gemmed',
+ 'GemmedNormalName',
+ 'Skillname2',
+ 'Skilldesc2',
+ 'Skillname3',
+ 'Skilldesc3',
+ 'Skillihand',
+ 'RunOn',
+ 'RunOff',
+ 'bank',
+ 'stash',
+ 'strGoldInStash',
+ 'box',
+ 'strUiMenu2',
+ 'strClose',
+ 'close',
+ 'TalkMenu',
+ 'NPCIdentify1',
+ 'NPCMenuTradeRepair',
+ 'NPCMenuTrade',
+ 'gamble',
+ 'lowercasecancel',
+ 'strBSArmor',
+ 'strBSWeapons',
+ 'strBSMisc',
+ 'NPCPurchaseItems',
+ 'NPCSellItems',
+ 'NPCRepairItems',
+ 'uniquextrastrong',
+ 'uniqueextrafast',
+ 'uniquecursed',
+ 'uniquemagicresistance',
+ 'uniquefireenchanted',
+ 'UndeadDescriptX',
+ 'DemonID',
+ 'Champion',
+ 'minion',
+ ])
- // 6. All ModStr*, strMod*, ItemStats*, Weapon*, StrGem*, GemXp*, ExInsert*, Socketable, quality/gold keys
for (const [k] of eng.dict.entries()) {
if (
k.startsWith('ModStr') ||
@@ -146,16 +413,15 @@ async function main() {
k.startsWith('StrGem') ||
k.startsWith('GemXp') ||
k.startsWith('ExInsert') ||
- k === 'Socketable' ||
- k === 'gld' ||
- k === 'Gold' ||
- k === 'Hiquality' ||
- k === 'Cracked' ||
- k === 'Crude' ||
- k === 'Damaged' ||
- k === 'Low Quality' ||
- k === 'Gemmed' ||
- k === 'GemmedNormalName'
+ k.startsWith('strchr') ||
+ k.startsWith('StrSklTree') ||
+ k.startsWith('StrSkill') ||
+ k.startsWith('minipanel') ||
+ k.startsWith('monsteruniqueprop') ||
+ k.startsWith('strScaryMonDesc') ||
+ k.startsWith('champ') ||
+ k.startsWith('Eskill') ||
+ explicitUiAndMonsterKeys.has(k)
) {
keys.add(k)
}
@@ -211,7 +477,23 @@ async function main() {
if (ev !== undefined) {
engOut[k] = ev
}
- const cv = chi.get(k)
+ const evTrim = ev?.trim() ?? ''
+ const cv =
+ chi.get(k) ??
+ EXPANSION_CHI_FALLBACKS[k] ??
+ ITEM_NAMES_ZH[ev ?? ''] ??
+ ITEM_NAMES_ZH[evTrim] ??
+ ITEM_NAMES_ZH[k] ??
+ SKILL_NAMES_ZH[ev ?? ''] ??
+ SKILL_NAMES_ZH[evTrim] ??
+ SKILL_NAMES_ZH[k] ??
+ CLASS_SKILL_NAMES_ZH[ev ?? ''] ??
+ CLASS_SKILL_NAMES_ZH[evTrim] ??
+ CLASS_SKILL_NAMES_ZH[k] ??
+ CLASS_RESTRICTION_SUFFIX_ZH[ev ?? ''] ??
+ CLASS_RESTRICTION_SUFFIX_ZH[evTrim] ??
+ CLASS_RESTRICTION_SUFFIX_ZH[k] ??
+ RUNE_INFO_ZH[k]?.nameZh
if (cv !== undefined) {
chiOut[k] = cv
}
@@ -240,6 +522,7 @@ async function main() {
if (expTbl && expTbl[2745]) {
engOut['strethereal'] = expTbl[2745]!
}
+ chiOut['strethereal'] = EXPANSION_CHI_FALLBACKS['strethereal']!
const outPath = path.join(process.cwd(), 'src/data/canonical-tbl-strings.ts')
const fileContent = `/**
@@ -274,6 +557,11 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = ${JSON.s
export const CANONICAL_CHI_TBL_DICT: Readonly> = ${JSON.stringify(chiOut, null, 2)}
export const CANONICAL_ISC_DESC_TABLE: Readonly> = ${JSON.stringify(iscMeta, null, 2)}
+
+export function resolveTblKey(key: string, lang: 'CHI' | 'ENG' = 'CHI'): string | undefined {
+ const dict = lang === 'ENG' ? CANONICAL_ENG_TBL_DICT : CANONICAL_CHI_TBL_DICT
+ return dict[key] ?? dict[key.trim()]
+}
`
fs.writeFileSync(outPath, fileContent, 'utf8')
diff --git a/scripts/verify-issue-503-browser.ts b/scripts/verify-issue-503-browser.ts
new file mode 100644
index 0000000..2757478
--- /dev/null
+++ b/scripts/verify-issue-503-browser.ts
@@ -0,0 +1,368 @@
+/**
+ * Automated Headless Browser Verification & Visual Audit for Issue #503:
+ * Full Chinese (`CHI`) and English (`ENG`) Bilingual Versions (`index.html`, `acts-zh.html`, `acts-en.html`, `acts.html`).
+ *
+ * Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP.
+ */
+
+import { spawn } from 'node:child_process'
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { createServer } from 'vite'
+
+const ARTIFACT_DIR =
+ process.argv[2] ||
+ '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/worker_503_1'
+
+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
+
+interface CdpSession {
+ evalJs: (expression: string) => Promise
+ saveScreenshot: (filename: string) => Promise
+ waitForGameReady: () => Promise
+}
+
+async function withChromePage(
+ url: string,
+ matchSubstr: string,
+ debugPort: number,
+ fn: (session: CdpSession) => Promise,
+): Promise {
+ const chromeProc = spawn('/usr/bin/google-chrome', [
+ '--headless=new',
+ `--remote-debugging-port=${debugPort}`,
+ '--no-sandbox',
+ '--disable-dev-shm-usage',
+ '--enable-webgl',
+ '--ignore-gpu-blocklist',
+ '--use-gl=angle',
+ '--use-angle=swiftshader',
+ '--window-size=1280,840',
+ url,
+ ])
+
+ try {
+ let wsUrl: string | null = null
+ for (let i = 0; i < 60; i++) {
+ await sleep(300)
+ try {
+ const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
+ const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
+ const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr))
+ if (page?.webSocketDebuggerUrl) {
+ wsUrl = page.webSocketDebuggerUrl
+ break
+ }
+ } catch {
+ // Wait for Chrome CDP
+ }
+ }
+
+ if (!wsUrl) {
+ throw new Error(`Failed to connect to Chrome CDP for ${url}`)
+ }
+ const ws = new WebSocket(wsUrl)
+ await new Promise(resolve => {
+ ws.onopen = () => resolve()
+ })
+
+ let idCounter = 1
+ const sendCommand = (method: string, params: Record = {}): Promise =>
+ new Promise((resolve, reject) => {
+ const id = idCounter++
+ const handler = (event: MessageEvent) => {
+ const msg = JSON.parse(String(event.data))
+ if (msg.id === id) {
+ ws.removeEventListener('message', handler)
+ if (msg.error) reject(new Error(JSON.stringify(msg.error)))
+ else resolve(msg.result as R)
+ }
+ }
+ ws.addEventListener('message', handler)
+ ws.send(JSON.stringify({ id, method, params }))
+ })
+
+ const evalJs = async (expression: string): Promise => {
+ const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ })
+ if (res.exceptionDetails) {
+ throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
+ }
+ return res.result.value
+ }
+
+ const saveScreenshot = async (filename: string): Promise => {
+ const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
+ writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64'))
+ console.log(` Saved screenshot: ${filename}`)
+ }
+
+ const waitForGameReady = async (): Promise => {
+ for (let i = 0; i < 120; i++) {
+ const ready = await evalJs(
+ `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
+ )
+ if (ready) {
+ await sleep(500)
+ return
+ }
+ await sleep(300)
+ }
+ throw new Error(`Timed out waiting for game ready at ${url}`)
+ }
+
+ await sendCommand('Runtime.enable')
+ await sendCommand('Page.enable')
+
+ const result = await fn({ evalJs, saveScreenshot, waitForGameReady })
+ ws.close()
+ return result
+ } finally {
+ chromeProc.kill('SIGKILL')
+ await sleep(200)
+ }
+}
+
+async function main(): Promise {
+ mkdirSync(ARTIFACT_DIR, { recursive: true })
+ const server = await createServer({
+ root: process.cwd(),
+ server: { port: 0, host: '127.0.0.1' },
+ })
+ await server.listen()
+ const address = server.httpServer?.address()
+ const port = typeof address === 'object' && address ? address.port : 5173
+ const baseUrl = `http://127.0.0.1:${port}`
+ console.log(`1. Vite dev server listening at ${baseUrl}`)
+
+ try {
+ // ── Phase A: Verify index.html bilingual entry portal ──
+ await withChromePage(`${baseUrl}/index.html`, 'index.html', 9241, async ({ evalJs, saveScreenshot }) => {
+ await sleep(400)
+ const indexCheck = await evalJs<{
+ zhHref: string | null
+ enHref: string | null
+ title: string
+ }>(`({
+ zhHref: document.getElementById('enter-zh')?.getAttribute('href') ?? null,
+ enHref: document.getElementById('enter-en')?.getAttribute('href') ?? null,
+ title: document.title,
+ })`)
+ console.log('2. Verified index.html entry portal:', JSON.stringify(indexCheck))
+ if (indexCheck.zhHref !== './acts-zh.html' || indexCheck.enHref !== './acts-en.html') {
+ throw new Error(`Unexpected index.html links: ${JSON.stringify(indexCheck)}`)
+ }
+ await saveScreenshot('index-bilingual.png')
+ })
+
+ // ── Phase B: Verify acts-zh.html (Chinese CHI edition) ──
+ await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9242, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
+ await waitForGameReady()
+ const zhCheck = await evalJs<{
+ lang: string
+ title: string
+ langSwitchHref: string | null
+ langSwitchText: string
+ hudText: string
+ actOpt1: string
+ heroSor: string
+ sceneSelectedText: string
+ charSheetDraws: string[]
+ }>(`(() => {
+ const inst = window.__d2webHudInstance;
+ const draws = [];
+ const origFill = CanvasRenderingContext2D.prototype.fillText;
+ CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) {
+ draws.push(String(text));
+ return origFill.call(this, text, ...rest);
+ };
+ try {
+ if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
+ if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
+ inst.inventory.handleMouseMove(561, 93);
+ inst.render(performance.now(), false);
+ } finally {
+ CanvasRenderingContext2D.prototype.fillText = origFill;
+ }
+ const sceneSel = document.getElementById('scene');
+ return {
+ lang: inst.lang,
+ title: document.title,
+ langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null,
+ langSwitchText: document.getElementById('lang-switch')?.textContent?.trim() ?? '',
+ hudText: document.getElementById('hud')?.textContent ?? '',
+ actOpt1: document.querySelector('#act option[value="1"]')?.textContent ?? '',
+ heroSor: document.querySelector('#hero option[value="so"]')?.textContent ?? '',
+ sceneSelectedText: sceneSel?.selectedOptions?.[0]?.textContent ?? '',
+ charSheetDraws: draws,
+ };
+ })()`)
+
+ console.log('3. Verified acts-zh.html state:', JSON.stringify({
+ lang: zhCheck.lang,
+ title: zhCheck.title,
+ langSwitchHref: zhCheck.langSwitchHref,
+ hudText: zhCheck.hudText,
+ actOpt1: zhCheck.actOpt1,
+ heroSor: zhCheck.heroSor,
+ sceneSelectedText: zhCheck.sceneSelectedText,
+ sampleDraws: zhCheck.charSheetDraws.slice(0, 14),
+ }))
+ if (zhCheck.lang !== 'CHI') {
+ throw new Error(`Expected acts-zh.html lang === 'CHI', got ${zhCheck.lang}`)
+ }
+ if (!zhCheck.langSwitchHref?.endsWith('acts-en.html?act=1')) {
+ throw new Error(`Expected #lang-switch href ending with 'acts-en.html?act=1', got ${zhCheck.langSwitchHref}`)
+ }
+ if (!zhCheck.actOpt1.includes('第一章') || !zhCheck.heroSor.includes('法师')) {
+ throw new Error(`Expected Chinese act/hero options in acts-zh.html, got ${JSON.stringify(zhCheck)}`)
+ }
+ await saveScreenshot('acts-zh-panels.png')
+ })
+
+ // ── Phase C: Verify acts-en.html (English ENG edition — strict 0 CJK audit) ──
+ await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9243, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
+ await waitForGameReady()
+ const enCheck = await evalJs<{
+ lang: string
+ title: string
+ langSwitchHref: string | null
+ domTexts: Record
+ cjkDomViolations: string[]
+ cjkCanvasViolations: string[]
+ sampleCanvasDraws: string[]
+ }>(`(() => {
+ const inst = window.__d2webHudInstance;
+ const cjkRe = /[\\u4e00-\\u9fff]/;
+
+ // 1. Check all DOM texts except #lang-switch (which shows "Lang: ZH")
+ const domTexts = {
+ title: document.title,
+ headerH1: document.querySelector('header h1')?.textContent || '',
+ hud: document.getElementById('hud')?.textContent || '',
+ heroOptions: Array.from(document.querySelectorAll('#hero option')).map(el => el.textContent || '').join(' | '),
+ actOptions: Array.from(document.querySelectorAll('#act option')).map(el => el.textContent || '').join(' | '),
+ sceneGroups: Array.from(document.querySelectorAll('#scene optgroup')).map(el => el.getAttribute('label') || '').join(' | '),
+ sceneOptions: Array.from(document.querySelectorAll('#scene option')).map(el => el.textContent || '').join(' | '),
+ variantOptions: Array.from(document.querySelectorAll('#variant option')).map(el => el.textContent || '').join(' | '),
+ lightingOptions: Array.from(document.querySelectorAll('#lighting option')).map(el => el.textContent || '').join(' | '),
+ densityOptions: Array.from(document.querySelectorAll('#density option')).map(el => el.textContent || '').join(' | '),
+ eliteOptions: Array.from(document.querySelectorAll('#elite option')).map(el => el.textContent || '').join(' | '),
+ controlLabels: Array.from(document.querySelectorAll('#controls label')).map(el => el.childNodes[0]?.textContent?.trim() || '').join(' | '),
+ hint: document.querySelector('#controls .hint')?.textContent || '',
+ status: document.getElementById('status')?.textContent || '',
+ dialog: document.getElementById('dialog')?.textContent || '',
+ labels: document.getElementById('labels')?.textContent || '',
+ };
+
+ const cjkDomViolations = [];
+ for (const [k, v] of Object.entries(domTexts)) {
+ if (cjkRe.test(v)) {
+ cjkDomViolations.push(k + ': ' + v);
+ }
+ }
+
+ // 2. Intercept all Canvas fillText/strokeText calls across all HUD panels
+ const canvasDraws = [];
+ const origFill = CanvasRenderingContext2D.prototype.fillText;
+ const origStroke = CanvasRenderingContext2D.prototype.strokeText;
+ CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) {
+ canvasDraws.push(String(text));
+ return origFill.call(this, text, ...rest);
+ };
+ CanvasRenderingContext2D.prototype.strokeText = function(text, ...rest) {
+ canvasDraws.push(String(text));
+ return origStroke.call(this, text, ...rest);
+ };
+
+ try {
+ // Exercise Quest Log, Waypoint, Merchant, Hireling, Stash, Cube, Skill Tree, Character, Inventory
+ for (const left of ['quest', 'wp', 'npc', 'hire', 'stash', 'cube', 'char']) {
+ if (inst.leftPanel !== left) inst.toggleLeftPanel(left);
+ inst.render(performance.now(), false);
+ }
+ for (const right of ['skill', 'inv']) {
+ if (inst.rightPanel !== right) inst.toggleRightPanel(right);
+ inst.render(performance.now(), false);
+ }
+ // Leave char + inv open with helm tooltip hovered for screenshot
+ if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
+ if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
+ inst.inventory.handleMouseMove(561, 93);
+ inst.render(performance.now(), false);
+ } finally {
+ CanvasRenderingContext2D.prototype.fillText = origFill;
+ CanvasRenderingContext2D.prototype.strokeText = origStroke;
+ }
+
+ const cjkCanvasViolations = canvasDraws.filter(t => cjkRe.test(t));
+
+ return {
+ lang: inst.lang,
+ title: document.title,
+ langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null,
+ domTexts,
+ cjkDomViolations,
+ cjkCanvasViolations,
+ sampleCanvasDraws: Array.from(new Set(canvasDraws)).slice(0, 25),
+ };
+ })()`)
+
+ console.log('4. Verified acts-en.html state:', JSON.stringify({
+ lang: enCheck.lang,
+ title: enCheck.title,
+ langSwitchHref: enCheck.langSwitchHref,
+ hud: enCheck.domTexts.hud,
+ actOptions: enCheck.domTexts.actOptions,
+ heroOptions: enCheck.domTexts.heroOptions,
+ cjkDomViolations: enCheck.cjkDomViolations,
+ cjkCanvasViolations: enCheck.cjkCanvasViolations,
+ sampleCanvasDraws: enCheck.sampleCanvasDraws,
+ }))
+
+ if (enCheck.lang !== 'ENG') {
+ throw new Error(`Expected acts-en.html lang === 'ENG', got ${enCheck.lang}`)
+ }
+ if (!enCheck.langSwitchHref?.endsWith('acts-zh.html?act=1')) {
+ throw new Error(`Expected #lang-switch href ending with 'acts-zh.html?act=1', got ${enCheck.langSwitchHref}`)
+ }
+ if (enCheck.cjkDomViolations.length > 0) {
+ throw new Error(`Found CJK in acts-en.html DOM: ${JSON.stringify(enCheck.cjkDomViolations)}`)
+ }
+ if (enCheck.cjkCanvasViolations.length > 0) {
+ throw new Error(`Found CJK in acts-en.html Canvas HUD: ${JSON.stringify(enCheck.cjkCanvasViolations)}`)
+ }
+ await saveScreenshot('acts-en-panels.png')
+ })
+
+ // ── Phase D: Verify acts.html?act=2&lang=en query override ──
+ await withChromePage(`${baseUrl}/acts.html?act=2&lang=en`, 'acts.html', 9244, async ({ evalJs, waitForGameReady }) => {
+ await waitForGameReady()
+ const overrideCheck = await evalJs<{
+ lang: string
+ hud: string
+ langSwitchHref: string | null
+ }>(`({
+ lang: window.__d2webHudInstance.lang,
+ hud: document.getElementById('hud')?.textContent || '',
+ langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null,
+ })`)
+ console.log('5. Verified /acts.html?act=2&lang=en override:', JSON.stringify(overrideCheck))
+ if (overrideCheck.lang !== 'ENG' || !overrideCheck.hud.includes('Act 2')) {
+ throw new Error(`Unexpected /acts.html?act=2&lang=en state: ${JSON.stringify(overrideCheck)}`)
+ }
+ })
+
+ console.log('✅ All Issue #503 Headless Browser E2E Checks PASSED!')
+ } finally {
+ await server.close()
+ }
+}
+
+main().catch(err => {
+ console.error('❌ Issue #503 Browser Verification Failed:', err)
+ process.exit(1)
+})
diff --git a/src/data/canonical-quest-data.ts b/src/data/canonical-quest-data.ts
index f8bcf54..18ca810 100644
--- a/src/data/canonical-quest-data.ts
+++ b/src/data/canonical-quest-data.ts
@@ -304,8 +304,8 @@ export const CANONICAL_QUEST_DATA: readonly CanonicalQuestItem[] = [
"objectiveKey": "qstsa2q22",
"objectiveEn": "Search the Halls of the Dead under the Dry Hills for the Cube. Search the Maggot Lair under the Far Oasis for the Shaft. Search the Claw Viper Temple for the Headpiece.",
"objectiveZh": "在乾燥的高地下面的死亡之殿中,好好找尋那個方塊。在遙遠的綠洲下面可以找到蛆蟲巢穴,裡面有木製的手柄。到利爪腹蛇神殿,可以找到金屬的花紋。",
- "completedKey": "A2Q2SuccessfulDeckard",
- "completedEn": "",
+ "completedKey": "A2Q2SuccessfulStaffCain",
+ "completedEn": "Excellent! You have a Horadric Staff. Carry it with you into Tal Rasha's Tomb. Find within the Tomb the chamber whose floor is inset with the Circle of Seven Symbols. Place the Staff into the receptacle you find there. That will open the secret passage into Tal Rasha's Burial Chamber.",
"completedZh": "赫拉迪克之杖已经由方块合成复原,可以用它开启塔·拉夏古墓中封印督瑞尔的密室大门。",
"rewardSummaryEn": "Horadric Cube and Horadric Staff",
"rewardSummaryZh": "赫拉迪克方块与合成赫拉迪克之杖",
@@ -802,8 +802,8 @@ export const CANONICAL_QUEST_DATA: readonly CanonicalQuestItem[] = [
"objectiveKey": "qstsa4q31",
"objectiveEn": "Take Mephisto's Soulstone to the Hellforge.",
"objectiveZh": "拿著墨菲斯托的靈魂之石,前往地獄熔爐。",
- "completedKey": "A4Q3SuccessfulDeckard",
- "completedEn": "",
+ "completedKey": "A4Q3SuccessfulCain",
+ "completedEn": "Congratulations, hero! Surely, even Diablo, himself, sensed the fury unleashed when you smashed his Brother's Soulstone.",
"completedZh": "墨菲斯托的灵魂石在地狱熔炉上被彻底砸碎,爆发出大量纯净的宝石与远古符文。",
"rewardSummaryEn": "High Runes and Perfect/Flawless Gems",
"rewardSummaryZh": "高级符文与完美/无瑕宝石掉落",
diff --git a/src/data/canonical-tbl-strings.ts b/src/data/canonical-tbl-strings.ts
index c414d30..6f76ef7 100644
--- a/src/data/canonical-tbl-strings.ts
+++ b/src/data/canonical-tbl-strings.ts
@@ -229,6 +229,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Ancient Statue 1": "Talic the Defender",
"Ancient Statue 2": "Madawc the Guardian",
"Ancient Statue 3": "Korlic the Protector",
+ "Ancient Tunnels": "Ancient Tunnels",
"Andariel": "Andariel",
"Andariel's Visage": "Andariel's Visage",
"Angel's": "Angel's",
@@ -244,6 +245,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Arach": "Arach",
"Arachnid Mesh": "Arachnid Mesh",
"Arcadian": "Arcadian",
+ "Arcane Sanctuary": "Arcane Sanctuary",
"Arcanna's Deathwand": "Arcanna's Deathwand",
"Arcanna's Flesh": "Arcanna's Flesh",
"Arcanna's Head": "Arcanna's Head",
@@ -262,6 +264,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Arkaine's Valor": "Arkaine's Valor",
"Arm of King Leoric": "Arm of King Leoric",
"Armor": "Armor",
+ "Arreat Plateau": "Arreat Plateau",
"Arreat's Face": "Arreat's Face",
"Artificer's": "Artisan's",
"AssOnly": "(Assassin Only)",
@@ -301,6 +304,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"BarOnly": "(Barbarian Only)",
"Baranar's Star": "Baranar's Star",
"Barbarian": "Barbarian",
+ "Barracks": "Barracks",
"Barricade Door": "Barricaded Door",
"Barricade Tower": "Barricaded Tower",
"Barricade Wall Left": "Barricade",
@@ -319,6 +323,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Bishibosh": "Bishibosh",
"Bitter": "Bitter",
"Black Hades": "Black Hades",
+ "Black Marsh": "Black Marsh",
"BlackArcher": "Black Archer",
"BlackLancer": "Black Lancer",
"BlackLocusts": "Black Locusts",
@@ -351,6 +356,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Blood Lord3": "Blood Lord",
"Blood Lord4": "Hell Lord",
"Blood Lord5": "Death Lord",
+ "Blood Moor": "Blood Moor",
"Blood Temptress": "Blood Temptress",
"Blood Witch": "Blood Witch",
"BloodBoss": "Blood Boss",
@@ -375,6 +381,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Bloodtree Stump": "Bloodtree Stump",
"Bloodwitch the Wild": "Bloodwitch the Wild",
"Bloody": "Bloody",
+ "Bloody Foothills": "Bloody Foothills",
"Blunderbore": "Blunderbore",
"Bog Creature": "Bog Creature",
"Bone": "Bone",
@@ -408,6 +415,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Bul-Kathos' Sacred Charge": "Bul-Kathos' Sacred Charge",
"Bul-Kathos' Tribal Guardian": "Bul-Kathos' Tribal Guardian",
"Burgundy": "Burgundy",
+ "Burial Grounds": "Burial Grounds",
"Buriza-Do Kyanon": "Buriza-Do Kyanon",
"Burly": "Burly",
"Burning": "Burning",
@@ -422,6 +430,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Calling": "Calling",
"Camphor": "Camphor",
"Cantor": "Cantor",
+ "Canyon of the Magi": "Canyon of the Magi",
"Captain's": "Captain's",
"Carbuncle": "Carbuncle",
"Caretaker's": "Caretaker's",
@@ -432,6 +441,10 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"CarrionBird": "Carrion Bird",
"Carver": "Carver",
"CarverShaman": "Carver Shaman",
+ "Catacombs Level 1": "Catacombs Level 1",
+ "Catacombs Level 2": "Catacombs Level 2",
+ "Catacombs Level 3": "Catacombs Level 3",
+ "Catacombs Level 4": "Catacombs Level 4",
"Catapult Spotter E": "Catapult",
"Catapult Spotter S": "Catapult",
"Catapult Spotter Siege": "Catapult",
@@ -446,12 +459,17 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Cathan's Sigil": "Cathan's Sigil",
"Cathan's Traps": "Cathan's Traps",
"Cathan's Visage": "Cathan's Visage",
+ "Cathedral": "Cathedral",
+ "Cave Level 1": "Cave Level 1",
+ "Cave Level 2": "Cave Level 2",
"CaveLeaper": "Cave Leaper",
"Celestial": "Celestial",
+ "Cellar of Pity": "Frozen River",
"Cerebus": "Cerebus' Bite",
"Champion": "Champion",
"Chance Guards": "Chance Guards",
"Chaos": "Chaos",
+ "Chaos Sanctum": "The Chaos Sanctuary",
"Charged": "Charged",
"Charged Bolt Sentry": "Charged Bolt Sentry",
"Charsi": "Charsi",
@@ -460,11 +478,14 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Chromatic": "Chromatic",
"Chromatic Ire": "Chromatic Ire",
"Cinnabar": "Cinnabar",
+ "City of the Damned": "City of the Damned",
"Civerb's Cudgel": "Civerb's Cudgel",
"Civerb's Icon": "Civerb's Icon",
"Civerb's Vestments": "Civerb's Vestments",
"Civerb's Ward": "Civerb's Ward",
"Class Specific": "Class-specific",
+ "Claw Viper Temple Level 1": "Claw Viper Temple Level 1",
+ "Claw Viper Temple Level 2": "Claw Viper Temple Level 2",
"ClawViper": "Claw Viper",
"ClayGolem": "Clay Golem",
"Cleglaw's Brace": "Cleglaw's Brace",
@@ -477,6 +498,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Cloudcrack": "Cloudcrack",
"Cobalt": "Cobalt",
"Coif of Glory": "Coif of Glory",
+ "Cold Plains": "Cold Plains",
"Coldcrow": "Coldcrow",
"Coldkill": "Coldkill",
"Coldsteel Eye": "Coldsteel Eye",
@@ -515,6 +537,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"CrushBiest": "Crush Beast",
"Crusher": "Crusher",
"Crushflange": "Crushflange",
+ "Crypt": "Crypt",
+ "Crystalized Cavern Level 1": "Crystalline Passage",
+ "Crystalized Cavern Level 2": "Glacial Trail",
"Culwens Point": "Culwen's Point",
"Cunning": "Cunning",
"Cutthroat1": "Bartuc's Cut-Throat",
@@ -524,6 +549,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Dangoon's Teaching": "Dangoon's Teaching",
"Dark Clan Crusher": "Dark Clan Crusher",
"Dark Elder": "Dark Elder",
+ "Dark Wood": "Dark Wood",
"DarkArcher": "Dark Archer",
"DarkFamiliar": "Dark Familiar",
"DarkHunter": "Dark Hunter",
@@ -566,6 +592,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"DemonID": "Demon",
"Demonhorn's Edge": "Demonhorn's Edge",
"Demonlimb": "Demon Limb",
+ "Den of Evil": "Den of Evil",
"Dense": "Dense",
"DesertWing": "Desert Wing",
"Devilkin": "Devilkin",
@@ -580,6 +607,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Dimoaks Hew": "Dimoak's Hew",
"Dire": "Dire",
"Disfigured": "Disfigured",
+ "Disused Fane": "Disused Fane",
+ "Disused Reliquary": "Disused Reliquary",
"Divine": "Divine",
"Djinnslayer": "Djinn Slayer",
"Dominus": "Siren",
@@ -606,11 +635,16 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Druid Hawk": "Raven",
"Druid Plague Poppy": "Plague Poppy",
"Druid Spirit Wolf": "Spirit Wolf",
+ "Dry Hills": "Dry Hills",
"Dummy": "an evil force",
"Dun": "Dun",
"DuneBeast": "Dune Beast",
"DungSoldier": "Dung Soldier",
+ "Durance of Hate Level 1": "Durance of Hate Level 1",
+ "Durance of Hate Level 2": "Durance of Hate Level 2",
+ "Durance of Hate Level 3": "Durance of Hate Level 3",
"Duriel": "Duriel",
+ "Duriel's Lair": "Tal Rasha's Chamber",
"Duriel's Shell": "Duriel's Shell",
"Duskdeep": "Duskdeep",
"Dwarf Star": "Dwarf Star",
@@ -620,6 +654,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Earthshaker": "Earthshaker",
"Earthshifter": "Earth Shifter",
"Eburin": "Eburine",
+ "Echo Chamber": "Drifter Cavern",
"Echoing": "Echoing",
"Elysian": "Elysian",
"Elzix": "Elzix",
@@ -633,6 +668,293 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Envenomed": "Foul",
"Eocene": "Righteous",
"Eschuta's temper": "Eschuta's Temper",
+ "EskillExpan1": " ",
+ "EskillKickPlur": " Kicks",
+ "EskillKickSing": " Kick",
+ "EskillLifeSteal": "Life Steal: ",
+ "EskillLowersResistX": "Lowers Resistance ",
+ "EskillManaSteal": "Mana Steal: ",
+ "EskillPassiveFeral": "Passive Bonus to Wolves and Bears",
+ "EskillPerBlade": " per blade",
+ "EskillPetLife": "Pet Life: ",
+ "EskillToMeleeX": " to melee attacks",
+ "EskillWolfDef": "Wolf Defense: ",
+ "Eskillan0": " ",
+ "Eskillan10": " ",
+ "Eskillan11": " ",
+ "Eskillan12": " ",
+ "Eskillan13": " ",
+ "Eskillan14": " ",
+ "Eskillan15": " ",
+ "Eskillan16": " ",
+ "Eskillan17": " ",
+ "Eskillan18": " ",
+ "Eskillan19": " ",
+ "Eskillan2": " ",
+ "Eskillan20": " ",
+ "Eskillan21": " ",
+ "Eskillan22": " ",
+ "Eskillan23": " ",
+ "Eskillan24": " ",
+ "Eskillan25": " ",
+ "Eskillan26": " ",
+ "Eskillan27": " ",
+ "Eskillan28": " ",
+ "Eskillan29": " ",
+ "Eskillan3": " ",
+ "Eskillan30": " ",
+ "Eskillan31": " ",
+ "Eskillan32": " ",
+ "Eskillan33": " ",
+ "Eskillan34": " ",
+ "Eskillan35": " ",
+ "Eskillan36": " ",
+ "Eskillan37": " ",
+ "Eskillan38": " ",
+ "Eskillan39": " ",
+ "Eskillan4": " ",
+ "Eskillan40": " ",
+ "Eskillan41": " ",
+ "Eskillan42": " ",
+ "Eskillan43": " ",
+ "Eskillan44": " ",
+ "Eskillan45": " ",
+ "Eskillan46": " ",
+ "Eskillan47": " ",
+ "Eskillan48": " ",
+ "Eskillan49": " ",
+ "Eskillan5": " ",
+ "Eskillan50": " ",
+ "Eskillan51": " ",
+ "Eskillan52": " ",
+ "Eskillan53": " ",
+ "Eskillan54": " ",
+ "Eskillan55": " ",
+ "Eskillan56": " ",
+ "Eskillan57": " ",
+ "Eskillan58": " ",
+ "Eskillan59": " ",
+ "Eskillan6": " ",
+ "Eskillan7": " ",
+ "Eskillan8": " ",
+ "Eskillan9": " ",
+ "Eskillbladesofice1": "cold damage: ",
+ "Eskillbladesofice2": "cold damage radius: ",
+ "Eskillbladesofice3": "freeze duration: ",
+ "Eskillchancetoafflict": "Chance to afflict target: ",
+ "Eskillchancetostun": "Chance to stun: ",
+ "Eskillelementaldmg": "Elemental Charge-up Damage: ",
+ "Eskillferalpets": "Feral Pets: ",
+ "Eskillfinishmove": "Finishing Move - ",
+ "Eskillfistsoffire1": "fire damage: ",
+ "Eskillfistsoffire2": "fire damage radius: ",
+ "Eskillfistsoffire3": "burning duration: ",
+ "Eskillincasehit": " hit",
+ "Eskillincasemastery": "%d Percent Chance of Critical Strike",
+ "Eskillincaseraven": "Mana Cost Per Raven: ",
+ "Eskillkickdamage": "Kick Damage: ",
+ "EskilllExpd1": " ",
+ "Eskillld0": " ",
+ "Eskillld10": " ",
+ "Eskillld11": " ",
+ "Eskillld12": " ",
+ "Eskillld13": " ",
+ "Eskillld14": " ",
+ "Eskillld15": " ",
+ "Eskillld16": " ",
+ "Eskillld17": " ",
+ "Eskillld18": " ",
+ "Eskillld19": " ",
+ "Eskillld2": " ",
+ "Eskillld20": " ",
+ "Eskillld21": " ",
+ "Eskillld22": " ",
+ "Eskillld23": " ",
+ "Eskillld24": " ",
+ "Eskillld25": " ",
+ "Eskillld26": " ",
+ "Eskillld27": " ",
+ "Eskillld28": " ",
+ "Eskillld29": " ",
+ "Eskillld3": " ",
+ "Eskillld30": " ",
+ "Eskillld31": " ",
+ "Eskillld32": " ",
+ "Eskillld33": " ",
+ "Eskillld34": " ",
+ "Eskillld35": " ",
+ "Eskillld36": " ",
+ "Eskillld37": " ",
+ "Eskillld38": " ",
+ "Eskillld39": " ",
+ "Eskillld4": " ",
+ "Eskillld40": " ",
+ "Eskillld41": " ",
+ "Eskillld42": " ",
+ "Eskillld43": " ",
+ "Eskillld44": " ",
+ "Eskillld45": " ",
+ "Eskillld46": " ",
+ "Eskillld47": " ",
+ "Eskillld48": " ",
+ "Eskillld49": " ",
+ "Eskillld5": " ",
+ "Eskillld50": " ",
+ "Eskillld51": " ",
+ "Eskillld52": " ",
+ "Eskillld53": " ",
+ "Eskillld54": " ",
+ "Eskillld55": " ",
+ "Eskillld56": " ",
+ "Eskillld57": " ",
+ "Eskillld58": " ",
+ "Eskillld59": " ",
+ "Eskillld6": " ",
+ "Eskillld7": " ",
+ "Eskillld8": " ",
+ "Eskillld9": " ",
+ "Eskilllowerresis": "Lowers Resistance ",
+ "Eskillmanarecov": "Mana Recovered: ",
+ "Eskillname0": " ",
+ "Eskillname10": " ",
+ "Eskillname11": " ",
+ "Eskillname12": " ",
+ "Eskillname13": " ",
+ "Eskillname14": " ",
+ "Eskillname15": " ",
+ "Eskillname16": " ",
+ "Eskillname17": " ",
+ "Eskillname18": " ",
+ "Eskillname19": " ",
+ "Eskillname2": " ",
+ "Eskillname20": " ",
+ "Eskillname21": " ",
+ "Eskillname22": " ",
+ "Eskillname23": " ",
+ "Eskillname24": " ",
+ "Eskillname25": " ",
+ "Eskillname26": " ",
+ "Eskillname27": " ",
+ "Eskillname28": " ",
+ "Eskillname29": " ",
+ "Eskillname3": " ",
+ "Eskillname30": " ",
+ "Eskillname31": " ",
+ "Eskillname32": " ",
+ "Eskillname33": " ",
+ "Eskillname34": " ",
+ "Eskillname35": " ",
+ "Eskillname36": " ",
+ "Eskillname37": " ",
+ "Eskillname38": " ",
+ "Eskillname39": " ",
+ "Eskillname4": " ",
+ "Eskillname40": " ",
+ "Eskillname41": " ",
+ "Eskillname42": " ",
+ "Eskillname43": " ",
+ "Eskillname44": " ",
+ "Eskillname45": " ",
+ "Eskillname46": " ",
+ "Eskillname47": " ",
+ "Eskillname48": " ",
+ "Eskillname49": " ",
+ "Eskillname5": " ",
+ "Eskillname50": " ",
+ "Eskillname51": " ",
+ "Eskillname52": " ",
+ "Eskillname53": " ",
+ "Eskillname54": " ",
+ "Eskillname55": " ",
+ "Eskillname56": " ",
+ "Eskillname57": " ",
+ "Eskillname58": " ",
+ "Eskillname59": " ",
+ "Eskillname6": " ",
+ "Eskillname7": " ",
+ "Eskillname8": " ",
+ "Eskillname9": " ",
+ "EskillnameExp1": " ",
+ "Eskillpercentatt": " Percent Attack",
+ "Eskillpercentdmg": " Percent Damage",
+ "Eskillpercentlif": " Percent Life",
+ "Eskillperhit12": " per hit",
+ "Eskillphoenix1": "meteor damage: ",
+ "Eskillphoenix2": "chain lightning damage: ",
+ "Eskillphoenix3": "chaos ice bolt damage: ",
+ "Eskillpowerup1": "Charge 1 - ",
+ "Eskillpowerup2": "Charge 2 - ",
+ "Eskillpowerup3": "Charge 3 - ",
+ "Eskillpowerupadd": "Adds ",
+ "Eskillpudburning": "burning damage",
+ "Eskillpuddgmper": " percent damage",
+ "Eskillpudlife": " percent life stealing",
+ "Eskillpudmana": " percent life and mana stealing ",
+ "EskillsExpd1": " ",
+ "Eskillsd0": " ",
+ "Eskillsd10": " ",
+ "Eskillsd11": " ",
+ "Eskillsd12": " ",
+ "Eskillsd13": " ",
+ "Eskillsd14": " ",
+ "Eskillsd15": " ",
+ "Eskillsd16": " ",
+ "Eskillsd17": " ",
+ "Eskillsd18": " ",
+ "Eskillsd19": " ",
+ "Eskillsd2": " ",
+ "Eskillsd20": " ",
+ "Eskillsd21": " ",
+ "Eskillsd22": " ",
+ "Eskillsd23": " ",
+ "Eskillsd24": " ",
+ "Eskillsd25": " ",
+ "Eskillsd26": " ",
+ "Eskillsd27": " ",
+ "Eskillsd28": " ",
+ "Eskillsd29": " ",
+ "Eskillsd3": " ",
+ "Eskillsd30": " ",
+ "Eskillsd31": " ",
+ "Eskillsd32": " ",
+ "Eskillsd33": " ",
+ "Eskillsd34": " ",
+ "Eskillsd35": " ",
+ "Eskillsd36": " ",
+ "Eskillsd37": " ",
+ "Eskillsd38": " ",
+ "Eskillsd39": " ",
+ "Eskillsd4": " ",
+ "Eskillsd40": " ",
+ "Eskillsd41": " ",
+ "Eskillsd42": " ",
+ "Eskillsd43": " ",
+ "Eskillsd44": " ",
+ "Eskillsd45": " ",
+ "Eskillsd46": " ",
+ "Eskillsd47": " ",
+ "Eskillsd48": " ",
+ "Eskillsd49": " ",
+ "Eskillsd5": " ",
+ "Eskillsd50": " ",
+ "Eskillsd51": " ",
+ "Eskillsd52": " ",
+ "Eskillsd53": " ",
+ "Eskillsd54": " ",
+ "Eskillsd55": " ",
+ "Eskillsd56": " ",
+ "Eskillsd57": " ",
+ "Eskillsd58": " ",
+ "Eskillsd59": " ",
+ "Eskillsd6": " ",
+ "Eskillsd7": " ",
+ "Eskillsd8": " ",
+ "Eskillsd9": " ",
+ "Eskillsinishup": "Finishing Move Bonuses",
+ "Eskillthunder1": "lightning damage: ",
+ "Eskillthunder2": "nova damage: ",
+ "Eskillthunder3": "charged bolt damage: ",
+ "Eskilltomeleeattacks": " to melee attacks",
"Ethereal Edge": "Ethereal Edge",
"Evil hut": "Evil Demon Hut",
"ExInsertSockets": "Can be Inserted into Socketed Items",
@@ -647,6 +969,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Fanatic": "Fanatic",
"FanaticMinion": "Fanatic Enslaved",
"Fangskin": "Fangskin",
+ "Far Oasis": "Far Oasis",
"Fara": "Fara",
"Fathom": "Death's Fathom",
"Fechmars Axe": "Axe of Fechmar",
@@ -673,6 +996,10 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Flamespike the Crawler": "Flamespike the Crawler",
"Flaming": "Flaming",
"Flayer": "Flayer",
+ "Flayer Dungeon Level 1": "Flayer Dungeon Level 1",
+ "Flayer Dungeon Level 2": "Flayer Dungeon Level 2",
+ "Flayer Dungeon Level 3": "Flayer Dungeon Level 3",
+ "Flayer Jungle": "Flayer Jungle",
"FlayerShaman": "Flayer Shaman",
"FleshArcher": "Flesh Archer",
"FleshBeast": "Flesh Beast",
@@ -683,6 +1010,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Fleshripper": "Fleshripper",
"Fletcher's": "Fletcher's",
"FlyingScimitar": "Flying Scimitar",
+ "Forgotten Reliquary": "Forgotten Reliquary",
+ "Forgotten Temple": "Forgotten Temple",
+ "Forgotten Tower": "Forgotten Tower",
"Forked": "Forked",
"Fortified": "Fortified",
"Fortuitous": "Fortuitous",
@@ -733,6 +1063,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Gimmershred": "Gimmershred",
"Ginther's Rift": "Ginther's Rift",
"Glacial": "Glacial",
+ "Glacial Caves Level 1": "The Ancients' Way",
+ "Glacial Caves Level 2": "Icy Cellar",
"Gleamscythe": "Gleamscythe",
"Glimmering": "Glimmering",
"Gloam": "Gloam",
@@ -759,6 +1091,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Gravenspine": "Gravenspine",
"Gravepalm": "Gravepalm",
"Graverobber's": "Graverobber's",
+ "Great Marsh": "Great Marsh",
"Great Wyrm's": "Great Wyrm's",
"GreaterHellSpawn": "Greater Hell Spawn",
"GreaterIceSpawn": "Greater Ice Spawn",
@@ -791,9 +1124,18 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Hailstone": "Hailstone",
"Halaberd's Reign": "Halaberd's Reign",
"Hallowed": "Hallowed",
+ "Halls of Anguish": "Halls of Anguish",
+ "Halls of Death's Calling": "Halls of Pain",
+ "Halls of Vaught": "Halls of Vaught",
+ "Halls of the Dead Level 1": "Halls of the Dead Level 1",
+ "Halls of the Dead Level 2": "Halls of the Dead Level 2",
+ "Halls of the Dead Level 3": "Halls of the Dead Level 3",
"Hand of Blessed Light": "Hand of Blessed Light",
+ "Harem Level 1": "Harem Level 1",
+ "Harem Level 2": "Harem Level 2",
"Harlequin Crest": "Harlequin Crest",
"Harpoonist's": "Harpoonist's",
+ "Harrogath": "Harrogath",
"Havoc": "Havoc",
"Hawk Branded": "Hawk Branded",
"Hawkeye": "Hawkeye",
@@ -811,6 +1153,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Hell Forge Hammer": "Hell Forge Hammer",
"Hell Temptress": "Hell Temptress",
"Hell Witch": "Hell Witch",
+ "Hell1": "Abaddon",
+ "Hell2": "Pit of Acheron",
+ "Hell3": "Infernal Pit",
"HellBuzzard": "Hell Buzzard",
"HellCat": "Hell Cat",
"HellClan": "Hell Clan",
@@ -832,6 +1177,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Hierophant's": "Hierophant's",
"Highlord's Wrath": "Highlord's Wrath",
"Hiquality": "Superior",
+ "Hole Level 1": "Hole Level 1",
+ "Hole Level 2": "Hole Level 2",
"HollowOne": "Hollow One",
"Holocaust": "Armageddon",
"Holy": "Holy",
@@ -884,6 +1231,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Inferno Sentry": "Inferno Sentry",
"Infernostride": "Infernostride",
"Infidel": "Infidel",
+ "Inner Cloister": "Inner Cloister",
"InsaneHellSpawn": "Insane Hell Spawn",
"InsaneIceSpawn": "Insane Ice Spawn",
"Invader": "Invader",
@@ -938,6 +1286,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Jade": "Jade",
"Jadetalon": "Jade Talon",
"Jagged": "Jagged",
+ "Jail Level 1": "Jail Level 1",
+ "Jail Level 2": "Jail Level 2",
+ "Jail Level 3": "Jail Level 3",
"Jalal's Mane": "Jalal's Mane",
"Jamella": "Jamella",
"Jerhyn": "Jerhyn",
@@ -961,6 +1312,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Knight's": "Knight's",
"Krintizs Skewer": "Skewer of Krintiz",
"Kuko Shakaku": "Kuko Shakaku",
+ "Kurast Bazaar": "Kurast Bazaar",
+ "Kurast Causeway": "Kurast Causeway",
+ "Kurast Docktown": "Kurast Docks",
"Lacerator": "Lacerator",
"Lance Guard": "Lance Guard",
"Lance of Yaggai": "Lance of Yaggai",
@@ -989,9 +1343,12 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Loath": "Loath",
"Lord De Seis": "Lord De Seis",
"Lord's": "Lord's",
+ "Lost City": "Lost City",
"Loud": "Alarming",
"Low Quality": "Low Quality",
+ "Lower Kurast": "Lower Kurast",
"Lucky": "Lucky",
+ "Lut Gholein": "Lut Gholein",
"Lycander's Aim": "Lycander's Aim",
"Lycander's Flank": "Lycander's Flank",
"Lysander": "Lysander",
@@ -1008,6 +1365,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Magekiller's": "Magekiller's",
"Magewrath": "Magewrath",
"Maggot": "Maggot",
+ "Maggot Lair Level 1": "Maggot Lair Level 1",
+ "Maggot Lair Level 2": "Maggot Lair Level 2",
+ "Maggot Lair Level 3": "Maggot Lair Level 3",
"Magma Torquer": "Magma Torquer",
"Magnus' Skin": "Magnus' Skin",
"Maiden's": "Maiden's",
@@ -1023,6 +1383,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Massive": "Massive",
"Master's": "Master's",
"Mauler": "Mauler",
+ "Mausoleum": "Mausoleum",
"MawFiend": "Maw Fiend",
"McAuley's Folly": "Sander's Folly",
"McAuley's Paragon": "Sander's Paragon",
@@ -1275,11 +1636,19 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"ModitemskonLevel": "%d%% Chance to cast level %d %s when you Level-Up",
"Moditemskondeath": "%d%% Chance to cast level %d %s when you Die",
"Modstr2v": "Enhanced Defense",
+ "Monastery Gate": "Monastery Gate",
"Monk's": "Monk's",
+ "Moo Moo Farm": "The Secret Cow Level",
"MoonClan": "Moon Clan",
"Moonfall": "Moonfall",
"Mosers Blessed Circle": "Moser's Blessed Circle",
"MummyGenerator": "Mummy Sarcophagus",
+ "NPCIdentify1": "Identify Items",
+ "NPCMenuTrade": "trade",
+ "NPCMenuTradeRepair": "trade/repair",
+ "NPCPurchaseItems": "Buy",
+ "NPCRepairItems": "Repair",
+ "NPCSellItems": "Sell",
"Nagelring": "Nagelring",
"Naj's Ancient Set": "Naj's Ancient Vestige",
"Naj's Circlet": "Naj's Circlet",
@@ -1309,6 +1678,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Nightwing's Veil": "Nightwing's Veil",
"Nihlathak": "Nihlathak",
"Nihlathak Town": "Nihlathak",
+ "Nihlathaks Temple": "Nihlathak's Temple",
"Nokozan Relic": "Nokozan Relic",
"Nord's Tenderizer": "Nord's Tenderizer",
"Nosferatu's Coil": "Nosferatu's Coil",
@@ -1324,6 +1694,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Order": "Order",
"Ormus' Robes": "Ormus' Robes",
"Orphan's Call": "Orphan's Call",
+ "Outer Cloister": "Outer Cloister",
+ "Outer Steppes": "Outer Steppes",
"OverLord": "Overlord",
"OverSeer": "Overseer",
"POW": "Barbarian Captive",
@@ -1334,8 +1706,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Pain Worm4": "Menace Worm",
"Pain Worm5": "Anguish Worm",
"PalOnly": "(Paladin Only)",
+ "Palace Cellar Level 2": "Palace Cellar Level 2",
+ "Palace Cellar Level 3": "Palace Cellar Level 3",
"Paladin": "Paladin",
"Paleocene": "Faithful",
+ "Pandemonium Run 1": "Matron's Den",
+ "Pandemonium Run 2": "Forgotten Sands",
+ "Pandemonium Run 3": "Furnace of Pain",
"Paradox": "Paradox",
"Pearl": "Pearl",
"Peasent Crown": "Peasant Crown",
@@ -1345,6 +1722,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Piercerib": "Rogue's Bow",
"Pierre Tombale Couant": "Pierre Tombale Couant",
"Pindleskin": "Pindleskin",
+ "Pit Level 1": "Pit Level 1",
+ "Pit Level 2": "Pit Level 2",
"PitLord": "Pit Lord",
"PitViper": "Pit Viper",
"Pitspawn Fouldog": "Pitspawn Fouldog",
@@ -1352,6 +1731,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"PlagueBearer": "Plague Bearer",
"PlagueBugs": "Plague Bugs",
"PlagueRI": "Plague",
+ "Plains of Despair": "Plains of Despair",
"Platinum": "Platinum",
"Pluckeye": "Pluckeye",
"PoisonSpinner": "Poison Spinner",
@@ -1406,11 +1786,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Ribcracker": "Ribcracker",
"Rift": "Rift",
"Riftwraith the Cannibal": "Riftwraith the Cannibal",
+ "Rigid Highlands": "Frigid Highlands",
"Rimeraven": "Raven Claw",
"Rings": "Rings",
"Riphook": "Riphook",
"Ripsaw": "Ripsaw",
"Rite of Passage": "Rite of Passage",
+ "River of Flame": "River of Flame",
"RiverStalkerHead": "River Stalker",
"RiverStalkerLimb": "River Stalker",
"Rixots Keen": "Rixot's Keen",
@@ -1422,12 +1804,19 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"RockWormYoung": "Rock Worm Young",
"Rockfleece": "Rockfleece",
"Rockstopper": "Rockstopper",
+ "Rocky Summit": "Arreat Summit",
+ "Rocky Waste": "Rocky Waste",
+ "Rogue Encampment": "Rogue Encampment",
"RogueScout": "Rogue Scout",
"Rose Branded": "Rose Branded",
"RotWalker": "Rot Walker",
"Ruby": "Ruby",
"Rugged": "Rugged",
+ "Ruined Fane": "Ruined Fane",
+ "Ruined Temple": "Ruined Temple",
"Rule": "Rule",
+ "RunOff": "Walk",
+ "RunOn": "Run",
"Rune": "Rune",
"Runemaster": "Rune Master",
"Runeword1": "Ancients' Pledge",
@@ -1636,6 +2025,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Serpent's": "Serpent's",
"SerpentMagus": "Serpent Magus",
"Serrated": "Serrated",
+ "Sewers Level 1": "Sewers Level 1",
+ "Sewers Level 2": "Sewers Level 2",
+ "Sewers Level 3": "Sewers Level 3",
"Sexton": "Sexton",
"Shadow": "Shadow",
"Shadow Master": "Shadow Master",
@@ -1766,6 +2158,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Spearmaiden's": "Spearmaiden's",
"Specter": "Specter",
"Spellsteel": "Spellsteel",
+ "Spider Cave": "Arachnid Lair",
+ "Spider Cavern": "Spider Cavern",
+ "Spider Forest": "Spider Forest",
"SpiderMagus": "Spider Magus",
"Spike Thorn": "Spike Thorn",
"SpikeFiend": "Spike Fiend",
@@ -1796,6 +2191,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Stone": "Stone",
"Stone Crusher": "Stone Crusher",
"Stoneraven": "Stoneraven",
+ "Stony Field": "Stony Field",
+ "Stony Tomb Level 1": "Stony Tomb Level 1",
+ "Stony Tomb Level 2": "Stony Tomb Level 2",
"Storm": "Storm",
"StormCaster": "Storm Caster",
"Stormchaser": "Stormchaser",
@@ -1813,6 +2211,128 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"StrGemX2": "Shields:",
"StrGemX3": "Weapons:",
"StrGemX4": "Armor:",
+ "StrSkill0": "",
+ "StrSkill1": "Next Level",
+ "StrSkill10": "To Attack Rating: ",
+ "StrSkill100": "Max Life: ",
+ "StrSkill101": "Max Mana: ",
+ "StrSkill102": "Max Stamina: ",
+ "StrSkill103": " percent fire damage",
+ "StrSkill104": " monster",
+ "StrSkill105": " monsters",
+ "StrSkill106": "Attack Speed: ",
+ "StrSkill107": "Life/Mana Recovered: ",
+ "StrSkill108": " points",
+ "StrSkill109": "Chance to redeem soul: ",
+ "StrSkill11": " hits",
+ "StrSkill110": "Successful Blocking: ",
+ "StrSkill111": " per yard",
+ "StrSkill112": "Thorns damage",
+ "StrSkill113": "Hydra Fire Damage: ",
+ "StrSkill114": "Resist Elemental Damage: ",
+ "StrSkill115": "Minimum Mana Required to Cast: ",
+ "StrSkill116": "Maximum Level Reached",
+ "StrSkill117": " (item)",
+ "StrSkill118": "You have been granted this skill by an item",
+ "StrSkill119": "You have not learned this skill yet",
+ "StrSkill12": "-",
+ "StrSkill13": "Cold Length: ",
+ "StrSkill14": "Poison Length: ",
+ "StrSkill15": " second",
+ "StrSkill16": " seconds",
+ "StrSkill17": "First Level",
+ "StrSkill18": "Radius: ",
+ "StrSkill19": " squares",
+ "StrSkill2": "Current Skill Level: ",
+ "StrSkill20": "Duration: ",
+ "StrSkill21": "Defense: ",
+ "StrSkill22": "Attack: ",
+ "StrSkill23": " percent",
+ "StrSkill24": " percent chance",
+ "StrSkill25": "Enemy Defense: ",
+ "StrSkill26": " yards",
+ "StrSkill27": " bolts",
+ "StrSkill28": " teeth",
+ "StrSkill29": "Freeze Length: ",
+ "StrSkill3": "Mana Cost: ",
+ "StrSkill30": "Attack Bonus: ",
+ "StrSkill31": "Defense Bonus: ",
+ "StrSkill32": "Fire Damage",
+ "StrSkill33": "Weakens Enemies by ",
+ "StrSkill34": " per second",
+ "StrSkill35": "Continuous Fire Damage: ",
+ "StrSkill36": " yard",
+ "StrSkill37": "Damage Taken: ",
+ "StrSkill38": "Target's Damage: ",
+ "StrSkill39": "Magic Damage: ",
+ "StrSkill4": "Damage: ",
+ "StrSkill40": "Absorbs ",
+ "StrSkill41": " damage",
+ "StrSkill42": "Life: ",
+ "StrSkill43": "Improved Missile Damage",
+ "StrSkill43a": " skeleton mage",
+ "StrSkill44": " skeletons total",
+ "StrSkill44a": " skeleton magi",
+ "StrSkill45": "Golem Level: ",
+ "StrSkill46": " percent of corpse Life",
+ "StrSkill47": " damage to Self",
+ "StrSkill48": "Stun Length: ",
+ "StrSkill49": " percent of shield strength",
+ "StrSkill5": "Fire Damage: ",
+ "StrSkill50": "Heals: ",
+ "StrSkill51": "Resist Fire: ",
+ "StrSkill52": "Resist Cold: ",
+ "StrSkill53": "Resist Lightning: ",
+ "StrSkill54": "Resist All: ",
+ "StrSkill55": " percent damage returned",
+ "StrSkill56": "Range: ",
+ "StrSkill57": "Target's Attack: ",
+ "StrSkill58": "Enemy runs up to ",
+ "StrSkill59": "Enemy runs for ",
+ "StrSkill6": "Cold Damage: ",
+ "StrSkill60": " per target",
+ "StrSkill61": "Multiple Hits",
+ "StrSkill62": "Freezes for ",
+ "StrSkill63": "over ",
+ "StrSkill63Patch": "over ",
+ "StrSkill64": " poison damage",
+ "StrSkill65": "Chance of losing durability: ",
+ "StrSkill66": "Target stutters for ",
+ "StrSkill67": "Ranged attacks slowed to ",
+ "StrSkill68": "Average ",
+ "StrSkill69": "Stamina Recovery Rate: ",
+ "StrSkill7": "Lightning Damage: ",
+ "StrSkill70": "Velocity: ",
+ "StrSkill71": "Stamina Bonus: ",
+ "StrSkill72": "Releases ",
+ "StrSkill73": " charged bolts",
+ "StrSkill74": "Duration reduced by ",
+ "StrSkill75": "Elemental Damages: ",
+ "StrSkill76": "Chance uninterruptable ",
+ "StrSkill77": "Enemies slowed ",
+ "StrSkill78": "Remains target for ",
+ "StrSkill79": " percent of attack damage",
+ "StrSkill8": "Poison Damage: ",
+ "StrSkill80": "Converts ",
+ "StrSkill81": " percent damage to life",
+ "StrSkill82": "Fire Duration: ",
+ "StrSkill83": "Fire Explosion Damage: ",
+ "StrSkill84": "Lightning Bolt Damage: ",
+ "StrSkill85": "Reduces curse duration by ",
+ "StrSkill86": "Attacks up to ",
+ "StrSkill87": " targets",
+ "StrSkill88": "Mana Recovery Rate: ",
+ "StrSkill89": "Walk/Run Speed: ",
+ "StrSkill9": " arrows",
+ "StrSkill90": "Resistances: ",
+ "StrSkill91": "Holy Bolt Damage: ",
+ "StrSkill92": " mana per knockback",
+ "StrSkill94": " skeleton total",
+ "StrSkill95": "Skeletons: ",
+ "StrSkill96": "Magi: ",
+ "StrSkill97": "Monsters: ",
+ "StrSkill98": "Lowers Maximum Resistances ",
+ "StrSkill99": "Chance to convert: ",
"StrSklTabItem1": "+%d to Javelin and Spear Skills",
"StrSklTabItem10": "+%d to Warcries",
"StrSklTabItem11": "+%d to Combat Skills",
@@ -1834,6 +2354,43 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"StrSklTabItem7": "+%d to Poison and Bone Skills",
"StrSklTabItem8": "+%d to Curses",
"StrSklTabItem9": "+%d to Summoning Skills",
+ "StrSklTree1": "Skill",
+ "StrSklTree10": "Bow and",
+ "StrSklTree11": "Crossbow",
+ "StrSklTree12": "Defensive",
+ "StrSklTree13": "Auras",
+ "StrSklTree14": "Offensive",
+ "StrSklTree15": "Combat",
+ "StrSklTree16": "Summoning",
+ "StrSklTree17": "Poison",
+ "StrSklTree18": "and Bone",
+ "StrSklTree19": "Curses",
+ "StrSklTree2": "Choices",
+ "StrSklTree20": "Warcries",
+ "StrSklTree21": "Combat",
+ "StrSklTree22": "Masteries",
+ "StrSklTree23": "Cold",
+ "StrSklTree24": "Lightning",
+ "StrSklTree25": "Fire",
+ "StrSklTree26": "Summoning",
+ "StrSklTree27": "Shape",
+ "StrSklTree28": "Shifting",
+ "StrSklTree29": "Elemental",
+ "StrSklTree3": "Remaining",
+ "StrSklTree30": "Traps",
+ "StrSklTree31": "Shadow",
+ "StrSklTree32": "Disciplines",
+ "StrSklTree33": "Martial",
+ "StrSklTree34": "Arts",
+ "StrSklTree4": "Skills",
+ "StrSklTree5": "Spells",
+ "StrSklTree6": "Javelin",
+ "StrSklTree7": "and Spear",
+ "StrSklTree8": "Passive",
+ "StrSklTree9": "and Magic",
+ "StrSklTreea": "Skill Tab 1",
+ "StrSklTreeb": "Skill Tab 2",
+ "StrSklTreec": "Skill Tab 3",
"Strange": "Strange",
"Strangler": "Strangler",
"String of Ears": "String of Ears",
@@ -1857,6 +2414,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Sureshrill Frost": "Sureshrill Frost",
"Swamp Dweller": "Swamp Dweller",
"SwampGhost": "Swamp Ghost",
+ "Swampy Pit Level 1": "Swampy Pit Level 1",
+ "Swampy Pit Level 2": "Swampy Pit Level 2",
+ "Swampy Pit Level 3": "Swampy Pit Level 3",
"Swordback Hold": "Swordback Hold",
"Swordguard": "Swordguard",
"Taintbreeder": "Taintbreeder",
@@ -1866,7 +2426,10 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Tal Rasha's Horadric Crest": "Tal Rasha's Horadric Crest",
"Tal Rasha's Howling Wind": "Tal Rasha's Guardianship",
"Tal Rasha's Lidless Eye": "Tal Rasha's Lidless Eye",
+ "Tal Rasha's Tomb": "Tal Rasha's Tomb",
"Tal Rasha's Wrappings": "Tal Rasha's Wrappings",
+ "TalkMenu": "talk",
+ "Tamoe Highland": "Tamoe Highland",
"Tancred's Battlegear": "Tancred's Battlegear",
"Tancred's Crowbill": "Tancred's Crowbill",
"Tancred's Hobnails": "Tancred's Hobnails",
@@ -1910,6 +2473,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"The Meat Scraper": "The Meat Scraper",
"The Minataur": "The Minotaur",
"The Oculus": "The Oculus",
+ "The Pandemonium Fortress": "The Pandemonium Fortress",
"The Patriarch": "The Patriarch",
"The Reaper's Toll": "The Reaper's Toll",
"The Reedeemer": "The Redeemer",
@@ -1924,24 +2488,157 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"The Tormentor": "The Tormentor",
"The Vile Husk": "The Vile Husk",
"The Ward": "The Ward",
+ "The Worldstone Chamber": "The Worldstone Chamber",
+ "The Worldstone Keep Level 1": "Worldstone Keep Level 1",
+ "The Worldstone Keep Level 2": "Worldstone Keep Level 2",
+ "The Worldstone Keep Level 3": "Worldstone Keep Level 3",
"Thin": "Thin",
"ThornBeast": "Thorn Beast",
"ThornBrute": "Thorn Brute",
"ThornedHulk": "Thorned Hulk",
"Thrasher": "Thrasher",
"Threash Socket": "Thresh Socket",
+ "Throne of Destruction": "Throne of Destruction",
"Thudergod's Vigor": "Thundergod's Vigor",
"Thunderstroke": "Thunderstroke",
"Tiamat's Rebuke": "Tiamat's Rebuke",
"Tin": "Tin",
"Tireless": "Tireless",
"Titan's Revenge": "Titan's Revenge",
+ "To Chaos Sanctum": "To The Chaos Sanctuary",
+ "To Duriel's Lair": "To Tal Rasha's Chamber",
+ "To Harrogath": "To Harrogath",
+ "To Hell1": "To Abaddon",
+ "To Hell2": "To The Pit of Acheron",
+ "To Hell3": "To The Infernal Pit",
+ "To Lower Kurast": "To Lower Kurast",
+ "To Lut Gholein": "To Lut Gholein",
+ "To Nihlathaks Temple": "To Nihlathak's Temple",
+ "To Tal Rasha's Tomb": "To Tal Rasha's Tomb",
+ "To The Ancient Tunnels": "To The Ancient Tunnels",
+ "To The Arcane Sanctuary": "To The Arcane Sanctuary",
+ "To The Arreat Plateau": "To The Arreat Plateau",
+ "To The Barracks": "To The Barracks",
+ "To The Black Marsh": "To The Black Marsh",
+ "To The Blood Moor": "To The Blood Moor",
+ "To The Bloody Foothills": "To The Bloody Foothills",
+ "To The Burial Grounds": "To The Burial Grounds",
+ "To The Canyon of the Magi": "To The Canyon of the Magi",
+ "To The Catacombs Level 1": "To The Catacombs Level 1",
+ "To The Catacombs Level 2": "To The Catacombs Level 2",
+ "To The Catacombs Level 3": "To The Catacombs Level 3",
+ "To The Catacombs Level 4": "To The Catacombs Level 4",
+ "To The Cathedral": "To The Cathedral",
+ "To The Cave Level 1": "To The Cave Level 1",
+ "To The Cave Level 2": "To The Cave Level 2",
+ "To The Cellar of Pity": "To The Frozen River",
+ "To The City of the Damned": "To The City of the Damned",
+ "To The Claw Viper Temple Level 1": "To The Claw Viper Temple Level 1",
+ "To The Claw Viper Temple Level 2": "To The Claw Viper Temple Level 2",
+ "To The Cold Plains": "To The Cold Plains",
+ "To The Crypt": "To The Crypt",
+ "To The Crystalized Cavern Level 1": "To The Crystalline Passage",
+ "To The Crystalized Cavern Level 2": "To The Glacial Trail",
+ "To The Dark Wood": "To The Dark Wood",
+ "To The Den of Evil": "To The Den of Evil",
+ "To The Disused Fane": "To The Disused Fane",
+ "To The Disused Reliquary": "To The Disused Reliquary",
+ "To The Dry Hills": "To The Dry Hills",
+ "To The Durance of Hate Level 1": "To The Durance of Hate Level 1",
+ "To The Durance of Hate Level 2": "To The Durance of Hate Level 2",
+ "To The Durance of Hate Level 3": "To The Durance of Hate Level 3",
+ "To The Echo Chamber": "To The Drifter Cavern",
+ "To The Far Oasis": "To The Far Oasis",
+ "To The Flayer Dungeon Level 1": "To The Flayer Dungeon Level 1",
+ "To The Flayer Dungeon Level 2": "To The Flayer Dungeon Level 2",
+ "To The Flayer Dungeon Level 3": "To The Flayer Dungeon Level 3",
+ "To The Flayer Jungle": "To The Flayer Jungle",
+ "To The Forgotten Reliquary": "To The Forgotten Reliquary",
+ "To The Forgotten Temple": "To The Forgotten Temple",
+ "To The Forgotten Tower": "To The Forgotten Tower",
+ "To The Glacier Caves Level 1": "To The Ancients' Way",
+ "To The Glacier Caves Level 2": "To The Icy Cellar",
+ "To The Great Marsh": "To The Great Marsh",
+ "To The Halls of Anguish": "To The Halls of Anguish",
+ "To The Halls of Death's Calling": "To The Halls of Pain",
+ "To The Halls of Vaught": "To The Halls of Vaught",
+ "To The Halls of the Dead Level 1": "To The Halls of the Dead Level 1",
+ "To The Halls of the Dead Level 2": "To The Halls of the Dead Level 2",
+ "To The Halls of the Dead Level 3": "To The Halls of the Dead Level 3",
+ "To The Harem Level 1": "To The Harem Level 1",
+ "To The Harem Level 2": "To The Harem Level 2",
+ "To The Hole Level 1": "To The Hole Level 1",
+ "To The Hole Level 2": "To The Hole Level 2",
+ "To The Inner Cloister": "To The Inner Cloister",
+ "To The Jail Level 1": "To The Jail Level 1",
+ "To The Jail Level 2": "To The Jail Level 2",
+ "To The Jail Level 3": "To The Jail Level 3",
+ "To The Kurast Bazaar": "To The Kurast Bazaar",
+ "To The Kurast Causeway": "To The Kurast Causeway",
+ "To The Kurast Docktown": "To The Kurast Docks",
+ "To The Lost City": "To The Lost City",
+ "To The Maggot Lair Level 1": "To The Maggot Lair Level 1",
+ "To The Maggot Lair Level 2": "To The Maggot Lair Level 2",
+ "To The Maggot Lair Level 3": "To The Maggot Lair Level 3",
+ "To The Mausoleum": "To The Mausoleum",
+ "To The Monastery Gate": "To The Monastery Gate",
+ "To The Moo Moo Farm": "To The Secret Cow Level",
+ "To The Outer Cloister": "To The Outer Cloister",
+ "To The Outer Steppes": "To The Outer Steppes",
+ "To The Palace Cellar Level 2": "To The Palace Cellar Level 2",
+ "To The Palace Cellar Level 3": "To The Palace Cellar Level 3",
+ "To The Pandemonium Fortress": "To The Pandemonium Fortress",
+ "To The Pit Level 1": "To The Pit Level 1",
+ "To The Pit Level 2": "To The Pit Level 2",
+ "To The Plains of Despair": "To The Plains of Despair",
+ "To The Rigid Highlands": "To The Frigid Highlands",
+ "To The River of Flame": "To The River of Flame",
+ "To The Rocky Summit": "To The Arreat Summit",
+ "To The Rocky Waste": "To The Rocky Waste",
+ "To The Rogue Encampment": "To The Rogue Encampment",
+ "To The Ruined Fane": "To The Ruined Fane",
+ "To The Ruined Temple": "To The Ruined Temple",
+ "To The Sewers Level 1": "To The Sewers Level 1",
+ "To The Sewers Level 2": "To The Sewers Level 2",
+ "To The Sewers Level 3": "To The Sewers Level 3",
+ "To The Spider Cave": "To The Arachnid Lair",
+ "To The Spider Cavern": "To The Spider Cavern",
+ "To The Spider Forest": "To The Spider Forest",
+ "To The Stony Field": "To The Stony Field",
+ "To The Stony Tomb Level 1": "To The Stony Tomb Level 1",
+ "To The Stony Tomb Level 2": "To The Stony Tomb Level 2",
+ "To The Swampy Pit Level 1": "To The Swampy Pit Level 1",
+ "To The Swampy Pit Level 2": "To The Swampy Pit Level 2",
+ "To The Swampy Pit Level 3": "To The Swampy Pit Level 3",
+ "To The Tamoe Highland": "To The Tamoe Highland",
+ "To The Throne of Destruction": "To The Throne of Destruction",
+ "To The Tower Cellar Level 1": "To The Tower Cellar Level 1",
+ "To The Tower Cellar Level 2": "To The Tower Cellar Level 2",
+ "To The Tower Cellar Level 3": "To The Tower Cellar Level 3",
+ "To The Tower Cellar Level 4": "To The Tower Cellar Level 4",
+ "To The Tower Cellar Level 5": "To The Tower Cellar Level 5",
+ "To The Tundra Wastelands": "To The Frozen Tundra",
+ "To The Valley of Snakes": "To The Valley of Snakes",
+ "To The Worldstone Chamber": "To The Worldstone Chamber",
+ "To The Worldstone Keep Level 1": "To The Worldstone Keep Level 1",
+ "To The Worldstone Keep Level 2": "To The Worldstone Keep Level 2",
+ "To The Worldstone Keep Level 3": "To The Worldstone Keep Level 3",
+ "To Travincal": "To Travincal",
+ "To Tristram": "To Tristram",
+ "To Underground Passage Level 1": "To Underground Passage Level 1",
+ "To Underground Passage Level 2": "To Underground Passage Level 2",
+ "To Upper Kurast": "To Upper Kurast",
"Todesfaelle Flamme": "Todesfaelle Flamme",
"Tomb Reaver": "Tomb Reaver",
"TombCreeper": "Tomb Creeper",
"TombViper": "Tomb Viper",
"Toorc Icefist": "Toorc Icefist",
"Toothrow": "Toothrow",
+ "Tower Cellar Level 1": "Tower Cellar Level 1",
+ "Tower Cellar Level 2": "Tower Cellar Level 2",
+ "Tower Cellar Level 3": "Tower Cellar Level 3",
+ "Tower Cellar Level 4": "Tower Cellar Level 4",
+ "Tower Cellar Level 5": "Tower Cellar Level 5",
"Toxic": "Toxic",
"Trainer's": "Trainer's",
"Trang-Oul's Avatar": "Trang-Oul's Avatar",
@@ -1951,12 +2648,15 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Trang-Oul's Scales": "Trang-Oul's Scales",
"Trang-Oul's Wing": "Trang-Oul's Wing",
"TrappedSoul": "Trapped Soul",
+ "Travincal": "Travincal",
"Treads of Cthon": "Treads of Cthon",
"TreeLurker": "Tree Lurker",
"Treehead WoodFist": "Treehead WoodFist",
"Trickster's": "Trickster's",
+ "Tristram": "Tristram",
"Triumphant": "Triumphant",
"Trump": "Fool's",
+ "Tundra Wastelands": "Frozen Tundra",
"Turquoise": "Turquoise",
"Turret": "a Turret",
"Twitchthroe": "Twitchthroe",
@@ -1971,13 +2671,17 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"Undead StygianDoll": "Undead Stygian Doll",
"UndeadDescriptX": "Undead",
"UndeadScavenger": "Undead Scavenger",
+ "Underground Passage Level 1": "Underground Passage Level 1",
+ "Underground Passage Level 2": "Underground Passage Level 2",
"Unearthly": "Unearthly",
"UnholyCorpse": "Unholy Corpse",
"Unraveler": "Unraveler",
+ "Upper Kurast": "Upper Kurast",
"Urdar": "Urdar",
"Valkiry Wing": "Valkyrie Wing",
"Valkyrie": "Valkyrie",
"Valkyrie's": "Valkyrie's",
+ "Valley of Snakes": "Valley of Snakes",
"Vampiregaze": "Vampire Gaze",
"Veil of Steel": "Veil of Steel",
"Venom Grip": "Venom Grip",
@@ -2134,6 +2838,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"baf": "Guardian Crown",
"bal": "Balanced Axe",
"band": "Band",
+ "bank": "Your Private Stash",
"bar": "Bardiche",
"barRI": "Bar",
"barb": "Barb",
@@ -2195,6 +2900,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"cleaver": "Cleaver",
"clm": "Claymore",
"cloak": "Cloak",
+ "close": "close",
"clutches": "Clutches",
"clw": "Claws",
"cm1": "Small Charm",
@@ -2265,6 +2971,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"ful": "Full Plate Mail",
"g33": "The Gidbinn",
"g34": "The Golden Bird",
+ "gamble": "gamble",
"gax": "Great Axe",
"gcb": "Chipped Sapphire",
"gcg": "Chipped Emerald",
@@ -2386,6 +3093,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"lock": "Lock",
"loom": "Loom",
"loop": "Loop",
+ "lowercasecancel": "cancel",
"lrg": "Large Shield",
"lsd": "Long Sword",
"lst": "Long Staff",
@@ -2408,6 +3116,16 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"meshif": "Meshif",
"mgl": "Chain Gloves",
"minion": "Minion",
+ "minipanelHelp": "Help",
+ "minipanelautomap": "Automap",
+ "minipanelchar": "Character",
+ "minipanelinv": "Inventory",
+ "minipanelmenubtn": "Game Menu (Esc)",
+ "minipanelmessage": "Message Log",
+ "minipanelparty": "Party Screen",
+ "minipanelquest": "Quest Log",
+ "minipanelspecial": " (%s)",
+ "minipaneltree": "Skill Tree",
"monsteruniqueprop1": "Cold Enchanted",
"monsteruniqueprop2": "Lightning Enchanted",
"monsteruniqueprop3": "Mana Burn",
@@ -3057,9 +3775,16 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"stake": "Stake",
"stalker": "Stalker",
"star": "Star",
+ "stash": "Stash",
"std": "Standard of Heroes",
"steals life": "Steals Life",
"stinger": "Stinger",
+ "strBSArmor": "Armor",
+ "strBSMisc": "Misc",
+ "strBSWeapons": "Weapons",
+ "strClose": "Close",
+ "strGoldInStash": "Gold in Stash:",
+ "strGoldLabel": "Gold",
"strModAllResistances": "All Resistances +%d",
"strModAllSkillLevels": "+%d to All Skill Levels",
"strModColdDamage": "+%d cold damage",
@@ -3081,7 +3806,30 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
"strScaryMonDesc4": "Immune to Poison",
"strScaryMonDesc5": "Immune to Physical",
"strScaryMonDesc6": "Immune to Magic",
+ "strScaryMonDescA": "",
+ "strScaryMonDescB": "",
+ "strUiMenu2": "Transmute",
"strap": "Strap",
+ "strchratr": "%s\nAttack Rating",
+ "strchrcol": "Cold\nResistance",
+ "strchrdef": "Defense",
+ "strchrdex": "Dexterity",
+ "strchreng": "Energy",
+ "strchrexp": "Experience",
+ "strchrfir": "Fire\nResistance",
+ "strchrlif": "Life",
+ "strchrlit": "Lightning\nResistance",
+ "strchrlvl": "Level",
+ "strchrman": "Mana",
+ "strchrnxtlvl": "Next Level",
+ "strchrpos": "Poison\nResistance",
+ "strchrrat": "%s\nRating",
+ "strchrrema": "Remaining",
+ "strchrskm": "Damage",
+ "strchrstat": "Stat Points",
+ "strchrstm": "Stamina",
+ "strchrstr": "Strength",
+ "strchrvit": "Vitality",
"stu": "Studded Leather",
"stun attack": "Stun Attack",
"suit": "Suit",
@@ -3256,9 +4004,96 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = {
}
export const CANONICAL_CHI_TBL_DICT: Readonly> = {
+ "0sc": "知识卷轴",
"2ax": "雙刃斧",
"2hs": "雙手劍",
+ "6bs": "橡木棍",
+ "6cb": "巨弓",
+ "6cs": "长老之杖",
+ "6hb": "刀锋之弓",
+ "6hx": "巨神十字弓",
+ "6l7": "十字军之弓",
+ "6lb": "阴影之弓",
+ "6ls": "石笋之杖",
+ "6lw": "九头蛇弓",
+ "6lx": "弹丸弩",
+ "6mx": "蛇发女妖十字弓",
+ "6rx": "恶魔十字弓",
+ "6s7": "钻石之弓",
+ "6sb": "蜘蛛之弓",
+ "6ss": "手杖",
+ "6sw": "庇护之弓",
+ "6ws": "执政官之杖",
+ "72a": "双头斧",
+ "72h": "传说之剑",
+ "7ar": "近身刃",
+ "7ax": "小月斧",
+ "7b7": "冠军之剑",
+ "7b8": "翼斧",
+ "7ba": "银刃斧",
+ "7bk": "翼刀",
+ "7bl": "传说尖刺",
+ "7br": "捕人叉",
+ "7bs": "征服之剑",
+ "7bt": "斩首斧",
+ "7bw": "巫妖法杖",
+ "7cl": "短棒",
+ "7cm": "高地之剑",
+ "7cr": "幻化之刃",
+ "7cs": "斗腰刀",
+ "7dg": "骸骨小刀",
+ "7di": "秘银小刀",
+ "7fb": "巨神之剑",
+ "7fc": "九头蛇刃",
+ "7fl": "天罚之锤",
+ "7ga": "冠军之斧",
+ "7gd": "巨神之刃",
+ "7gi": "光荣之斧",
+ "7gl": "鬼魂尖枪",
+ "7gm": "雷霆之锤",
+ "7gs": "炎魔之刃",
+ "7gw": "破隐法杖",
+ "7h7": "巨长斧",
+ "7ha": "印第安战斧",
+ "7ja": "亥伯龙标枪",
+ "7kr": "尖牙小刀",
+ "7la": "狂野之斧",
+ "7ls": "神秘之剑",
+ "7lw": "猛禽爪",
+ "7m7": "食人魔之锤",
+ "7ma": "强化钉锤",
+ "7mp": "战刺",
+ "7mt": "恶魔流星锤",
+ "7o7": "食人魔之斧",
+ "7p7": "战枪",
+ "7pa": "神秘之斧",
+ "7pi": "冥河标枪",
+ "7qr": "近身剪刃",
+ "7qs": "炽天使法杖",
+ "7s7": "炎魔长矛",
+ "7s8": "锐利之斧",
+ "7sb": "优雅之剑",
+ "7sc": "强威权杖",
+ "7sm": "土耳其剑",
+ "7sp": "暴君之棒",
+ "7sr": "亥伯龙之矛",
+ "7ss": "弯形大刀",
+ "7st": "鬼魂之矛",
+ "7ta": "飞斧",
+ "7tk": "飞刀",
+ "7tr": "冥河长枪",
+ "7ts": "翼鱼叉",
+ "7tw": "符纹爪",
+ "7vo": "巨神之斧",
"7wa": "狂戰士斧",
+ "7wb": "腕剑",
+ "7wc": "巨型锐利之斧",
+ "7wd": "神话之剑",
+ "7wh": "传说之锤",
+ "7wn": "磨光法杖",
+ "7ws": "神使之杖",
+ "7xf": "战拳",
+ "7yw": "鬼魂法杖",
"8bs": "哥德之棍",
"8cb": "雙弓",
"8cs": "杉木之棍",
@@ -3278,6 +4113,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"8ws": "符文之棍",
"92a": "強化雙斧",
"92h": "斬鐵劍",
+ "9ar": "格斗剪",
"9ax": "切肉斧",
"9b7": "羅佳柏斧",
"9b8": "戟",
@@ -3292,6 +4128,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"9cl": "棍棒",
"9cm": "雙刃鐮",
"9cr": "空間之刃",
+ "9cs": "手镰",
"9dg": "銳匕",
"9di": "詩歌匕首",
"9fb": "瑞韓德",
@@ -3310,6 +4147,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"9kr": "強波刀",
"9la": "軍斧",
"9ls": "符文劍",
+ "9lw": "巨爪",
"9m9": "巨戰木棍",
"9ma": "凸緣釘頭錘",
"9mp": "喙鉗",
@@ -3317,6 +4155,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"9p9": "長槍",
"9pa": "戰戟",
"9pi": "卓越標槍",
+ "9qr": "近身剪",
"9qs": "聖水噴杖",
"9s8": "戰鬥鐮刀",
"9s9": "銳矛",
@@ -3331,32 +4170,56 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"9tk": "戰鬥飛鏢",
"9tr": "弗斯西那",
"9ts": "魚叉",
+ "9tw": "巨鹰爪",
"9vo": "比爾",
"9wa": "納卡",
+ "9wb": "腕刺",
"9wc": "殘酷鐮刀",
"9wd": "古代之劍",
"9wh": "戰鬥鐵鎚",
"9wn": "燒焦之杖",
"9ws": "神屬權杖",
+ "9xf": "格斗爪",
"9yw": "淨化之杖",
"AbyssKnight": "地獄騎士",
+ "Act 5 Combatant": "野蛮人战士",
+ "Act 5 Townguard": "守卫",
"Afflicted": "苦難怪",
"Akara": "阿卡拉",
"AlbinoRoach": "白骨聖甲蟲",
+ "Aldur's Advance": "艾尔多的成长",
+ "Aldur's Deception": "艾尔多的欺瞒",
+ "Aldur's Gauntlet": "艾尔多的节奏",
+ "Aldur's Stony Gaze": "艾尔多的冷酷凝视",
+ "Aldur's Watchtower": "艾尔多的守卫",
+ "Alma Negra": "阿尔玛·尼格拉",
"AmaOnly": "(限亞馬遜使用)",
"Amazon": "亞馬遜",
"Amber": "琥珀之",
+ "Amethyst": "紫宝石",
"Amulet of the Viper": "蝮蛇護符",
+ "Ancient Barbarian 1": "塔力克",
+ "Ancient Barbarian 2": "马道克",
+ "Ancient Barbarian 3": "科力克",
"Ancient Kaa the Soulless": "古代無魂之卡",
+ "Ancient Statue 1": "守护者塔力克",
+ "Ancient Statue 2": "守卫者马道克",
+ "Ancient Statue 3": "保卫者科力克",
+ "Ancient Tunnels": "古代通道",
"Andariel": "安達利爾",
+ "Andariel's Visage": "安达利尔的面貌",
"Angel's": "天使之",
"Angelic Halo": "天使之光",
"Angelic Mantle": "天使的披風",
"Angelic Sickle": "天使的鐮刀",
"Angelic Wings": "天使之翼",
"Angelical Raiment": "天上的衣服",
+ "Annihilus": "毁灭小护身符",
+ "Anodized Elite": "地狱佳人",
"Apparition": "幽影",
"Arach": "奧拉克",
+ "Arachnid Mesh": "蜘蛛之网",
+ "Arcane Sanctuary": "神秘庇難所",
"Arcanna's Deathwand": "阿卡娜的死亡之杖",
"Arcanna's Flesh": "阿卡娜的血肉",
"Arcanna's Head": "阿卡娜的頭",
@@ -3369,18 +4232,49 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Arctic Gear": "北極裝置",
"Arctic Horn": "北極號角",
"Arctic Mitts": "北極手套",
+ "Arioc's Needle": "阿里欧克之针",
+ "Arkaine's Valor": "阿凯尼的荣耀",
+ "Arm of King Leoric": "李奥瑞克王的骨杖",
+ "Armor": "防具",
+ "Arreat's Face": "亚瑞特的面容",
"AssOnly": "(限刺客使用)",
"Assailant": "攻擊者",
"Assassin": "刺客",
+ "Athena's Wrath": "雅典娜的愤怒",
"Atma": "亞特瑪",
+ "Atma's Scarab": "阿特玛的圣甲虫",
+ "Atma's Wail": "阿特玛的哭喊",
+ "Axe Dweller": "持斧者",
"Azure": "天青之",
"Azurewrath": "青色忿怒",
+ "Baal Crab": "巴尔",
+ "Baal Crab Clone": "巴尔",
+ "Baal Crab to Stairs": "巴尔",
+ "Baal Subject 1": "毁灭者卡兰索",
+ "Baal Subject 2": "被诅咒的阿克姆",
+ "Baal Subject 3": "血腥巴特克",
+ "Baal Subject 4": "不洁者凡塔",
+ "Baal Subject 5": "折磨者利斯特",
+ "Baal Subject Mummy": "解开者",
+ "Baal Taunt": "巴尔",
+ "Baal Tentacle": "腐烂触须",
+ "Baal Throne": "巴尔",
+ "BaalColdMage": "死亡法师",
+ "Baals Minion": "毁灭的仆从",
+ "Baezil's Vortex": "贝西尔的漩涡",
"Balrog": "巴羅格",
"Bane": "剋星",
"Bane Ash": "禍根之灰",
"Banished": "受放逐者",
"BarOnly": "(限野蠻人使用)",
+ "Baranar's Star": "巴拉那之星",
"Barbarian": "野蠻人",
+ "Barracks": "軍營",
+ "Barricade Door": "路障门",
+ "Barricade Tower": "路障塔",
+ "Barricade Wall Left": "路障",
+ "Barricade Wall Right": "路障",
+ "Bear": "熊",
"Beast": "野獸之",
"Beetleburst": "爆開的甲蟲",
"BerserkSlayer": " ",
@@ -3390,8 +4284,11 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Berserker's Hauberk": "狂戰士的鎖子甲",
"Berserker's Headgear": "狂戰士的頭戴",
"Beryl": "海藍之",
+ "Bing Sz Wang": "冰之王",
"Bishibosh": "畢須博須",
"Bitter": "受難之",
+ "Black Hades": "黑色哈迪斯",
+ "Black Marsh": "黑色荒地",
"BlackArcher": "黑色弓箭手",
"BlackLancer": "黑色長槍手",
"BlackLocusts": "黑色蝗蟲",
@@ -3399,15 +4296,31 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"BlackRogue": "黑色蘿格",
"BlackSoul": "黑色靈魂",
"BlackVultureNest": "黑色開膛手之巢",
+ "Blackbog's Sharp": "黑沼之锋",
+ "Blackhand Key": "黑手之钥",
+ "Blackhorn's Face": "黑角面具",
+ "Blackleach Blade": "黑水之刃",
+ "Blackoak Shield": "黑橡树盾",
"Blacktongue": "黑色之舌",
+ "Blade Creeper": "刀刃守卫",
+ "Blade of Ali Baba": "阿里巴巴之刃",
"Bladebone": "肩胛骨",
"Bladebuckle": "鋒利扣帶",
"Blank": "虛無之",
"Blastbark": "爆裂的吠叫",
+ "Blaze Ripper": "烈焰撕裂者",
"Blessed": "祝福之",
"Blinkbats Form": "眨眼蝙蝠的外觀",
"Blood": "血之",
"Blood Crescent": "血紅新月",
+ "Blood Lord1": "月亮之王",
+ "Blood Lord2": "暗夜之王",
+ "Blood Lord3": "鲜血之王",
+ "Blood Lord4": "地狱之王",
+ "Blood Lord5": "死亡之王",
+ "Blood Moor": "鮮血荒地",
+ "Blood Temptress": "鲜血女妖",
+ "Blood Witch": "鲜血女巫",
"BloodBoss": " ",
"BloodBringer": " ",
"BloodClan": "血腥一族",
@@ -3421,9 +4334,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"BloodWing": "血翼",
"BloodWingNest": "血翼之巢",
"Bloodfist": "血拳",
+ "Bloodletter": "放血者",
+ "Bloodmoon": "血月",
"Bloodraven": "血鳥",
+ "Bloodraven's Charge": "血鸟的袭击",
"Bloodrise": "血昇",
"Bloodthief": "血之偷",
+ "Bloodtree Stump": "血树残桩",
"Bloodwitch the Wild": "瘋狂血腥女巫",
"Blunderbore": "大笨獸",
"Bog Creature": "泥沼生物",
@@ -3433,7 +4350,12 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"BoneWarrior": "白骨戰士",
"Boneash": "骨灰",
"Bonebreak": "碎骨者",
+ "Boneflame": "骨焰",
"Boneflesh": "骨肉",
+ "Bonehew": "破骨",
+ "Bonesaw Breaker": "碎骨破甲者",
+ "Boneshade": "骨影",
+ "Boneslayer Blade": "斩骨者之刃",
"Bonesob": "碎骨",
"Brainhew": "腦袋",
"Bramble": "野薔薇之",
@@ -3445,66 +4367,129 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Bronze": "青銅之",
"Brutal": "殘忍之",
"Brute": "禽獸",
+ "Bul Katho's Wedding Band": "布尔凯索之戒",
+ "Bul-Kathos' Children": "布尔凯索的子嗣",
+ "Bul-Kathos' Sacred Charge": "布尔凯索的神圣代价",
+ "Bul-Kathos' Tribal Guardian": "布尔凯索的部族守护",
"Burgundy": "紅棕之",
+ "Burial Grounds": "埋骨之地",
+ "Buriza-Do Kyanon": "布理撒-多·凯南 (大炮)",
"BurningDead": "燃燒死屍",
"BurningDeadArcher": "燃燒的死亡弓箭手",
"BurningDeadMage": "燃燒的死法師",
"BurningSoul": "燃燒的靈魂",
+ "Butcher's Pupil": "屠夫的徒弟",
"Bverrit Keep": "貝弗提的紀念",
"Cadaver": "解剖死屍怪",
"Cantor": "領唱者",
+ "Canyon of the Magi": "術士的峽谷",
+ "Carin Shard": "凯恩碎片",
"Carrion": "腐屍之",
+ "Carrion Wind": "腐肉之风",
"CarrionBird": "腐食鳥",
"Carver": "利刃魔",
"CarverShaman": "利刃巫師",
+ "Catacombs Level 1": "地下墓穴第一層",
+ "Catacombs Level 2": "地下墓穴第二層",
+ "Catacombs Level 3": "地下墓穴第三層",
+ "Catacombs Level 4": "地下墓穴第四層",
+ "Catapult Spotter E": "投石车",
+ "Catapult Spotter S": "投石车",
+ "Catapult Spotter Siege": "投石车",
+ "Catapult Spotter W": "投石车",
+ "CatapultE": "投石车",
+ "CatapultS": "投石车",
+ "CatapultSiege": "投石车",
+ "CatapultW": "投石车",
"Cathan's Mesh": "卡珊之網",
"Cathan's Rule": "卡珊之尺",
"Cathan's Seal": "卡珊的封印",
"Cathan's Sigil": "卡珊的魔咒",
"Cathan's Traps": "卡珊的陷阱",
"Cathan's Visage": "卡珊的容貌",
+ "Cathedral": "大教堂",
+ "Cave Level 1": "洞窟第一層",
+ "Cave Level 2": "洞窟第二層",
"CaveLeaper": "洞穴跳躍者",
+ "Cerebus": "地狱犬之咬",
"Champion": "頭目",
"Chance Guards": "運氣守護",
"Chaos": "混沌之",
+ "Chaos Sanctum": "混沌庇難所",
+ "Charged Bolt Sentry": "充能弹卫哨",
"Charsi": "恰西",
+ "Chipped Amethyst": "碎裂的紫宝石",
+ "Chipped Diamond": "碎裂的钻石",
+ "Chipped Emerald": "碎裂的绿宝石",
+ "Chipped Ruby": "碎裂的红宝石",
+ "Chipped Saphire": "碎裂的蓝宝石",
+ "Chipped Skull": "碎裂的骷髅",
+ "Chipped Topaz": "碎裂的黄宝石",
+ "Chromatic Ire": "多彩之怒",
+ "City of the Damned": "神罰之城",
"Civerb's Cudgel": "希弗伯的短棍",
"Civerb's Icon": "希弗伯的圖案",
"Civerb's Vestments": "希弗伯的法衣",
"Civerb's Ward": "希弗柏的防護",
+ "Class Specific": "职业专属",
+ "Claw Viper Temple Level 1": "利爪腹蛇神殿第一層",
+ "Claw Viper Temple Level 2": "利爪腹蛇神殿第二層",
"ClawViper": "利爪腹蛇",
"ClayGolem": "黏土石魔",
"Cleglaw's Brace": "克雷得勞的支柱",
"Cleglaw's Claw": "克雷得勞之爪",
"Cleglaw's Pincers": "克雷得勞之鉗",
"Cleglaw's Tooth": "克雷得勞之牙",
+ "Cliffkiller": "悬崖杀手",
"CloudStalker": "煙霧偷襲者",
"CloudStalkerNest": "煙霧偷襲者之巢",
+ "Cloudcrack": "裂云",
"Cobalt": "鈷之",
"Coif of Glory": "光榮布帽",
+ "Cold Plains": "冰冷之原",
"Coldcrow": "冰冷鳥鴉",
+ "Coldkill": "冷酷杀手",
+ "Coldsteel Eye": "冷钢之眼",
"Coldworm the Burrower": "鑽地的冰蟲",
+ "Constricting Ring": "束缚之戒",
"ConsumedFireBoar": " ",
"ConsumedIceBoar": " ",
"Coral": "珊瑚之",
"Corpse": "屍體之",
"CorpseSpitter": "噴吐屍體怪",
"Corpsefire": "屍體發火",
+ "Corpsemourn": "尸体的哀伤",
"Corpulent": "癡肥怪",
"Corruption": "毀滅之",
"Council Member": "議會成員",
+ "Cow King's Hide": "牛魔王之皮",
+ "Cow King's Hoofs": "牛魔王之蹄",
+ "Cow King's Horns": "牛魔王之角",
+ "Cow King's Leathers": "牛魔王之皮甲",
"Cracked": "破碎的",
+ "Crainte Vomir": "克林特·佛姆",
+ "Cranebeak": "鹤嘴",
+ "Credendum": "信条",
+ "Crescent Moon": "新月",
"Crimson": "棗紅之",
+ "Crow Caw": "乌鸦哀鸣",
+ "Crown of Ages": "年纪之冠",
+ "Crown of Thieves": "盗贼皇冠",
"Crude": "粗製的",
"Cruel": "殘忍之",
"CrushBiest": " ",
"Crusher": "壓碎者",
"Crushflange": "壓碎的凸邊",
+ "Crypt": "墓地",
"Culwens Point": "庫爾溫的尖端",
"Cutthroat1": "巴特克的猛擊",
+ "Dac Farren": "达克法恩",
"Damaged": "損壞的",
"Damned": "詛咒怪",
+ "Dangoon's Teaching": "檀君的教导",
+ "Dark Clan Crusher": "暗族碎骨者",
"Dark Elder": "黑暗長老",
+ "Dark Wood": "黑暗森林",
"DarkArcher": "黑暗弓箭手",
"DarkFamiliar": "黑暗熟視者",
"DarkHunter": "黑暗獵人",
@@ -3516,21 +4501,38 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"DarkShape": "黑暗身影",
"DarkSpearwoman": "黑暗女槍手",
"DarkStalker": "黑暗偷襲者",
+ "Darkfear": "黑暗恐惧",
+ "Darkforge Spawn": "黑暗力量之源",
"Darkglow": "擴散黑暗",
+ "Darksight Helm": "暗视之盔",
"Deadly": "致命之",
"DeamonSteed": " ",
"Death": "死亡之",
+ "Death Mauler1": "死亡重击者",
+ "Death Mauler2": "死亡斗士",
+ "Death Mauler3": "死亡斩击者",
+ "Death Mauler4": "死亡狂战士",
+ "Death Mauler5": "死亡统领",
+ "Death Sentry": "死亡卫哨",
"Death's Disguise": "死亡的偽裝",
"Death's Guard": "死亡的守護",
"Death's Hand": "死亡之手",
"Death's Touch": "死亡之觸",
"DeathClan": "死亡一族",
+ "Deathbit": "死亡碎片",
+ "Deathcleaver": "死亡之刀",
+ "Deaths's Web": "死亡之网",
"Deathspade": "死亡之鏟",
"Decayed": "腐屍",
"DeckardCain": "迪卡.凱恩",
"DefiledWarrior": " ",
"Demon": "惡魔之",
+ "Demon Machine": "恶魔机械",
+ "Demon's Arch": "恶魔之拱",
"DemonID": "惡魔",
+ "Demonhorn's Edge": "恶魔号角的边缘",
+ "Demonlimb": "恶魔肢体",
+ "Den of Evil": "邪惡洞窟",
"DesertWing": "沙漠之翼",
"Devilkin": "小惡魔",
"DevilkinShaman": "小惡魔巫師",
@@ -3540,41 +4542,317 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"DevourerQueen": "吞食者皇后",
"DevourerYoung": "吞噬者幼生",
"Diablo": "暗黑破壞神",
+ "Diamond": "钻石",
"Dimoaks Hew": "迪馬克之劈砍",
"Dire": "悲慘之",
"Disfigured": "醜陋怪",
+ "Disused Fane": "廢棄的寺院",
+ "Disused Reliquary": "廢棄的聖物",
+ "Djinnslayer": "斩魔刀",
"Dominus": " ",
"Doom": "毀滅之",
"DoomApe": "毀滅猴怪",
"DoomKnight": "厄運騎士",
+ "Doombringer": "末日毁灭者",
"Doomspittle": "毀滅投石者",
"Dopplezon": "誘弭",
+ "Dracul's Grasp": "卓古拉之握",
"Dragon's": "龍之",
+ "Dragonscale": "龙鳞",
"Drake's": "德爾克之",
"Dread": "恐懼之",
+ "Drehya": "安雅",
"DriedCorpse": "乾屍",
"Drognan": "卓格南",
"DrownedCarcass": "溺斃死屍",
"DruOnly": "(限德魯伊使用)",
"Druid": "德魯依",
+ "Druid Bear": "灰熊",
+ "Druid Cycle of Life": "太阳藤",
+ "Druid Fenris": "狂狼",
+ "Druid Hawk": "乌鸦",
+ "Druid Plague Poppy": "瘟疫藤",
+ "Druid Spirit Wolf": "鬼狼",
+ "Dry Hills": "乾燥的高地",
"Dummy": "一股邪惡力量",
"DuneBeast": "沙丘野獸",
"DungSoldier": "水肥戰士",
+ "Durance of Hate Level 1": "憎恨的囚牢第一層",
+ "Durance of Hate Level 2": "憎恨的囚牢第二層",
+ "Durance of Hate Level 3": "憎恨的囚牢第三層",
"Duriel": "都瑞爾",
+ "Duriel's Lair": "都瑞爾的房間",
+ "Duriel's Shell": "督瑞尔的壳",
"Duskdeep": "黃昏深處",
+ "Dwarf Star": "矮人之星",
"Eagle": "老鷹之",
+ "Eaglehorn": "鹰号角",
+ "Earthshaker": "撼地者",
+ "Earthshifter": "大地变形者",
"Elzix": "艾吉斯",
"Embalmed": "防腐屍怪",
"Emerald": "祖母綠之",
"Empyrion": "埃之及",
+ "Endlesshail": "无尽冰雹",
"Entropy": "幽冥之",
+ "Eschuta's temper": "艾斯屈塔的脾气",
+ "EskillExpan1": " ",
+ "EskillKickSing": "脚踢",
+ "EskillLowersResistX": "降低抵抗",
+ "EskillToMeleeX": "對肉搏攻擊",
+ "Eskillan0": " ",
+ "Eskillan10": " ",
+ "Eskillan11": " ",
+ "Eskillan12": " ",
+ "Eskillan13": " ",
+ "Eskillan14": " ",
+ "Eskillan15": " ",
+ "Eskillan16": " ",
+ "Eskillan17": " ",
+ "Eskillan18": " ",
+ "Eskillan19": " ",
+ "Eskillan2": " ",
+ "Eskillan20": " ",
+ "Eskillan21": " ",
+ "Eskillan22": " ",
+ "Eskillan23": " ",
+ "Eskillan24": " ",
+ "Eskillan25": " ",
+ "Eskillan26": " ",
+ "Eskillan27": " ",
+ "Eskillan28": " ",
+ "Eskillan29": " ",
+ "Eskillan3": " ",
+ "Eskillan30": " ",
+ "Eskillan31": " ",
+ "Eskillan32": " ",
+ "Eskillan33": " ",
+ "Eskillan34": " ",
+ "Eskillan35": " ",
+ "Eskillan36": " ",
+ "Eskillan37": " ",
+ "Eskillan38": " ",
+ "Eskillan39": " ",
+ "Eskillan4": " ",
+ "Eskillan40": " ",
+ "Eskillan41": " ",
+ "Eskillan42": " ",
+ "Eskillan43": " ",
+ "Eskillan44": " ",
+ "Eskillan45": " ",
+ "Eskillan46": " ",
+ "Eskillan47": " ",
+ "Eskillan48": " ",
+ "Eskillan49": " ",
+ "Eskillan5": " ",
+ "Eskillan50": " ",
+ "Eskillan51": " ",
+ "Eskillan52": " ",
+ "Eskillan53": " ",
+ "Eskillan54": " ",
+ "Eskillan55": " ",
+ "Eskillan56": " ",
+ "Eskillan57": " ",
+ "Eskillan58": " ",
+ "Eskillan59": " ",
+ "Eskillan6": " ",
+ "Eskillan7": " ",
+ "Eskillan8": " ",
+ "Eskillan9": " ",
+ "EskilllExpd1": " ",
+ "Eskillld0": " ",
+ "Eskillld10": " ",
+ "Eskillld11": " ",
+ "Eskillld12": " ",
+ "Eskillld13": " ",
+ "Eskillld14": " ",
+ "Eskillld15": " ",
+ "Eskillld16": " ",
+ "Eskillld17": " ",
+ "Eskillld18": " ",
+ "Eskillld19": " ",
+ "Eskillld2": " ",
+ "Eskillld20": " ",
+ "Eskillld21": " ",
+ "Eskillld22": " ",
+ "Eskillld23": " ",
+ "Eskillld24": " ",
+ "Eskillld25": " ",
+ "Eskillld26": " ",
+ "Eskillld27": " ",
+ "Eskillld28": " ",
+ "Eskillld29": " ",
+ "Eskillld3": " ",
+ "Eskillld30": " ",
+ "Eskillld31": " ",
+ "Eskillld32": " ",
+ "Eskillld33": " ",
+ "Eskillld34": " ",
+ "Eskillld35": " ",
+ "Eskillld36": " ",
+ "Eskillld37": " ",
+ "Eskillld38": " ",
+ "Eskillld39": " ",
+ "Eskillld4": " ",
+ "Eskillld40": " ",
+ "Eskillld41": " ",
+ "Eskillld42": " ",
+ "Eskillld43": " ",
+ "Eskillld44": " ",
+ "Eskillld45": " ",
+ "Eskillld46": " ",
+ "Eskillld47": " ",
+ "Eskillld48": " ",
+ "Eskillld49": " ",
+ "Eskillld5": " ",
+ "Eskillld50": " ",
+ "Eskillld51": " ",
+ "Eskillld52": " ",
+ "Eskillld53": " ",
+ "Eskillld54": " ",
+ "Eskillld55": " ",
+ "Eskillld56": " ",
+ "Eskillld57": " ",
+ "Eskillld58": " ",
+ "Eskillld59": " ",
+ "Eskillld6": " ",
+ "Eskillld7": " ",
+ "Eskillld8": " ",
+ "Eskillld9": " ",
+ "Eskillname0": " ",
+ "Eskillname10": " ",
+ "Eskillname11": " ",
+ "Eskillname12": " ",
+ "Eskillname13": " ",
+ "Eskillname14": " ",
+ "Eskillname15": " ",
+ "Eskillname16": " ",
+ "Eskillname17": " ",
+ "Eskillname18": " ",
+ "Eskillname19": " ",
+ "Eskillname2": " ",
+ "Eskillname20": " ",
+ "Eskillname21": " ",
+ "Eskillname22": " ",
+ "Eskillname23": " ",
+ "Eskillname24": " ",
+ "Eskillname25": " ",
+ "Eskillname26": " ",
+ "Eskillname27": " ",
+ "Eskillname28": " ",
+ "Eskillname29": " ",
+ "Eskillname3": " ",
+ "Eskillname30": " ",
+ "Eskillname31": " ",
+ "Eskillname32": " ",
+ "Eskillname33": " ",
+ "Eskillname34": " ",
+ "Eskillname35": " ",
+ "Eskillname36": " ",
+ "Eskillname37": " ",
+ "Eskillname38": " ",
+ "Eskillname39": " ",
+ "Eskillname4": " ",
+ "Eskillname40": " ",
+ "Eskillname41": " ",
+ "Eskillname42": " ",
+ "Eskillname43": " ",
+ "Eskillname44": " ",
+ "Eskillname45": " ",
+ "Eskillname46": " ",
+ "Eskillname47": " ",
+ "Eskillname48": " ",
+ "Eskillname49": " ",
+ "Eskillname5": " ",
+ "Eskillname50": " ",
+ "Eskillname51": " ",
+ "Eskillname52": " ",
+ "Eskillname53": " ",
+ "Eskillname54": " ",
+ "Eskillname55": " ",
+ "Eskillname56": " ",
+ "Eskillname57": " ",
+ "Eskillname58": " ",
+ "Eskillname59": " ",
+ "Eskillname6": " ",
+ "Eskillname7": " ",
+ "Eskillname8": " ",
+ "Eskillname9": " ",
+ "EskillnameExp1": " ",
+ "EskillsExpd1": " ",
+ "Eskillsd0": " ",
+ "Eskillsd10": " ",
+ "Eskillsd11": " ",
+ "Eskillsd12": " ",
+ "Eskillsd13": " ",
+ "Eskillsd14": " ",
+ "Eskillsd15": " ",
+ "Eskillsd16": " ",
+ "Eskillsd17": " ",
+ "Eskillsd18": " ",
+ "Eskillsd19": " ",
+ "Eskillsd2": " ",
+ "Eskillsd20": " ",
+ "Eskillsd21": " ",
+ "Eskillsd22": " ",
+ "Eskillsd23": " ",
+ "Eskillsd24": " ",
+ "Eskillsd25": " ",
+ "Eskillsd26": " ",
+ "Eskillsd27": " ",
+ "Eskillsd28": " ",
+ "Eskillsd29": " ",
+ "Eskillsd3": " ",
+ "Eskillsd30": " ",
+ "Eskillsd31": " ",
+ "Eskillsd32": " ",
+ "Eskillsd33": " ",
+ "Eskillsd34": " ",
+ "Eskillsd35": " ",
+ "Eskillsd36": " ",
+ "Eskillsd37": " ",
+ "Eskillsd38": " ",
+ "Eskillsd39": " ",
+ "Eskillsd4": " ",
+ "Eskillsd40": " ",
+ "Eskillsd41": " ",
+ "Eskillsd42": " ",
+ "Eskillsd43": " ",
+ "Eskillsd44": " ",
+ "Eskillsd45": " ",
+ "Eskillsd46": " ",
+ "Eskillsd47": " ",
+ "Eskillsd48": " ",
+ "Eskillsd49": " ",
+ "Eskillsd5": " ",
+ "Eskillsd50": " ",
+ "Eskillsd51": " ",
+ "Eskillsd52": " ",
+ "Eskillsd53": " ",
+ "Eskillsd54": " ",
+ "Eskillsd55": " ",
+ "Eskillsd56": " ",
+ "Eskillsd57": " ",
+ "Eskillsd58": " ",
+ "Eskillsd59": " ",
+ "Eskillsd6": " ",
+ "Eskillsd7": " ",
+ "Eskillsd8": " ",
+ "Eskillsd9": " ",
+ "Ethereal Edge": "无形之刃",
+ "Evil hut": "邪恶魔屋",
+ "ExInsertSockets": "可镶嵌于带凹槽的物品中",
"ExInsertSocketsX": "可以放入凹槽物品",
+ "Executioner's Justice": "刽子手的裁决",
+ "Eyeback Unleashed": "狂暴者眼魔",
"Faithful": "忠誠信使",
"Fallen": "沈淪魔",
"FallenShaman": "沈淪巫師",
"FanaticMinion": " ",
"Fangskin": "牙皮",
+ "Far Oasis": "遙遠的綠洲",
"Fara": "法拉",
+ "Fathom": "死亡深度",
"Fechmars Axe": "費屈瑪之斧",
"Feeder": "餵食者",
"FeederNest": "貪食者之巢",
@@ -3590,17 +4868,41 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"FireBoar": " ",
"FireGolem": "火石魔",
"FireTower": "火焰塔",
+ "Firelizard's Talons": "火蜥蜴之爪",
"FlameSpider": "火焰蜘蛛",
+ "Flamebellow": "火焰号角",
"Flamespike the Crawler": "爬行者-火釘",
+ "Flawed Amethyst": "裂开的紫宝石",
+ "Flawed Diamond": "裂开的钻石",
+ "Flawed Emerald": "裂开的绿宝石",
+ "Flawed Ruby": "裂开的红宝石",
+ "Flawed Saphire": "裂开的蓝宝石",
+ "Flawed Skull": "裂开的骷髅",
+ "Flawed Topaz": "裂开的黄宝石",
+ "Flawless Amethyst": "无瑕疵的紫宝石",
+ "Flawless Diamond": "无瑕疵的钻石",
+ "Flawless Emerald": "无瑕疵的绿宝石",
+ "Flawless Ruby": "无瑕疵的红宝石",
+ "Flawless Skull": "无瑕疵的骷髅",
+ "Flawless Topaz": "无瑕疵的黄宝石",
"Flayer": "剝皮者",
+ "Flayer Dungeon Level 1": "剝皮地窖第一層",
+ "Flayer Dungeon Level 2": "剝皮地窖第二層",
+ "Flayer Dungeon Level 3": "剝皮地窖第三層",
+ "Flayer Jungle": "剝皮叢林",
"FlayerShaman": "剝皮巫師",
"FleshArcher": "血肉弓箭手",
"FleshBeast": "血肉野獸",
"FleshHunter": "血肉獵人",
"FleshLancer": "血肉長槍手",
"FleshSpawner": "血肉復甦者",
+ "Fleshrender": "撕肉者",
+ "Fleshripper": "裂肉者",
"Fletcher's": "製箭者之",
"FlyingScimitar": "飛空彎刀",
+ "Forgotten Reliquary": "遺忘的聖物",
+ "Forgotten Temple": "遺忘神殿",
+ "Forgotten Tower": "遺忘之塔",
"Forked": "分叉之",
"Fortified": "防禦強化之",
"Fortuitous": "偶然之",
@@ -3609,61 +4911,115 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"FrenziedHellSpawn": " ",
"FrenziedIceSpawn": " ",
"Frostburn": "霜燃",
+ "Frostwind": "冰霜之风",
+ "Frozen Horror1": "冰冻潜行者",
+ "Frozen Horror2": "冰冻恐惧",
+ "Frozen Horror3": "冰冻灾祸",
+ "Frozen Horror4": "冰冻魔怪",
+ "Frozen Horror5": "冰冻焦灼者",
+ "Frozenstein": "弗罗森斯坦",
"Gale": "狂風之",
"GargantuanBeast": "巨大野獸",
+ "Gargoyle's Bite": "石像鬼之噬",
"GargoyleTrap": "翼魔陷阱",
"Garnet": "深紅之",
"Geglash": "基格列斯",
"Geleb Flamefinger": "火焰之指-吉列布",
+ "GemXp1": "头盔:",
+ "GemXp2": "盾牌:",
+ "GemXp3": "武器:",
+ "GemXp4": "护甲:",
"Gemmed": "鑲上寶石的",
"GemmedNormalName": "%0 %1",
"Gheed": "基得",
+ "Gheed's Fortune": "基德的财运",
"Ghost": "鬼魂",
+ "Ghostflame": "鬼火焰",
"Ghoul": "食屍鬼",
"GhoulLord": "食屍鬼之王",
"GhoulRI": "食屍鬼之",
+ "Ghoulhide": "食尸鬼外皮",
"GiantLamprey": "巨大八目蟲",
"GiantLampreyEgg": "巨型八目蟲之蛋",
"GiantLampreyQueen": "巨型八目蟲皇后",
"GiantLampreyYoung": "巨型八目蟲幼生",
"GiantUrchin": "有刺巨獸",
+ "Giantmaimer": "巨人残害者",
+ "Giantskull": "巨骷髅",
+ "Gimmershred": "碎片贪婪者",
+ "Ginther's Rift": "金瑟的裂隙",
"Gleamscythe": "閃耀的鐮刀",
"Glimmering": "微光之",
"Gloam": "薄暮之魂",
"Gloombat": "幽暗蝙蝠",
+ "Gloomstrap": "阴暗陷阱",
"Glorious": "光榮之",
"Glowing": "發光之",
"Glyph": "繪畫文字之",
"Goblin Toe": "小妖精腳趾",
+ "Godstrike Arch": "金击圆弧",
"Gold": "金之",
"Goldskin": "黃金之皮",
"Goldwrap": "金色包裏",
"Gorbelly": "血腹獸",
+ "Gore Ripper": "血肉撕裂者",
"GoreBearer": " ",
"Gorefoot": "血腳",
+ "Gorerider": "蚀肉骑士",
"Goreshovel": "血塊之鏟",
"Grand Vizier of Chaos": "雄偉的混沌大臣",
"Gravenspine": "墓穴之脊",
+ "Gravepalm": "墓穴的抚弄",
+ "Great Marsh": "龐大濕地",
"GreaterHellSpawn": " ",
"GreaterIceSpawn": " ",
"Greiz": "格雷茲",
"Greyform": "薄暮外觀",
+ "Griffon's Eye": "格利风之眼",
"Grim": "冷酷之",
+ "Grim's Burning Dead": "格里姆的燃烧死者",
"Griswold": "格里斯瓦得",
+ "Griswold's Heart": "格瑞斯华尔德的之心",
+ "Griswold's Honor": "格瑞斯华尔德的荣耀",
+ "Griswold's Legacy": "格瑞斯华尔德的传奇",
+ "Griswold's Valor": "格瑞斯华尔德的勇气",
"Griswolds Edge": "格理斯瓦得的銳利",
+ "Griswolds's Redemption": "格瑞斯华尔德的救赎",
"Groper": "厄運施術者",
"Grotesque": "怪誕魔",
"GrotesqueWyrm": "怪異懷姆獸",
"Guard": "守護",
"Guardian": "守護者",
+ "Guardian Angel": "守护天使",
+ "Guardian Naga": "守护那迦",
+ "Guillaume's Face": "吉永之脸",
"Gull": "海鷗",
+ "Gutsiphon": "吸魂器",
+ "Haemosu's Adament": "解慕漱的坚决",
"Hailstone": "霰之",
+ "Halaberd's Reign": "哈拉伯德的统治",
+ "Halls of the Dead Level 1": "死亡之殿第一層",
+ "Halls of the Dead Level 2": "死亡之殿第二層",
+ "Halls of the Dead Level 3": "死亡之殿第三層",
+ "Hand of Blessed Light": "祝福之光之手",
+ "Harem Level 1": "女眷住處第一層",
+ "Harem Level 2": "女眷住處第二層",
+ "Harlequin Crest": "谐角之冠",
"Havoc": "浩劫之",
"Hawkmail": "鷹甲",
+ "Headhunter's Glory": "猎头人的荣耀",
+ "Headstriker": "击头者",
+ "Heart Carver": "刻心者",
+ "Heart of Wolverine": "狼獾之心",
+ "Heaven's Brethren": "天堂的信徒",
+ "Heaven's Light": "天堂之光",
+ "Heaven's Taebaek": "太白的荣耀",
"Heavenly Garb": "天堂裝束",
"Heirophant": "海爾奧芬特",
"Hell Bovine": "地獄之牛",
"Hell Forge Hammer": "地獄熔爐鐵鎚",
+ "Hell Temptress": "地狱女妖",
+ "Hell Witch": "地狱女巫",
"HellBuzzard": "地獄兀鷹",
"HellCat": "地獄貓",
"HellClan": "地獄一族",
@@ -3674,13 +5030,24 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Hellcast": "投擲地獄",
"Hellclap": "作響的地獄",
"Hellfire Torch": "地獄火炬",
+ "Hellmouth": "地狱之嘴",
"Hellplague": "地獄瘟疫",
+ "Hellrack": "地狱刑具",
+ "Hellslayer": "地狱阔斧",
+ "Herald of Zakarum": "撒卡兰姆使者",
+ "Hexfire": "妖火",
+ "Highlord's Wrath": "大君之怒",
"Hiquality": "超強的",
+ "Hole Level 1": "地洞第一層",
+ "Hole Level 2": "地洞第二層",
"HollowOne": "中空屍怪",
"Holocaust": "世界末日之",
"Holy": "神聖之",
+ "Homunculus": "侏儒",
+ "Hone Sundan": "宏·森丹",
"Horadric Staff": "赫拉迪克法杖",
"Horadrim Ancient": "古代赫拉迪姆",
+ "Horizon's Tornado": "地平线的龙卷风",
"Horror": "恐怖白骨",
"HorrorArcher": "恐怖弓箭手",
"HorrorMage": "恐怖法師",
@@ -3693,12 +5060,25 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Hsarus' Iron Stay": "哈斯拉柏的鐵胸衣",
"HungryDead": "飢餓死者",
"Huntress": "女獵人",
+ "Husoldal Evo": "胡索达·伊佛",
+ "Hwanin's Justice": "桓因的正义",
+ "Hwanin's Majesty": "桓因的威严",
+ "Hwanin's Refuge": "桓因的庇护",
+ "Hwanin's Seal": "桓因的祝福",
+ "Hwanin's Splendor": "桓因的光辉",
"Hydra": "九頭蛇",
"IceBoar": " ",
"IceSpawn": " ",
"Iceblink": "冰雪眨眼",
"Icehawk Riftwing": "裂縫之翼-冰鷹",
"Ichorsting": "靈液之刺",
+ "Immortal King": "不朽之王",
+ "Immortal King's Detail": "不朽之王的琐事",
+ "Immortal King's Forge": "不朽之王的熔炉",
+ "Immortal King's Pillar": "不朽之王的之柱",
+ "Immortal King's Soul Cage": "不朽之王的灵魂牢笼",
+ "Immortal King's Stone Crusher": "不朽之王的碎魂者",
+ "Immortal King's Will": "不朽之王的意志",
"Imp": "小惡魔之",
"Imp1": " ",
"Imp2": " ",
@@ -3710,10 +5090,15 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Infernal Sign": "地獄符號",
"Infernal Tools": "地獄的工具",
"Infernal Torch": "地獄火炬",
+ "Inferno Sentry": "地狱火卫哨",
+ "Infernostride": "地狱阔步",
"Infidel": "異教徒",
+ "Inner Cloister": "內側迴廊",
"InsaneHellSpawn": " ",
"InsaneIceSpawn": " ",
"Invader": "入侵者",
+ "Invis Pet": "仆从",
+ "Io Rune": "符文:破",
"Iratha's Coil": "依雷撒的線圈",
"Iratha's Collar": "依雷撒的項圈",
"Iratha's Cord": "依雷撒之繩",
@@ -3723,15 +5108,21 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Iron": "鐵之",
"Iron Wolf": "鐵狼",
"IronGolem": "鐵石魔",
+ "Ironpelt": "铁皮",
"Ironstone": "鐵石",
+ "Ironward": "阿斯特龙之铁的保护区",
"Iros Torch": "衣洛的火炬",
"Isenhart's Armory": "依森哈德的武器室",
"Isenhart's Case": "依森哈德的外殼",
"Isenhart's Horns": "依森哈德的號角",
"Isenhart's Lightbrand": "依森哈德的光之烙鐵",
"Isenhart's Parry": "依森哈德的閃避",
+ "Islestrike": "岛屿击",
"Ismail Vilehand": "邪惡之手-伊斯梅爾",
"Itchies": "渴望者",
+ "ItemExpansiveChanc1": "击中时有 %d%% 几率施展等级 %d %s",
+ "ItemExpansiveChanc2": "受击时有 %d%% 几率施展等级 %d %s",
+ "ItemExpansiveChancX": "攻击时有 %d%% 几率施展等级 %d %s",
"ItemStats1a": "不符合需求",
"ItemStats1b": "未辨視",
"ItemStats1c": "使用次數:",
@@ -3753,50 +5144,106 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"ItemStatsrejuv2": "回復100%的生命和法力",
"Izual": "衣卒爾",
"Jade": "翠綠之",
+ "Jadetalon": "碧玉爪",
"Jagged": "鋸齒之",
+ "Jah Rune": "符文:乔",
+ "Jail Level 1": "監牢第一層",
+ "Jail Level 2": "監牢第二層",
+ "Jail Level 3": "監牢第三層",
+ "Jalal's Mane": "加尔的长发",
"Jamella": "賈梅拉",
"Jerhyn": "杰海因",
"JungleHunter": "叢林獵人",
"JungleUrchin": "灌木倒刺怪",
"Kaelan": "凱倫",
"Kashya": "卡夏",
+ "Kelpie Snare": "水魔陷阱",
+ "Kerke's Sanctuary": "杰尔克的圣堂",
"KhalimFlail": "克林姆的連枷",
"Kinemils Awl": "金麥爾的錐子",
"King's": "國王之",
+ "Kira's Guardian": "奇拉的守护",
"Knell Striker": "敲擊喪鐘者",
"Knight's": "騎士之",
"Krintizs Skewer": "格林提斯的肉叉",
+ "Kuko Shakaku": "库寇沙卡库",
+ "Kurast Bazaar": "庫拉斯特商場",
+ "Kurast Causeway": "庫拉斯特堤道",
+ "Kurast Docktown": "庫拉斯特海港",
+ "Lacerator": "撕裂者",
+ "Lance Guard": "长枪守卫",
"Lance of Yaggai": "雅該長矛",
+ "Langer Briser": "兰格·布里瑟",
"Lapis": "天藍之",
+ "Larzuk": "拉苏克",
+ "Larzuk's Champion": "拉苏克的冠军",
"Lasher": " ",
+ "Lavagout": "熔岩羊角",
+ "Laying of Hands": "安置手礼",
"Lays Eggs": "四處下蛋",
"Lazarus Spire": "拉撒羅斯之螺旋",
"Leadcrow": "引導烏鴉",
"Leatherarm": "爬行的容貌",
"Lenyms Cord": "雷尼摩",
+ "Leviathan": "利维坦",
+ "Lidless Wall": "警戒之墙",
+ "Lightning Sentry": "闪电卫哨",
"LightningSpire": "閃電螺旋",
+ "Lightsabre": "光之军刀",
"Lilith": "莉莉絲",
"Lizard's": "蜥蜴之",
"Loath": "不願之",
"Lord De Seis": "西希之王",
"Lord's": "君王之",
+ "Lost City": "遺失的城市",
"Low Quality": "劣質的",
+ "Lower Kurast": "庫拉斯特下層",
+ "Lut Gholein": "魯.高因",
+ "Lycander's Aim": "雷山德的指标",
+ "Lycander's Flank": "雷山德的侧翼",
"Lysander": "雷山德",
+ "M'avina's Battle Hymn": "马维娜的战斗诗歌",
+ "M'avina's Caster": "马维娜的施展",
+ "M'avina's Embrace": "马维娜的拥抱",
+ "M'avina's Icy Clutch": "马维娜的冰握",
+ "M'avina's Tenet": "马维娜的教义",
+ "M'avina's True Sight": "马维娜的真实之眼",
"Maelstromwrath": "漩渦",
"Maffer Dragonhand": "龍手-馬弗",
"Mage": "法師",
"Magefist": "法師之拳",
+ "Magewrath": "法师之怒",
"Maggot": "蛆蟲",
+ "Maggot Lair Level 1": "蛆蟲巢穴第一層",
+ "Maggot Lair Level 2": "蛆蟲巢穴第二層",
+ "Maggot Lair Level 3": "蛆蟲巢穴第三層",
+ "Magma Torquer": "岩浆扭曲者",
+ "Magnus' Skin": "马格纳斯之皮",
"Maiden's": "處女之",
"Malachai": "哈德爾",
+ "Malah": "玛拉",
"Manald Heal": "瑪那得的治療",
+ "Mang Song's Lesson": "梅格之歌的教训",
+ "Mara's Kaleidoscope": "马拉的万花筒",
"Marauder": "掠奪者",
+ "Marrowwalk": "骨髓行走",
"Massive": "穩重之",
"Mauler": "巨槌",
+ "Mausoleum": "大陵寢",
"MawFiend": "深淵惡魔",
+ "McAuley's Folly": "山德的愚行",
+ "McAuley's Paragon": "山德的模范",
+ "McAuley's Riprap": "山德的碎石",
+ "McAuley's Superstition": "山德的迷信",
+ "McAuley's Taboo": "山德的禁忌",
+ "Medusa's Gaze": "美杜莎的凝视",
+ "Megaflow Rectifier": "矫正者怪异",
"Mephisto": "墨菲斯托",
"Merciless": "絕情之",
+ "Merman's Speed": "鱼人的链轮",
"Meshif": "馬席夫",
+ "Messerschmidt's Reaver": "梅西施密特的劫掠者",
+ "Metalgrid": "金属网格",
"Meteoric": "隕鐵之",
"Milabrega's Diadem": "米拉伯佳的王冠",
"Milabrega's Orb": "米拉伯佳之球",
@@ -3946,7 +5393,53 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"ModStr6g": "穿刺攻擊",
"ModStr6h": "射出魔法箭矢",
"ModStr6i": "箭矢或十字弓彈火焰爆炸",
+ "ModStre10L": "几率施展",
+ "ModStre10a": "聚气",
+ "ModStre10b": "等级",
+ "ModStre10c": "每级",
+ "ModStre10d": "(%d/%d 次聚气)",
+ "ModStre10e": "(",
+ "ModStre10f": ")",
+ "ModStre10g": "隐秘",
+ "ModStre10h": "毒素免疫",
+ "ModStre10i": "诅咒",
+ "ModStre10j": "每个队伍成员",
+ "ModStre10k": "踢击伤害:",
+ "ModStre8a": " 德鲁伊技能",
"ModStre8b": "刺客技能",
+ "ModStre8c": " 凹槽",
+ "ModStre8d": " 对恶魔的攻击准确率",
+ "ModStre8e": " 对不死生物的攻击准确率",
+ "ModStre8f": " 对恶魔的伤害",
+ "ModStre8g": "% 攻击准确率",
+ "ModStre8h": " 标枪与长矛技能",
+ "ModStre8i": " 被动与魔法技能",
+ "ModStre8j": " 弓与十字弓技能",
+ "ModStre8k": " 防御灵气技能",
+ "ModStre8l": " 攻击灵气技能",
+ "ModStre8m": " 战斗技能",
+ "ModStre8n": " 召唤技能",
+ "ModStre8o": " 毒素与白骨技能",
+ "ModStre8p": " 诅咒",
+ "ModStre8q": " 战嚎技能",
+ "ModStre8r": " 战斗技能",
+ "ModStre8s": " 战斗专家技能",
+ "ModStre8t": " 冰冷技能",
+ "ModStre8u": " 闪电技能",
+ "ModStre8v": " 火焰技能",
+ "ModStre8w": " 召唤技能",
+ "ModStre8x": " 变形技能",
+ "ModStre8y": " 元素技能",
+ "ModStre8z": " 陷阱技能",
+ "ModStre9a": " 影子训练技能",
+ "ModStre9b": " 武学艺术技能",
+ "ModStre9c": "(以角色等级决定)",
+ "ModStre9d": "(在夜间增加)",
+ "ModStre9e": "(在白天增加)",
+ "ModStre9f": "(在黎明附近增加)",
+ "ModStre9g": "(在黄昏附近增加)",
+ "ModStre9h": " 次聚气的",
+ "ModStre9i": "增加堆叠数量",
"ModStre9j": "火焰傷害降低",
"ModStre9k": "冰冷傷害降低",
"ModStre9l": "閃電傷害降低",
@@ -3956,6 +5449,14 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"ModStre9p": "吸收冰冷傷害",
"ModStre9q": "吸收閃電傷害",
"ModStre9r": "吸收毒素傷害",
+ "ModStre9s": "无法破坏",
+ "ModStre9t": "每秒修复 %d 点耐久度",
+ "ModStre9u": "每 %d 秒修复 1 点耐久度",
+ "ModStre9v": "自动恢复数量",
+ "ModStre9w": "施展等级 %d",
+ "ModStre9x": "当你挥击时",
+ "ModStre9y": "当你被击中时",
+ "ModStre9z": "当你击中敌人时",
"Moditem2ExpG": "轉為經驗值獲得",
"Moditem2allattrib": "轉為所有的屬性",
"ModitemAttratvsM": "轉為命中率相對",
@@ -3977,70 +5478,151 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"ModitemskonLevel": "當你升級 %d%% 機會施展等級 %d %s",
"Moditemskondeath": "當你死去 %d%% 機會施展等級 %d %s",
"Modstr2v": "防禦強化",
+ "Monastery Gate": "僧院大門",
"Monk's": "僧人之",
+ "Moo Moo Farm": "秘密母牛關卡",
"MoonClan": "月亮一族",
+ "Moonfall": "落月",
+ "Mosers Blessed Circle": "摩西尔的祝福之环",
"MummyGenerator": "木乃伊石棺",
+ "NPCIdentify1": "辨視物品",
+ "NPCMenuTrade": "交易",
+ "NPCMenuTradeRepair": "交易/修理",
+ "NPCPurchaseItems": "買",
+ "NPCRepairItems": "修復",
+ "NPCSellItems": "賣",
"Nagelring": "拿各的戒指",
+ "Naj's Ancient Set": "娜吉的远古遗迹",
+ "Naj's Circlet": "娜吉的小环冠",
+ "Naj's Light Plate": "娜吉的轻凯甲",
+ "Naj's Puzzler": "娜吉的解谜杖",
"Natalya": "娜塔亞",
+ "Natalya's Mark": "娜塔亚的记号",
+ "Natalya's Odium": "娜塔亚的非难",
+ "Natalya's Shadow": "娜塔亚的影子",
+ "Natalya's Soul": "娜塔亚的灵魂",
+ "Natalya's Totem": "娜塔亚的图腾",
+ "Nature's Peace": "大自然的和平",
"NecOnly": "(限死靈法師)",
"NecroMage": "死靈師",
"NecroSkeleton": "死靈骷髏",
"Necromancer": "死靈法師",
"Necromancer's": "死靈法師之",
+ "Nethercrow": "冥界渡鸦",
"NightClan": "黑夜一族",
"NightLord": "黑夜之王",
"NightMarauder": "夜間屠殺魔",
"NightSlinger": "黑夜投石怪",
"NightTiger": "夜行老虎",
"Nightsmoke": "夜煙",
+ "Nightwing's Veil": "夜翼面纱",
+ "Nihlathak": "尼拉塞克",
+ "Nihlathak Town": "尼拉塞克",
"Nokozan Relic": "諾科藍遺物",
+ "Nord's Tenderizer": "诺德的制肉嫩化器",
+ "Nosferatu's Coil": "诺斯费拉图的线圈",
+ "Oak Sage": "橡木智者",
"OblivionKnight": "遺忘騎士",
"Ocher": "赭黃之",
+ "Odium": "憎恨",
+ "Ondal's Almighty": "奥达尔的全能",
+ "Ondal's Wisdom": "奥达尔的智慧",
"Order": "秩序之",
+ "Ormus' Robes": "奥玛斯的长袍",
+ "Orphan's Call": "孤儿的呼唤",
+ "Outer Cloister": "外側迴廊",
+ "Outer Steppes": "郊外大草原",
"OverLord": " ",
"OverSeer": " ",
+ "POW": "被俘的野蛮人",
"Pain": "痛苦之",
+ "Pain Worm1": "痛苦之虫",
+ "Pain Worm2": "折磨之虫",
+ "Pain Worm3": "苦难之虫",
+ "Pain Worm4": "威胁之虫",
+ "Pain Worm5": "剧痛之虫",
"PalOnly": "(限聖騎士)",
+ "Palace Cellar Level 2": "皇宮監牢第二層",
+ "Palace Cellar Level 3": "皇宮監牢第三層",
"Paladin": "聖騎士",
+ "Pandemonium Run 1": "主母巢穴",
+ "Pandemonium Run 2": "遺忘沙漠",
+ "Pandemonium Run 3": "痛苦熔爐",
+ "Peasent Crown": "农夫王冠",
"Pelt": "投擲",
"Pelta Lunata": "新月小盾",
+ "Perfect Amethyst": "完美的紫宝石",
+ "Perfect Diamond": "完美的钻石",
+ "Perfect Emerald": "完美的绿宝石",
+ "Perfect Ruby": "完美的红宝石",
+ "Perfect Saphire": "完美的蓝宝石",
+ "Perfect Skull": "完美的骷髅",
+ "Perfect Topaz": "完美的黄宝石",
"Piercerib": "蘿格之弓",
+ "Pierre Tombale Couant": "皮尔·通巴尔·库앙",
+ "Pindleskin": "暴躁外皮",
+ "Pit Level 1": "地穴第一層",
+ "Pit Level 2": "地穴第二層",
"PitLord": "洞穴之王",
"PitViper": "洞窟腹蛇",
"Pitspawn Fouldog": "洞穴重生的邪惡之犬",
+ "Plague Bearer": "瘟疫使者",
"PlagueBearer": "瘟疫散佈者",
"PlagueBugs": "瘟疫之蟲",
"PlagueRI": "瘟疫之",
+ "Plains of Despair": "絕望平原",
"Platinum": "白金之",
"Pluckeye": "勇氣之眼",
"PoisonSpinner": "毒氣旋轉者",
+ "Pompe's Wrath": "庞贝之怒",
"PreservedDead": "不朽死屍怪",
"Priest's": "牧師之",
"Prismatic": "稜鏡之",
+ "Prison Door": "牢门",
"ProwlingDead": " ",
"Pullspite": "暴風之擊",
+ "Pus Spiter": "毒液喷射者",
+ "Putrid Defiler1": "腐败玷污者",
+ "Putrid Defiler2": "卑劣玷污者",
+ "Putrid Defiler3": "恶臭玷污者",
+ "Putrid Defiler4": "酸臭玷污者",
+ "Putrid Defiler5": "腥臭玷污者",
+ "Qual-Kehk": "夸尔凯克",
+ "Que-Hegan's Wisdon": "奎-黑刚的智慧",
"QuillBear": "羽熊怪",
"QuillRat": "硬毛老鼠",
"Radament": "羅達門特",
+ "Radimant's Sphere": "罗达门特之球体",
+ "Rainbow Facet": "彩虹刻面",
"Rakanishu": "拉卡尼休",
"Rakescar": "火鉤之傷",
"RatMan": "鼠人",
"RatManShaman": "鼠人巫師",
"Rattlecage": "作響之籠",
"Raven": "渡鳥之",
+ "Raven Frost": "乌鸦之霜",
+ "Ravenlore": "乌鸦之王",
"RazorBeast": "剃刀野獸",
"RazorPitDemon": "峭壁潛伏者",
"RazorSpine": "剃刀山脊怪",
+ "Razoredge": "剃刀之刃",
+ "Razorswitch": "剃刀之枝",
+ "Razortail": "穿刺腰带",
"Razortine": "剃刀之叉",
"ReanimatedHorde": " ",
"Red": "紅之",
"Returned": "復活的怪物",
"ReturnedArcher": "復生的弓箭手",
"ReturnedMage": "復生的法師",
+ "Ribcracker": "肋骨粉碎者",
"Rift": "破開之",
"Riftwraith the Cannibal": "食人的裂口之怒",
"Rimeraven": "渡鳥之爪",
+ "Rings": "戒指",
+ "Riphook": "裂钩",
"Ripsaw": "粗齒大鋸",
+ "Rite of Passage": "脚踝之礼",
+ "River of Flame": "火焰之河",
"RiverStalkerHead": "河流潛行水怪",
"RiverStalkerLimb": "河流潛行觸角",
"Rixots Keen": "瑞克撒特的輓歌",
@@ -4050,13 +5632,31 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"RockWormQueen": "石蟲皇后",
"RockWormYoung": "石蟲幼生",
"Rockfleece": "石之毛",
+ "Rockstopper": "岩石制动器",
+ "Rocky Waste": "碎石荒地",
+ "Rogue Encampment": "蘿格營地",
"RogueScout": "蘿格斥候",
"RotWalker": " ",
"Ruby": "紅寶石之",
"Rugged": "凹凸之",
+ "Ruined Fane": "殘破的寺院",
+ "Ruined Temple": "殘破神殿",
"Rule": "法規",
+ "RunOff": "行走",
+ "RunOn": "跑步",
"Rune": "符號之",
+ "Runemaster": "符文大师",
+ "Runeword108": "祈祷",
+ "Runeword122": "庇护所",
+ "Runeword142": "恐惧",
+ "Runeword153": "复仇",
+ "Runeword154": "毒液的",
+ "Runeword17": "新月",
+ "Runeword2": "世界末日之",
"Runeword22": "迪勒瑞姆",
+ "Runeword44": "狂怒",
+ "Runeword58": "饥饿",
+ "Runeword73": "闪电",
"Russet": "赤楬之",
"Rusthandle": "生銹的把手",
"SaberCat": "劍齒貓",
@@ -4070,17 +5670,38 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"SandMaggotYoung": "沙蟲幼生",
"SandRaider": "沙地騎士",
"SandWarrior": "死亡甲蟲",
+ "Sandstorm Trek": "沙暴之旅",
+ "Saphire": "蓝宝石",
"Sapphire": "蔚藍之",
+ "Saracen's Chance": "萨拉森的机会",
"Sarina the Battlemaid": "戰場處子-沙利娜",
"Savage": "野蠻之",
+ "Sazabi's Cobalt Redeemer": "沙撒璧的钴蓝救世主",
+ "Sazabi's Ghost Liberator": "沙撒璧的鬼魂释放者",
+ "Sazabi's Grand Tribute": "沙撒璧的宏伟贡品",
+ "Sazabi's Mental Sheath": "沙撒璧的精神之鞘",
"Scarab": "聖甲蟲",
+ "Schaefer's Hammer": "史恰佛之锤",
+ "Seraph's Hymn": "炽天使之韵",
"Serpent Lord": "海蛇之王",
"Serpent's": "海蛇之",
"SerpentMagus": "法蛇法師",
+ "Sewers Level 1": "下水道第一層",
+ "Sewers Level 2": "下水道第二層",
+ "Sewers Level 3": "下水道第三層",
"Sexton": "教堂司事",
"Shadow": "陰影之",
+ "Shadow Master": "影子大师",
+ "Shadow Warrior": "影子战士",
+ "Shadowdancer": "影舞者",
"Shadowfang": "影之牙",
+ "Shadowkiller": "影杀者",
+ "Shael Rune": "符文:夏",
+ "Shaftstop": "谢夫特斯坦布 (物免甲)",
"Sharp": "尖銳之",
+ "Sharp Tooth Sayer": "利齿杀手",
+ "Siege Beast": "攻城兽",
+ "Siege Boss": "督军山克",
"Sigon's Complete Steel": "西剛的全套刀劍",
"Sigon's Gage": "西剛的挑戰",
"Sigon's Guard": "西剛的守護",
@@ -4088,17 +5709,89 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Sigon's Shelter": "西剛的遮蔽",
"Sigon's Visor": "西剛之面甲",
"Sigon's Wrap": "西剛的披肩",
+ "Sigurd's Staunch": "西格德的潜行",
+ "Silkweave": "丝绸之织",
"Silver": "銀之",
"Skeleton": "骷髏",
"SkeletonArcher": "骷髏弓箭手",
+ "Skillname222": "渡鸟之",
+ "Skillname223": "剧毒蔓藤",
"Skillname224": "狼人變化",
+ "Skillname225": "狼人变化",
+ "Skillname226": "火风暴",
+ "Skillname227": "橡木智者",
+ "Skillname228": "召唤鬼狼",
+ "Skillname229": "熊人变化",
+ "Skillname230": "熔岩巨石",
+ "Skillname231": "极地风暴",
+ "Skillname232": "食尸蔓藤",
+ "Skillname233": "野性狂暴",
+ "Skillname234": "大木棍",
+ "Skillname235": "裂缝",
+ "Skillname236": "飓风装甲",
+ "Skillname237": "狼獾之心",
+ "Skillname238": "召唤狂狼",
+ "Skillname239": "狂犬病",
+ "Skillname240": "焰爪",
+ "Skillname241": "龙卷风",
+ "Skillname242": "太阳蔓藤",
+ "Skillname243": "饥饿",
+ "Skillname244": "震波",
+ "Skillname245": "火山",
+ "Skillname246": "暴风",
+ "Skillname247": "荆棘之灵",
+ "Skillname248": "召唤灰熊",
+ "Skillname249": "狂怒",
+ "Skillname250": "世界末日之",
+ "Skillname251": "飓风",
+ "Skillname252": "火焰爆震",
+ "Skillname253": "支配利爪",
+ "Skillname254": "心灵战锤",
+ "Skillname255": "虎击",
+ "Skillname256": "龙爪",
+ "Skillname257": "电能网",
+ "Skillname258": "刃之守卫",
+ "Skillname259": "加速",
+ "Skillname260": "焰拳",
+ "Skillname261": "双龙爪",
+ "Skillname262": "电能守卫",
+ "Skillname263": "复仇之火",
+ "Skillname264": "武器格挡",
+ "Skillname265": "魔影密室",
+ "Skillname266": "眼镜蛇击",
+ "Skillname267": "刃之怒",
+ "Skillname268": "能量消解",
+ "Skillname269": "影子战士",
+ "Skillname270": "雷电爪",
+ "Skillname271": "神龙摆尾",
+ "Skillname272": "雷电守卫",
+ "Skillname273": "复仇狱火",
+ "Skillname274": "心灵震爆",
+ "Skillname275": "寒冰刃",
+ "Skillname276": "飞龙在天",
+ "Skillname277": "亡者守卫",
+ "Skillname278": "刃之盾",
+ "Skillname279": "毒液的",
+ "Skillname280": "暗影宗师",
+ "Skillname281": "凤凰击",
+ "Skin of the Flayerd One": "剥皮者之皮",
+ "Skin of the Vipermagi": "海蛇皮甲",
"Skull": "骷髏之",
+ "Skullcollector": "骷髅收集者",
+ "Skullder's Ire": "斯库尔德的愤怒",
+ "Skystrike": "天击",
"Slayer's": "屠殺者之",
"Slayerexp": " ",
"Slime Prince": "黏蟲王子",
"Slinger": "投石怪",
"Snake's": "蛇之",
"Snakecord": "蛇索",
+ "Snapchip Shatter": "碎冰冲击者",
+ "SnowYeti1": "雪地流浪者",
+ "SnowYeti2": "雪怪",
+ "SnowYeti3": "寒冰怪",
+ "SnowYeti4": "冰封深渊怪",
+ "Snowclash": "雪之冲突",
"Socketable": "有凹槽",
"Soldier's": "士兵之",
"SorOnly": "(限法師)",
@@ -4107,28 +5800,184 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Soul Harvest": "靈魂採集者",
"SoulKiller": "靈魂殺手",
"SoulKillerShaman": "靈魂殺手巫師",
+ "Souldrain": "吸魂者",
+ "Soulfeast Tine": "噬魂叉",
"Soulflay": "剝皮靈魂",
"Sparking Mail": "火花之甲",
"SpearCat": "矛之貓",
"Specter": "妖魂",
+ "Spellsteel": "钢铁魔咒",
+ "Spider Cave": "蜘蛛巢穴",
+ "Spider Cavern": "蜘蛛洞窟",
+ "Spider Forest": "蜘蛛森林",
"SpiderMagus": "蜘蛛魔法師",
+ "Spike Thorn": "尖刺根源",
"SpikeFiend": "長釘惡魔",
"SpikeGiant": "尖釘巨魔",
"Spikefist": "釘拳魔",
+ "Spineripper": "裂脊者",
+ "Spire of Honor": "荣耀尖塔",
"Spirit": "精神之",
+ "Spirit Ward": "灵魂庇护",
+ "Spirit of Barbs": "荆棘之灵",
+ "Spiritforge": "灵魂熔炉",
+ "Spiritkeeper": "灵魂看守者",
+ "Spiritual Custodian": "幽暗信徒",
"Staff of Kings": "國王之杖",
+ "Stealskull": "偷取骷髅",
"Steel": "鋼之",
+ "Steel Carapice": "钢铁甲壳",
"SteelWeevil": "鋼鐵聖甲蟲",
"Steelclash": "作響的金屬",
"Steeldriver": "鐵製大鎚",
"Steelgoad": "鐵刺棒",
+ "Steelpillar": "钢柱",
+ "Steelrend": "碎钢",
+ "Steelshade": "钢铁鬼影",
"Stone": "石塊之",
+ "Stone Crusher": "碎石者",
+ "Stoneraven": "石乌鸦",
+ "Stony Field": "石塊曠野",
+ "Stony Tomb Level 1": "石製古墓第一層",
+ "Stony Tomb Level 2": "石製古墓第二層",
"Storm": "暴風之",
"StormCaster": "暴風施術者",
+ "Stormchaser": "风暴追逐者",
"Stormeye": "暴風眼",
"Stormguild": "暴風公會",
+ "Stormlash": "暴风之结",
+ "Stormrider": "暴风骑士",
+ "Stormshield": "暴风之盾",
+ "Stormspike": "暴风尖刺",
+ "Stormspire": "暴风尖塔",
"Stormtree": "暴風之樹",
"Stoutnail": "堅硬的指甲",
+ "StrGemX1": "头盔:",
+ "StrGemX2": "盾牌:",
+ "StrGemX3": "武器:",
+ "StrGemX4": "护甲:",
+ "StrSkill0": "",
+ "StrSkill1": "下一等級",
+ "StrSkill10": "變動準確率:",
+ "StrSkill100": "生命最大值:",
+ "StrSkill101": "法力最大值: ",
+ "StrSkill102": "耐力最大值: ",
+ "StrSkill103": "%的火焰傷害",
+ "StrSkill104": "怪物",
+ "StrSkill105": "怪物群",
+ "StrSkill106": "準確率: ",
+ "StrSkill107": "生命/法力回復: ",
+ "StrSkill108": "點",
+ "StrSkill109": "救贖靈魂的機率:",
+ "StrSkill11": "擊中",
+ "StrSkill110": "成功格擋: ",
+ "StrSkill111": "每碼",
+ "StrSkill112": "刺針傷害",
+ "StrSkill113": "九頭蛇火焰傷害: ",
+ "StrSkill114": "反抗元素傷害:",
+ "StrSkill115": "施展法術至少須要法力點數為: ",
+ "StrSkill116": "到達最高等級",
+ "StrSkill117": "(物品)",
+ "StrSkill118": "你已經從一個物品中取得這項技能",
+ "StrSkill119": "你還沒有學到這項技能",
+ "StrSkill12": "-",
+ "StrSkill13": "冰凍時間:",
+ "StrSkill14": "中毒時間: ",
+ "StrSkill15": "秒",
+ "StrSkill16": "秒",
+ "StrSkill17": "第一級",
+ "StrSkill18": "半徑:",
+ "StrSkill19": "方型範圍",
+ "StrSkill2": "目前技能等級:",
+ "StrSkill20": "持續:",
+ "StrSkill21": "防禦:",
+ "StrSkill22": "攻擊:",
+ "StrSkill23": "百分比",
+ "StrSkill24": "百分機率",
+ "StrSkill25": "敵人防禦:",
+ "StrSkill26": "碼",
+ "StrSkill27": "彈",
+ "StrSkill28": "牙",
+ "StrSkill29": "冰凍時間:",
+ "StrSkill3": "法力耗費:",
+ "StrSkill30": "攻擊加成:",
+ "StrSkill31": "防禦加成:",
+ "StrSkill32": "火焰傷害",
+ "StrSkill33": "可以讓敵人變弱",
+ "StrSkill34": "每秒",
+ "StrSkill35": "持續性火焰傷害: ",
+ "StrSkill36": "碼",
+ "StrSkill37": "受到傷害: ",
+ "StrSkill38": "目標的傷害: ",
+ "StrSkill39": "魔法傷害:",
+ "StrSkill4": "傷害:",
+ "StrSkill40": "吸收",
+ "StrSkill41": "傷害",
+ "StrSkill42": "生命:",
+ "StrSkill43": "加強投射傷害",
+ "StrSkill43a": "骷髏法師",
+ "StrSkill44": "骷髏總數",
+ "StrSkill44a": "骷髏法師",
+ "StrSkill45": "石魔等級:",
+ "StrSkill46": "%的屍體生命",
+ "StrSkill47": "本身受損",
+ "StrSkill48": "昏迷時間:",
+ "StrSkill49": "%的盾牌強度",
+ "StrSkill5": "火焰傷害:",
+ "StrSkill50": "治療:",
+ "StrSkill51": "抵抗火焰:",
+ "StrSkill52": "抵抗冰凍: ",
+ "StrSkill53": "抵抗閃電:",
+ "StrSkill54": "全面抵抗:",
+ "StrSkill55": "%的傷害會回彈",
+ "StrSkill56": "射程",
+ "StrSkill57": "目標的攻擊: ",
+ "StrSkill58": "敵人跑上來要",
+ "StrSkill59": "敵人逃竄是因為",
+ "StrSkill6": "冰凍傷害:",
+ "StrSkill60": "每個目標",
+ "StrSkill61": "多重攻擊",
+ "StrSkill62": "冰凍是因為",
+ "StrSkill63": "結束 ",
+ "StrSkill63Patch": "結束",
+ "StrSkill64": "中毒傷害",
+ "StrSkill65": "損失耐久度機率:",
+ "StrSkill66": "目標受到",
+ "StrSkill67": "長距離攻擊減慢到",
+ "StrSkill68": "平均 ",
+ "StrSkill69": "耐力回復率:",
+ "StrSkill7": "閃電傷害:",
+ "StrSkill70": "加速度: ",
+ "StrSkill71": "耐力加成:",
+ "StrSkill72": "放出 ",
+ "StrSkill73": "充能的光彈",
+ "StrSkill74": "持續性減低為",
+ "StrSkill75": "元素傷害: ",
+ "StrSkill76": "破除中斷的機率",
+ "StrSkill77": "敵人減慢",
+ "StrSkill78": "剩餘的目標受到",
+ "StrSkill79": "%的攻擊傷害",
+ "StrSkill8": "毒素傷害:",
+ "StrSkill80": "轉換",
+ "StrSkill81": "%的傷害到生命",
+ "StrSkill82": "火焰持續: ",
+ "StrSkill83": "火焰爆炸傷害: ",
+ "StrSkill84": "閃電球傷害: ",
+ "StrSkill85": "詛咒持續時間降低",
+ "StrSkill86": "攻擊上昇到",
+ "StrSkill87": "目標",
+ "StrSkill88": "法力回復比率: ",
+ "StrSkill89": "行走/跑步速度: ",
+ "StrSkill9": "箭矢",
+ "StrSkill90": "抵抗:",
+ "StrSkill91": "聖光彈的傷害: ",
+ "StrSkill92": "法力用於每次還擊",
+ "StrSkill94": "骷髏總數",
+ "StrSkill95": "骷髏:",
+ "StrSkill96": "法師:",
+ "StrSkill97": "怪物:",
+ "StrSkill98": "降低最大的抵抗",
+ "StrSkill99": "轉換的機會:",
"StrSklTabItem1": "+%d 鏢槍和長矛技能",
"StrSklTabItem10": "+%d 戰嚎",
"StrSklTabItem11": "+%d 戰鬥技能",
@@ -4150,7 +5999,36 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"StrSklTabItem7": "+%d 毒素和白骨技能",
"StrSklTabItem8": "+%d 詛咒",
"StrSklTabItem9": "+%d召喚技能",
+ "StrSklTree1": "技能",
+ "StrSklTree10": "弓和",
+ "StrSklTree11": "十字弓",
+ "StrSklTree12": "防禦性",
+ "StrSklTree13": "靈氣",
+ "StrSklTree14": "攻擊",
+ "StrSklTree15": "作戰",
+ "StrSklTree16": "召喚",
+ "StrSklTree17": "毒素",
+ "StrSklTree18": "和白骨",
+ "StrSklTree19": "詛咒",
+ "StrSklTree2": "選擇",
+ "StrSklTree20": "吶喊",
+ "StrSklTree21": "戰鬥",
+ "StrSklTree22": "專家",
+ "StrSklTree23": "冰凍",
+ "StrSklTree24": "閃電",
+ "StrSklTree25": "火焰",
+ "StrSklTree3": "剩餘",
+ "StrSklTree4": "技能",
+ "StrSklTree5": "法術",
+ "StrSklTree6": "標槍",
+ "StrSklTree7": "和長矛",
+ "StrSklTree8": "被動和",
+ "StrSklTree9": "魔法",
+ "StrSklTreea": "技能標籤1",
+ "StrSklTreeb": "技能標籤2",
+ "StrSklTreec": "技能標籤3",
"Strangler": "扼殺者",
+ "String of Ears": "长串之耳",
"Strong": "強壯之",
"Sturdy": "結實之",
"StygianDog": "冥河之犬",
@@ -4164,14 +6042,29 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Succubusexp": " ",
"Sucker": "吸食者",
"SuckerNest": "吸食者之巢",
+ "Suicide Branch": "自杀支系",
"Summoner": "召喚者",
"Summoner's": "召喚者之",
"SuperKhalimFlail": "克林姆的意志",
+ "Sureshrill Frost": "尖啸冰霜",
"Swamp Dweller": "沼澤住民",
"SwampGhost": "沼澤的鬼魂",
+ "Swampy Pit Level 1": "沼澤地洞第一層",
+ "Swampy Pit Level 2": "沼澤地洞第二層",
+ "Swampy Pit Level 3": "沼澤地洞第三層",
"Swordback Hold": "劍背之架",
+ "Swordguard": "剑卫",
"Taintbreeder": "污染的飼育者",
"Tainted": "污染怪",
+ "Tal Rasha's Adjudication": "塔·拉夏的判决",
+ "Tal Rasha's Fire-Spun Cloth": "塔·拉夏的织细衣服",
+ "Tal Rasha's Horadric Crest": "塔·拉夏的赫拉迪克纹章",
+ "Tal Rasha's Howling Wind": "塔·拉夏的守护",
+ "Tal Rasha's Lidless Eye": "塔·拉夏的无睑之眼",
+ "Tal Rasha's Tomb": "塔.拉夏的古墓",
+ "Tal Rasha's Wrappings": "塔·拉夏的外袍",
+ "TalkMenu": "交談",
+ "Tamoe Highland": "泰摩高地",
"Tancred's Battlegear": "坦克雷的戰鬥工具",
"Tancred's Crowbill": "坦克雷的鴉嘴鉗",
"Tancred's Hobnails": "坦克雷的平頭釘",
@@ -4181,48 +6074,191 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Tangerine": "紅柑之",
"Tarnhelm": "塔因頭盔",
"Tearhaunch": "淚之臀",
+ "Telling of Beads": "珠串之语",
+ "Templar's Might": "圣堂武士的力量",
"TempleGuard": "神殿守衛",
+ "The Atlantian": "亚特兰提斯",
"The Battlebranch": "戰鬥支系",
+ "The Cat's Eye": "猫眼",
"The Centurion": "百夫長",
"The Chieftan": "族長",
"The Countess": "女伯爵",
"The Cow King": "母牛之王",
+ "The Cranium Basher": "碎脑锤",
"The Diggler": "迪格勒",
+ "The Disciple": "门徒",
"The Dragon Chang": "張龍",
"The Eye of Etlich": "艾利屈之眼",
"The Face of Horror": "恐懼之臉",
"The Feature Creep": "海法斯特盔甲製作者",
+ "The Fetid Sprinkler": "恶臭洒水器",
+ "The Gavel of Pain": "痛苦木槌",
"The Generals Tan Do Li Ga": "坦-杜-裡-嘎將軍",
+ "The Gladiator's Bane": "角斗士的祸根",
"The Gnasher": "牙齒",
+ "The Grandfather": "祖父",
"The Grim Reaper": "冷酷開膛手",
"The Hand of Broc": "柏克之手",
"The Humongous": "巨大無比",
+ "The Impaler": "穿刺者",
"The Iron Jang Bong": "鐵檢棒",
"The Jade Tan Do": "坦杜之玉",
"The Mahim-Oak Curio": "瑪哈姆橡木古董",
+ "The Meat Scraper": "刮肉者",
+ "The Minataur": "米诺陶斯",
+ "The Oculus": "眼球",
+ "The Pandemonium Fortress": "群魔堡壘",
"The Patriarch": "族長",
+ "The Reaper's Toll": "死神的丧钟",
+ "The Reedeemer": "救世主",
+ "The Rising Sun": "旭日东升",
"The Salamander": "火精靈",
+ "The Scalper": "剥皮者",
"The Smith": "鐵匠",
+ "The Spirit Shroud": "灵魂帷幕",
"The Stone of Jordan": "喬丹之石",
"The Summoner": "召喚者",
"The Tannr Gorerod": "坦之血杖",
"The Tormentor": "痛苦之人",
+ "The Vile Husk": "卑劣躯壳",
"The Ward": "囚房",
"ThornBeast": "有刺野獸",
"ThornBrute": "有刺怪獸",
"ThornedHulk": "刺木魔",
"Thrasher": "打穀魔",
+ "Threash Socket": "剥壳凹槽",
+ "Thudergod's Vigor": "雷神之力",
+ "Thunderstroke": "雷击",
+ "Tiamat's Rebuke": "提亚玛特的训诫",
"Tireless": "不倦之",
+ "Titan's Revenge": "泰坦的复仇",
+ "To Chaos Sanctum": "前往混沌庇難所",
+ "To Duriel's Lair": "前往都瑞爾的房間",
+ "To Lower Kurast": "前往庫拉斯特下層",
+ "To Lut Gholein": "前往魯.高因",
+ "To Tal Rasha's Tomb": "前往塔.拉夏的古墓",
+ "To The Ancient Tunnels": "前往古代通道",
+ "To The Arcane Sanctuary": "前往神秘庇難所",
+ "To The Barracks": "前往軍營",
+ "To The Black Marsh": "前往黑色荒地",
+ "To The Blood Moor": "前往鮮血荒地",
+ "To The Burial Grounds": "前往埋骨之地",
+ "To The Canyon of the Magi": "前往術士的峽谷",
+ "To The Catacombs Level 1": "前往地下墓穴第一層",
+ "To The Catacombs Level 2": "前往地下墓穴第二層",
+ "To The Catacombs Level 3": "前往地下墓穴第三層",
+ "To The Catacombs Level 4": "前往地下墓穴第四層",
+ "To The Cathedral": "前往大教堂",
+ "To The Cave Level 1": "前往洞窟第一層",
+ "To The Cave Level 2": "前往洞窟第二層",
+ "To The City of the Damned": "前往神罰之城",
+ "To The Claw Viper Temple Level 1": "前往利爪腹蛇神殿第一層",
+ "To The Claw Viper Temple Level 2": "前往利爪腹蛇神殿第二層",
+ "To The Cold Plains": "前往冰冷之原",
+ "To The Crypt": "前往墓地",
+ "To The Dark Wood": "前往黑暗森林",
+ "To The Den of Evil": "前往邪惡洞窟",
+ "To The Disused Fane": "前往廢棄的寺院",
+ "To The Disused Reliquary": "前往廢棄的聖物",
+ "To The Dry Hills": "前往乾燥的高地",
+ "To The Durance of Hate Level 1": "前往憎恨的囚牢第一層",
+ "To The Durance of Hate Level 2": "前往憎恨的囚牢第二層",
+ "To The Durance of Hate Level 3": "前往憎恨的囚牢第三層",
+ "To The Far Oasis": "前往遙遠的綠洲",
+ "To The Flayer Dungeon Level 1": "前往剝皮地窖第一層",
+ "To The Flayer Dungeon Level 2": "前往剝皮地窖第二層",
+ "To The Flayer Dungeon Level 3": "前往剝皮地窖第三層",
+ "To The Flayer Jungle": "前往剝皮叢林",
+ "To The Forgotten Reliquary": "前往遺忘的聖物",
+ "To The Forgotten Temple": "前往遺忘神殿",
+ "To The Forgotten Tower": "前往遺忘之塔",
+ "To The Great Marsh": "前往龐大濕地",
+ "To The Halls of the Dead Level 1": "前往死亡之殿第一層",
+ "To The Halls of the Dead Level 2": "前往死亡之殿第二層",
+ "To The Halls of the Dead Level 3": "前往死亡之殿第三層",
+ "To The Harem Level 1": "前往女眷住處第一層",
+ "To The Harem Level 2": "前往女眷住處第二層",
+ "To The Hole Level 1": "前往洞窟第一層",
+ "To The Hole Level 2": "前往洞穴第二層",
+ "To The Inner Cloister": "前往內側迴廊",
+ "To The Jail Level 1": "前往監牢第一層",
+ "To The Jail Level 2": "前往監牢第二層",
+ "To The Jail Level 3": "前往監牢第三層",
+ "To The Kurast Bazaar": "前往庫拉斯特商場",
+ "To The Kurast Causeway": "前往庫拉斯特堤道",
+ "To The Kurast Docktown": "前往庫拉斯特海港",
+ "To The Lost City": "前往遺失的城市",
+ "To The Maggot Lair Level 1": "前往蛆蟲巢穴第一層",
+ "To The Maggot Lair Level 2": "前往蛆蟲巢穴第二層",
+ "To The Maggot Lair Level 3": "前往蛆蟲巢穴第三層",
+ "To The Mausoleum": "前往大陵寢",
+ "To The Monastery Gate": "前往僧院大門",
+ "To The Moo Moo Farm": "前往秘密母牛關卡",
+ "To The Outer Cloister": "前往外側迴廊",
+ "To The Outer Steppes": "前往郊外大草原",
+ "To The Palace Cellar Level 2": "前往皇宮監牢第二層",
+ "To The Palace Cellar Level 3": "前往皇宮監牢第三層",
+ "To The Pandemonium Fortress": "前往群魔堡壘",
+ "To The Pit Level 1": "前往地洞第一層",
+ "To The Pit Level 2": "前往地洞第二層",
+ "To The Plains of Despair": "前往絕望平原",
+ "To The River of Flame": "前往火焰之河",
+ "To The Rocky Waste": "前往碎石荒地",
+ "To The Rogue Encampment": "前往蘿格營地",
+ "To The Ruined Fane": "前往殘破的寺院",
+ "To The Ruined Temple": "前往殘破神殿",
+ "To The Sewers Level 1": "前往下水道第一層",
+ "To The Sewers Level 2": "前往下水道第二層",
+ "To The Sewers Level 3": "前往下水道第三層",
+ "To The Spider Cave": "前往蜘蛛巢穴",
+ "To The Spider Cavern": "前往蜘蛛洞窟",
+ "To The Spider Forest": "前往蜘蛛森林",
+ "To The Stony Field": "前往石塊曠野",
+ "To The Stony Tomb Level 1": "前往石製古墓第一層",
+ "To The Stony Tomb Level 2": "前往石製古墓第二層",
+ "To The Swampy Pit Level 1": "前往沼澤地洞第一層",
+ "To The Swampy Pit Level 2": "前往沼澤地洞第二層",
+ "To The Swampy Pit Level 3": "前往沼澤地洞第三層",
+ "To The Tamoe Highland": "前往泰摩高地",
+ "To The Tower Cellar Level 1": "前往高塔地牢第一層",
+ "To The Tower Cellar Level 2": "前往高塔地牢第二層",
+ "To The Tower Cellar Level 3": "前往高塔地牢第三層",
+ "To The Tower Cellar Level 4": "前往高塔地牢第四層",
+ "To The Tower Cellar Level 5": "前往高塔地牢第五層",
+ "To The Valley of Snakes": "前往群蛇峽谷",
+ "To Travincal": "前往崔凡克",
+ "To Tristram": "前往崔斯特瑞姆",
+ "To Underground Passage Level 1": "前往地底通道第一層",
+ "To Underground Passage Level 2": "前往地底通道第二層",
+ "To Upper Kurast": "前往庫拉斯特上層",
+ "Todesfaelle Flamme": "死亡之火",
+ "Tomb Reaver": "盗墓者",
"TombCreeper": "古墓爬蟲",
"TombViper": "古墓腹蛇",
"Toorc Icefist": "冰拳-托克",
+ "Toothrow": "排齿",
+ "Topaz": "黄宝石",
+ "Tower Cellar Level 1": "高塔地牢第一層",
+ "Tower Cellar Level 2": "高塔地牢第二層",
+ "Tower Cellar Level 3": "高塔地牢第三層",
+ "Tower Cellar Level 4": "高塔地牢第四層",
+ "Tower Cellar Level 5": "高塔地牢第五層",
+ "Trang-Oul's Avatar": "塔格奥的化身",
+ "Trang-Oul's Claws": "塔格奥之爪",
+ "Trang-Oul's Girth": "塔格奥之束带",
+ "Trang-Oul's Guise": "塔格奥之外装",
+ "Trang-Oul's Scales": "塔格奥之鳞",
+ "Trang-Oul's Wing": "塔格奥之翼",
"TrappedSoul": "被困的靈魂",
+ "Travincal": "崔凡克",
"Treads of Cthon": "凱松的征服",
"TreeLurker": "樹木潛伏者",
"Treehead WoodFist": "樹頭木拳",
+ "Tristram": "崔斯特瑞姆",
"Triumphant": "成功之",
"Turret": "砲塔",
"Twitchthroe": "抽動的掙扎",
+ "Tyrael's Might": "泰瑞尔的力量",
"Umbral Disk": "陰影圓盤",
"Umes Lament": "烏米的慟哭",
"Undead Crown": "不死皇冠",
@@ -4233,13 +6269,22 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Undead StygianDoll": "不死冥河娃娃",
"UndeadDescriptX": "不死的",
"UndeadScavenger": "不死腐食者",
+ "Underground Passage Level 1": "地底通道第一層",
+ "Underground Passage Level 2": "地底通道第二層",
"UnholyCorpse": " ",
"Unraveler": "解答者",
+ "Upper Kurast": "庫拉斯特上層",
"Urdar": "烏達爾獸",
+ "Valkiry Wing": "女武神之翼",
"Valkyrie": "女武神",
"Valkyrie's": "女武神之",
+ "Valley of Snakes": "群蛇峽谷",
+ "Vampiregaze": "吸血鬼的凝视",
+ "Veil of Steel": "钢铁面纱",
+ "Venom Grip": "剧毒之握",
"VenomLord": "邪魔之王",
"Venomsward": "毒液牢房",
+ "Verdugo's Hearty Cord": "维尔登戈的心结",
"Vicious": "惡毒之",
"Victors Silk": "勝利者之絲綢",
"Vidala's Ambush": "維達拉的突襲",
@@ -4252,19 +6297,30 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"VileLancer": "邪惡長槍手",
"VileTemptress": " ",
"VileWitch": " ",
+ "Vine Creature": "食尸藤",
+ "Vinvear Molech": "温韦尔·摩洛克",
"Viper": "蝮蛇之",
+ "Viperfork": "毒蛇叉",
"Viridian": "銘綠之",
+ "Visceratuant": "维斯尔图特",
"Vulpine": "奸詐之",
"WailingBeast": "哀泣之獸",
+ "Wake of Destruction": "火焰之径",
"Wall of the Eyeless": "無眼之牆",
"War Bonnet": "畢格因的軟帽",
+ "Warlord's Trust": "战神的信任",
"Warp": "扭曲之",
"WarpedFallen": "傳送魔",
"WarpedShaman": "傳送巫師",
+ "Warpspear": "扭曲之矛",
"Warrior's": "戰士之",
"Warriv": "瓦瑞夫",
+ "Warriv's Warder": "瓦瑞夫的守护者",
+ "Warshrike": "战争翼刀",
+ "Wartraveler": "战争旅者",
"WaterWatcherHead": "水域看管水怪",
"WaterWatcherLimb": "水域看管觸角",
+ "Waterwalk": "水上漂",
"WeaponAttackFast": "快速攻擊速度",
"WeaponAttackFastest": "超高速攻擊速度",
"WeaponAttackNormal": "一般攻擊速度",
@@ -4279,6 +6335,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"WeaponDescH2H": " ",
"WeaponDescJavelin": "標槍等級",
"WeaponDescMace": "釘鎚等級",
+ "WeaponDescOrb": "法珠等级",
"WeaponDescPoleArm": "長棍等級",
"WeaponDescSpear": "長矛等級",
"WeaponDescStaff": "杖等級",
@@ -4288,22 +6345,37 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"Web Mage the Burning": "燃燒者-韋布",
"Weird": "怪異",
"Whichwild String": "狂野之弦",
+ "Widowmaker": "寡妇制造者",
+ "Wihtstan's Guard": "维斯坦的武装",
+ "Wilhelm's Pride": "威廉的自尊",
+ "Windforce": "风之力",
+ "Windhammer": "风之锤",
"Winged Death": "死亡之翼",
"WingedNightmare": "有翼惡夢",
+ "Wisp": "鬼火投射者",
"Witch Doctor Endugu": "古巫醫-印都",
"Witherstring": "凋謝之戒",
+ "Wizardspike": "巫师之刺",
"Wizendraw": "凋謝之畫",
"Woestave": "煩惱詩集",
+ "Wolf": "狼",
+ "WolfRider1": "沉沦魔狼骑兵",
+ "WolfRider2": "削肉者狼骑兵",
+ "WolfRider3": "恶魔狼骑兵",
+ "Wolfhowl": "狼嚎",
"WorldKiller": "鮮血蛆蟲",
"WorldKillerEgg": "血蛆之蛋",
"WorldKillerQueen": "血蛆皇后",
"WorldKillerYoung": "血蛆幼生",
"Wormskull": "溫暖骷髏",
"Wraith": "忿怒",
+ "Wraithflight": "幽灵火焰",
"Wraithra": "忿怒",
"Wyand Voidfinger": "空虛之指-維恩",
"Wyrm's": "維特之",
"Yeti": "雅提",
+ "Zakarum's Hand": "撒卡兰姆之手",
+ "Zakarum's Salvation": "撒卡兰姆的救赎",
"Zakarumite": "撒卡蘭姆",
"Zealot": "狂戰士",
"Zombie": "僵屍",
@@ -4311,14 +6383,46 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"aar": "古代裝甲",
"aegis": "庇護",
"alkor": "艾柯",
+ "am1": "牡鹿之弓",
+ "am2": "反射之弓",
+ "am3": "少女之矛",
+ "am4": "少女长矛",
+ "am5": "少女标枪",
+ "am6": "白蜡木弓",
+ "am7": "祭典之弓",
+ "am8": "祭典之矛",
+ "am9": "祭典长矛",
+ "ama": "祭典标枪",
+ "amb": "女族长之弓",
+ "amc": "大院长之弓",
+ "amd": "女族长之矛",
+ "ame": "女族长之长矛",
+ "amf": "女族长之标枪",
"amu": "護身符",
"aqv": "箭矢",
"asheara": "艾席拉",
"ass": "滑鼠右鍵點選後可以增加一點技能點數\n技能之書",
"axe": "斧",
+ "axf": "手斧爪",
+ "ba1": "颚骨帽",
+ "ba2": "尖牙盔",
+ "ba3": "角盔",
+ "ba4": "突击盔",
+ "ba5": "复仇者之盔",
+ "ba6": "颚骨面甲",
+ "ba7": "狮头尖盔",
+ "ba8": "狂怒面具",
+ "ba9": "野蛮人头盔",
+ "baa": "屠杀者护面",
+ "bab": "杀戮头盔",
+ "bac": "狂暴面甲",
+ "bad": "毁灭者头盔",
"badge": "徽章",
+ "bae": "征服者皇冠",
+ "baf": "守护者皇冠",
"bal": "平衡斧",
"band": "箍",
+ "bank": "你的私人儲藏箱",
"bar": "大砍刀",
"barRI": "酒吧",
"barb": "倒鉤",
@@ -4344,10 +6448,12 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"brn": "叉",
"brow": "額頭",
"brs": "胸甲",
+ "brz": "一股邪恶力量",
"bsd": "闊劍",
"bsh": "白骨盾牌",
"bst": "戰鬥法杖",
"bsw": "巨劍",
+ "btl": "刃爪",
"btx": "戰鬥斧",
"buc": "圓盾",
"buckle": "釦帶",
@@ -4358,6 +6464,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"casque": "盔",
"cbw": "組合弓",
"ceh": "充盈的憎恨精华",
+ "ces": "皮手甲",
"chain": "鏈條",
"champberserkX": "狂戰士",
"champfanaticX": "狂信者",
@@ -4365,6 +6472,10 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"champpossessedX": "著魔者",
"chant": "曲",
"chn": "鎖子甲",
+ "ci0": "头环",
+ "ci1": "宝冠",
+ "ci2": "三重冠",
+ "ci3": "权冠",
"circle": "環",
"circlet": "飾環",
"clasp": "扣鉤",
@@ -4373,7 +6484,12 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"cleaver": "切肉刀",
"clm": "雙刃大刀",
"cloak": "斗蓬",
+ "close": "關閉",
"clutches": "手爪",
+ "clw": "爪",
+ "cm1": "小护身符",
+ "cm2": "大型护身符",
+ "cm3": "超大型护身符",
"coat": "外袍",
"coil": "盤繞",
"collar": "項圈",
@@ -4400,9 +6516,19 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"dr3": " ",
"dr4": " ",
"dr5": " ",
+ "dr6": "阿尔法头盔",
+ "dr7": "狮鹫兽头饰",
+ "dr8": "猎人的伪装",
+ "dr9": "神圣羽毛",
+ "dra": "图腾面具",
"drains mana": "吸取法力",
"drains mana and stamina": "吸取法力和耐力",
"drains stamina": "吸取耐力",
+ "drb": "鲜血之灵",
+ "drc": "太阳之灵",
+ "drd": "大地之灵",
+ "dre": "天空之灵",
+ "drf": "梦境之灵",
"dummy": "一股邪惡力量",
"ear": "耳",
"eats and spits corpses": "吃下並吐出死屍",
@@ -4410,6 +6536,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"elx": "靈葯",
"emblem": "象徵",
"eye": "眼",
+ "eyz": "一股邪恶力量",
"fang": "尖牙",
"fed": "溃烂的毁灭精华",
"fhl": "高級頭盔",
@@ -4421,11 +6548,14 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"flc": "彎形大刀",
"fld": "實戰鎧甲",
"fletch": "箭羽",
+ "flg": "一股邪恶力量",
"flight": "翔",
+ "fng": "一股邪恶力量",
"fringe": "邊飾",
"ful": "高級戰甲",
"g33": "吉得賓",
"g34": "黃金鳥",
+ "gamble": "賭博",
"gax": "卓越之斧",
"gcb": "碎裂的藍寶石",
"gcg": "碎裂的綠寶石",
@@ -4511,9 +6641,12 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"hp5": "超級治療葯劑",
"hratli": "赫拉鐵力",
"hrb": "葯草",
+ "hrn": "角",
+ "hrt": "一股邪恶力量",
"hst": "赫拉迪克之杖",
"hxb": "重十字弓",
"ibk": "辨視之書",
+ "ice": "解冻安雅的药剂\n玛拉的药剂",
"impaler": "刺穿者",
"increaseswithplaylevelX": "(以角色等級決定)",
"isc": "辨視卷軸",
@@ -4521,12 +6654,15 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"j34": "玉製小人",
"jack": "水兵",
"jav": "標槍",
+ "jaw": "一股邪恶力量",
+ "jew": "珠宝",
"key": "鑰匙",
"kit": "輕盾",
"knell": "喪鐘",
"knot": "結",
"knuckle": "指節",
"kri": "波形刀",
+ "ktr": "拳剑",
"lance": "長槍",
"larva": "幼蟲",
"lash": "抽打",
@@ -4541,6 +6677,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"lock": "鎖",
"loom": "織布機",
"loop": "圈",
+ "lowercasecancel": "取消",
"lrg": "大盾牌",
"lsd": "長劍",
"lst": "長棍",
@@ -4563,6 +6700,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"meshif": "馬席夫",
"mgl": "鎖鍊手套",
"minion": "嘍囉",
+ "minipanelHelp": "求助",
+ "minipanelautomap": "自動地圖",
+ "minipanelchar": "人物",
+ "minipanelinv": "物品",
+ "minipanelmenubtn": "遊戲選單(Esc)",
+ "minipanelmessage": "訊息記錄",
+ "minipanelparty": "隊伍畫面",
+ "minipanelquest": "任務記錄",
+ "minipanelspecial": "(%s)",
+ "minipaneltree": "技能樹",
"monsteruniqueprop1": "冰冷強化",
"monsteruniqueprop2": "閃電強化",
"monsteruniqueprop3": "燃燒法力",
@@ -4590,8 +6737,17 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"ne3": " ",
"ne4": " ",
"ne5": " ",
+ "ne6": "木乃伊印记",
+ "ne7": "物神印记",
+ "ne8": "教堂司事印记",
+ "ne9": "领唱者印记",
+ "nea": "教皇印记",
+ "neb": "仆从头骨",
"necklace": "項鍊",
+ "ned": "监督者头骨",
+ "nee": "女妖头骨",
"needle": "針",
+ "nef": "血王头骨",
"neg": "地獄爪牙之骨",
"nock": "搭弦",
"noose": "套索",
@@ -4600,6 +6756,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"ob3": " ",
"ob4": " ",
"ob5": " ",
+ "ob6": "发光法珠",
+ "ob7": "水晶天球",
+ "ob8": "云雾法球",
+ "ob9": "闪耀法球",
+ "oba": "涡流水晶",
+ "obb": "天堂之石",
+ "obc": "怪异法球",
+ "obd": "恶魔之心",
+ "obe": "漩涡法珠",
+ "obf": "次元碎片",
"of Absorption": "吸收",
"of Accuracy": "準確的",
"of Alacrity": "輕快的",
@@ -4696,6 +6862,21 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"opm": "爆炸葯劑",
"ops": "油",
"ormus": "奧瑪斯",
+ "pa1": "圆盾",
+ "pa2": "饰盾",
+ "pa3": "纹章盾",
+ "pa4": "艾尔之盾",
+ "pa5": "皇冠之盾",
+ "pa6": "阿卡拉圆盾",
+ "pa7": "阿卡拉饰盾",
+ "pa8": "保护者盾牌",
+ "pa9": "饰金盾牌",
+ "paa": "皇家盾牌",
+ "pab": "神圣小盾",
+ "pac": "神圣轻圆盾",
+ "pad": "库拉斯特之盾",
+ "pae": "撒卡兰姆盾牌",
+ "paf": "旋风盾",
"pale": "蒼白",
"pax": "長柄戰斧",
"picket": "尖木",
@@ -4711,12 +6892,43 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"qf1": "克林姆的連枷",
"qf2": "克林姆的意志",
"qhr": "克林姆的心臟",
+ "qll": "一股邪恶力量",
"quarrel": "爭吵",
"qui": "布甲",
"quill": "羽",
+ "r01": "艾尔",
+ "r02": "艾德",
+ "r03": "特尔",
+ "r04": "那夫",
+ "r05": "爱斯",
+ "r06": "伊司",
+ "r07": "塔尔",
+ "r08": "拉尔",
+ "r09": "欧特",
+ "r10": "书尔",
+ "r11": "安姆",
+ "r12": "索尔",
"r13": "符文:夏",
+ "r14": "多尔",
+ "r15": "海尔",
"r16": "符文:破",
+ "r17": "卢姆",
+ "r18": "科",
+ "r19": "法尔",
+ "r20": "蓝姆",
+ "r21": "普尔",
+ "r22": "乌姆",
+ "r23": "马尔",
+ "r24": "伊斯特",
+ "r25": "古尔",
+ "r26": "伐克斯",
+ "r27": "欧姆",
+ "r28": "罗",
+ "r29": "瑟",
+ "r30": "贝",
"r31": "符文:喬",
+ "r32": "查姆",
+ "r33": "萨德",
"raises Carvers": "復甦的利刃魔",
"raises Dark Ones": "復甦的黑暗魔",
"raises Devilkin": "復甦的小惡魔",
@@ -4750,6 +6962,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"scratch": "抓",
"scy": "鐮刀",
"scythe": "鐮刀",
+ "scz": "一股邪恶力量",
"sever": "斷裂者",
"shank": "小腿",
"shell": "殼",
@@ -4921,15 +7134,18 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"skillsname61": "支配之焰",
"skl": "無暇疵的骷髏",
"skp": "骷髏帽",
+ "skr": "剪咬刀",
"sku": "骷髏",
"skz": "完美的骷髏",
"slayer": "屠殺者",
"slippers": "便鞋",
"smasher": "擊碎者",
"sml": "小盾牌",
+ "sol": "灵魂之",
"song": "歌",
"spawn": "重生的",
"spc": "狼牙棒",
+ "spe": "一股邪恶力量",
"spell": "法術",
"spike": "釘",
"spiral": "螺旋",
@@ -4946,9 +7162,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"stake": "棍子",
"stalker": "潛行者",
"star": "星",
+ "stash": "儲藏箱",
"std": "英雄旗幟",
"steals life": "偷取生命",
"stinger": "有刺者",
+ "strBSArmor": "裝甲",
+ "strBSMisc": "其他",
+ "strBSWeapons": "武器",
+ "strClose": "關閉",
+ "strGoldInStash": "儲藏起來的金錢:",
+ "strGoldLabel": "金幣",
"strModAllResistances": "所有抗性 +%d",
"strModAllSkillLevels": "+%d 到所有技能等級",
"strModColdDamage": "+%d 冰冷傷害",
@@ -4970,12 +7193,36 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"strScaryMonDesc4": "毒素系無效",
"strScaryMonDesc5": "實體攻擊無效",
"strScaryMonDesc6": "魔法系無效",
+ "strScaryMonDescA": "",
+ "strScaryMonDescB": "",
+ "strUiMenu2": "改變",
"strap": "皮帶",
+ "strchratr": "%s\n準確率",
+ "strchrcol": "冰冷抵抗力",
+ "strchrdef": "防禦",
+ "strchrdex": "敏捷",
+ "strchreng": "精力",
+ "strchrexp": "經驗",
+ "strchrfir": "火焰抵抗力",
+ "strchrlif": "生命",
+ "strchrlit": "閃電抵抗力",
+ "strchrlvl": "等級",
+ "strchrman": "法力",
+ "strchrnxtlvl": "下一等級",
+ "strchrpos": "毒素抵抗力",
+ "strchrrat": "%s\n比率",
+ "strchrrema": "剩餘",
+ "strchrskm": "傷害",
+ "strchrstat": "狀況點數",
+ "strchrstm": "耐力",
+ "strchrstr": "力量",
+ "strchrvit": "體力",
"stu": "鑲嵌甲",
"stun attack": "昏眩攻擊",
"suit": "衣",
"sunder": "撕碎者",
"swb": "短巨戰弓",
+ "tal": "一股邪恶力量",
"talisman": "護符",
"tax": "飛斧",
"tbk": "城鎮傳送之書",
@@ -4992,6 +7239,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"tow": "塔盾",
"tower": "塔",
"tr1": "赫拉迪克卷軸",
+ "tr2": "右键点击施放\n抗性卷轴",
"track": "追蹤",
"trample": "蹂躪",
"tread": "踏步",
@@ -5000,12 +7248,58 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"tsp": "飛矛",
"turn": "轉",
"tyrael": "泰瑞爾",
+ "uap": "军帽",
+ "uar": "神圣盔甲",
+ "ucl": "甲片甲",
+ "uea": "古龙皮",
+ "uh9": "骸骨面罩",
+ "uhb": "急速靴",
+ "uhc": "巨神腰带",
+ "uhg": "食人魔铁手套",
+ "uhl": "巨型海螺",
+ "uhm": "螺旋头盔",
+ "uhn": "骸骨链甲",
+ "uit": "统治者大盾",
+ "ukp": "九头蛇头盖骨盔",
+ "ula": "圣甲虫壳皮甲",
+ "ulb": "古龙皮靴",
+ "ulc": "蛛网腰带",
+ "uld": "海妖壳甲",
+ "ulg": "荆棘连指手套",
+ "ulm": "活动面甲",
+ "ult": "地狱锻甲",
+ "umb": "骸骨靴",
+ "umc": "秘银腰带",
+ "umg": "吸血鬼护腕",
+ "uml": "月精灵护盾",
+ "ung": "钻石锁子甲",
"uniquecursed": "特別詛咒",
"uniqueextrafast": "特別快速",
"uniquefireenchanted": "火焰強化",
"uniquemagicresistance": "魔法抵抗",
"uniquextrastrong": "特別強壯",
"unused": "一股邪惡力量",
+ "uow": "宙斯之盾",
+ "upk": "刀刃刺盾",
+ "upl": "炎魔皮板甲",
+ "urg": "亥伯龙之盾",
+ "urn": "皇冠之冠",
+ "urs": "巨型鳞铠胸甲",
+ "ush": "巨魔巢穴骨盾",
+ "usk": "恶魔头盖骨面具",
+ "utb": "镜化靴",
+ "utc": "巨魔腰带",
+ "utg": "十字军铁手套",
+ "uth": "漆甲",
+ "utp": "执政官铠甲",
+ "uts": "保护盾牌",
+ "utu": "线羊毛皮甲",
+ "uuc": "小圆盾",
+ "uui": "暮色寿衣",
+ "uul": "阴影铠甲",
+ "uvb": "圣甲虫壳靴",
+ "uvc": "吸血鬼獠牙腰带",
+ "uvg": "吸血鬼骸骨手套",
"vbl": "輕扣帶",
"vbt": "重靴",
"veil": "面紗",
@@ -5028,6 +7322,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"wood": "木",
"wrack": "破滅",
"wrap": "包裹",
+ "wrb": "腕刀",
"wsc": "巨戰鐮刀",
"wsd": "巨戰之劍",
"wsp": "巨戰權杖",
@@ -5091,7 +7386,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = {
"uniqueteleport": "傳送",
"uniquestoneskin": "皮膚硬化",
"uniquemultishot": "多重射擊",
- "UniqueAuraEnchanted": "靈氣加強"
+ "UniqueAuraEnchanted": "靈氣加強",
+ "strethereal": "无形的 (无法修复)"
}
export const CANONICAL_ISC_DESC_TABLE: Readonly> = {
@@ -8408,3 +10704,8 @@ export const CANONICAL_ISC_DESC_TABLE: Readonly 0 && item.nameZh !== name) {
- return item.nameZh.includes('(') ? item.nameZh : `${item.nameZh} (${name})`
+ return item.nameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
}
const baseEn = typeof item?.base?.name === 'string' && item.base.name.length > 0 ? item.base.name : undefined
if (quality === 'unique' && item?.uniqueItemDef) {
const uEn = item.uniqueItemDef.index || name
const uZh = translateItemName(uEn)
- return uZh && uZh !== uEn ? `${uZh} (${uEn})` : uEn
+ return uZh && uZh !== uEn ? uZh : uEn
}
if (quality === 'set' && item?.setItemDef) {
const sEn = item.setItemDef.index || name
const sZh = translateItemName(sEn)
- return sZh && sZh !== sEn ? `${sZh} (${sEn})` : sEn
+ return sZh && sZh !== sEn ? sZh : sEn
}
if ((quality === 'rare' || quality === 'craft') && item?.rolledRareAffixes) {
const rEn = item.rolledRareAffixes.name || name
const rZh = translateRareName(rEn)
- return rZh && rZh !== rEn ? `${rZh} (${rEn})` : rEn
+ return rZh && rZh !== rEn ? rZh : rEn
}
if (quality === 'magic' && item?.rolledMagicAffixes) {
const mEn = item.rolledMagicAffixes.name || name
const mZh = translateMagicName(mEn, baseEn ?? name)
- return mZh && mZh !== mEn ? `${mZh} (${mEn})` : mEn
+ return mZh && mZh !== mEn ? mZh : mEn
}
const directZh = translateItemName(name)
if (directZh && directZh !== name) {
- return `${directZh} (${name})`
+ return directZh
}
if (quality === 'rare' || quality === 'craft') {
const rZh = translateRareName(name)
- if (rZh && rZh !== name) return `${rZh} (${name})`
+ if (rZh && rZh !== name) return rZh
}
if (quality === 'magic') {
const mZh = translateMagicName(name, baseEn)
- if (mZh && mZh !== name) return `${mZh} (${name})`
+ if (mZh && mZh !== name) return mZh
}
return name
}
@@ -294,7 +293,7 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
name = baseNameEn
nameZh =
translatedBase && translatedBase !== baseNameEn
- ? `${translatedBase} (${baseNameEn})`
+ ? translatedBase
: baseNameEn
} else {
nameZh = formatBilingualGroundItemName(item, name, quality)
diff --git a/src/game/level-names-zh.ts b/src/game/level-names-zh.ts
index edbe774..b0af430 100644
--- a/src/game/level-names-zh.ts
+++ b/src/game/level-names-zh.ts
@@ -14,6 +14,16 @@ export const ACT_NAMES_ZH: Readonly> = {
5: '第五章',
}
+/** Canonical English Act names (1..5). */
+export const ACT_NAMES_EN: Readonly> = {
+ 1: 'Act I',
+ 2: 'Act II',
+ 3: 'Act III',
+ 4: 'Act IV',
+ 5: 'Act V',
+}
+
+
/** `Levels.txt` 的 `Id` → 中文场景名。 */
export const LEVEL_NAMES_ZH: Readonly> = {
// Act 1
@@ -170,6 +180,8 @@ export const LEVEL_NAMES_ZH: Readonly> = {
* (`TownN1`、`CourtW`、`LutN`…)。**只白名单这几个**,不做"末尾字母是方位"的猜测:
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
*/
+import { isEn } from '../i18n/lang.ts'
+
const DIRECTION_SUFFIXES: Readonly> = {
townn1: '北门',
towne1: '东门',
@@ -182,6 +194,18 @@ const DIRECTION_SUFFIXES: Readonly> = {
lutw: '西门',
}
+const DIRECTION_SUFFIXES_EN: Readonly> = {
+ townn1: 'North Gate',
+ towne1: 'East Gate',
+ towns1: 'South Gate',
+ townw1: 'West Gate',
+ courtn: 'North',
+ courte: 'East',
+ courtw: 'West',
+ lutn: 'North Gate',
+ lutw: 'West Gate',
+}
+
/**
* 把一个变体后缀变成显示用的中文(拿不准就原样返回 DS1 名)。
*
@@ -194,6 +218,16 @@ export function variantLabelZh(suffix: string): string {
return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix
}
+export function variantLabelEn(suffix: string): string {
+ const match = /^var(\d+)$/i.exec(suffix)
+ if (match) return `Variant ${match[1]}`
+ return DIRECTION_SUFFIXES_EN[suffix.toLowerCase()] ?? suffix
+}
+
+export function variantLabel(suffix: string): string {
+ return isEn() ? variantLabelEn(suffix) : variantLabelZh(suffix)
+}
+
/** Canonical English level name keys corresponding to Levels.txt Id. */
export const CANONICAL_LEVEL_NAME_KEYS: Readonly> = {
1: 'Rogue Encampment',
@@ -618,6 +652,40 @@ export function sceneNameEn(
return getLevelNames(slug).nameEn
}
+export function sceneName(
+ slug: string | number | null | undefined,
+ fallback?: string,
+): string {
+ return isEn() ? sceneNameEn(slug, fallback) : sceneNameZh(slug, fallback)
+}
+
+export function levelDisplayName(
+ levelIdOrSlug: number | string | null | undefined,
+ lang?: 'CHI' | 'ENG' | 'zh' | 'en',
+): string {
+ const names = getLevelNames(levelIdOrSlug)
+ const useEn = lang !== undefined ? (lang === 'ENG' || lang === 'en') : isEn()
+ return useEn ? names.nameEn : names.nameZh
+}
+
+export function sceneDisplayName(
+ slug: string | number | null | undefined,
+ fallback?: string,
+ lang?: 'CHI' | 'ENG' | 'zh' | 'en',
+): string {
+ const useEn = lang !== undefined ? (lang === 'ENG' || lang === 'en') : isEn()
+ return useEn ? sceneNameEn(slug, fallback) : sceneNameZh(slug, fallback)
+}
+
+export function variantDisplayLabel(
+ suffix: string,
+ lang?: 'CHI' | 'ENG' | 'zh' | 'en',
+): string {
+ const useEn = lang !== undefined ? (lang === 'ENG' || lang === 'en') : isEn()
+ return useEn ? variantLabelEn(suffix) : variantLabelZh(suffix)
+}
+
+
function lookupTbl(tbl: D2Tbl | Map, key: string): string | undefined {
if (tbl instanceof Map) {
return tbl.get(key)
diff --git a/src/game/monsters.ts b/src/game/monsters.ts
index 9389e06..1db4797 100644
--- a/src/game/monsters.ts
+++ b/src/game/monsters.ts
@@ -74,6 +74,7 @@ import type { D2Table } from './acts.ts'
import { cell, parseTable } from './acts.ts'
import type { MonsterPack, MonsterStats } from './combat.ts'
import { Rng } from './rng.ts'
+import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts'
/** Which difficulty a number is being read for. */
export type Difficulty = 'normal' | 'nightmare' | 'hell'
@@ -921,23 +922,77 @@ export const CANONICAL_ELITE_MODIFIERS: readonly {
readonly name: string
readonly tblKey: string
readonly label: string
+ readonly labelZh: string
readonly champion: boolean
}[] = [
- { id: 5, name: 'strong', tblKey: 'uniquextrastrong', label: 'Extra Strong', champion: true },
- { id: 6, name: 'fast', tblKey: 'uniqueextrafast', label: 'Extra Fast', champion: true },
- { id: 7, name: 'cursed', tblKey: 'uniquecursed', label: 'Cursed', champion: false },
- { id: 8, name: 'magicresistant', tblKey: 'uniquemagicresistance', label: 'Magic Resistant', champion: false },
- { id: 9, name: 'fireenchant', tblKey: 'uniquefireenchanted', label: 'Fire Enchanted', champion: false },
- { id: 17, name: 'lightenchant', tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted', champion: false },
- { id: 18, name: 'coldenchant', tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted', champion: false },
- { id: 25, name: 'manahit', tblKey: 'monsteruniqueprop3', label: 'Mana Burn', champion: false },
- { id: 26, name: 'teleport', tblKey: 'monsteruniqueprop5', label: 'Teleportation', champion: false },
- { id: 27, name: 'spectralhit', tblKey: 'monsteruniqueprop4', label: 'Spectral Hit', champion: false },
- { id: 28, name: 'stoneskin', tblKey: 'monsteruniqueprop6', label: 'Stone Skin', champion: false },
- { id: 29, name: 'multishot', tblKey: 'monsteruniqueprop7', label: 'Multiple Shots', champion: false },
- { id: 30, name: 'aura', tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted', champion: false },
+ { id: 5, name: 'strong', tblKey: 'uniquextrastrong', label: 'Extra Strong', labelZh: '特别强壮', champion: true },
+ { id: 6, name: 'fast', tblKey: 'uniqueextrafast', label: 'Extra Fast', labelZh: '特别快速', champion: true },
+ { id: 7, name: 'cursed', tblKey: 'uniquecursed', label: 'Cursed', labelZh: '特别诅咒', champion: false },
+ { id: 8, name: 'magicresistant', tblKey: 'uniquemagicresistance', label: 'Magic Resistant', labelZh: '魔法抵抗', champion: false },
+ { id: 9, name: 'fireenchant', tblKey: 'uniquefireenchanted', label: 'Fire Enchanted', labelZh: '火焰强化', champion: false },
+ { id: 17, name: 'lightenchant', tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted', labelZh: '闪电强化', champion: false },
+ { id: 18, name: 'coldenchant', tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted', labelZh: '冰冷强化', champion: false },
+ { id: 25, name: 'manahit', tblKey: 'monsteruniqueprop3', label: 'Mana Burn', labelZh: '法力燃烧', champion: false },
+ { id: 26, name: 'teleport', tblKey: 'monsteruniqueprop5', label: 'Teleportation', labelZh: '传送', champion: false },
+ { id: 27, name: 'spectralhit', tblKey: 'monsteruniqueprop4', label: 'Spectral Hit', labelZh: '幽灵一击', champion: false },
+ { id: 28, name: 'stoneskin', tblKey: 'monsteruniqueprop6', label: 'Stone Skin', labelZh: '石头皮肤', champion: false },
+ { id: 29, name: 'multishot', tblKey: 'monsteruniqueprop7', label: 'Multiple Shots', labelZh: '多重射击', champion: false },
+ { id: 30, name: 'aura', tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted', labelZh: '灵气强化', champion: false },
]
+/**
+ * Formats an elite modifier code or label (`strong`, `Extra Strong`, etc.) in the active language.
+ */
+export function formatEliteAffixLabel(modNameOrLabel: string, lang: TblLang = getTblLang()): string {
+ const lower = modNameOrLabel.trim().toLowerCase()
+ const entry = CANONICAL_ELITE_MODIFIERS.find(
+ m => m.name.toLowerCase() === lower || m.label.toLowerCase() === lower || m.labelZh === modNameOrLabel.trim(),
+ )
+ if (entry) {
+ return lang === 'ENG' ? entry.label : entry.labelZh
+ }
+ const tblVal = lookupTbl(modNameOrLabel.trim(), lang)
+ if (tblVal) return tblVal
+ return modNameOrLabel
+}
+
+export const affixDisplayName = formatEliteAffixLabel
+
+
+/**
+ * Resolves a monster's localized display name from `.tbl` (`MonStats.txt` `NameStr` / `SuperUniques.txt` `Name`).
+ */
+export function monsterDisplayName(
+ statsOrName: { readonly id?: string; readonly name?: string; readonly nameZh?: string; readonly superUniqueId?: string } | string,
+ lang: TblLang = getTblLang(),
+): string {
+ if (typeof statsOrName === 'string') {
+ const trimmed = statsOrName.trim()
+ const fromTbl = lookupTbl(trimmed, lang)
+ if (fromTbl) return fromTbl
+ const strippedNum = trimmed.replace(/\d+$/, '')
+ if (strippedNum && strippedNum !== trimmed) {
+ const fromStripped = lookupTbl(strippedNum, lang)
+ if (fromStripped) return fromStripped
+ }
+ return trimmed
+ }
+ if (lang === 'CHI' && statsOrName.nameZh) {
+ return statsOrName.nameZh
+ }
+ const candidates = [
+ statsOrName.superUniqueId,
+ statsOrName.name,
+ statsOrName.id,
+ statsOrName.id?.replace(/\d+$/, ''),
+ ].filter((c): c is string => Boolean(c && c.trim().length > 0))
+ for (const key of candidates) {
+ const val = lookupTbl(key.trim(), lang)
+ if (val) return val
+ }
+ return statsOrName.name || statsOrName.id || 'Monster'
+}
+
/**
* Roll elite modifiers (`MonUMod.txt`) for a champion or unique pack.
*/
diff --git a/src/game/npc-table.ts b/src/game/npc-table.ts
index 2142a70..08b6c5a 100644
--- a/src/game/npc-table.ts
+++ b/src/game/npc-table.ts
@@ -1,4 +1,6 @@
import type { SkillCostEntry as CanonicalSkillCostEntry } from '../data/canonical-vendor-data';
+import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT, resolveTblKey } from '../data/canonical-tbl-strings.ts';
+import { getTblLang } from '../i18n/lang.ts';
export interface ParsedTxtTable {
readonly headers: readonly string[];
@@ -876,3 +878,224 @@ export function getTownNpcDescriptorByVendorId(
d => d.vendorId === vendorId && (actIndex === undefined || d.actIndex === actIndex),
) ?? TOWN_NPC_DESCRIPTORS.find(d => d.vendorId === vendorId);
}
+
+const NPC_NAME_TBL_KEYS: Readonly> = {
+ akara: 'akara',
+ gheed: 'gheed',
+ charsi: 'charsi',
+ cain: 'DeckardCain',
+ cain1: 'DeckardCain',
+ cain2: 'DeckardCain',
+ cain3: 'DeckardCain',
+ cain4: 'DeckardCain',
+ cain5: 'DeckardCain',
+ 'deckard cain': 'DeckardCain',
+ warriv: 'warriv',
+ warriv1: 'warriv',
+ warriv2: 'warriv',
+ kashya: 'kashya',
+ fara: 'fara',
+ lysander: 'lysander',
+ drognan: 'drognan',
+ elzix: 'elzix',
+ atma: 'atma',
+ greiz: 'greiz',
+ jerhyn: 'jerhyn',
+ meshif: 'meshif',
+ meshif1: 'meshif',
+ meshif2: 'meshif',
+ geglash: 'geglash',
+ hratli: 'hratli',
+ alkor: 'alkor',
+ ormus: 'ormus',
+ asheara: 'asheara',
+ natalya: 'natalya',
+ halbu: 'halbu',
+ jamella: 'jamella',
+ tyrael: 'tyrael',
+ tyrael1: 'tyrael',
+ tyrael2: 'tyrael',
+ malah: 'malah',
+ larzuk: 'larzuk',
+ drehya: 'anya',
+ anya: 'anya',
+ nihlathak: 'nihlathak',
+ 'qual-kehk': 'qual-kehk',
+};
+
+const NPC_GREETING_ZH_BY_KEY: Readonly> = {
+ akara: [
+ '我是阿卡拉,目盲之眼修女会的高阶女祭司。',
+ '欢迎回来,我的朋友。我们仍在清理修道院,但你可以随时在这里休息。',
+ ],
+ gheed: [
+ '祝你好运,伙伴!我是基德,在这被遗忘的营地里我一定会成为你最好的朋友。',
+ '快来看看!花点金币交易,或者赌一把神秘物品!',
+ ],
+ charsi: [
+ '你好!我是恰西,营地里的铁匠。',
+ '很高兴在这里看到强壮的冒险者。需要打造或修理你的装备吗?',
+ ],
+ cain1: [
+ '请留步,听我一言!',
+ '把你找到的未辨识物品带给我,我会揭开它们隐藏的力量。',
+ ],
+ cain2: [
+ '请留步,听我一言!',
+ '赫拉迪姆的奥秘深藏于鲁·高因之下。让我为你辨识圣物吧。',
+ ],
+ cain3: [
+ '请留步,听我一言!',
+ '墨菲斯托的腐化已经扭曲了库拉斯特。让我看看你发现了什么遗物。',
+ ],
+ cain4: [
+ '请留步,听我一言!',
+ '即便在地狱之门前,赫拉迪姆的智慧依然指引着我们。',
+ ],
+ cain5: [
+ '请留步,听我一言!',
+ '亚瑞特山脉守护着世界之石。让我为你辨识你发现的宝物。',
+ ],
+ fara: [
+ '你好,流浪者。我是法拉,曾经是东方萨卡兰姆的圣骑士。',
+ '如今我在鲁·高因以铁匠与治疗者的身份维生。',
+ ],
+ lysander: [
+ '你想干什么?大声点!调配药剂对听力可不太好。',
+ '要买药水就快点,别等这儿炸了!',
+ ],
+ drognan: [
+ '黑暗的时代确实已经降临鲁·高因。',
+ '如果你寻求奥术知识、法杖或手杖,不妨看看我的货物。',
+ ],
+ elzix: [
+ '很高兴见到你,旅行者。我是艾尔夏克,旅店老板兼商人。',
+ '年轻时我曾是沙漠里的强盗。想交易还是试试手气?',
+ ],
+ hratli: [
+ '欢迎,陌生人。我是赫拉铁力,一名专精金属附魔的铁匠。',
+ '我的结界守护着这片海港,我的铁砧能让你的刀剑更加锋利。',
+ ],
+ alkor: [
+ '哦,太好了,又来一个打扰我研究的冒险者。',
+ '如果你想买药剂或者把金币赌光,那就快一点。',
+ ],
+ ormus: [
+ '你正在和奥玛斯说话。他曾是一名伟大的法师,如今却像沉船里的老鼠一样苟活。',
+ '奥玛斯可以治愈你的创伤,并交易神秘的法袍。',
+ ],
+ asheara: [
+ '你好。我是艾席拉,铁狼卫队的指挥官。',
+ '如果你打算深入丛林,不妨先看看我的武器与盔甲。',
+ ],
+ halbu: ['凡人,随时让你的武器保持锋利,让你的盔甲坚固完好。'],
+ jamella: ['圣光祝福你,英雄。让我为你恢复体力并提供神圣的装备。'],
+ malah: [
+ '欢迎来到哈洛加斯,旅行者。我是马拉,这座围城要塞的治疗师。',
+ '让我为你包扎伤口,或提供药剂与卷轴。',
+ ],
+ larzuk: [
+ '看什么看?没见过野蛮人铁匠吗?',
+ '看看我的钢铁作品——全北方高地最顶尖的装备!',
+ ],
+ drehya: [
+ '感谢你从冰冻囚牢中救出我,英雄。',
+ '请看看我的商品,或者试试赌博的运气。',
+ ],
+ nihlathak: [
+ '你为什么来烦我?哈洛加斯不需要外来者。',
+ '如果你非要浪费金币,我这里倒有一些古怪玩意儿可以让你赌一把。',
+ ],
+ warriv1: ['你好,陌生人。一旦通往东方的关隘安全了,我的商队就会带你前往鲁·高因。'],
+ kashya: ['我的罗格斥候伤亡惨重。在战场上证明你的勇气吧,战士。'],
+ atma: ['欢迎来到我的酒馆,旅行者。在躲避沙漠烈日的同时,让我为你治愈伤口吧。'],
+ greiz: ['我的沙漠佣兵日夜守卫着鲁·高因的城墙。'],
+ jerhyn: ['欢迎来到鲁·高因。黑暗力量正在沙漠深处蠢蠢欲动。'],
+ meshif1: ['嘿,旅行者!只要你的使命需要,我的帆船随时可以横渡双子海。'],
+ geglash: ['看什么看?要么再来一杯,要么赶紧走开!'],
+ natalya: ['在丛林里睁大眼睛。三大魔神正在崔凡克集结。'],
+ tyrael1: ['向你致敬,勇士。审判迪亚波罗的时刻已经到来。'],
+ 'qual-kehk': ['一旦你解救了冰冻高地上的战士,最精锐的野蛮人将与你并肩作战。'],
+};
+
+const NPC_NAME_ZH_BY_KEY: Readonly> = {
+ akara: '阿卡拉',
+ gheed: '基德',
+ charsi: '恰西',
+ cain: '迪卡·凯恩',
+ cain1: '迪卡·凯恩',
+ cain2: '迪卡·凯恩',
+ cain3: '迪卡·凯恩',
+ cain4: '迪卡·凯恩',
+ cain5: '迪卡·凯恩',
+ 'deckard cain': '迪卡·凯恩',
+ warriv: '瓦瑞夫',
+ warriv1: '瓦瑞夫',
+ warriv2: '瓦瑞夫',
+ kashya: '卡夏',
+ fara: '法拉',
+ lysander: '雷山德',
+ drognan: '卓格南',
+ elzix: '艾尔夏',
+ atma: '亚特玛',
+ greiz: '格雷兹',
+ jerhyn: '杰海因',
+ meshif: '马席夫',
+ meshif1: '马席夫',
+ meshif2: '马席夫',
+ geglash: '基格拉什',
+ hratli: '赫拉铁力',
+ alkor: '艾柯',
+ ormus: '奥玛斯',
+ asheara: '艾席拉',
+ natalya: '娜塔亚',
+ halbu: '海尔布',
+ jamella: '贾梅拉',
+ tyrael: '泰瑞尔',
+ tyrael1: '泰瑞尔',
+ tyrael2: '泰瑞尔',
+ malah: '玛拉',
+ larzuk: '拉苏克',
+ drehya: '安雅',
+ anya: '安雅',
+ nihlathak: '尼拉塞克',
+ 'qual-kehk': '夸尔凯克',
+};
+
+export function getLocalizedNpcName(
+ nameOrDesc: TownNpcServiceDescriptor | string,
+ lang: 'CHI' | 'ENG' = getTblLang(),
+): string {
+ const desc = typeof nameOrDesc === 'string' ? getTownNpcDescriptorByName(nameOrDesc) : nameOrDesc;
+ const rawKey =
+ typeof nameOrDesc === 'string'
+ ? nameOrDesc.trim()
+ : nameOrDesc.npcTxtKey || nameOrDesc.displayName;
+ const lowerKey = rawKey.toLowerCase();
+ const fallbackEn =
+ desc?.displayName ?? (typeof nameOrDesc === 'string' ? nameOrDesc.trim() : nameOrDesc.displayName);
+ const tblKey = NPC_NAME_TBL_KEYS[lowerKey] ?? rawKey;
+ if (lang === 'ENG') {
+ return resolveTblKey(tblKey, 'ENG') ?? fallbackEn;
+ }
+ return NPC_NAME_ZH_BY_KEY[lowerKey] ?? resolveTblKey(tblKey, 'CHI') ?? fallbackEn;
+}
+
+export function getLocalizedNpcGreetings(
+ descriptor: TownNpcServiceDescriptor,
+ lang: 'CHI' | 'ENG' = 'CHI',
+): readonly string[] {
+ if (lang === 'ENG') {
+ return descriptor.greetingLines;
+ }
+ return NPC_GREETING_ZH_BY_KEY[descriptor.npcTxtKey.toLowerCase()] ?? descriptor.greetingLines;
+}
+
+export function npcDisplayName(
+ nameOrDesc: TownNpcServiceDescriptor | string,
+ lang?: 'CHI' | 'ENG',
+): string {
+ return getLocalizedNpcName(nameOrDesc, lang ?? 'CHI');
+}
+
+
diff --git a/src/i18n/lang.ts b/src/i18n/lang.ts
new file mode 100644
index 0000000..cb9a02b
--- /dev/null
+++ b/src/i18n/lang.ts
@@ -0,0 +1,151 @@
+/**
+ * Centralized language detection & runtime state (`CHI` / `'zh'` vs `ENG` / `'en'`)
+ * for Diablo II v1.13c web port (Issue #503).
+ */
+
+import { resolveTblKey } from '../data/canonical-tbl-strings.ts'
+
+export type GameLang = 'zh' | 'en'
+export type TblLang = 'CHI' | 'ENG'
+
+export function lookupTbl(key: string, lang: TblLang = getTblLang()): string | undefined {
+ return resolveTblKey(key, lang)
+}
+
+
+let overrideLang: GameLang | null = null
+const listeners = new Set<(lang: TblLang) => void>()
+
+export function toGameLang(lang: GameLang | TblLang | string): GameLang {
+ const norm = String(lang).trim().toLowerCase()
+ return norm === 'en' || norm === 'eng' || norm === 'english' ? 'en' : 'zh'
+}
+
+export function toTblLang(lang: GameLang | TblLang | string): TblLang {
+ return toGameLang(lang) === 'en' ? 'ENG' : 'CHI'
+}
+
+export function detectLangFromUrl(urlStr?: string): TblLang {
+ if (typeof urlStr === 'string' && urlStr.trim() !== '') {
+ try {
+ const u = new URL(urlStr, 'http://localhost')
+ const q = u.searchParams.get('lang')?.trim().toLowerCase()
+ if (q === 'en' || q === 'eng' || q === 'english') return 'ENG'
+ if (q === 'zh' || q === 'chi' || q === 'zh-cn' || q === 'cn' || q === 'chinese') return 'CHI'
+ const path = u.pathname.toLowerCase()
+ if (path.endsWith('/acts-en.html') || path.endsWith('acts-en.html') || path.endsWith('/arena-en.html') || path.endsWith('arena-en.html')) {
+ return 'ENG'
+ }
+ if (path.endsWith('/acts-zh.html') || path.endsWith('acts-zh.html') || path.endsWith('/arena-zh.html') || path.endsWith('arena-zh.html')) {
+ return 'CHI'
+ }
+ return 'CHI'
+ } catch {
+ // fall through
+ }
+ }
+ return detectInitialLang() === 'en' ? 'ENG' : 'CHI'
+}
+
+export function detectInitialLang(): GameLang {
+ if (typeof window !== 'undefined' && window.location) {
+ try {
+ const params = new URLSearchParams(window.location.search || '')
+ const q = params.get('lang')?.trim().toLowerCase()
+ if (q === 'en' || q === 'eng' || q === 'english') return 'en'
+ if (q === 'zh' || q === 'chi' || q === 'zh-cn' || q === 'cn' || q === 'chinese') return 'zh'
+ } catch {
+ // Ignore malformed query string
+ }
+ const path = (window.location.pathname || '').toLowerCase()
+ if (path.endsWith('/acts-en.html') || path.endsWith('acts-en.html') || path.endsWith('/arena-en.html') || path.endsWith('arena-en.html')) {
+ return 'en'
+ }
+ if (path.endsWith('/acts-zh.html') || path.endsWith('acts-zh.html') || path.endsWith('/arena-zh.html') || path.endsWith('arena-zh.html')) {
+ return 'zh'
+ }
+ }
+ if (typeof document !== 'undefined' && document.documentElement?.lang) {
+ const docLang = document.documentElement.lang.trim().toLowerCase()
+ if (docLang.startsWith('en')) return 'en'
+ if (docLang.startsWith('zh')) return 'zh'
+ }
+ return 'zh'
+}
+
+export function getGameLang(): GameLang {
+ return overrideLang ?? detectInitialLang()
+}
+
+export function getCurrentLang(): GameLang {
+ return getGameLang()
+}
+
+export function getTblLang(): TblLang {
+ return getGameLang() === 'en' ? 'ENG' : 'CHI'
+}
+
+export function isEn(): boolean {
+ return getGameLang() === 'en'
+}
+
+export function isEnglish(): boolean {
+ return getGameLang() === 'en'
+}
+
+function notifyListeners(): void {
+ const tbl = getTblLang()
+ for (const fn of listeners) {
+ try {
+ fn(tbl)
+ } catch {
+ // ignore listener errors
+ }
+ }
+}
+
+export function setGameLang(lang: GameLang | TblLang | null): void {
+ if (lang === null) {
+ overrideLang = null
+ notifyListeners()
+ return
+ }
+ overrideLang = toGameLang(lang)
+ notifyListeners()
+}
+
+export function setCurrentLang(lang: GameLang | TblLang | null): void {
+ setGameLang(lang)
+}
+
+export function setTblLang(lang: TblLang | GameLang | null): void {
+ setGameLang(lang)
+}
+
+export function initLangFromBrowser(): TblLang {
+ overrideLang = detectInitialLang()
+ notifyListeners()
+ return getTblLang()
+}
+
+export function onTblLangChange(listener: (lang: TblLang) => void): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+export function buildLangSwitchHref(
+ targetLang?: TblLang | GameLang,
+ currentPath?: string,
+ currentSearch?: string,
+): string {
+ const target = targetLang ? toTblLang(targetLang) : (getTblLang() === 'ENG' ? 'CHI' : 'ENG')
+ const searchStr = currentSearch ?? (typeof window !== 'undefined' && window.location ? window.location.search : '')
+ const params = new URLSearchParams(searchStr || '')
+ params.delete('lang')
+ const qs = params.toString()
+ const targetPage = target === 'ENG' ? '/acts-en.html' : '/acts-zh.html'
+ void currentPath
+ return qs ? `${targetPage}?${qs}` : targetPage
+}
diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts
index fa0de08..f1f4f7c 100644
--- a/src/scene/act-scene.ts
+++ b/src/scene/act-scene.ts
@@ -41,6 +41,8 @@ import {
enrichMonsterPacks,
ELITE_HEALTH_MULTIPLIER,
MONUMOD_CONSTANTS,
+ formatEliteAffixLabel,
+ monsterDisplayName,
type MonsterRank,
} from '../game/monsters.ts'
import { Rng } from '../game/rng.ts'
@@ -53,7 +55,7 @@ import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/de
import type { DropTables } from '../game/drop-pipeline.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
-import { getTownNpcDescriptorByName } from '../game/npc-table.ts'
+import { getTownNpcDescriptorByName, npcDisplayName } from '../game/npc-table.ts'
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
import { npcDialog } from '../game/quests.ts'
import type { NpcDef } from '../game/quests.ts'
@@ -88,7 +90,23 @@ import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.ts'
import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts'
import { AudioManager } from '../audio/manager.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
-import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
+import {
+ ACT_NAMES_EN,
+ ACT_NAMES_ZH,
+ LEVEL_NAMES_ZH,
+ getLevelNames,
+ levelDisplayName,
+ sceneDisplayName,
+ sceneNameZh,
+ variantDisplayLabel,
+ variantLabelZh,
+} from '../game/level-names-zh.ts'
+import {
+ buildLangSwitchHref,
+ getTblLang,
+ initLangFromBrowser,
+ type TblLang,
+} from '../i18n/lang.ts'
import { GameLoop } from '../sim/loop.ts'
import { KeyboardInput } from '../sim/input.ts'
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts'
@@ -107,6 +125,7 @@ import type {
import type { AutomapLayerData } from '../formats/automap-table.ts'
import { HudManager } from '../ui/hud-manager.ts'
import type { MonsterTargetHudInfo } from '../ui/world-panels.ts'
+import { getSkillName as getSkillDisplayName } from '../ui/skill-tree-panel.ts'
import type { InventoryPanel } from '../ui/inventory.ts'
import { LightGrid } from '../game/engine/light-grid.ts'
import { Environment } from '../game/engine/environment.ts'
@@ -274,7 +293,9 @@ export function hitTestWarp(
}
}
- const badgeText = `通往 ${getLevelNames(warp.toLevelId).nameZh}`
+ const badgeText = getTblLang() === 'ENG'
+ ? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
+ : `通往 ${getLevelNames(warp.toLevelId).nameZh}`
const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5))
const inBadge =
Math.abs(worldX - labelPos.x) <= badgeHalfWidth &&
@@ -327,7 +348,9 @@ export function hitTestEntrance(
}
const labelPos = getEntranceLabelWorldPos(entrance, grid)
- const badgeText = `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
+ const badgeText = getTblLang() === 'ENG'
+ ? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
+ : `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5))
const inBadge =
Math.abs(worldX - labelPos.x) <= badgeHalfWidth &&
@@ -1212,6 +1235,7 @@ export type LightingPreset =
export interface LightingPresetConfig {
readonly id: LightingPreset
readonly labelZh: string
+ readonly labelEn: string
readonly enabled: boolean
readonly overrideAmbient?: {
readonly intensity: number
@@ -1225,57 +1249,73 @@ export const LIGHTING_PRESETS: Readonly 0) {
@@ -2121,7 +2162,9 @@ export function castSkill(
hudManager?.inventory?.equipped?.weapon2 ??
(typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null)
if (!shield) {
- status.textContent = skillId === 97 ? '重击需要装备盾牌!' : '圣盾需要装备盾牌!'
+ status.textContent = isEn
+ ? (skillId === 97 ? 'Smite requires a shield!' : 'Holy Shield requires a shield!')
+ : (skillId === 97 ? '重击需要装备盾牌!' : '圣盾需要装备盾牌!')
return false
}
}
@@ -2136,7 +2179,7 @@ export function castSkill(
// Starting cast cost: 6 mana (1.13c Skills.txt)
const startCost = 6
if (currentMana < startCost) {
- status.textContent = '法力不足!'
+ status.textContent = isEn ? 'Not enough mana!' : '法力不足!'
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
return false
}
@@ -2153,7 +2196,7 @@ export function castSkill(
if (currentMana <= 0) {
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
- status.textContent = '法力不足!'
+ status.textContent = isEn ? 'Not enough mana!' : '法力不足!'
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
return false
}
@@ -2181,7 +2224,7 @@ export function castSkill(
const manaCost = getSkillMana(skillId, hudManager)
const currentMana = hudManager !== null ? hudManager.mana : player.mana
if (currentMana < manaCost) {
- status.textContent = '法力不足!'
+ status.textContent = isEn ? 'Not enough mana!' : '法力不足!'
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
return false
}
@@ -2228,7 +2271,9 @@ export function castSkill(
const ammo = context.equippedAmmo ?? hudManager?.inventory?.equipped?.weapon2 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null)
const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudManager?.inventory)
if (!hasAmmo) {
- status.textContent = weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'
+ status.textContent = isEn
+ ? (weaponInfo.ammoType === 'arrow' ? 'Out of arrows!' : 'Out of bolts!')
+ : (weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!')
return false
}
@@ -2258,7 +2303,9 @@ export function castSkill(
engine.projectiles.push(projectile)
engine.metrics.casts += 1
- status.textContent = weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'
+ status.textContent = isEn
+ ? (weaponInfo.subtype === 'bow' ? 'Attack: Shoot Arrow' : 'Attack: Shoot Bolt')
+ : (weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭')
return true
}
@@ -2297,8 +2344,13 @@ export function castSkill(
if (targetMonster !== null) {
damageMonster(engine.world, targetMonster.index, damage)
engine.metrics.playerHits += 1
- const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
- status.textContent = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}`
+ if (isEn) {
+ const attackLabel = weaponInfo.kind === 'unarmed' ? 'Unarmed Attack' : 'Attack'
+ status.textContent = `${attackLabel} hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}`
+ } else {
+ const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
+ status.textContent = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}`
+ }
} else {
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
}
@@ -2319,9 +2371,9 @@ export function castSkill(
ov.y = player.y
}
}
- status.textContent = '已施展传送'
+ status.textContent = isEn ? 'Cast Teleport' : '已施展传送'
} else {
- status.textContent = '无法传送到该位置'
+ status.textContent = isEn ? 'Cannot teleport there' : '无法传送到该位置'
}
return true
}
@@ -2361,7 +2413,9 @@ export function castSkill(
}
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `施展静态力场,削弱 ${hitCount} 个目标`
+ status.textContent = isEn
+ ? `Cast Static Field (${hitCount} targets)`
+ : `施展静态力场,削弱 ${hitCount} 个目标`
return true
}
@@ -2388,7 +2442,7 @@ export function castSkill(
if (dist > maxRange) {
refundTelekinesisMana()
- status.textContent = '目标超出心灵传动范围!'
+ status.textContent = isEn ? 'Target out of Telekinesis range!' : '目标超出心灵传动范围!'
return false
}
@@ -2426,7 +2480,9 @@ export function castSkill(
engine.spawnOverlay('light_cast_2', { x: targetMonster.x, y: targetMonster.y })
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `心灵传动命中:${targetMonster.stats.name || targetMonster.stats.id}`
+ status.textContent = isEn
+ ? `Telekinesis hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}`
+ : `心灵传动命中:${targetMonster.stats.name || targetMonster.stats.id}`
return true
}
@@ -2448,16 +2504,20 @@ export function castSkill(
hudManager.syncPublishedState()
}
if (res.remaining && res.remaining > 0) {
- status.textContent = `心灵传动拾取金币:${res.amount}(地面剩余:${res.remaining})`
+ status.textContent = isEn
+ ? `Telekinesis picked up gold: ${res.amount} (${res.remaining} remaining)`
+ : `心灵传动拾取金币:${res.amount}(地面剩余:${res.remaining})`
} else {
- status.textContent = `心灵传动拾取金币:${res.amount}`
+ status.textContent = isEn
+ ? `Telekinesis picked up gold: ${res.amount}`
+ : `心灵传动拾取金币:${res.amount}`
}
} else {
engine.metrics.inventoryRefusals += 1
triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now())
playInventoryFullFeedback()
- status.textContent = '金币已达携带上限。'
- showNotification('无法携带更多金币。')
+ status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。'
+ showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。')
}
} else {
if (hudManager) {
@@ -2468,7 +2528,7 @@ export function castSkill(
if (!placed) {
engine.metrics.inventoryRefusals += 1
triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now())
- status.textContent = '包裹已满。'
+ status.textContent = isEn ? 'I am overburdened.' : '包裹已满。'
return true
}
}
@@ -2479,17 +2539,21 @@ export function castSkill(
}
engine.metrics.pickups += 1
hudManager.syncPublishedState()
- status.textContent = `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
+ status.textContent = isEn
+ ? `Telekinesis picked up: ${nearbyGroundItem.name}`
+ : `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
} else {
engine.pickupItem(nearbyGroundItem.id)
- status.textContent = `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
+ status.textContent = isEn
+ ? `Telekinesis picked up: ${nearbyGroundItem.name}`
+ : `心灵传动拾取物品:${nearbyGroundItem.nameZh || nearbyGroundItem.name}`
}
}
} else {
// Equipment forbidden
engine.metrics.inventoryRefusals += 1
engine.world.events.push({ kind: 'itemRefused', x: nearbyGroundItem.x, y: nearbyGroundItem.y, text: 'cannot pickup equipment with telekinesis' } as any)
- status.textContent = '心灵传动无法拾取装备!'
+ status.textContent = isEn ? 'Cannot pick up equipment with Telekinesis!' : '心灵传动无法拾取装备!'
}
player.cooldown = 0
engine.metrics.casts += 1
@@ -2502,7 +2566,7 @@ export function castSkill(
if (clickedWp) {
engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY })
if (context.waypointNetwork?.activate(clickedWp.waypointId)) {
- status.textContent = `传送点已激活:${runtime.level}`
+ status.textContent = isEn ? `Waypoint activated: ${runtime.level}` : `传送点已激活:${runtime.level}`
}
if (hudManager) {
hudManager.worldPanels.setCurrentWaypoint(clickedWp.waypointId, runtime.levelId)
@@ -2528,7 +2592,7 @@ export function castSkill(
}
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '心灵传动打开储物箱'
+ status.textContent = isEn ? 'Telekinesis opened Stash' : '心灵传动打开储物箱'
return true
}
}
@@ -2542,7 +2606,7 @@ export function castSkill(
engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY })
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '心灵传动进入传送门'
+ status.textContent = isEn ? 'Telekinesis entered Town Portal' : '心灵传动进入传送门'
context.onEnterPortal()
return true
}
@@ -2567,12 +2631,14 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
const destName = getLevelNames(bestWarp.warp.toLevelId).nameZh
- status.textContent = `心灵传动触发入口:通往 ${destName}`
+ status.textContent = isEn
+ ? `Telekinesis triggered warp: To ${levelDisplayName(bestWarp.warp.toLevelId, 'ENG')}`
+ : `心灵传动触发入口:通往 ${destName}`
context.onEnterWarp?.(bestWarp.warp)
return true
}
refundTelekinesisMana()
- status.textContent = '目标超出心灵传动范围!'
+ status.textContent = isEn ? 'Target out of Telekinesis range!' : '目标超出心灵传动范围!'
return false
}
}
@@ -2581,7 +2647,7 @@ export function castSkill(
engine.spawnOverlay('light_cast_2', { x: targetX, y: targetY })
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '施展心灵传动'
+ status.textContent = isEn ? 'Cast Telekinesis' : '施展心灵传动'
return true
}
@@ -2620,7 +2686,7 @@ export function castSkill(
player.cooldown = 45
engine.metrics.casts += 1
- status.textContent = '施展暴风雪'
+ status.textContent = isEn ? 'Cast Blizzard' : '施展暴风雪'
return true
}
@@ -2653,7 +2719,7 @@ export function castSkill(
player.cooldown = 30
engine.metrics.casts += 1
- status.textContent = '施展陨石'
+ status.textContent = isEn ? 'Cast Meteor' : '施展陨石'
return true
}
@@ -2699,7 +2765,7 @@ export function castSkill(
player.cooldown = 35
engine.metrics.casts += 1
- status.textContent = '施展火墙'
+ status.textContent = isEn ? 'Cast Fire Wall' : '施展火墙'
return true
}
@@ -2721,7 +2787,7 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '施展烈火之径'
+ status.textContent = isEn ? 'Cast Blaze' : '施展烈火之径'
return true
}
@@ -2757,7 +2823,7 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '施展地狱火'
+ status.textContent = isEn ? 'Cast Inferno' : '施展地狱火'
return true
}
@@ -2783,7 +2849,7 @@ export function castSkill(
player.coldArmorSlvl = slvl
engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
player.cooldown = 0
- status.textContent = '冰封装甲已激活'
+ status.textContent = isEn ? 'Frozen Armor active' : '冰封装甲已激活'
engine.metrics.casts += 1
return true
}
@@ -2797,7 +2863,7 @@ export function castSkill(
player.coldArmorSlvl = slvl
engine.spawnOverlay('shiverarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
player.cooldown = 0
- status.textContent = '碎冰装甲已激活'
+ status.textContent = isEn ? 'Shiver Armor active' : '碎冰装甲已激活'
engine.metrics.casts += 1
return true
}
@@ -2811,7 +2877,7 @@ export function castSkill(
player.coldArmorSlvl = slvl
engine.spawnOverlay('chillingarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
player.cooldown = 0
- status.textContent = '寒冰装甲已激活'
+ status.textContent = isEn ? 'Chilling Armor active' : '寒冰装甲已激活'
engine.metrics.casts += 1
return true
}
@@ -2836,7 +2902,7 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '雷云风暴已激活'
+ status.textContent = isEn ? 'Thunder Storm active' : '雷云风暴已激活'
return true
}
@@ -2854,7 +2920,7 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '能量护盾已激活'
+ status.textContent = isEn ? 'Energy Shield active' : '能量护盾已激活'
return true
}
@@ -2877,7 +2943,7 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = '强化已激活'
+ status.textContent = isEn ? 'Enchant active' : '强化已激活'
return true
}
@@ -2936,7 +3002,7 @@ export function castSkill(
player.cooldown = 40
engine.metrics.casts += 1
- status.textContent = '施展九头海蛇'
+ status.textContent = isEn ? 'Cast Hydra' : '施展九头海蛇'
return true
}
@@ -2957,7 +3023,9 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `施展白骨装甲,吸收 ${capacity} 点物理伤害`
+ status.textContent = isEn
+ ? `Cast Bone Armor, absorbs ${capacity} physical damage`
+ : `施展白骨装甲,吸收 ${capacity} 点物理伤害`
return true
}
@@ -2966,7 +3034,7 @@ export function castSkill(
const weapon = context.equippedWeapon ?? hudManager?.inventory?.equipped?.weapon1 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon1 : null)
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.subtype !== 'dagger') {
- status.textContent = '淬毒匕首需要装备匕首类武器!'
+ status.textContent = isEn ? 'Poison Dagger requires a dagger equipped!' : '淬毒匕首需要装备匕首类武器!'
return false
}
@@ -3007,11 +3075,16 @@ export function castSkill(
damageMonster(engine.world, targetMonster.index, pDmg.minDamage)
engine.metrics.playerHits += 1
engine.metrics.casts += 1
- status.textContent = `淬毒匕首(+${arBonus}%命中)命中:${targetMonster.stats.name || targetMonster.stats.id},造成 ${pDmg.minDamage}-${pDmg.maxDamage} 点毒素伤害`
+ const mName = monsterDisplayName(targetMonster.stats)
+ status.textContent = isEn
+ ? `Poison Dagger (+${arBonus}% AR) hit: ${mName}, dealt ${pDmg.minDamage}-${pDmg.maxDamage} poison damage`
+ : `淬毒匕首(+${arBonus}%命中)命中:${targetMonster.stats.name || targetMonster.stats.id},造成 ${pDmg.minDamage}-${pDmg.maxDamage} 点毒素伤害`
} else {
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
engine.metrics.casts += 1
- status.textContent = `淬毒匕首(+${arBonus}%命中):未命中目标`
+ status.textContent = isEn
+ ? `Poison Dagger (+${arBonus}% AR): missed target`
+ : `淬毒匕首(+${arBonus}%命中):未命中目标`
}
return true
}
@@ -3036,7 +3109,7 @@ export function castSkill(
}
if (!targetCorpse) {
- status.textContent = '需要有效的目标尸体!'
+ status.textContent = isEn ? 'Requires a valid target corpse!' : '需要有效的目标尸体!'
return false
}
@@ -3066,7 +3139,9 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `尸体爆炸(范围 ${(radiusPx / 40).toFixed(1)} 码)消灭尸体,命中 ${hitCount} 个目标`
+ status.textContent = isEn
+ ? `Corpse Explosion (Radius ${(radiusPx / 40).toFixed(1)} yds) detonated corpse, hit ${hitCount} targets`
+ : `尸体爆炸(范围 ${(radiusPx / 40).toFixed(1)} 码)消灭尸体,命中 ${hitCount} 个目标`
return true
}
@@ -3093,7 +3168,9 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `施展骨墙(生命 ${wallStats.hp},持续 ${wallStats.durationSeconds} 秒,${wallStats.segmentCount} 骨节)`
+ status.textContent = isEn
+ ? `Cast Bone Wall (Life ${wallStats.hp}, Duration ${wallStats.durationSeconds}s, ${wallStats.segmentCount} segments)`
+ : `施展骨墙(生命 ${wallStats.hp},持续 ${wallStats.durationSeconds} 秒,${wallStats.segmentCount} 骨节)`
return true
}
@@ -3121,7 +3198,7 @@ export function castSkill(
}
if (!targetCorpse) {
- status.textContent = '需要有效的目标尸体!'
+ status.textContent = isEn ? 'Requires a valid target corpse!' : '需要有效的目标尸体!'
return false
}
@@ -3148,7 +3225,9 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `毒爆(持续 ${peDmg.durationSeconds.toFixed(1)} 秒,伤害 ${peDmg.minDamage}-${peDmg.maxDamage})命中 ${hitCount} 个目标`
+ status.textContent = isEn
+ ? `Poison Explosion (Duration ${peDmg.durationSeconds.toFixed(1)}s, Damage ${peDmg.minDamage}-${peDmg.maxDamage}) hit ${hitCount} targets`
+ : `毒爆(持续 ${peDmg.durationSeconds.toFixed(1)} 秒,伤害 ${peDmg.minDamage}-${peDmg.maxDamage})命中 ${hitCount} 个目标`
return true
}
@@ -3169,7 +3248,9 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `施展骨牢(生命 ${prisonStats.hp},持续 ${prisonStats.durationSeconds} 秒,${prisonStats.segmentCount} 骨柱)`
+ status.textContent = isEn
+ ? `Cast Bone Prison (Life ${prisonStats.hp}, Duration ${prisonStats.durationSeconds}s, ${prisonStats.segmentCount} segments)`
+ : `施展骨牢(生命 ${prisonStats.hp},持续 ${prisonStats.durationSeconds} 秒,${prisonStats.segmentCount} 骨柱)`
return true
}
@@ -3210,7 +3291,7 @@ export function castSkill(
}
if (!targetMonster) {
- status.textContent = '需要有效的目标!'
+ status.textContent = isEn ? 'Requires a valid target!' : '需要有效的目标!'
return false
}
@@ -3221,12 +3302,12 @@ export function castSkill(
targetMonster.stats.rank === 'unique'
)
if (isBossOrChamp) {
- status.textContent = '首领和头目免疫吸引!'
+ status.textContent = isEn ? 'Bosses and Champions are immune to Attract!' : '首领和头目免疫吸引!'
return false
}
if (targetMonster.curse?.curseType === 2 || targetMonster.curse?.name === 'attract') {
- status.textContent = '目标已被吸引!'
+ status.textContent = isEn ? 'Target is already Attracted!' : '目标已被吸引!'
return false
}
@@ -3241,7 +3322,9 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `吸引(持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中目标`
+ status.textContent = isEn
+ ? `Attract (Duration ${curseEffect.durationSeconds.toFixed(1)}s) hit target`
+ : `吸引(持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中目标`
return true
}
@@ -3298,7 +3381,10 @@ export function castSkill(
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `${curseEffect.skillName}(范围 ${(curseEffect.radiusPx / 40).toFixed(1)} 码,持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中 ${affectedCount} 个目标`
+ const curseName = isEn ? getSkillDisplayName(skillId, 'ENG') : curseEffect.skillName
+ status.textContent = isEn
+ ? `${curseName} (Radius ${(curseEffect.radiusPx / 40).toFixed(1)} yds, Duration ${curseEffect.durationSeconds.toFixed(1)}s) hit ${affectedCount} targets`
+ : `${curseEffect.skillName}(范围 ${(curseEffect.radiusPx / 40).toFixed(1)} 码,持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中 ${affectedCount} 个目标`
return true
}
@@ -3310,15 +3396,21 @@ export function castSkill(
// Passive skills (69, 79, 89)
if (skillId === 69) {
- status.textContent = `支配骷髅(等级 ${slvl}):被动提升骷髅与复活怪物的生命与伤害`
+ status.textContent = isEn
+ ? `Skeleton Mastery (Level ${slvl}): Passively increases life and damage of raised skeletons and revived monsters`
+ : `支配骷髅(等级 ${slvl}):被动提升骷髅与复活怪物的生命与伤害`
return true
}
if (skillId === 79) {
- status.textContent = `支配石魔(等级 ${slvl}):被动提升石魔的生命与移动速度`
+ status.textContent = isEn
+ ? `Golem Mastery (Level ${slvl}): Passively increases life and velocity of Golems`
+ : `支配石魔(等级 ${slvl}):被动提升石魔的生命与移动速度`
return true
}
if (skillId === 89) {
- status.textContent = `召唤物抗性(等级 ${slvl}):被动提升所有召唤物的元素抗性`
+ status.textContent = isEn
+ ? `Summon Resist (Level ${slvl}): Passively increases elemental resistances of all summons`
+ : `召唤物抗性(等级 ${slvl}):被动提升所有召唤物的元素抗性`
return true
}
@@ -3338,7 +3430,9 @@ export function castSkill(
}
if (!targetCorpse) {
- status.textContent = '需要未被消耗的怪物尸体(非首领)!'
+ status.textContent = isEn
+ ? 'Requires an unconsumed monster corpse (non-Boss)!'
+ : '需要未被消耗的怪物尸体(非首领)!'
return false
}
@@ -3353,13 +3447,19 @@ export function castSkill(
if (skillId === 70) {
const maxSkel = calculateSkeletonMaxCount(slvl)
- status.textContent = `复生骷髅(等级 ${slvl},上限 ${maxSkel} 只)召唤成功`
+ status.textContent = isEn
+ ? `Raise Skeleton (Level ${slvl}, Max ${maxSkel}) summoned`
+ : `复生骷髅(等级 ${slvl},上限 ${maxSkel} 只)召唤成功`
} else if (skillId === 80) {
const maxMage = calculateSkeletalMageMaxCount(slvl)
- status.textContent = `复生骷髅法师(等级 ${slvl},上限 ${maxMage} 只)召唤成功`
+ status.textContent = isEn
+ ? `Raise Skeletal Mage (Level ${slvl}, Max ${maxMage}) summoned`
+ : `复生骷髅法师(等级 ${slvl},上限 ${maxMage} 只)召唤成功`
} else {
const maxRevive = calculateReviveMaxCount(slvl)
- status.textContent = `重生(等级 ${slvl},上限 ${maxRevive} 只,持续 180 秒)召唤成功`
+ status.textContent = isEn
+ ? `Revive (Level ${slvl}, Max ${maxRevive}, Duration 180s) summoned`
+ : `重生(等级 ${slvl},上限 ${maxRevive} 只,持续 180 秒)召唤成功`
}
return true
}
@@ -3370,7 +3470,9 @@ export function castSkill(
engine.spawnOverlay('bonecast', { x: targetX, y: targetY })
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `钢铁石魔(等级 ${slvl},继承装备属性与反伤)召唤成功`
+ status.textContent = isEn
+ ? `Iron Golem (Level ${slvl}, inherits item properties and thorns) summoned`
+ : `钢铁石魔(等级 ${slvl},继承装备属性与反伤)召唤成功`
return true
}
@@ -3378,7 +3480,9 @@ export function castSkill(
engine.spawnOverlay('bonecast', { x: targetX, y: targetY })
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `粘土石魔(等级 ${slvl},附带减速攻击与受击减速)召唤成功`
+ status.textContent = isEn
+ ? `Clay Golem (Level ${slvl}, slows enemies on hit) summoned`
+ : `粘土石魔(等级 ${slvl},附带减速攻击与受击减速)召唤成功`
return true
}
@@ -3386,7 +3490,9 @@ export function castSkill(
engine.spawnOverlay('bonecast', { x: targetX, y: targetY })
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `鲜血石魔(等级 ${slvl},附带 70% 生命链接与伤害分担)召唤成功`
+ status.textContent = isEn
+ ? `Blood Golem (Level ${slvl}, 70% life link) summoned`
+ : `鲜血石魔(等级 ${slvl},附带 70% 生命链接与伤害分担)召唤成功`
return true
}
@@ -3394,7 +3500,9 @@ export function castSkill(
engine.spawnOverlay('bonecast', { x: targetX, y: targetY })
player.cooldown = 0
engine.metrics.casts += 1
- status.textContent = `火焰石魔(等级 ${slvl},附带圣火脉冲与火焰吸收)召唤成功`
+ status.textContent = isEn
+ ? `Fire Golem (Level ${slvl}, Holy Fire aura & fire absorb) summoned`
+ : `火焰石魔(等级 ${slvl},附带圣火脉冲与火焰吸收)召唤成功`
return true
}
}
@@ -3404,7 +3512,7 @@ export function castSkill(
let range = 450
let damage = 28
let delay = 0
- let skillName = '技能'
+ let skillName = isEn ? 'Skill' : '技能'
let missileType: string | undefined = undefined
let pierce: boolean | undefined = undefined
let aoeRadius: number | undefined = undefined
@@ -3857,7 +3965,8 @@ export function castSkill(
const cdTicks = getSkillCooldownTicks(skillId)
player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 38 || skillId === 43 || skillId === 44 || skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10))
engine.metrics.casts += 1
- status.textContent = `施展技能:${skillName}`
+ const displaySkillName = isEn ? getSkillDisplayName(skillId, 'ENG') : skillName
+ status.textContent = isEn ? `Cast skill: ${displaySkillName}` : `施展技能:${skillName}`
return true
}
@@ -4361,9 +4470,18 @@ export class SceneMouseController {
}
const activeNpcMenuName = this.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase()
+ const currentLang = getTblLang()
+ const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1)
for (const npc of this.engine.npcEntities ?? []) {
if (npc.def.name.toLowerCase() === 'dummy') continue
- if (activeNpcMenuName && npc.def.name.toLowerCase() === activeNpcMenuName) continue
+ const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc)
+ const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name
+ if (
+ activeNpcMenuName &&
+ (npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName)
+ ) {
+ continue
+ }
const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x
const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
@@ -4372,7 +4490,7 @@ export class SceneMouseController {
if (domX <= -100 || domX >= rect.width + 100) continue
if (domY <= -100 || domY >= rect.height + 100) continue
const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container)
- if (label.textContent !== npc.def.name) label.textContent = npc.def.name
+ if (label.textContent !== npcLabelText) label.textContent = npcLabelText
label.className = 'd2-overhead-label d2-npc-label'
if (label.dataset) {
label.dataset.kind = 'npc'
@@ -4407,7 +4525,9 @@ export class SceneMouseController {
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) {
const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container)
- const portalLabelText = `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}`
+ const portalLabelText = currentLang === 'ENG'
+ ? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}`
+ : `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}`
if (label.textContent !== portalLabelText) label.textContent = portalLabelText
label.className = 'd2-overhead-label d2-portal-label'
if (label.dataset) {
@@ -4438,8 +4558,9 @@ export class SceneMouseController {
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container)
- const destName = getLevelNames(warp.toLevelId).nameZh
- const badgeText = `通往 ${destName}`
+ const badgeText = currentLang === 'ENG'
+ ? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
+ : `通往 ${getLevelNames(warp.toLevelId).nameZh}`
if (label.textContent !== badgeText) label.textContent = badgeText
const isHovered = this.hoveredWarp === warp
label.className = isHovered
@@ -4471,8 +4592,9 @@ export class SceneMouseController {
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
const label = this.labelPool[labelCount] ?? this.allocateLabelElement(container)
- const destName = getLevelNames(entrance.toLevelId).nameZh
- const badgeText = `通往 ${destName}`
+ const badgeText = currentLang === 'ENG'
+ ? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
+ : `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
if (label.textContent !== badgeText) label.textContent = badgeText
const isHovered = this.hoveredEntrance === entrance
label.className = isHovered
@@ -4528,7 +4650,10 @@ export class SceneMouseController {
openStash(stash: TownStashEntity): void {
const player = this.engine.world.player
player.facing = facingOf(stash.x - player.x, stash.y - player.y)
- this.status.textContent = `打开私人储物箱 (${stash.nameZh})`
+ const isEn = getTblLang() === 'ENG'
+ this.status.textContent = isEn
+ ? `Open ${stash.nameEn}`
+ : `打开私人储物箱 (${stash.nameZh})`
this.playStashOpenSound()
if (this.hudManager && this.hudManager.leftPanel !== 'stash') {
this.hudManager.toggleLeftPanel('stash')
@@ -4542,6 +4667,7 @@ export class SceneMouseController {
const player = this.engine.world.player
const dist = Math.hypot(item.x - player.x, item.y - player.y)
const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64)
+ const isEn = getTblLang() === 'ENG'
if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) {
this.navTarget = null
this.pendingInteraction = null
@@ -4561,16 +4687,20 @@ export class SceneMouseController {
this.hudManager.syncPublishedState()
}
if (res.remaining && res.remaining > 0) {
- this.status.textContent = `拾起金币:${res.amount}(地面剩余:${res.remaining})`
+ this.status.textContent = isEn
+ ? `Picked up Gold: ${res.amount} (Remaining on ground: ${res.remaining})`
+ : `拾起金币:${res.amount}(地面剩余:${res.remaining})`
} else {
- this.status.textContent = `拾起金币:${res.amount}`
+ this.status.textContent = isEn
+ ? `Picked up Gold: ${res.amount}`
+ : `拾起金币:${res.amount}`
}
} else {
this.engine.metrics.inventoryRefusals += 1
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
this.playInventoryFullFeedback()
- this.status.textContent = '金币已达携带上限。'
- this.showNotification('无法携带更多金币。')
+ this.status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。'
+ this.showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。')
}
} else {
if (this.hudManager) {
@@ -4582,7 +4712,7 @@ export class SceneMouseController {
this.engine.metrics.inventoryRefusals += 1
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
this.playInventoryFullFeedback()
- this.showNotification('包裹已满。')
+ this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。')
return
}
}
@@ -4593,14 +4723,18 @@ export class SceneMouseController {
}
this.engine.metrics.pickups += 1
this.hudManager.syncPublishedState()
- this.status.textContent = `拾起物品:${item.nameZh || item.name}`
+ this.status.textContent = isEn
+ ? `Picked up item: ${item.name}`
+ : `拾起物品:${item.nameZh || item.name}`
} else {
const res = this.engine.pickupItem(item.id)
if (!res.success && res.reason === 'full') {
this.playInventoryFullFeedback()
- this.showNotification('包裹已满。')
+ this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。')
} else if (res.success) {
- this.status.textContent = `拾起物品:${item.nameZh || item.name}`
+ this.status.textContent = isEn
+ ? `Picked up item: ${item.name}`
+ : `拾起物品:${item.nameZh || item.name}`
}
}
}
@@ -4886,11 +5020,11 @@ export class SceneMouseController {
player.facing = facingOf(npc.x - player.x, npc.y - player.y)
if (npc.def.questId !== null) this.engine.questLog.accept(npc.def.questId)
this.engine.dialog = npcDialog(npc.def, this.engine.questLog)
- this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}`
-
const runtime = this.getRuntime()
const actIndex = Math.max(0, (runtime.act ?? 1) - 1)
const descriptor = getTownNpcDescriptorByName(npc.def.name, actIndex)
+ this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}`
+
if (descriptor) {
if (descriptor.canHeal) {
if (player.maxHp !== undefined) player.hp = player.maxHp
@@ -4998,7 +5132,9 @@ export class SceneMouseController {
this.pendingInteraction = null
player.facing = facingOf(clickedWp.pos.x - player.x, clickedWp.pos.y - player.y)
if (this.waypointNetwork.activate(clickedWp.wp.waypointId)) {
- this.status.textContent = `传送点已激活:${runtime.level}`
+ const isEn = getTblLang() === 'ENG'
+ const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level
+ this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}`
}
if (this.hudManager) {
this.hudManager.worldPanels.setCurrentWaypoint(clickedWp.wp.waypointId, runtime.levelId)
@@ -5142,8 +5278,10 @@ export class SceneMouseController {
this.isLeftMouseDown = false
this.activeClickType = 'none'
player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y)
- const destName = getLevelNames(warp.toLevelId).nameZh
- this.status.textContent = `进入:${destName}`
+ const isEn = getTblLang() === 'ENG'
+ this.status.textContent = isEn
+ ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}`
+ : `进入:${getLevelNames(warp.toLevelId).nameZh}`
this.onEnterWarp?.(warp)
} else {
this.pendingInteraction = { kind: 'warp', warp, anchorPos, arrivePos }
@@ -5165,8 +5303,10 @@ export class SceneMouseController {
this.isLeftMouseDown = false
this.activeClickType = 'none'
player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y)
- const destName = getLevelNames(entrance.toLevelId).nameZh
- this.status.textContent = `进入:${destName}`
+ const isEn = getTblLang() === 'ENG'
+ this.status.textContent = isEn
+ ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}`
+ : `进入:${getLevelNames(entrance.toLevelId).nameZh}`
this.onEnterEntrance?.(entrance)
} else {
this.pendingInteraction = { kind: 'entrance', entrance, anchorPos, arrivePos }
@@ -5389,7 +5529,10 @@ export class SceneMouseController {
this.lastPlayerPos = null
player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y)
if (this.waypointNetwork.activate(waypoint.waypointId)) {
- this.status.textContent = `传送点已激活:${this.getRuntime().level}`
+ const rt = this.getRuntime()
+ const isEn = getTblLang() === 'ENG'
+ const lvlLabel = isEn ? levelDisplayName(rt.levelId, 'ENG') : rt.level
+ this.status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}`
}
if (this.hudManager) {
this.hudManager.worldPanels.setCurrentWaypoint(waypoint.waypointId, this.getRuntime().levelId)
@@ -5436,8 +5579,10 @@ export class SceneMouseController {
this.isLeftMouseDown = false
this.activeClickType = 'none'
player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y)
- const destName = getLevelNames(warp.toLevelId).nameZh
- this.status.textContent = `进入:${destName}`
+ const isEn = getTblLang() === 'ENG'
+ this.status.textContent = isEn
+ ? `Enter: ${levelDisplayName(warp.toLevelId, 'ENG')}`
+ : `进入:${getLevelNames(warp.toLevelId).nameZh}`
this.onEnterWarp?.(warp)
} else {
this.navTarget = { x: arrivePos.x, y: arrivePos.y }
@@ -5452,8 +5597,10 @@ export class SceneMouseController {
this.isLeftMouseDown = false
this.activeClickType = 'none'
player.facing = facingOf(anchorPos.x - player.x, anchorPos.y - player.y)
- const destName = getLevelNames(entrance.toLevelId).nameZh
- this.status.textContent = `进入:${destName}`
+ const isEn = getTblLang() === 'ENG'
+ this.status.textContent = isEn
+ ? `Enter: ${levelDisplayName(entrance.toLevelId, 'ENG')}`
+ : `进入:${getLevelNames(entrance.toLevelId).nameZh}`
this.onEnterEntrance?.(entrance)
} else {
this.navTarget = { x: arrivePos.x, y: arrivePos.y }
@@ -6140,12 +6287,19 @@ async function buildPackRuntime(
])),
objects: scene.objects.length,
frames: scene.frames.length,
- notes: [
- `首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`,
- ...(scene.monsters?.packs && scene.monsters.packs.length > 0
- ? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join('、')})`]
- : []),
- ],
+ notes: getTblLang() === 'ENG'
+ ? [
+ `Initial pages ${priority.join(',')}, total ${String(pages.length)} pages`,
+ ...(scene.monsters?.packs && scene.monsters.packs.length > 0
+ ? [`Monsters: ${String(scene.monsters.budget)} / ${String(scene.monsters.packs.length)} packs (${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join(', ')})`]
+ : []),
+ ]
+ : [
+ `首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`,
+ ...(scene.monsters?.packs && scene.monsters.packs.length > 0
+ ? [`怪物:${String(scene.monsters.budget)} 只 / ${String(scene.monsters.packs.length)} 群(${Array.from(new Set([...(scene.monsters.types ?? []), ...(scene.monsters.packs ?? []).flatMap(p => p.members.map(m => m.id))])).join('、')})`]
+ : []),
+ ],
pages,
objectPages,
priorityPages: priority,
@@ -6358,19 +6512,27 @@ export function missingMonsterArtIds(
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
* @returns the fragment, ready to append to the HUD line.
*/
-export function monsterArtStatusText(load: {
- readonly missing: readonly string[]
- readonly errors: number
- readonly layerFailures: number
-}): string {
+export function monsterArtStatusText(
+ load: {
+ readonly missing: readonly string[]
+ readonly errors: number
+ readonly layerFailures: number
+ },
+ lang: TblLang = getTblLang(),
+): string {
+ const isEn = lang === 'ENG'
const parts: string[] = []
if (load.missing.length > 0) {
- const shown = load.missing.slice(0, 3).join('、')
+ const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、')
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
- parts.push(`怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`)
+ parts.push(
+ isEn
+ ? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})`
+ : `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`,
+ )
}
- if (load.errors > 0) parts.push(`加载错误 ${String(load.errors)}`)
- if (load.layerFailures > 0) parts.push(`缺层 ${String(load.layerFailures)}`)
+ if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`)
+ if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${String(load.layerFailures)}`)
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
}
@@ -6380,7 +6542,10 @@ export function monsterArtStatusText(load: {
* @param planned - how many monsters the plan produced.
* @returns the fragment, ready to append to the HUD line.
*/
-export function monsterPlanStatusText(planned: number): string {
+export function monsterPlanStatusText(planned: number, lang: TblLang = getTblLang()): string {
+ if (lang === 'ENG') {
+ return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}`
+ }
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
}
@@ -7665,14 +7830,18 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
let currentLightingPreset: LightingPreset = normalizeLightingPreset(param('lighting', 'auto'))
let lightingEnabled = LIGHTING_PRESETS[currentLightingPreset].enabled
state.lightingPreset = currentLightingPreset
- state.lightingMode = LIGHTING_PRESETS[currentLightingPreset].labelZh
+ state.lightingMode = getTblLang() === 'ENG'
+ ? LIGHTING_PRESETS[currentLightingPreset].labelEn
+ : LIGHTING_PRESETS[currentLightingPreset].labelZh
const switchLighting = (preset: string): LightingPreset => {
currentLightingPreset = normalizeLightingPreset(preset)
const cfg = LIGHTING_PRESETS[currentLightingPreset]
lightingEnabled = cfg.enabled
+ const isEn = getTblLang() === 'ENG'
+ const modeLabel = isEn ? cfg.labelEn : cfg.labelZh
state.lightingPreset = currentLightingPreset
- state.lightingMode = cfg.labelZh
+ state.lightingMode = modeLabel
if (lightingSelect !== null && lightingSelect.value !== currentLightingPreset) {
lightingSelect.value = currentLightingPreset
}
@@ -7687,9 +7856,10 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
} catch {
// ignore in non-browser context
}
- status.textContent = `已切换场景光照为:${cfg.labelZh}`
+ const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
+ status.textContent = `${msgPrefix}${modeLabel}`
setTimeout(() => {
- if (status.textContent.startsWith('已切换场景光照为:')) {
+ if (status.textContent.startsWith(msgPrefix)) {
status.textContent = ''
}
}, 2500)
@@ -7708,7 +7878,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const nextToken = normalizeHeroToken(newHeroToken)
if (nextToken === currentHeroToken && character !== null) return true
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
- status.textContent = `正在切换角色至 ${nextInfo.nameZh} …`
+ const isEn = getTblLang() === 'ENG'
+ const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh
+ status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …`
const nextChar = await loadCharacterArt(
runtime.charBases,
activePalette,
@@ -7743,15 +7915,20 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
if (nextChar.clips?.nu) {
playerAnimator.play('nu', nextChar.clips.nu, { restart: true })
}
- status.textContent = `已切换角色为 ${nextInfo.nameZh}(${String(nextChar.members)} 层素材)。`
+ const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 '
+ status.textContent = isEn
+ ? `${switchedPrefix}${heroDispName} (${String(nextChar.members)} layers).`
+ : `${switchedPrefix}${heroDispName}(${String(nextChar.members)} 层素材)。`
setTimeout(() => {
- if (status.textContent.startsWith('已切换角色为')) {
+ if (status.textContent.startsWith(switchedPrefix)) {
status.textContent = ''
}
}, 3000)
return true
}
- status.textContent = `切换角色 ${nextInfo.nameZh} 失败。`
+ status.textContent = isEn
+ ? `Failed to switch hero to ${heroDispName}.`
+ : `切换角色 ${heroDispName} 失败。`
return false
}
@@ -7974,7 +8151,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
chatInput = document.createElement('input')
chatInput.id = 'd2-chat-input'
chatInput.type = 'text'
- chatInput.placeholder = '输入 /players 1-8 或消息,按 Enter 发送…'
+ chatInput.placeholder = getTblLang() === 'ENG'
+ ? 'Type /players 1-8 or message, press Enter to send…'
+ : '输入 /players 1-8 或消息,按 Enter 发送…'
chatInput.className = 'overlay'
chatInput.style.position = 'fixed'
chatInput.style.left = '12px'
@@ -7994,6 +8173,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const openChat = () => {
if (!chatInput) return
+ chatInput.placeholder = getTblLang() === 'ENG'
+ ? 'Type /players 1-8 or message, press Enter to send…'
+ : '输入 /players 1-8 或消息,按 Enter 发送…'
chatInput.style.display = 'block'
chatInput.value = ''
chatInput.focus()
@@ -8217,9 +8399,10 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
*/
const castTownPortal = (): boolean => {
if (travelling) return false
+ const isEn = getTblLang() === 'ENG'
if (isTownLevel(runtime.levelId)) {
playInventoryFullFeedback()
- showNotification('无法在城镇中开启回城传送门', 2000, status)
+ showNotification(isEn ? 'Cannot cast Town Portal in town' : '无法在城镇中开启回城传送门', 2000, status)
return false
}
@@ -8267,7 +8450,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
playerAnimator.play('sc', character.clips['sc'], { restart: true })
}
const townNames = getLevelNames(townLevelId)
- status.textContent = `已开启返回 ${townNames.nameZh} 的传送门`
+ status.textContent = isEn
+ ? `Opened Town Portal to ${townNames.nameEn}`
+ : `已开启返回 ${townNames.nameZh} 的传送门`
return true
}
@@ -8306,13 +8491,19 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
onDropItemToGround: (item) => {
const player = engine.world.player
const groundItem = engine.dropItem(item, player.x, player.y)
- status.textContent = `已将「${groundItem.nameZh}」丢弃在地面`
+ const isEn = getTblLang() === 'ENG'
+ status.textContent = isEn
+ ? `Dropped "${groundItem.name}" on ground`
+ : `已将「${groundItem.nameZh}」丢弃在地面`
},
onDropGoldToGround: (amount) => {
const player = engine.world.player
const groundGold = engine.dropGold(amount, player.x, player.y)
if (groundGold) {
- status.textContent = `已将 ${amount.toLocaleString()} 金币丢弃在地面`
+ const isEn = getTblLang() === 'ENG'
+ status.textContent = isEn
+ ? `Dropped ${amount.toLocaleString()} Gold on ground`
+ : `已将 ${amount.toLocaleString()} 金币丢弃在地面`
}
},
})
@@ -8606,6 +8797,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
travelling = true
mouseController.clearTarget()
const fromLevelId = runtime.levelId
+ const isEn = getTblLang() === 'ENG'
try {
await fade.out(fadeMs)
const next = await loadRuntimeForLevel(
@@ -8617,7 +8809,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
isTownLevel(toLevelId) ? townVariantByAct.get(runtime.act) : undefined,
)
if (next === null) {
- status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
+ status.textContent = isEn
+ ? `Level ${String(toLevelId)} not found in pack.`
+ : `资源包里没有关卡 ${String(toLevelId)}。`
return
}
await next.loadPages(renderer, next.priorityPages, () => {
@@ -8671,7 +8865,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
await swapLevel(next, landing)
preloadRemaining(next, renderer)
} catch (err) {
- status.textContent = `切换关卡失败:${(err as Error).message}`
+ status.textContent = isEn
+ ? `Level transition failed: ${(err as Error).message}`
+ : `切换关卡失败:${(err as Error).message}`
} finally {
await fade.in(fadeMs)
travelling = false
@@ -8689,11 +8885,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
if (travelling || runtime.source !== 'pack') return
travelling = true
mouseController.clearTarget()
+ const isEn = getTblLang() === 'ENG'
try {
await fade.out(WARP_FADE_MS)
const index = await getPackIndex(runtime.base)
if (index === null) {
- status.textContent = '无法读取资源包索引。'
+ status.textContent = isEn ? 'Failed to read pack index.' : '无法读取资源包索引。'
return
}
const normalizedLevelId = targetLevelId === 120 ? 123 : targetLevelId
@@ -8713,7 +8910,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
)
}
if (pool.length === 0) {
- status.textContent = `资源包里没有传送点关卡:${slug}`
+ status.textContent = isEn
+ ? `Waypoint level not found in pack: ${slug}`
+ : `资源包里没有传送点关卡:${slug}`
return
}
@@ -8748,9 +8947,13 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
minimapCache = null
const names = getLevelNames(next.levelId ?? next.level)
- status.textContent = `已传送到:${names.nameZh}(Act ${String(next.act)})`
+ status.textContent = isEn
+ ? `Teleported to: ${names.nameEn} (Act ${String(next.act)})`
+ : `已传送到:${names.nameZh}(Act ${String(next.act)})`
} catch (err) {
- status.textContent = `传送点传送失败:${(err as Error).message}`
+ status.textContent = isEn
+ ? `Waypoint teleport failed: ${(err as Error).message}`
+ : `传送点传送失败:${(err as Error).message}`
} finally {
await fade.in(WARP_FADE_MS)
travelling = false
@@ -8792,7 +8995,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}
}
if (waypointNetwork.activate(waypoint.waypointId)) {
- status.textContent = `传送点已激活:${runtime.level}`
+ const isEn = getTblLang() === 'ENG'
+ const lvlLabel = isEn ? levelDisplayName(runtime.levelId, 'ENG') : runtime.level
+ status.textContent = isEn ? `Waypoint activated: ${lvlLabel}` : `传送点已激活:${lvlLabel}`
}
if (input.takeWaypoint()) {
// No destination picker yet, so the waypoint does the one thing a
@@ -8821,9 +9026,10 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
* arrays, and the render loop asks for this every frame.
*/
const minimapLevel = (): MinimapLevel => {
+ const currentLang = getTblLang()
// Live runtimes have no entry path, so fall back to the level name; either
// way the key only has to change when the level does.
- const key = runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath
+ const key = `${currentLang}:${runtime.entryPath === '' ? `live:${runtime.level}` : runtime.entryPath}`
if (minimapCache !== null && minimapCache.key === key) return minimapCache.level
const marker = (
x: number,
@@ -8833,7 +9039,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
): MinimapMarker => {
const targetName =
toLevelId !== undefined && toLevelId > 0
- ? getLevelNames(toLevelId).nameZh
+ ? levelDisplayName(toLevelId, currentLang)
: undefined
return {
cellX: Math.floor(x / SUB_TILES_PER_TILE),
@@ -8845,7 +9051,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}
const resolvedLevelName =
runtime.levelId > 0
- ? getLevelNames(runtime.levelId).nameZh || runtime.level
+ ? levelDisplayName(runtime.levelId, currentLang) || runtime.level
: runtime.level
const level: MinimapLevel = {
key,
@@ -8858,7 +9064,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
...(runtime.automap !== undefined ? { automap: runtime.automap } : {}),
levelId: runtime.levelId,
levelName: resolvedLevelName,
- difficulty: '普通',
+ difficulty: currentLang === 'ENG' ? 'Normal' : '普通',
preRevealTown: isTownLevel(runtime.levelId),
markers: [
...runtime.entrances.map(entrance => marker(entrance.x, entrance.y, 'exit', entrance.toLevelId)),
@@ -9192,13 +9398,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
})
state.lightingPreset = currentLightingPreset
- state.lightingMode = presetCfg.labelZh
+ state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
state.ambientIntensity = effectiveAmbIntensity
state.ambientColor = [effR, effG, effB]
} else {
renderer.setLighting(null)
+ const presetCfg = LIGHTING_PRESETS[currentLightingPreset]
state.lightingPreset = currentLightingPreset
- state.lightingMode = LIGHTING_PRESETS[currentLightingPreset].labelZh
+ state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
state.ambientIntensity = 255
state.ambientColor = [255, 255, 255]
}
@@ -9565,6 +9772,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
renderer.setLighting(null)
}
// Last in the batch, so the panel sits over every world layer.
+ const isEnRender = getTblLang() === 'ENG'
const automapEntities = [
...engine.npcEntities
.filter(
@@ -9578,7 +9786,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
x: npc.x,
y: npc.y,
kind: 'npc' as const,
- name: npc.def.name,
+ name: isEnRender ? npc.def.name : npcDisplayName(npc.def.name),
})),
...engine.world.monsters
.filter(m => m.state !== 'dead' && m.hp > 0 && m.stats.rank === 'unique')
@@ -9586,7 +9794,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
x: m.x,
y: m.y,
kind: 'boss' as const,
- name: m.stats.name || m.stats.id,
+ name: monsterDisplayName(m.stats),
})),
]
const automapPortals =
@@ -9596,14 +9804,16 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
x: activePortalPos.x,
y: activePortalPos.y,
kind: 'blue' as const,
- label: `传送门 -> ${getLevelNames(activePortalMouth.toLevelId).nameZh}`,
+ label: isEnRender
+ ? `Town Portal -> ${getLevelNames(activePortalMouth.toLevelId).nameEn}`
+ : `传送门 -> ${getLevelNames(activePortalMouth.toLevelId).nameZh}`,
},
]
: []
const automapStashes = (runtime.stashes ?? []).map(s => ({
x: s.x,
y: s.y,
- label: s.nameZh,
+ label: isEnRender ? s.nameEn : s.nameZh,
}))
minimap.draw(
renderer,
@@ -9703,20 +9913,35 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const simTpsText = loop.simTps.toFixed(1)
const renderFpsText = loop.fps.toFixed(0)
const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1)
- hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
- + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.shadows?.length ?? 0)} 阴影 / ${String(runtime.walls.length)} 墙`
- + ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
- + ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
- + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
- + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
- // A level whose monsters are all red boxes used to look exactly like a
- // healthy one here.
- + monsterPlanStatusText(state.monstersPlanned)
- + monsterArtStatusText({
- missing: state.missingMonsterArt,
- errors: state.monsterArtErrors,
- layerFailures: state.monsterArtLayerFailures,
- })
+ if (isEnRender) {
+ hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? 'pack' : 'mpq'}`
+ + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} cells · ${String(runtime.floors.length)} floors / ${String(runtime.shadows?.length ?? 0)} shadows / ${String(runtime.walls.length)} walls`
+ + ` · pages ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
+ + ` · objects ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ + ` · ${String(state.quadsDrawn)} quads / ${String(state.drawCalls)} draws (culled ${String(culledDraws)})`
+ + (skippedDraws > 0 ? ` · pending ${String(skippedDraws)}` : '')
+ + monsterPlanStatusText(state.monstersPlanned)
+ + monsterArtStatusText({
+ missing: state.missingMonsterArt,
+ errors: state.monsterArtErrors,
+ layerFailures: state.monsterArtLayerFailures,
+ })
+ } else {
+ hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
+ + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.shadows?.length ?? 0)} 阴影 / ${String(runtime.walls.length)} 墙`
+ + ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
+ + ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
+ + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
+ // A level whose monsters are all red boxes used to look exactly like a
+ // healthy one here.
+ + monsterPlanStatusText(state.monstersPlanned)
+ + monsterArtStatusText({
+ missing: state.missingMonsterArt,
+ errors: state.monsterArtErrors,
+ layerFailures: state.monsterArtLayerFailures,
+ })
+ }
}
},
})
@@ -9899,6 +10124,8 @@ async function populatePackSelectors(packBase: string, current: { act: number; l
return
}
+ const isEn = getTblLang() === 'ENG'
+
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
interface Scene {
readonly slug: string
@@ -9912,7 +10139,11 @@ async function populatePackSelectors(packBase: string, current: { act: number; l
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
let scene = scenes.find(candidate => candidate.slug === entry.slug)
if (scene === undefined) {
- scene = { slug: entry.slug, name: sceneNameZh(entry.slug, entry.levelName), variants: [] }
+ scene = {
+ slug: entry.slug,
+ name: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'),
+ variants: [],
+ }
scenes.push(scene)
}
;(scene.variants as PackIndex['levels'][number][]).push(entry)
@@ -9933,7 +10164,9 @@ async function populatePackSelectors(packBase: string, current: { act: number; l
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
actSelect.replaceChildren(...acts.map(act => optionFor(
String(act),
- `${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
+ isEn
+ ? `${ACT_NAMES_EN[act] ?? `Act ${String(act)}`}`
+ : `${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
act === current.act,
)))
actSelect.disabled = acts.length === 0
@@ -9955,7 +10188,11 @@ async function populatePackSelectors(packBase: string, current: { act: number; l
// an empty box; the option text repeats the scene name only when it is the only one.
variantSelect.replaceChildren(...variants.map(entry => {
const suffix = suffixOf(entry)
- const label = variants.length > 1 ? variantLabelZh(suffix) : `${scene?.name ?? entry.levelName}(${suffix})`
+ const label = variants.length > 1
+ ? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI')
+ : isEn
+ ? `${scene?.name ?? entry.levelName} (${suffix})`
+ : `${scene?.name ?? entry.levelName}(${suffix})`
return optionFor(entry.label, label, entry.label === selectedLabel)
}))
variantSelect.disabled = variants.length === 0
@@ -10238,6 +10475,8 @@ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
* Boot the page from pre-packed assets.
*/
async function boot(): Promise {
+ const activeLang: TblLang = initLangFromBrowser()
+ const isEn = activeLang === 'ENG'
void initBrowserArenaMode()
const canvas = document.querySelector('#view')!
const status = document.querySelector('#status')!
@@ -10246,6 +10485,17 @@ async function boot(): Promise {
const sceneSelect = document.querySelector('#scene')!
const variantSelect = document.querySelector('#variant')!
const lightingSelect = document.querySelector('#lighting')
+ const langSwitch = document.querySelector('#lang-switch')
+
+ if (langSwitch !== null && typeof location !== 'undefined') {
+ const targetLang: TblLang = isEn ? 'CHI' : 'ENG'
+ langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN'
+ langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
+ langSwitch.addEventListener('click', () => {
+ langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
+ })
+ }
+
if (lightingSelect !== null) {
lightingSelect.disabled = false
lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto'))
@@ -10325,7 +10575,9 @@ async function boot(): Promise {
const packParam = param('pack', '')
const packBases = packParam === '' ? DEFAULT_PACKS : [packParam]
for (const packBase of packBases) {
- status.textContent = `正在读取资源包 ${packBase}/index.json …`
+ status.textContent = isEn
+ ? `Loading pack ${packBase}/index.json …`
+ : `正在读取资源包 ${packBase}/index.json …`
const baseParamForChar = param('base', '')
const charBases = baseParamForChar === '' ? DEFAULT_BASES : [baseParamForChar]
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
@@ -10334,10 +10586,14 @@ async function boot(): Promise {
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.variant })
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
- status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
+ status.textContent = isEn
+ ? 'WebGL context lost, attempting recovery…'
+ : 'WebGL 上下文丢失,正在尝试恢复…'
},
onContextRestored: () => {
- status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
+ status.textContent = isEn
+ ? 'WebGL context restored, reloading…'
+ : 'WebGL 上下文已恢复,正在重新加载…'
location.reload()
},
})
@@ -10351,7 +10607,7 @@ async function boot(): Promise {
throw new Error('no pack location worked')
} catch (err) {
state.error = (err as Error).message
- status.textContent = `启动失败:${state.error}`
+ status.textContent = isEn ? `Boot failed: ${state.error}` : `启动失败:${state.error}`
if (heroSelect !== null) heroSelect.disabled = false
actSelect.disabled = false
sceneSelect.disabled = false
@@ -10360,3 +10616,4 @@ async function boot(): Promise {
}
if (typeof document !== 'undefined') void boot()
+
diff --git a/src/ui/character-sheet.ts b/src/ui/character-sheet.ts
index 3a01663..cbcb834 100644
--- a/src/ui/character-sheet.ts
+++ b/src/ui/character-sheet.ts
@@ -9,6 +9,7 @@
*/
import type { D2FontRenderer } from './font.ts'
+import { getTblLang, type TblLang } from '../i18n/lang.ts'
export type BaseStatKey = 'str' | 'dex' | 'vit' | 'ene'
@@ -47,6 +48,7 @@ export const STAT_ALLOC_BUTTONS: Readonly 0) {
- font.drawText(ctx, '剩余属性点', ox + 56, oy + 366, { font: 'font8', color: 'red', align: 'center' })
+ font.drawText(ctx, isEn ? 'Stat Points Remaining' : '剩余属性点', ox + 56, oy + 366, { font: 'font8', color: 'red', align: 'center' })
font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 366, {
font: 'fontexocet10',
color: 'red',
@@ -217,35 +220,35 @@ export class CharacterSheetPanel {
}
// 6. Combat Ratings, Stamina, Life, Mana
- font.drawText(ctx, '左键技能伤害', ox + 166, oy + 91, { font: 'font8', color: 'tan' })
+ font.drawText(ctx, isEn ? 'Left Attack Damage' : '左键技能伤害', ox + 166, oy + 91, { font: 'font8', color: 'tan' })
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 285, oy + 91, {
font: 'font8',
color: 'blue',
align: 'center',
})
- font.drawText(ctx, '右键技能伤害', ox + 166, oy + 115, { font: 'font8', color: 'tan' })
+ font.drawText(ctx, isEn ? 'Right Attack Damage' : '右键技能伤害', ox + 166, oy + 115, { font: 'font8', color: 'tan' })
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 285, oy + 115, {
font: 'font8',
color: 'blue',
align: 'center',
})
- font.drawText(ctx, '命中率 (AR)', ox + 166, oy + 152, { font: 'font8', color: 'tan' })
+ font.drawText(ctx, isEn ? 'Attack Rating' : '命中率', ox + 166, oy + 152, { font: 'font8', color: 'tan' })
font.drawText(ctx, String(this.attrs.attackRating), ox + 290, oy + 152, {
font: 'font8',
color: 'white',
align: 'center',
})
- font.drawText(ctx, '防御力 (Def)', ox + 166, oy + 201, { font: 'font8', color: 'tan' })
+ font.drawText(ctx, isEn ? 'Defense' : '防御力', ox + 166, oy + 201, { font: 'font8', color: 'tan' })
font.drawText(ctx, String(this.attrs.defense), ox + 290, oy + 201, {
font: 'font8',
color: 'blue',
align: 'center',
})
- font.drawText(ctx, '耐力', ox + 166, oy + 238, { font: 'font8', color: 'tan' })
+ font.drawText(ctx, isEn ? 'Stamina' : '耐力', ox + 166, oy + 238, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxStamina)}`, ox + 251, oy + 238, {
font: 'font8',
color: 'white',
@@ -257,7 +260,7 @@ export class CharacterSheetPanel {
align: 'center',
})
- font.drawText(ctx, '生命', ox + 166, oy + 262, { font: 'font8', color: 'tan' })
+ font.drawText(ctx, isEn ? 'Life' : '生命', ox + 166, oy + 262, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxHp)}`, ox + 251, oy + 262, {
font: 'font8',
color: 'blue',
@@ -269,7 +272,7 @@ export class CharacterSheetPanel {
align: 'center',
})
- font.drawText(ctx, '法力', ox + 166, oy + 300, { font: 'font8', color: 'tan' })
+ font.drawText(ctx, isEn ? 'Mana' : '法力', ox + 166, oy + 300, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxMana)}`, ox + 251, oy + 300, {
font: 'font8',
color: 'blue',
@@ -283,10 +286,10 @@ export class CharacterSheetPanel {
// 7. Four Elemental Resistances
const resRows = [
- { label: '火焰抗性 (Fire)', y: 341, val: this.attrs.fireRes },
- { label: '冰冷抗性 (Cold)', y: 365, val: this.attrs.coldRes },
- { label: '闪电抗性 (Light)', y: 389, val: this.attrs.lightRes },
- { label: '毒素抗性 (Pois)', y: 413, val: this.attrs.poisRes },
+ { label: isEn ? 'Fire Resistance' : '火焰抗性', y: 341, val: this.attrs.fireRes },
+ { label: isEn ? 'Cold Resistance' : '冰冷抗性', y: 365, val: this.attrs.coldRes },
+ { label: isEn ? 'Lightning Resistance' : '闪电抗性', y: 389, val: this.attrs.lightRes },
+ { label: isEn ? 'Poison Resistance' : '毒素抗性', y: 413, val: this.attrs.poisRes },
] as const
for (const res of resRows) {
font.drawText(ctx, res.label, ox + 166, oy + res.y, { font: 'font8', color: 'tan' })
diff --git a/src/ui/control-bar.ts b/src/ui/control-bar.ts
index 341fa80..6b0236b 100644
--- a/src/ui/control-bar.ts
+++ b/src/ui/control-bar.ts
@@ -12,6 +12,7 @@
*/
import type { D2FontRenderer } from './font.ts'
+import type { TblLang } from '../i18n/lang.ts'
export type MiniPanelAction = 'char' | 'inv' | 'skill' | 'automap' | 'quest' | 'waypoint'
@@ -36,17 +37,18 @@ export const MINIPANEL_BOUNDS = { x: 312, y: 522, width: 176, height: 26 } as co
export const STAT_PLUS_BTN_BOUNDS = { x: 206, y: 558, width: 30, height: 30 } as const
export const SKILL_PLUS_BTN_BOUNDS = { x: 565, y: 558, width: 30, height: 30 } as const
-const MINIPANEL_ITEMS: readonly { action: MiniPanelAction; label: string; hotkey: string }[] = [
- { action: 'char', label: '角色属性', hotkey: 'A/C' },
- { action: 'inv', label: '物品栏', hotkey: 'I/B' },
- { action: 'skill', label: '技能树', hotkey: 'T' },
- { action: 'automap', label: '自动地图', hotkey: 'Tab' },
- { action: 'quest', label: '任务日志', hotkey: 'Q' },
- { action: 'waypoint', label: '小站/储物箱', hotkey: 'V' },
+const MINIPANEL_ITEMS: readonly { action: MiniPanelAction; labelZh: string; labelEn: string; hotkey: string }[] = [
+ { action: 'char', labelZh: '角色属性', labelEn: 'Character', hotkey: 'A/C' },
+ { action: 'inv', labelZh: '物品栏', labelEn: 'Inventory', hotkey: 'I/B' },
+ { action: 'skill', labelZh: '技能树', labelEn: 'Skill Tree', hotkey: 'T' },
+ { action: 'automap', labelZh: '自动地图', labelEn: 'Automap', hotkey: 'Tab' },
+ { action: 'quest', labelZh: '任务日志', labelEn: 'Quest Log', hotkey: 'Q' },
+ { action: 'waypoint', labelZh: '小站/储物箱', labelEn: 'Waypoint / Stash', hotkey: 'V' },
]
export class ControlBarHud {
miniPanelOpen = true
+ lang: TblLang = 'CHI'
hoveredElement: 'exp' | 'stamina' | 'run' | 'menu' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null
handleMouseMove(logicalX: number, logicalY: number): void {
@@ -213,11 +215,14 @@ export class ControlBarHud {
}
// 8. Hover Tooltip over Experience / Stamina / Mini-panel buttons
+ const isEn = this.lang === 'ENG'
if (this.hoveredElement === 'exp') {
this.drawMiniTooltip(
ctx,
font,
- `经验值: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)`,
+ isEn
+ ? `Experience: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)`
+ : `经验值: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)`,
EXP_BAR_BOUNDS.x + 60,
518,
)
@@ -225,20 +230,46 @@ export class ControlBarHud {
this.drawMiniTooltip(
ctx,
font,
- `耐力: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? '奔跑中' : '行走中'})`,
+ isEn
+ ? `Stamina: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? 'Running' : 'Walking'})`
+ : `耐力: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? '奔跑中' : '行走中'})`,
STAMINA_BAR_BOUNDS.x + 51,
518,
)
} else if (this.hoveredElement === 'run') {
- this.drawMiniTooltip(ctx, font, `切换行走/奔跑 (R) — 当前: ${state.isRunning ? '奔跑' : '行走'}`, RUN_BTN_BOUNDS.x + 8, 518)
+ this.drawMiniTooltip(
+ ctx,
+ font,
+ isEn
+ ? `Toggle Run/Walk (R) — Current: ${state.isRunning ? 'Run' : 'Walk'}`
+ : `切换行走/奔跑 (R) — 当前: ${state.isRunning ? '奔跑' : '行走'}`,
+ RUN_BTN_BOUNDS.x + 8,
+ 518,
+ )
} else if (this.hoveredElement === 'statPlus') {
- this.drawMiniTooltip(ctx, font, `未分配属性点: ${state.unspentStatPoints} (点击打开角色面板 A)`, STAT_PLUS_BTN_BOUNDS.x + 15, 518)
+ this.drawMiniTooltip(
+ ctx,
+ font,
+ isEn
+ ? `Unallocated Stat Points: ${state.unspentStatPoints} (Click to open Character Sheet A)`
+ : `未分配属性点: ${state.unspentStatPoints} (点击打开角色面板 A)`,
+ STAT_PLUS_BTN_BOUNDS.x + 15,
+ 518,
+ )
} else if (this.hoveredElement === 'skillPlus') {
- this.drawMiniTooltip(ctx, font, `未分配技能点: ${state.unspentSkillPoints} (点击打开技能树 T)`, SKILL_PLUS_BTN_BOUNDS.x + 15, 518)
+ this.drawMiniTooltip(
+ ctx,
+ font,
+ isEn
+ ? `Unallocated Skill Points: ${state.unspentSkillPoints} (Click to open Skill Tree T)`
+ : `未分配技能点: ${state.unspentSkillPoints} (点击打开技能树 T)`,
+ SKILL_PLUS_BTN_BOUNDS.x + 15,
+ 518,
+ )
} else if (this.hoveredElement && this.hoveredElement !== 'menu') {
const found = MINIPANEL_ITEMS.find(m => m.action === this.hoveredElement)
if (found) {
- this.drawMiniTooltip(ctx, font, `${found.label} (${found.hotkey})`, 400, 502)
+ this.drawMiniTooltip(ctx, font, `${isEn ? found.labelEn : found.labelZh} (${found.hotkey})`, 400, 502)
}
}
}
diff --git a/src/ui/globes.ts b/src/ui/globes.ts
index 58df81c..f306449 100644
--- a/src/ui/globes.ts
+++ b/src/ui/globes.ts
@@ -7,6 +7,7 @@
*/
import type { D2FontRenderer } from './font.ts'
+import type { TblLang } from '../i18n/lang.ts'
export interface GlobeState {
readonly hp: number
@@ -45,6 +46,7 @@ export class GlobesHud {
lifeHovered = false
manaHovered = false
proceduralFallbackCount = 0
+ lang: TblLang = 'CHI'
constructor() {
this.syncFallbackCountToWindow()
@@ -168,10 +170,11 @@ export class GlobesHud {
}
// 4. Numerical readouts above globes (`Life: X / Y`, `Mana: X / Y`)
+ const isEn = this.lang === 'ENG'
if (this.lifeTextLocked || this.lifeHovered) {
font.drawText(
ctx,
- `生命: ${Math.max(0, Math.round(state.hp))} / ${Math.round(state.maxHp)}`,
+ `${isEn ? 'Life' : '生命'}: ${Math.max(0, Math.round(state.hp))} / ${Math.round(state.maxHp)}`,
LIFE_GLOBE_BOUNDS.x + 40,
504,
{ font: 'fontexocet10', color: 'white', align: 'center' },
@@ -180,7 +183,7 @@ export class GlobesHud {
if (this.manaTextLocked || this.manaHovered) {
font.drawText(
ctx,
- `法力: ${Math.max(0, Math.round(state.mana))} / ${Math.round(state.maxMana)}`,
+ `${isEn ? 'Mana' : '法力'}: ${Math.max(0, Math.round(state.mana))} / ${Math.round(state.maxMana)}`,
MANA_GLOBE_BOUNDS.x + 40,
504,
{ font: 'fontexocet10', color: 'white', align: 'center' },
diff --git a/src/ui/ground-labels.ts b/src/ui/ground-labels.ts
index 7d55647..8514674 100644
--- a/src/ui/ground-labels.ts
+++ b/src/ui/ground-labels.ts
@@ -19,6 +19,7 @@ import {
translateRareName,
translateMagicName,
} from '../game/tooltip-i18n.ts'
+import { isEn } from '../i18n/lang.ts'
/**
* Authentic 1.13c Diablo II quality color codes for ground item labels.
@@ -114,8 +115,9 @@ export function getGroundItemQualityColor(item: GroundItemEntity): string {
* Format the user-facing display text for a ground item label.
*/
export function formatGroundItemLabelText(item: GroundItemEntity): string {
+ const en = isEn()
if (item.isGold) {
- return `${item.amount} 金币`
+ return en ? `${item.amount} Gold` : `${item.amount} 金币`
}
const src = item.item as any
if (isGroundSourceUnidentified(src)) {
@@ -132,8 +134,11 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string {
: item.name
if (rawBaseName) {
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
+ if (en) {
+ return baseNameEn
+ }
if (item.nameZh && item.name === baseNameEn) {
- return item.nameZh
+ return item.nameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
}
const translatedBase =
translateItemName(rawBaseName) !== rawBaseName
@@ -142,12 +147,15 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string {
return translatedBase || baseNameEn
}
}
- if (item.nameZh) {
- return item.nameZh
+ if (en) {
+ return item.name || src?.name || src?.item?.name || 'Item'
}
- const srcNameZh = (item.item as any)?.nameZh ?? (item.item as any)?.item?.nameZh
+ if (item.nameZh) {
+ return item.nameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
+ }
+ const srcNameZh = src?.nameZh ?? src?.item?.nameZh
if (typeof srcNameZh === 'string' && srcNameZh.trim() !== '') {
- return srcNameZh
+ return srcNameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
}
if (item.name) {
const q = (item.quality || 'normal').toLowerCase()
@@ -155,10 +163,13 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string {
return translateRareName(item.name)
}
if (q === 'magic') {
- const baseName = (item.item as any)?.base?.name ?? (item.item as any)?.item?.base?.name
+ const baseName = src?.base?.name ?? src?.item?.base?.name
return translateMagicName(item.name, baseName)
}
- return translateItemName(item.name)
+ if (src?.base || src?.code || src?.item?.base || src?.item?.code) {
+ return translateItemName(item.name)
+ }
+ return item.name.replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
}
return '物品'
}
diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts
index 685c7dc..70904af 100644
--- a/src/ui/hotkeys.ts
+++ b/src/ui/hotkeys.ts
@@ -12,7 +12,8 @@
import type { D2FontRenderer, D2ColorCode } from './font.ts'
import { SKILLS_BY_ID } from '../data/skills-catalog.ts'
import type { TooltipViewModel } from '../game/skill-calc-engine.ts'
-import type { FormattedTooltipLine } from './skill-tree-panel.ts'
+import { formatDamageTypeEn, type FormattedTooltipLine } from './skill-tree-panel.ts'
+import type { TblLang } from '../i18n/lang.ts'
export interface HotkeySkillEntry {
readonly skillId: number
@@ -347,6 +348,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
]
export class SkillHotkeysHud {
+ lang: TblLang = 'CHI'
leftSkillId = 47 // Fire Ball
rightSkillId = 64 // Frozen Orb
/** Which Speedbar popup is currently expanded ('left', 'right', or null). */
@@ -933,41 +935,55 @@ export class SkillHotkeysHud {
/**
* Formats authentic Diablo II 1.13c multi-line tooltip lines for quickbar and speedbar popups.
*/
- formatTooltipLines(skillId: number, side: 'left' | 'right', isSpeedbar: boolean): FormattedTooltipLine[] {
+ formatTooltipLines(
+ skillId: number,
+ side: 'left' | 'right',
+ isSpeedbar: boolean,
+ lang: TblLang = this.lang,
+ ): FormattedTooltipLine[] {
const lines: FormattedTooltipLine[] = []
+ const isEn = lang === 'ENG'
- // 1. Check if special default skill (Attack 0, Throw 2, Unsummon 3)
- if (skillId === 0) {
- lines.push({
- text: '普通攻击 (Attack)',
- color: 'gold',
- font: 'fontexocet10',
- align: 'center',
- })
+ const pushHotkeyLine = () => {
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
- text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
+ text: isEn
+ ? (boundKey ? `Current Hotkey: ${boundKey} (Press F1-F8 to rebind)` : 'Press F1-F8 to set hotkey')
+ : (boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键'),
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
- text: `快捷键: ${boundKey}`,
+ text: isEn ? `Hotkey: ${boundKey}` : `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
+ }
+
+ // 1. Check if special default skill (Attack 0, Throw 2, Unsummon 3)
+ if (skillId === 0) {
lines.push({
- text: '使用当前装备的武器或赤手空拳进行基础物理攻击。',
+ text: isEn ? 'Attack' : '普通攻击',
+ color: 'gold',
+ font: 'fontexocet10',
+ align: 'center',
+ })
+ pushHotkeyLine()
+ lines.push({
+ text: isEn
+ ? 'Normal physical attack with equipped weapon or bare hands.'
+ : '使用当前装备的武器或赤手空拳进行基础物理攻击。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
- text: '法力消耗: 0',
+ text: isEn ? 'Mana Cost: 0' : '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
@@ -977,35 +993,20 @@ export class SkillHotkeysHud {
if (skillId === 2) {
lines.push({
- text: '投掷 (Throw)',
+ text: isEn ? 'Throw' : '投掷',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
- const boundKey = this.getHotkeyLabelForSkill(side, skillId)
- if (isSpeedbar) {
- lines.push({
- text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- } else if (boundKey) {
- lines.push({
- text: `快捷键: ${boundKey}`,
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- }
+ pushHotkeyLine()
lines.push({
- text: '投掷当前装备的投掷类武器。',
+ text: isEn ? 'Throw equipped throwing weapon.' : '投掷当前装备的投掷类武器。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
- text: '法力消耗: 0',
+ text: isEn ? 'Mana Cost: 0' : '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
@@ -1015,35 +1016,20 @@ export class SkillHotkeysHud {
if (skillId === 3) {
lines.push({
- text: '取消召唤 (Unsummon)',
+ text: isEn ? 'Unsummon' : '取消召唤',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
- const boundKey = this.getHotkeyLabelForSkill(side, skillId)
- if (isSpeedbar) {
- lines.push({
- text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- } else if (boundKey) {
- lines.push({
- text: `快捷键: ${boundKey}`,
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- }
+ pushHotkeyLine()
lines.push({
- text: '点击你所召唤的仆从或宠物以将其解散。',
+ text: isEn ? 'Click a summoned minion or pet to dismiss it.' : '点击你所召唤的仆从或宠物以将其解散。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
- text: '法力消耗: 0',
+ text: isEn ? 'Mana Cost: 0' : '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
@@ -1053,35 +1039,20 @@ export class SkillHotkeysHud {
if (skillId === 4) {
lines.push({
- text: '左手投掷 (Left Hand Throw)',
+ text: isEn ? 'Left Hand Throw' : '左手投掷',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
- const boundKey = this.getHotkeyLabelForSkill(side, skillId)
- if (isSpeedbar) {
- lines.push({
- text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- } else if (boundKey) {
- lines.push({
- text: `快捷键: ${boundKey}`,
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- }
+ pushHotkeyLine()
lines.push({
- text: '使用副手投掷当前装备的投掷类武器。',
+ text: isEn ? 'Throw off-hand throwing weapon.' : '使用副手投掷当前装备的投掷类武器。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
- text: '法力消耗: 0',
+ text: isEn ? 'Mana Cost: 0' : '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
@@ -1092,29 +1063,18 @@ export class SkillHotkeysHud {
if (skillId === 217 || skillId === 218) {
const isBook = skillId === 218
lines.push({
- text: isBook ? '辨识之书 (Tome of Identify)' : '辨识卷轴 (Scroll of Identify)',
+ text: isEn
+ ? (isBook ? 'Tome of Identify' : 'Scroll of Identify')
+ : (isBook ? '辨识之书' : '辨识卷轴'),
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
- const boundKey = this.getHotkeyLabelForSkill(side, skillId)
- if (isSpeedbar) {
- lines.push({
- text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- } else if (boundKey) {
- lines.push({
- text: `快捷键: ${boundKey}`,
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- }
+ pushHotkeyLine()
lines.push({
- text: '使用后辨识未鉴定的魔法、稀有或暗金物品。',
+ text: isEn
+ ? 'Identifies an unidentified magic, rare, set, or unique item.'
+ : '使用后辨识未鉴定的魔法、稀有或暗金物品。',
color: 'white',
font: 'font8',
align: 'center',
@@ -1122,7 +1082,7 @@ export class SkillHotkeysHud {
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
- text: `数量: ${charges}`,
+ text: isEn ? `Quantity: ${charges}` : `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
font: 'font8',
align: 'center',
@@ -1134,29 +1094,18 @@ export class SkillHotkeysHud {
if (skillId === 219 || skillId === 220) {
const isBook = skillId === 220
lines.push({
- text: isBook ? '城镇传送之书 (Tome of Town Portal)' : '城镇传送卷轴 (Scroll of Town Portal)',
+ text: isEn
+ ? (isBook ? 'Tome of Town Portal' : 'Scroll of Town Portal')
+ : (isBook ? '城镇传送之书' : '城镇传送卷轴'),
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
- const boundKey = this.getHotkeyLabelForSkill(side, skillId)
- if (isSpeedbar) {
- lines.push({
- text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- } else if (boundKey) {
- lines.push({
- text: `快捷键: ${boundKey}`,
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- }
+ pushHotkeyLine()
lines.push({
- text: '打开一个通往最近城镇的安全蓝色传送门。',
+ text: isEn
+ ? 'Opens a magical blue portal to the nearest town.'
+ : '打开一个通往最近城镇的安全蓝色传送门。',
color: 'white',
font: 'font8',
align: 'center',
@@ -1164,7 +1113,7 @@ export class SkillHotkeysHud {
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
- text: `数量: ${charges}`,
+ text: isEn ? `Quantity: ${charges}` : `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
font: 'font8',
align: 'center',
@@ -1183,35 +1132,20 @@ export class SkillHotkeysHud {
// Title
lines.push({
- text: `${nameZh} (${nameEn})`,
+ text: isEn ? nameEn : nameZh,
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
// Hotkey Info
- const boundKey = this.getHotkeyLabelForSkill(side, skillId)
- if (isSpeedbar) {
- lines.push({
- text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- } else if (boundKey) {
- lines.push({
- text: `快捷键: ${boundKey}`,
- color: 'tan',
- font: 'font8',
- align: 'center',
- })
- }
+ pushHotkeyLine()
// Aura Tag
const isAura = isAuraSkill(skillId) || entry?.isAura
if (isAura) {
lines.push({
- text: '灵气技能 (Aura · 仅右键生效)',
+ text: isEn ? 'Aura Skill (Right-Click Only)' : '灵气技能 (仅右键生效)',
color: 'yellow',
font: 'font8',
align: 'center',
@@ -1222,7 +1156,7 @@ export class SkillHotkeysHud {
if (this.isSkillOnCooldown(skillId)) {
const rem = this.getSkillCooldownRemaining(skillId)
lines.push({
- text: `[冷却中: ${(rem / 1000).toFixed(1)} 秒]`,
+ text: isEn ? `[Cooldown: ${(rem / 1000).toFixed(1)} sec]` : `[冷却中: ${(rem / 1000).toFixed(1)} 秒]`,
color: 'red',
font: 'font8',
align: 'center',
@@ -1234,7 +1168,9 @@ export class SkillHotkeysHud {
const maxCharges = this.getSkillMaxCharges(skillId)
if (charges !== undefined) {
lines.push({
- text: `剩余充能: ${charges}${maxCharges !== undefined ? ` / ${maxCharges}` : ''}`,
+ text: isEn
+ ? `Charges: ${charges}${maxCharges !== undefined ? ` / ${maxCharges}` : ''}`
+ : `剩余充能: ${charges}${maxCharges !== undefined ? ` / ${maxCharges}` : ''}`,
color: charges > 0 ? 'blue' : 'red',
font: 'font8',
align: 'center',
@@ -1244,7 +1180,9 @@ export class SkillHotkeysHud {
if (vm) {
// Current level
const soft = vm.effectiveLevel - vm.hardPoints
- const lvlText = `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
+ const lvlText = isEn
+ ? `Current Skill Level: ${vm.effectiveLevel}${soft > 0 ? ` (Base ${vm.hardPoints} + Gear +${soft})` : ''}`
+ : `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
lines.push({
text: lvlText,
color: soft > 0 ? 'blue' : 'white',
@@ -1255,7 +1193,7 @@ export class SkillHotkeysHud {
// Mana cost
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
lines.push({
- text: `法力消耗: ${vm.current.manaCost}`,
+ text: isEn ? `Mana Cost: ${vm.current.manaCost}` : `法力消耗: ${vm.current.manaCost}`,
color: 'white',
font: 'font8',
align: 'center',
@@ -1265,7 +1203,7 @@ export class SkillHotkeysHud {
// Cast delay
if (vm.current.delaySeconds > 0) {
lines.push({
- text: `施法延迟: ${vm.current.delaySeconds} 秒`,
+ text: isEn ? `Casting Delay: ${vm.current.delaySeconds} sec` : `施法延迟: ${vm.current.delaySeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
@@ -1273,7 +1211,9 @@ export class SkillHotkeysHud {
}
// Description narrative
- const desc = vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh
+ const desc = isEn
+ ? (vm.skill.shortDesc || vm.shortDesc || vm.skill.longDesc || vm.longDesc)
+ : (vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh)
if (desc) {
lines.push({
text: desc,
@@ -1287,14 +1227,16 @@ export class SkillHotkeysHud {
if (vm.current.damage.hasDamage) {
const d = vm.current.damage
lines.push({
- text: `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
+ text: isEn
+ ? `${formatDamageTypeEn(d.type)}: ${d.finalMin} - ${d.finalMax}`
+ : `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
color: 'blue',
font: 'font8',
align: 'center',
})
if (d.durationSeconds !== undefined && d.durationSeconds > 0) {
lines.push({
- text: `持续时间: ${d.durationSeconds} 秒`,
+ text: isEn ? `Duration: ${d.durationSeconds} sec` : `持续时间: ${d.durationSeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
@@ -1302,7 +1244,7 @@ export class SkillHotkeysHud {
}
if (d.weaponDamagePercent !== undefined && d.weaponDamagePercent > 0) {
lines.push({
- text: `武器伤害: ${d.weaponDamagePercent}%`,
+ text: isEn ? `Weapon Damage: ${d.weaponDamagePercent}%` : `武器伤害: ${d.weaponDamagePercent}%`,
color: 'white',
font: 'font8',
align: 'center',
@@ -1312,9 +1254,10 @@ export class SkillHotkeysHud {
// Effects
for (const eff of vm.current.effects) {
- if (eff.textZh) {
+ const effText = isEn ? eff.textEn : eff.textZh
+ if (effText) {
lines.push({
- text: eff.textZh,
+ text: effText,
color: 'blue',
font: 'font8',
align: 'center',
@@ -1325,15 +1268,19 @@ export class SkillHotkeysHud {
// Synergies received
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
lines.push({
- text: '[协同技能加成]',
+ text: isEn ? '[Synergy Bonuses]' : '[协同技能加成]',
color: 'blue',
font: 'fontexocet10',
align: 'center',
})
for (const syn of vm.synergiesReceived) {
- const text = syn.hardPoints > 0
- ? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
- : `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`
+ const text = isEn
+ ? (syn.hardPoints > 0
+ ? `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level (Current +${syn.currentBonusPct}%)`
+ : `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`)
+ : (syn.hardPoints > 0
+ ? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
+ : `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`)
lines.push({
text,
color: syn.hardPoints > 0 ? 'blue' : 'gray',
@@ -1347,20 +1294,22 @@ export class SkillHotkeysHud {
const level = entry?.level ?? 1
const mana = entry?.manaCost ?? catalogEntry?.mana ?? 0
lines.push({
- text: `当前技能等级: ${level}`,
+ text: isEn ? `Current Skill Level: ${level}` : `当前技能等级: ${level}`,
color: 'white',
font: 'font8',
align: 'center',
})
if (mana > 0) {
lines.push({
- text: `法力消耗: ${mana}`,
+ text: isEn ? `Mana Cost: ${mana}` : `法力消耗: ${mana}`,
color: 'white',
font: 'font8',
align: 'center',
})
}
- const desc = catalogEntry?.shortDescZh || catalogEntry?.longDescZh
+ const desc = isEn
+ ? (catalogEntry?.shortDesc || catalogEntry?.longDesc)
+ : (catalogEntry?.shortDescZh || catalogEntry?.longDescZh)
if (desc) {
lines.push({
text: desc,
@@ -1371,7 +1320,9 @@ export class SkillHotkeysHud {
}
if (catalogEntry && catalogEntry.minDam > 0) {
lines.push({
- text: `基础伤害: ${catalogEntry.minDam} - ${catalogEntry.maxDam}`,
+ text: isEn
+ ? `Base Damage: ${catalogEntry.minDam} - ${catalogEntry.maxDam}`
+ : `基础伤害: ${catalogEntry.minDam} - ${catalogEntry.maxDam}`,
color: 'blue',
font: 'font8',
align: 'center',
diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts
index be83d01..165abbf 100644
--- a/src/ui/hud-manager.ts
+++ b/src/ui/hud-manager.ts
@@ -49,6 +49,14 @@ import {
type ClassProfileState,
} from '../game/class-starter-profiles.ts'
import type { TownNpcServiceDescriptor, VendorNpcId } from '../game/npc-table.ts'
+import {
+ detectInitialLang,
+ setCurrentLang,
+ toTblLang,
+ type GameLang,
+ type TblLang,
+} from '../i18n/lang.ts'
+import { setTooltipLocale } from '../game/item-tooltip.ts'
export interface PublishedHudState {
ready: boolean
@@ -316,6 +324,28 @@ export class HudManager {
currentClass: CharacterClassCode | null = null
private classProfiles = new Map()
+ private _lang: TblLang = 'CHI'
+
+ get lang(): TblLang {
+ return this._lang
+ }
+
+ set lang(val: TblLang) {
+ this.setLang(val)
+ }
+
+ setLang(lang: GameLang | TblLang): void {
+ const tbl = toTblLang(lang)
+ this._lang = tbl
+ setCurrentLang(tbl === 'ENG' ? 'en' : 'zh')
+ setTooltipLocale(tbl === 'ENG' ? 'en' : 'zh')
+ this.worldPanels.lang = tbl
+ this.charSheet.lang = tbl
+ this.skillTree.lang = tbl
+ this.hotkeys.lang = tbl
+ this.controlBar.lang = tbl
+ this.globes.lang = tbl
+ }
constructor(
private readonly hudCanvas: HTMLCanvasElement,
@@ -338,6 +368,7 @@ export class HudManager {
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
},
) {
+ this.setLang(detectInitialLang())
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
if (this.hudCanvas) {
this.hudCanvas.style.cursor = 'none'
@@ -885,11 +916,11 @@ export class HudManager {
onOpenVendor: (desc, mode) => {
this.openVendorSession(desc, mode)
},
- onIdentifyAll: (desc) => {
+ onIdentifyAll: (_desc) => {
const count = this.worldPanels.identifyAllUiItems(this.inventory)
this.worldPanels.showAreaBanner(
count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品',
- desc.displayName,
+ count > 0 ? `Identified ${count} items` : 'No unidentified items in inventory',
)
},
},
diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts
index a6d0b1d..22814fb 100644
--- a/src/ui/inventory.ts
+++ b/src/ui/inventory.ts
@@ -44,26 +44,32 @@ export interface UiInventoryItem {
readonly name: string
readonly nameZh: string
readonly baseNameZh: string
+ readonly baseNameEn?: string | undefined
readonly quality: UiItemQuality
readonly invWidth: number
readonly invHeight: number
readonly allowedSlots: readonly EquipSlotId[]
readonly defense?: number | undefined
readonly damage?: string | undefined
+ readonly damageEn?: string | undefined
readonly reqLevel?: number | undefined
readonly reqStr?: number | undefined
readonly reqDex?: number | undefined
readonly reqClass?: CharacterClassCode | undefined
readonly stats: readonly { text: string; color?: D2ColorCode }[]
+ readonly statsEn?: readonly { text: string; color?: D2ColorCode }[] | undefined
readonly invtransform?: string | undefined
readonly chrtransform?: string | undefined
readonly durability?: { readonly current: number; readonly max: number } | undefined
readonly speedText?: string | undefined
+ readonly speedTextEn?: string | undefined
readonly runewordRunes?: string | undefined
readonly sockets?: number | undefined
readonly ethereal?: boolean | undefined
readonly setPieces?: readonly string[] | undefined
+ readonly setPiecesEn?: readonly string[] | undefined
readonly setBonuses?: readonly { text: string; color?: D2ColorCode }[] | undefined
+ readonly setBonusesEn?: readonly { text: string; color?: D2ColorCode }[] | undefined
readonly flippyFile?: string | undefined
readonly dropSound?: string | undefined
readonly dropSfxFrame?: number | undefined
@@ -1062,6 +1068,10 @@ export function setTomeQuantity(item: UiInventoryItem, newQty: number): UiInvent
;(item as { baseNameZh: string }).baseNameZh = `${item.baseNameZh} (数量: ${qty})`
}
+ if (item.baseNameEn && /Quantity\s*[::]\s*\d+/i.test(item.baseNameEn)) {
+ ;(item as { baseNameEn: string }).baseNameEn = item.baseNameEn.replace(/Quantity\s*[::]\s*\d+/gi, `Quantity: ${qty}`)
+ }
+
if (Array.isArray(item.stats)) {
const hasQtyLine = item.stats.some(s => /^(数量|Quantity)\s*[::]\s*\d+/i.test(s.text))
if (hasQtyLine) {
@@ -1071,6 +1081,15 @@ export function setTomeQuantity(item: UiInventoryItem, newQty: number): UiInvent
}
}
+ if (Array.isArray(item.statsEn)) {
+ const hasQtyLineEn = item.statsEn.some(s => /^(数量|Quantity)\s*[::]\s*\d+/i.test(s.text))
+ if (hasQtyLineEn) {
+ ;(item as { statsEn: readonly { text: string; color?: D2ColorCode }[] }).statsEn = item.statsEn.map(s =>
+ /^(数量|Quantity)\s*[::]\s*\d+/i.test(s.text) ? { ...s, text: `Quantity: ${qty}` } : s,
+ )
+ }
+ }
+
if (item.rawItem) {
;(item.rawItem as { quantity?: number; stack?: number }).quantity = qty
;(item.rawItem as { quantity?: number; stack?: number }).stack = qty
@@ -1080,7 +1099,11 @@ export function setTomeQuantity(item: UiInventoryItem, newQty: number): UiInvent
}
export function withTomeQuantity(item: UiInventoryItem, quantity: number): UiInventoryItem {
- const cloned: UiInventoryItem = { ...item, stats: [...item.stats] }
+ const cloned: UiInventoryItem = {
+ ...item,
+ stats: [...item.stats],
+ ...(item.statsEn ? { statsEn: [...item.statsEn] } : {}),
+ }
return setTomeQuantity(cloned, quantity)
}
@@ -1202,30 +1225,50 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
name: rebuilt.name,
nameZh: rebuilt.nameZh,
baseNameZh: rebuilt.baseNameZh,
+ ...(rebuilt.baseNameEn !== undefined ? { baseNameEn: rebuilt.baseNameEn } : {}),
quality: rebuilt.quality,
stats: rebuilt.stats.filter(
(s: { text: string; color?: D2ColorCode }) =>
!s.text.includes('未辨识') && !s.text.includes('未鉴定') && !s.text.includes('Unidentified'),
),
+ ...(rebuilt.statsEn !== undefined
+ ? {
+ statsEn: rebuilt.statsEn.filter(
+ (s: { text: string; color?: D2ColorCode }) => !s.text.includes('Unidentified'),
+ ),
+ }
+ : {}),
...(rebuilt.defense !== undefined ? { defense: rebuilt.defense } : {}),
...(rebuilt.damage !== undefined ? { damage: rebuilt.damage } : {}),
+ ...(rebuilt.damageEn !== undefined ? { damageEn: rebuilt.damageEn } : {}),
...(rebuilt.reqLevel !== undefined ? { reqLevel: rebuilt.reqLevel } : {}),
...(rebuilt.reqStr !== undefined ? { reqStr: rebuilt.reqStr } : {}),
...(rebuilt.reqDex !== undefined ? { reqDex: rebuilt.reqDex } : {}),
...(rebuilt.reqClass !== undefined ? { reqClass: rebuilt.reqClass } : {}),
...(rebuilt.durability !== undefined ? { durability: rebuilt.durability } : {}),
...(rebuilt.sockets !== undefined ? { sockets: rebuilt.sockets } : {}),
+ ...(rebuilt.speedText !== undefined ? { speedText: rebuilt.speedText } : {}),
+ ...(rebuilt.speedTextEn !== undefined ? { speedTextEn: rebuilt.speedTextEn } : {}),
...(rebuilt.invtransform !== undefined ? { invtransform: rebuilt.invtransform } : {}),
...(rebuilt.chrtransform !== undefined ? { chrtransform: rebuilt.chrtransform } : {}),
...(rebuilt.setPieces !== undefined ? { setPieces: rebuilt.setPieces } : {}),
+ ...(rebuilt.setPiecesEn !== undefined ? { setPiecesEn: rebuilt.setPiecesEn } : {}),
...(rebuilt.setBonuses !== undefined ? { setBonuses: rebuilt.setBonuses } : {}),
+ ...(rebuilt.setBonusesEn !== undefined ? { setBonusesEn: rebuilt.setBonusesEn } : {}),
identified: true,
isGambleMystery: false,
})
- } else if (Array.isArray(item.stats)) {
- ;(item as { stats: readonly { text: string; color?: D2ColorCode }[] }).stats = item.stats.filter(
- s => !s.text.includes('未辨识') && !s.text.includes('未鉴定') && !s.text.includes('Unidentified'),
- )
+ } else {
+ if (Array.isArray(item.stats)) {
+ ;(item as { stats: readonly { text: string; color?: D2ColorCode }[] }).stats = item.stats.filter(
+ s => !s.text.includes('未辨识') && !s.text.includes('未鉴定') && !s.text.includes('Unidentified'),
+ )
+ }
+ if (Array.isArray(item.statsEn)) {
+ ;(item as { statsEn: readonly { text: string; color?: D2ColorCode }[] }).statsEn = item.statsEn.filter(
+ s => !s.text.includes('Unidentified'),
+ )
+ }
}
return item
diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts
index 8e4fe46..ad93fe2 100644
--- a/src/ui/item-bridge.ts
+++ b/src/ui/item-bridge.ts
@@ -409,53 +409,61 @@ export function itemToUiInventoryItem(
quality = rawQ as UiItemQuality
}
- // 2. Bilingual Name & Subtitle Construction
+ // 2. Pure Chinese & Pure English Name & Subtitle Construction
let nameZh: string
let baseNameZh: string
+ let baseNameEn: string
const baseEn = base.name || rawItem.name || 'Item'
const rawBaseZh = translateItemName(baseEn)
- const formattedBaseZh = rawBaseZh && rawBaseZh !== baseEn ? `${rawBaseZh} (${baseEn})` : baseEn
+ const formattedBaseZh = rawBaseZh && rawBaseZh !== baseEn ? rawBaseZh : baseEn
if (quality === 'unique' && rawItem.uniqueItemDef) {
const uEn = rawItem.uniqueItemDef.index || rawItem.name
const uZh = translateItemName(uEn)
- nameZh = uZh && uZh !== uEn ? `${uZh} (${uEn})` : uEn
+ nameZh = uZh && uZh !== uEn ? uZh : uEn
baseNameZh = formattedBaseZh
+ baseNameEn = baseEn
} else if (quality === 'set' && rawItem.setItemDef) {
const sEn = rawItem.setItemDef.index || rawItem.name
const sZh = translateItemName(sEn)
const setEn = rawItem.setItemDef.set
const setZh = setEn ? translateItemName(setEn) : ''
- nameZh = sZh && sZh !== sEn ? `${sZh} (${sEn})` : sEn
+ nameZh = sZh && sZh !== sEn ? sZh : sEn
baseNameZh = setEn && setZh && setZh !== setEn
- ? `${formattedBaseZh} · [${setZh} (${setEn})]`
+ ? `${formattedBaseZh} · [${setZh}]`
: formattedBaseZh
+ baseNameEn = setEn ? `${baseEn} · [${setEn}]` : baseEn
} else if (quality === 'rare' && rawItem.rolledRareAffixes) {
const rEn = rawItem.rolledRareAffixes.name || rawItem.name
const rZh = translateRareName(rEn)
- nameZh = rZh && rZh !== rEn ? `${rZh} (${rEn})` : rEn
+ nameZh = rZh && rZh !== rEn ? rZh : rEn
baseNameZh = formattedBaseZh
+ baseNameEn = baseEn
} else if (quality === 'magic' && rawItem.rolledMagicAffixes) {
const mEn = rawItem.rolledMagicAffixes.name || rawItem.name
const mZh = translateMagicName(mEn, baseEn)
- nameZh = mZh && mZh !== mEn ? `${mZh} (${mEn})` : mEn
+ nameZh = mZh && mZh !== mEn ? mZh : mEn
baseNameZh = formattedBaseZh
+ baseNameEn = baseEn
} else {
const itemEn = rawItem.name || baseEn
const itemZh = translateItemName(itemEn)
- nameZh = itemZh && itemZh !== itemEn ? `${itemZh} (${itemEn})` : itemEn
+ nameZh = itemZh && itemZh !== itemEn ? itemZh : itemEn
baseNameZh = formattedBaseZh
+ baseNameEn = baseEn
}
- // Use pre-localized overrides if present
+ // Use pre-localized overrides if present (strip any mixed English parentheticals)
if ((rawItem as any).nameZh) {
- const rawZh = String((rawItem as any).nameZh)
- const itemEn = rawItem.name || baseEn
- nameZh = rawZh.includes('(') || rawZh === itemEn ? rawZh : `${rawZh} (${itemEn})`
+ const rawZh = String((rawItem as any).nameZh).replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
+ nameZh = rawZh
}
if ((rawItem as any).baseNameZh) {
- baseNameZh = (rawItem as any).baseNameZh
+ baseNameZh = String((rawItem as any).baseNameZh).replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
+ }
+ if ((rawItem as any).baseNameEn) {
+ baseNameEn = String((rawItem as any).baseNameEn)
}
// 3. invFile & Dye Resolution
@@ -480,15 +488,22 @@ export function itemToUiInventoryItem(
dropTables,
)
- // 5. Tooltip & Formatted Stats
+ // 5. Tooltip & Formatted Stats (both CHI and ENG)
const isGambleMystery = Boolean(rawItem.isGambleMystery || rawItem.flags?.gamble)
const tt = formatItemTooltip({ ...rawItem, base } as Item, 'zh')
+ const ttEn = formatItemTooltip({ ...rawItem, base } as Item, 'en')
let stats: { text: string; color?: D2ColorCode }[] = isGambleMystery
- ? [{ text: '未鉴定 (Unidentified Gamble Item)', color: 'red' }]
+ ? [{ text: '未辨识', color: 'red' }]
: (tt.lines || []).map(l => ({
text: l.text,
color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode,
}))
+ let statsEn: { text: string; color?: D2ColorCode }[] = isGambleMystery
+ ? [{ text: 'Unidentified', color: 'red' }]
+ : (ttEn.lines || []).map(l => ({
+ text: l.text,
+ color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode,
+ }))
// Fallback for items with flat stats dictionary but no rolled props
if (!isGambleMystery && stats.length === 0 && rawItem.stats && typeof rawItem.stats === 'object') {
@@ -500,20 +515,26 @@ export function itemToUiInventoryItem(
}))
if (fallbackStats.length > 0) {
stats = fallbackStats
+ if (statsEn.length === 0) statsEn = fallbackStats
}
}
// Damage evaluation
let damage: string | undefined = undefined
+ let damageEn: string | undefined = undefined
if (!isGambleMystery) {
if (tt.oneHandDamage && tt.twoHandDamage) {
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
+ damageEn = `${tt.oneHandDamage.min} to ${tt.oneHandDamage.max} / Two-Hand: ${tt.twoHandDamage.min} to ${tt.twoHandDamage.max}`
} else if (tt.twoHandDamage) {
damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
+ damageEn = `Two-Hand Damage: ${tt.twoHandDamage.min} to ${tt.twoHandDamage.max}`
} else if (tt.oneHandDamage) {
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}`
+ damageEn = `${tt.oneHandDamage.min} to ${tt.oneHandDamage.max}`
} else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) {
damage = String((base as any).damage)
+ damageEn = String((base as any).damage)
}
}
@@ -547,7 +568,14 @@ export function itemToUiInventoryItem(
!isGambleMystery &&
!stats.some(s => s.text.includes('未鉴定') || s.text.includes('未辨识') || s.text.includes('Unidentified'))
) {
- stats = [{ text: '未辨识 (Unidentified)', color: 'red' }, ...stats]
+ stats = [{ text: '未辨识', color: 'red' }, ...stats]
+ }
+ if (
+ isUnidentified &&
+ !isGambleMystery &&
+ !statsEn.some(s => s.text.includes('Unidentified'))
+ ) {
+ statsEn = [{ text: 'Unidentified', color: 'red' }, ...statsEn]
}
const isTome = code === 'tbk' || code === 'ibk'
@@ -563,6 +591,9 @@ export function itemToUiInventoryItem(
if (isTome && rawQty !== undefined && !baseNameZh.includes('数量:') && !stats.some(s => s.text.includes('数量:'))) {
stats = [...stats, { text: `数量: ${rawQty}`, color: 'white' }]
}
+ if (isTome && rawQty !== undefined && !baseNameEn.includes('Quantity:') && !statsEn.some(s => s.text.includes('Quantity:'))) {
+ statsEn = [...statsEn, { text: `Quantity: ${rawQty}`, color: 'white' }]
+ }
const id = rawItem.uniqueId
? `item-${code}-${rawItem.uniqueId}`
@@ -606,6 +637,7 @@ export function itemToUiInventoryItem(
name: rawItem.name || baseEn,
nameZh,
baseNameZh,
+ baseNameEn,
quality: isGambleMystery ? 'normal' : quality,
invWidth: rawItem.invWidth ?? base.invWidth ?? 1,
invHeight: rawItem.invHeight ?? base.invHeight ?? 1,
@@ -616,20 +648,25 @@ export function itemToUiInventoryItem(
? { defense: rawItem.defense ?? base.defense }
: {}),
...(damage !== undefined ? { damage } : {}),
+ ...(damageEn !== undefined ? { damageEn } : {}),
...(effectiveReqLevel > 0 ? { reqLevel: effectiveReqLevel } : {}),
...(effectiveReqStr > 0 ? { reqStr: effectiveReqStr } : {}),
...(effectiveReqDex > 0 ? { reqDex: effectiveReqDex } : {}),
...(reqClass !== undefined ? { reqClass } : {}),
stats,
+ statsEn,
...(!isGambleMystery && invtransform ? { invtransform } : {}),
...(!isGambleMystery && chrtransform ? { chrtransform } : {}),
...(durability ? { durability } : {}),
...(sockets !== undefined && sockets > 0 ? { sockets } : {}),
...(ethereal ? { ethereal: true } : {}),
...(!isGambleMystery && ((rawItem as any).speedText || tt.speedText) ? { speedText: (rawItem as any).speedText || tt.speedText } : {}),
+ ...(!isGambleMystery && ((rawItem as any).speedTextEn || ttEn.speedText) ? { speedTextEn: (rawItem as any).speedTextEn || ttEn.speedText } : {}),
...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes } : {}),
...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}),
+ ...(!isGambleMystery && ttEn.setPieces ? { setPiecesEn: ttEn.setPieces } : {}),
...(!isGambleMystery && tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
+ ...(!isGambleMystery && ttEn.setBonuses ? { setBonusesEn: ttEn.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}),
...(typeof dropSound === 'string' && dropSound.length > 0 ? { dropSound } : {}),
...(typeof dropSfxFrame === 'number' && Number.isFinite(dropSfxFrame) ? { dropSfxFrame } : {}),
diff --git a/src/ui/skill-tree-panel.ts b/src/ui/skill-tree-panel.ts
index 92970f3..7763e72 100644
--- a/src/ui/skill-tree-panel.ts
+++ b/src/ui/skill-tree-panel.ts
@@ -11,11 +11,20 @@
import type { D2FontRenderer, D2ColorCode } from './font.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import { SKILLS_CATALOG } from '../data/skills-catalog.ts'
+import type { TblLang } from '../i18n/lang.ts'
import {
buildTooltipViewModel,
type TooltipViewModel,
type SkillCalcState,
} from '../game/skill-calc-engine.ts'
+import { getTblLang } from '../i18n/lang.ts'
+
+export function getSkillName(skillId: number, lang: TblLang = getTblLang()): string {
+ const entry = SKILLS_CATALOG.find(s => s.id === skillId)
+ if (!entry) return `Skill #${skillId}`
+ return lang === 'ENG' ? entry.name : (entry.nameZh || entry.name)
+}
+
export interface FormattedTooltipLine {
readonly text: string
@@ -105,44 +114,46 @@ export interface SkillTreeTabInfo {
readonly tab: 0 | 1 | 2
readonly titleZh: string
readonly subZh: string
+ readonly titleEn: string
+ readonly subEn: string
readonly y: number
}
export const CLASS_VERTICAL_TABS: Record = {
ama: [
- { tab: 2, titleZh: '标枪和', subZh: '长矛', y: 95 },
- { tab: 1, titleZh: '被动和', subZh: '魔法', y: 195 },
- { tab: 0, titleZh: '弓和', subZh: '十字弓', y: 295 },
+ { tab: 2, titleZh: '标枪和', subZh: '长矛', titleEn: 'Javelin', subEn: 'and Spear', y: 95 },
+ { tab: 1, titleZh: '被动和', subZh: '魔法', titleEn: 'Passive', subEn: 'and Magic', y: 195 },
+ { tab: 0, titleZh: '弓和', subZh: '十字弓', titleEn: 'Bow and', subEn: 'Crossbow', y: 295 },
],
sor: [
- { tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 },
- { tab: 1, titleZh: '闪电', subZh: '法术', y: 195 },
- { tab: 0, titleZh: '火焰', subZh: '法术', y: 295 },
+ { tab: 2, titleZh: '冰冷', subZh: '法术', titleEn: 'Cold', subEn: 'Spells', y: 95 },
+ { tab: 1, titleZh: '闪电', subZh: '法术', titleEn: 'Lightning', subEn: 'Spells', y: 195 },
+ { tab: 0, titleZh: '火焰', subZh: '法术', titleEn: 'Fire', subEn: 'Spells', y: 295 },
],
nec: [
- { tab: 2, titleZh: '召唤', subZh: '法术', y: 95 },
- { tab: 1, titleZh: '毒素和', subZh: '白骨', y: 195 },
- { tab: 0, titleZh: '诅咒', subZh: '法术', y: 295 },
+ { tab: 2, titleZh: '召唤', subZh: '法术', titleEn: 'Summoning', subEn: 'Spells', y: 95 },
+ { tab: 1, titleZh: '毒素和', subZh: '白骨', titleEn: 'Poison', subEn: 'and Bone', y: 195 },
+ { tab: 0, titleZh: '诅咒', subZh: '法术', titleEn: 'Curses', subEn: '', y: 295 },
],
pal: [
- { tab: 2, titleZh: '防御', subZh: '灵气', y: 95 },
- { tab: 1, titleZh: '攻击', subZh: '灵气', y: 195 },
- { tab: 0, titleZh: '作战', subZh: '技能', y: 295 },
+ { tab: 2, titleZh: '防御', subZh: '灵气', titleEn: 'Defensive', subEn: 'Auras', y: 95 },
+ { tab: 1, titleZh: '攻击', subZh: '灵气', titleEn: 'Offensive', subEn: 'Auras', y: 195 },
+ { tab: 0, titleZh: '作战', subZh: '技能', titleEn: 'Combat', subEn: 'Skills', y: 295 },
],
bar: [
- { tab: 2, titleZh: '呐喊', subZh: '技能', y: 95 },
- { tab: 1, titleZh: '战斗', subZh: '专家', y: 195 },
- { tab: 0, titleZh: '战斗', subZh: '技能', y: 295 },
+ { tab: 2, titleZh: '呐喊', subZh: '技能', titleEn: 'Warcries', subEn: '', y: 95 },
+ { tab: 1, titleZh: '战斗', subZh: '专家', titleEn: 'Combat', subEn: 'Masteries', y: 195 },
+ { tab: 0, titleZh: '战斗', subZh: '技能', titleEn: 'Combat', subEn: 'Skills', y: 295 },
],
dru: [
- { tab: 2, titleZh: '元素', subZh: '技能', y: 95 },
- { tab: 1, titleZh: '变形', subZh: '技能', y: 195 },
- { tab: 0, titleZh: '召唤', subZh: '技能', y: 295 },
+ { tab: 2, titleZh: '元素', subZh: '技能', titleEn: 'Elemental', subEn: 'Skills', y: 95 },
+ { tab: 1, titleZh: '变形', subZh: '技能', titleEn: 'Shape', subEn: 'Shifting', y: 195 },
+ { tab: 0, titleZh: '召唤', subZh: '技能', titleEn: 'Summoning', subEn: 'Skills', y: 295 },
],
ass: [
- { tab: 2, titleZh: '武学', subZh: '艺术', y: 95 },
- { tab: 1, titleZh: '暗影', subZh: '武艺', y: 195 },
- { tab: 0, titleZh: '陷阱', subZh: '技能', y: 295 },
+ { tab: 2, titleZh: '武学', subZh: '艺术', titleEn: 'Martial', subEn: 'Arts', y: 95 },
+ { tab: 1, titleZh: '暗影', subZh: '武艺', titleEn: 'Shadow', subEn: 'Disciplines', y: 195 },
+ { tab: 0, titleZh: '陷阱', subZh: '技能', titleEn: 'Traps', subEn: '', y: 295 },
],
}
@@ -180,8 +191,28 @@ function buildClassSkillTrees(): Record = buildClassSkillTrees()
+export function formatDamageTypeEn(type: string): string {
+ switch (type) {
+ case 'fire':
+ return 'Fire Damage'
+ case 'ltng':
+ return 'Lightning Damage'
+ case 'cold':
+ return 'Cold Damage'
+ case 'pois':
+ return 'Poison Damage'
+ case 'mag':
+ return 'Magic Damage'
+ case 'physical':
+ return 'Physical Damage'
+ default:
+ return 'Damage'
+ }
+}
+
export class SkillTreePanel {
visible = false
+ lang: TblLang = 'CHI'
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */
activeTab: 0 | 1 | 2 = 2
unspentSkillPoints = 12
@@ -421,8 +452,10 @@ export class SkillTreePanel {
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
}
+ const isEn = this.lang === 'ENG'
+
// Remaining Skill Points Header Plaque (built-in DC6 recessed stone box at x=255..297, y=61..83)
- font.drawText(ctx, '剩余技能点', ox + 276, oy + 38, {
+ font.drawText(ctx, isEn ? 'Skill Choices' : '剩余技能点', ox + 276, oy + 38, {
font: 'font8',
color: 'gold',
align: 'center',
@@ -450,16 +483,26 @@ export class SkillTreePanel {
ctx.lineWidth = active ? 2 : 1
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
}
- font.drawText(ctx, vt.titleZh, ox + 275, oy + vt.y + 52, {
- font: 'fontexocet10',
- color: active ? 'gold' : 'tan',
- align: 'center',
- })
- font.drawText(ctx, vt.subZh, ox + 275, oy + vt.y + 72, {
- font: 'fontexocet10',
- color: active ? 'gold' : 'tan',
- align: 'center',
- })
+ const title = isEn ? vt.titleEn : vt.titleZh
+ const sub = isEn ? vt.subEn : vt.subZh
+ if (sub) {
+ font.drawText(ctx, title, ox + 275, oy + vt.y + 52, {
+ font: 'fontexocet10',
+ color: active ? 'gold' : 'tan',
+ align: 'center',
+ })
+ font.drawText(ctx, sub, ox + 275, oy + vt.y + 72, {
+ font: 'fontexocet10',
+ color: active ? 'gold' : 'tan',
+ align: 'center',
+ })
+ } else {
+ font.drawText(ctx, title, ox + 275, oy + vt.y + 62, {
+ font: 'fontexocet10',
+ color: active ? 'gold' : 'tan',
+ align: 'center',
+ })
+ }
}
// 4. Draw Skill Nodes & Level Sockets on Active Tab
@@ -522,12 +565,13 @@ export class SkillTreePanel {
}
}
- static formatSkillTooltipLines(vm: TooltipViewModel): FormattedTooltipLine[] {
+ static formatSkillTooltipLines(vm: TooltipViewModel, lang: TblLang = 'CHI'): FormattedTooltipLine[] {
const lines: FormattedTooltipLine[] = []
+ const isEn = lang === 'ENG'
// 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center)
lines.push({
- text: `${vm.skill.nameZh} (${vm.skill.name})`,
+ text: isEn ? vm.skill.name : (vm.skill.nameZh || vm.skill.name),
color: 'gold',
font: 'fontexocet10',
align: 'center',
@@ -536,7 +580,7 @@ export class SkillTreePanel {
// 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs
if (!vm.levelMet) {
lines.push({
- text: `需要角色等级: ${vm.reqlevel}`,
+ text: isEn ? `Required Level: ${vm.reqlevel}` : `需要角色等级: ${vm.reqlevel}`,
color: 'red',
font: 'font8',
align: 'center',
@@ -545,7 +589,7 @@ export class SkillTreePanel {
for (const prereq of vm.prereqs) {
if (!prereq.met) {
lines.push({
- text: `需要前置技能: ${prereq.nameZh}`,
+ text: isEn ? `Required Skill: ${prereq.name}` : `需要前置技能: ${prereq.nameZh}`,
color: 'red',
font: 'font8',
align: 'center',
@@ -554,7 +598,9 @@ export class SkillTreePanel {
}
// 3. Narrative: Short/Long description in White (#e8e4dc, font8)
- const desc = vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh
+ const desc = isEn
+ ? (vm.skill.shortDesc || vm.shortDesc || vm.skill.longDesc || vm.longDesc)
+ : (vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh)
if (desc) {
lines.push({
text: desc,
@@ -567,7 +613,9 @@ export class SkillTreePanel {
// 4. Current Level header: Blue/White
if (vm.isLearned) {
const soft = vm.effectiveLevel - vm.hardPoints
- const lvlText = `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
+ const lvlText = isEn
+ ? `Current Skill Level: ${vm.effectiveLevel}${soft > 0 ? ` (Base ${vm.hardPoints} + Gear +${soft})` : ''}`
+ : `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
lines.push({
text: lvlText,
color: soft > 0 ? 'blue' : 'white',
@@ -576,7 +624,7 @@ export class SkillTreePanel {
})
} else {
lines.push({
- text: '未习得 (预览 1 级效果)',
+ text: isEn ? 'Unlearned (Level 1 Preview)' : '未习得 (预览 1 级效果)',
color: 'gray',
font: 'font8',
align: 'center',
@@ -586,7 +634,7 @@ export class SkillTreePanel {
// 5. Current Stats: Mana cost, damage ranges (with synergies and masteries), attack bonus, duration, cast delay in Blue/White (#6868ff / #e8e4dc, font8)
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
lines.push({
- text: `法力消耗: ${vm.current.manaCost}`,
+ text: isEn ? `Mana Cost: ${vm.current.manaCost}` : `法力消耗: ${vm.current.manaCost}`,
color: 'white',
font: 'font8',
align: 'center',
@@ -594,7 +642,7 @@ export class SkillTreePanel {
}
if (vm.current.delaySeconds > 0) {
lines.push({
- text: `施法延迟: ${vm.current.delaySeconds} 秒`,
+ text: isEn ? `Casting Delay: ${vm.current.delaySeconds} sec` : `施法延迟: ${vm.current.delaySeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
@@ -603,14 +651,16 @@ export class SkillTreePanel {
if (vm.current.damage.hasDamage) {
const d = vm.current.damage
lines.push({
- text: `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
+ text: isEn
+ ? `${formatDamageTypeEn(d.type)}: ${d.finalMin} - ${d.finalMax}`
+ : `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
color: 'blue',
font: 'font8',
align: 'center',
})
if (d.durationSeconds !== undefined && d.durationSeconds > 0) {
lines.push({
- text: `持续时间: ${d.durationSeconds} 秒`,
+ text: isEn ? `Duration: ${d.durationSeconds} sec` : `持续时间: ${d.durationSeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
@@ -618,7 +668,7 @@ export class SkillTreePanel {
}
if (d.weaponDamagePercent !== undefined && d.weaponDamagePercent > 0) {
lines.push({
- text: `武器伤害: ${d.weaponDamagePercent}%`,
+ text: isEn ? `Weapon Damage: ${d.weaponDamagePercent}%` : `武器伤害: ${d.weaponDamagePercent}%`,
color: 'white',
font: 'font8',
align: 'center',
@@ -626,9 +676,10 @@ export class SkillTreePanel {
}
}
for (const eff of vm.current.effects) {
- if (eff.textZh) {
+ const effText = isEn ? eff.textEn : eff.textZh
+ if (effText) {
lines.push({
- text: eff.textZh,
+ text: effText,
color: 'blue',
font: 'font8',
align: 'center',
@@ -639,14 +690,14 @@ export class SkillTreePanel {
// 6. Next Level Preview: Header [下一等级] (Lv X) in Green (#00fc00, font8), followed by next level values. Omitted if unlearned or maxed (20 base points).
if (vm.nextLevel && vm.isLearned && vm.hardPoints < 20) {
lines.push({
- text: `[下一等级] (Lv ${vm.nextLevel.level})`,
+ text: isEn ? `[Next Level] (Lv ${vm.nextLevel.level})` : `[下一等级] (Lv ${vm.nextLevel.level})`,
color: 'green',
font: 'font8',
align: 'center',
})
if (vm.nextLevel.manaCost !== vm.current.manaCost) {
lines.push({
- text: `法力消耗: ${vm.nextLevel.manaCost}`,
+ text: isEn ? `Mana Cost: ${vm.nextLevel.manaCost}` : `法力消耗: ${vm.nextLevel.manaCost}`,
color: 'green',
font: 'font8',
align: 'center',
@@ -655,14 +706,16 @@ export class SkillTreePanel {
if (vm.nextLevel.damage.hasDamage) {
const nd = vm.nextLevel.damage
lines.push({
- text: `${nd.typeZh || '伤害'}: ${nd.finalMin} - ${nd.finalMax}`,
+ text: isEn
+ ? `${formatDamageTypeEn(nd.type)}: ${nd.finalMin} - ${nd.finalMax}`
+ : `${nd.typeZh || '伤害'}: ${nd.finalMin} - ${nd.finalMax}`,
color: 'green',
font: 'font8',
align: 'center',
})
if (nd.durationSeconds !== undefined && nd.durationSeconds > 0 && nd.durationSeconds !== vm.current.damage.durationSeconds) {
lines.push({
- text: `持续时间: ${nd.durationSeconds} 秒`,
+ text: isEn ? `Duration: ${nd.durationSeconds} sec` : `持续时间: ${nd.durationSeconds} 秒`,
color: 'green',
font: 'font8',
align: 'center',
@@ -670,7 +723,7 @@ export class SkillTreePanel {
}
if (nd.weaponDamagePercent !== undefined && nd.weaponDamagePercent !== vm.current.damage.weaponDamagePercent) {
lines.push({
- text: `武器伤害: ${nd.weaponDamagePercent}%`,
+ text: isEn ? `Weapon Damage: ${nd.weaponDamagePercent}%` : `武器伤害: ${nd.weaponDamagePercent}%`,
color: 'green',
font: 'font8',
align: 'center',
@@ -678,9 +731,10 @@ export class SkillTreePanel {
}
}
for (const eff of vm.nextLevel.effects) {
- if (eff.textZh) {
+ const effText = isEn ? eff.textEn : eff.textZh
+ if (effText) {
lines.push({
- text: eff.textZh,
+ text: effText,
color: 'green',
font: 'font8',
align: 'center',
@@ -689,25 +743,29 @@ export class SkillTreePanel {
}
} else if (vm.hardPoints >= 20) {
lines.push({
- text: '[已达技能点投入上限 (20/20)]',
+ text: isEn ? '[Max Base Skill Level Reached (20/20)]' : '[已达技能点投入上限 (20/20)]',
color: 'gold',
font: 'font8',
align: 'center',
})
}
- // 7. Synergies Section: Header [协同技能加成] in Blue (fontexocet10), followed by each synergy provider line: • : 每个有效技能点增加 % 伤害 (当前 +%)
+ // 7. Synergies Section: Header [协同技能加成] in Blue (fontexocet10), followed by each synergy provider line
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
lines.push({
- text: '[协同技能加成]',
+ text: isEn ? '[Synergy Bonuses]' : '[协同技能加成]',
color: 'blue',
font: 'fontexocet10',
align: 'center',
})
for (const syn of vm.synergiesReceived) {
- const text = syn.hardPoints > 0
- ? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
- : `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`
+ const text = isEn
+ ? (syn.hardPoints > 0
+ ? `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level (Current +${syn.currentBonusPct}%)`
+ : `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`)
+ : (syn.hardPoints > 0
+ ? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
+ : `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`)
lines.push({
text,
color: syn.hardPoints > 0 ? 'blue' : 'gray',
@@ -718,7 +776,7 @@ export class SkillTreePanel {
}
if (vm.masteryInfo && vm.masteryInfo.active) {
lines.push({
- text: `• ${vm.masteryInfo.descriptionZh}`,
+ text: `• ${isEn ? vm.masteryInfo.descriptionEn : vm.masteryInfo.descriptionZh}`,
color: 'blue',
font: 'font8',
align: 'left',
@@ -752,7 +810,7 @@ export class SkillTreePanel {
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
if (!vm) return
- const lines = SkillTreePanel.formatSkillTooltipLines(vm)
+ const lines = SkillTreePanel.formatSkillTooltipLines(vm, this.lang)
if (lines.length === 0) return
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts
index 3f98894..6ef8ec5 100644
--- a/src/ui/world-panels.ts
+++ b/src/ui/world-panels.ts
@@ -32,19 +32,23 @@ import {
type UiInventoryItem,
} from './inventory.ts'
import type { CharacterClassCode } from '../game/classes.ts'
+import { getTblLang, type TblLang } from '../i18n/lang.ts'
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
import {
type TownNpcServiceDescriptor,
type VendorNpcId,
getTownNpcDescriptorByName,
getTownNpcDescriptorByVendorId,
+ getLocalizedNpcName,
+ getLocalizedNpcGreetings,
} from '../game/npc-table.ts'
import type { VendorTabId } from '../game/vendor-generator.ts'
import { VendorSessionManager } from '../game/vendor-service.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { isItemRepairable } from '../game/item-cost.ts'
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../data/canonical-tbl-strings.ts'
-import { CANONICAL_ELITE_MODIFIERS } from '../game/monsters.ts'
+import { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../game/monsters.ts'
+import { getSocketableInfo } from '../game/gems-data.ts'
export {
GRID_ITEM_BG_BLUE,
@@ -330,13 +334,14 @@ export function computeNpcDialogLayout(
anchorX: number,
anchorY: number,
font?: Pick | null,
+ lang: TblLang = 'CHI',
): NpcDialogLayout {
const measure = (text: string): number =>
font ? font.measureText(text, 'font16') : text.length * 10
let maxLineWidth = measure(npcName)
for (const opt of options) {
- const w = measure(opt.labelZh)
+ const w = measure(lang === 'ENG' ? opt.labelEn : opt.labelZh)
if (w > maxLineWidth) maxLineWidth = w
}
@@ -602,6 +607,7 @@ export function findWaypointBySlug(slug: string): WaypointEntry | undefined {
}
export class WorldPanelsHud {
+ lang: TblLang = getTblLang()
selectedActTab = 1
selectedQuestIdx = 0
stashGold = 2_500_000
@@ -974,7 +980,7 @@ export class WorldPanelsHud {
const unitCost = hit.item.buyCost ?? 1
if (playerInventory.gold < unitCost) {
- this.showVendorStatus('金币不足 (Not enough gold)', 'red')
+ this.showVendorStatus(this.lang === 'ENG' ? 'Not enough gold' : '金币不足', 'red')
return { ok: false, reason: 'not_enough_gold', boughtCount: 0, totalCost: 0 }
}
@@ -984,7 +990,7 @@ export class WorldPanelsHud {
if (this.vendorMode === 'gamble') {
const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight)
if (!freeSlot) {
- this.showVendorStatus('背包空间不足 (Inventory full)', 'red')
+ this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 }
}
const gambleRes = this.vendorSession.buyGambleItem(
@@ -995,7 +1001,7 @@ export class WorldPanelsHud {
this.vendorCharLevel,
)
if (!gambleRes.success || !gambleRes.item) {
- this.showVendorStatus('无法赌博该物品', 'red')
+ this.showVendorStatus(this.lang === 'ENG' ? 'Cannot gamble this item' : '无法赌博该物品', 'red')
return { ok: false, reason: gambleRes.error ?? 'gamble_failed', boughtCount: 0, totalCost: 0 }
}
playerInventory.gold = gambleRes.remainingGold
@@ -1003,7 +1009,10 @@ export class WorldPanelsHud {
const resolvedUi = itemToUiInventoryItem({ ...(gambleRes.item as any), sellCost }, tables)
playerInventory.gridItems.push({ item: resolvedUi, col: freeSlot.col, row: freeSlot.row })
this.gamblePlacements.splice(hitIdx, 1)
- this.showVendorStatus(`赌博获得: ${resolvedUi.nameZh}`, 'gold')
+ this.showVendorStatus(
+ this.lang === 'ENG' ? `Gambled: ${resolvedUi.name}` : `赌博获得: ${resolvedUi.nameZh}`,
+ 'gold',
+ )
return { ok: true, boughtCount: 1, totalCost: gambleRes.goldSpent }
}
@@ -1028,7 +1037,7 @@ export class WorldPanelsHud {
const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight)
if (!freeSlot) {
if (boughtCount === 0) {
- this.showVendorStatus('背包空间不足 (Inventory full)', 'red')
+ this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 }
}
break
@@ -1066,7 +1075,9 @@ export class WorldPanelsHud {
if (boughtCount > 0) {
this.showVendorStatus(
- `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`,
+ this.lang === 'ENG'
+ ? `Bought ${hit.item.name}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} Gold)`
+ : `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`,
'gold',
)
return { ok: true, boughtCount, totalCost }
@@ -1084,7 +1095,10 @@ export class WorldPanelsHud {
const vendorId = this.activeVendorDescriptor.vendorId
// Quest items cannot be sold
if (item.rawItem?.base?.quest && item.rawItem.base.quest > 0) {
- this.showVendorStatus('任务物品无法出售 (Cannot sell quest item)', 'red')
+ this.showVendorStatus(
+ this.lang === 'ENG' ? 'Cannot sell quest item' : '任务物品无法出售',
+ 'red',
+ )
return { ok: false, goldReceived: 0 }
}
@@ -1125,7 +1139,12 @@ export class WorldPanelsHud {
}
}
- this.showVendorStatus(`已出售 ${item.nameZh} (+${sellPrice} 金币)`, 'gold')
+ this.showVendorStatus(
+ this.lang === 'ENG'
+ ? `Sold ${item.name} (+${sellPrice} Gold)`
+ : `已出售 ${item.nameZh} (+${sellPrice} 金币)`,
+ 'gold',
+ )
return { ok: true, goldReceived: sellPrice }
}
@@ -1137,17 +1156,26 @@ export class WorldPanelsHud {
return { ok: false, cost: 0 }
}
if (!item.durability || item.durability.current >= item.durability.max) {
- this.showVendorStatus('该物品无需修理', 'tan')
+ this.showVendorStatus(
+ this.lang === 'ENG' ? 'Item does not need repair' : '该物品无需修理',
+ 'tan',
+ )
return { ok: false, cost: 0 }
}
if (item.ethereal) {
- this.showVendorStatus('无形物品无法修理 (Cannot repair ethereal item)', 'red')
+ this.showVendorStatus(
+ this.lang === 'ENG' ? 'Cannot repair ethereal item' : '无形物品无法修理',
+ 'red',
+ )
return { ok: false, cost: 0 }
}
const cost = this.getPlayerItemRepairPrice(item)
if (cost <= 0) return { ok: false, cost: 0 }
if (playerInventory.gold < cost) {
- this.showVendorStatus('金币不足,无法修理', 'red')
+ this.showVendorStatus(
+ this.lang === 'ENG' ? 'Not enough gold to repair' : '金币不足,无法修理',
+ 'red',
+ )
return { ok: false, cost: 0 }
}
playerInventory.gold -= cost
@@ -1158,7 +1186,12 @@ export class WorldPanelsHud {
if (item.rawItem) {
item.rawItem.durability = item.durability.max
}
- this.showVendorStatus(`已修理 ${item.nameZh} (-${cost} 金币)`, 'gold')
+ this.showVendorStatus(
+ this.lang === 'ENG'
+ ? `Repaired ${item.name} (-${cost} Gold)`
+ : `已修理 ${item.nameZh} (-${cost} 金币)`,
+ 'gold',
+ )
return { ok: true, cost }
}
@@ -1199,7 +1232,7 @@ export class WorldPanelsHud {
let repairedCount = 0
let totalCost = 0
for (const { item, cost } of this.collectRepairablePlayerItems(playerInventory)) {
- if (!item.durability) continue
+ if ( !item.durability) continue
if (playerInventory.gold < cost) break
const maxDur = item.durability.max
playerInventory.gold -= cost
@@ -1214,9 +1247,17 @@ export class WorldPanelsHud {
}
}
if (repairedCount > 0) {
- this.showVendorStatus(`已修理 ${repairedCount} 件装备 (-${totalCost} 金币)`, 'gold')
+ this.showVendorStatus(
+ this.lang === 'ENG'
+ ? `Repaired ${repairedCount} items (-${totalCost} Gold)`
+ : `已修理 ${repairedCount} 件装备 (-${totalCost} 金币)`,
+ 'gold',
+ )
} else {
- this.showVendorStatus('所有装备完好,无需修理', 'tan')
+ this.showVendorStatus(
+ this.lang === 'ENG' ? 'All equipment is at full durability' : '所有装备完好,无需修理',
+ 'tan',
+ )
}
return { repairedCount, totalCost }
}
@@ -1281,22 +1322,43 @@ export class WorldPanelsHud {
return undefined
}
- getQuestDisplayText(quest: QuestEntry): QuestDisplayText {
+ getQuestDisplayText(quest: QuestEntry, lang: TblLang = this.lang): QuestDisplayText {
const status = this.getQuestStatus(quest)
- const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
+ const isEn = lang === 'ENG'
+ const statusTag = isEn
+ ? status === 'completed'
+ ? '[Completed]'
+ : status === 'active'
+ ? '[Active]'
+ : '[Locked]'
+ : status === 'completed'
+ ? '[已完成]'
+ : status === 'active'
+ ? '[进行中]'
+ : '[未开启]'
- const title = this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn
+ const title = isEn
+ ? quest.titleEn || this.lookupQuestString(quest.titleKey) || quest.titleZh
+ : this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn
let body = ''
- const rewardText = quest.rewardSummaryZh || quest.rewardSummaryEn
+ const rewardText = isEn
+ ? quest.rewardSummaryEn || quest.rewardSummaryZh
+ : quest.rewardSummaryZh || quest.rewardSummaryEn
if (status === 'completed') {
const tblCompleted = this.lookupQuestString(quest.completedKey)
- body = tblCompleted || quest.completedZh || quest.completedEn || quest.description
+ body = isEn
+ ? quest.completedEn || tblCompleted || quest.completedZh || quest.description
+ : tblCompleted || quest.completedZh || quest.completedEn || quest.description
} else if (status === 'active') {
const tblObjective = this.lookupQuestString(quest.objectiveKey)
- body = tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
+ body = isEn
+ ? quest.objectiveEn || tblObjective || quest.objectiveZh || quest.description
+ : tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
} else {
- body = '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。'
+ body = isEn
+ ? 'This quest has not begun yet. Speak to townsfolk or explore the wilderness to begin.'
+ : '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。'
}
return {
@@ -1779,7 +1841,11 @@ export class WorldPanelsHud {
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
- return computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
+ const headerName =
+ this.lang === 'ENG'
+ ? (this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, 'ENG') : getLocalizedNpcName(this.npcMenu.npcName, 'ENG'))
+ : this.npcMenu.npcName
+ return computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
}
isPointInNpcMenu(
@@ -1840,7 +1906,11 @@ export class WorldPanelsHud {
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
- const layout = computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
+ const headerName =
+ this.lang === 'ENG'
+ ? (this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, 'ENG') : getLocalizedNpcName(this.npcMenu.npcName, 'ENG'))
+ : this.npcMenu.npcName
+ const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
if (
logicalX < layout.left ||
@@ -2187,6 +2257,8 @@ export class WorldPanelsHud {
const ox = 80
const oy = 60
+ const isEn = this.lang === 'ENG'
+
if (kind === 'quest') {
if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy)
this.drawActTabs(ctx, ox, oy, font, assets.questTabsImg)
@@ -2226,16 +2298,22 @@ export class WorldPanelsHud {
color: status === 'completed' ? 'gold' : 'white',
align: 'center',
})
- font.drawText(ctx, q.titleZh, sx + 40, sy + 56, {
+ font.drawText(ctx, isEn ? q.titleEn : q.titleZh, sx + 40, sy + 56, {
font: 'font8',
color: status === 'completed' ? 'gold' : 'tan',
align: 'center',
})
- font.drawText(ctx, status === 'completed' ? '已完成' : '进行中', sx + 40, sy + 76, {
- font: 'font8',
- color: status === 'completed' ? 'green' : 'blue',
- align: 'center',
- })
+ font.drawText(
+ ctx,
+ status === 'completed' ? (isEn ? 'Completed' : '已完成') : (isEn ? 'Active' : '进行中'),
+ sx + 40,
+ sy + 76,
+ {
+ font: 'font8',
+ color: status === 'completed' ? 'green' : 'blue',
+ align: 'center',
+ },
+ )
}
ctx.strokeStyle = selected ? '#e8c26b' : status === 'completed' ? '#6c5838' : '#3a3224'
@@ -2251,14 +2329,14 @@ export class WorldPanelsHud {
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
- font.drawText(ctx, `${qInfo.title} (${activeQ.titleEn}) ${qInfo.statusTag}`, ox + 160, oy + 282, {
+ font.drawText(ctx, `${qInfo.title} ${qInfo.statusTag}`, ox + 160, oy + 282, {
font: 'fontexocet10',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',
})
const rewardSuffix = qInfo.status === 'completed'
- ? (qInfo.rewardText ? `\n[奖励: ${qInfo.rewardText}]` : '')
- : (qInfo.rewardText ? `\n[目标: ${qInfo.rewardText}]` : '')
+ ? (qInfo.rewardText ? (isEn ? `\n[Reward: ${qInfo.rewardText}]` : `\n[奖励: ${qInfo.rewardText}]`) : '')
+ : (qInfo.rewardText ? (isEn ? `\n[Objective: ${qInfo.rewardText}]` : `\n[目标: ${qInfo.rewardText}]`) : '')
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 5)
}
} else if (kind === 'waypoint') {
@@ -2291,23 +2369,29 @@ export class WorldPanelsHud {
}
}
- font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 80, wy + 21, {
+ font.drawText(ctx, isEn ? wp.nameEn : wp.nameZh, ox + 80, wy + 21, {
font: 'fontexocet10',
color: isUnlocked ? (isHovered ? 'white' : 'gold') : 'gray',
})
}
} else if (kind === 'stash') {
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
- font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 172, oy + 35, {
+ font.drawText(ctx, isEn ? 'Private Stash' : '私人储物箱', ox + 172, oy + 35, {
font: 'font8',
color: 'gold',
align: 'center',
})
- font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 61, {
- font: 'font8',
- color: 'white',
- align: 'center',
- })
+ font.drawText(
+ ctx,
+ isEn ? `Stash Gold: ${this.stashGold.toLocaleString()}` : `储物箱金币: ${this.stashGold.toLocaleString()}`,
+ ox + 160,
+ oy + 61,
+ {
+ font: 'font8',
+ color: 'white',
+ align: 'center',
+ },
+ )
// 6x8 grid cell borders
for (let r = 0; r < STASH_GRID_ORIGIN.rows; r++) {
@@ -2352,11 +2436,19 @@ export class WorldPanelsHud {
}
} else if (kind === 'cube') {
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
- font.drawText(ctx, `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`, ox + 160, oy + 32, {
- font: 'fontexocet10',
- color: 'gold',
- align: 'center',
- })
+ font.drawText(
+ ctx,
+ isEn
+ ? `Horadric Cube (Transmuted: ${this.cubeTransmuteCount})`
+ : `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`,
+ ox + 160,
+ oy + 32,
+ {
+ font: 'fontexocet10',
+ color: 'gold',
+ align: 'center',
+ },
+ )
// 3x4 Horadric Cube grid cell borders (`Inventory.txt` Transmogrify Box1)
for (let r = 0; r < CUBE_GRID_ORIGIN.rows; r++) {
@@ -2433,11 +2525,11 @@ export class WorldPanelsHud {
)
}
- // D2Client.dll `0x6fb49720..0x6fb497c8`: Centered framed hover tooltip (`改變` / `關閉` in Font16)
+ // D2Client.dll `0x6fb49720..0x6fb497c8`: Centered framed hover tooltip (`改變`/`Transmute`, `關閉`/`Close` in Font16)
if (this.hoveredCubeButton !== null) {
const spec =
this.hoveredCubeButton === 'transmute' ? CUBE_TRANSMUTE_BTN_BOUNDS : CUBE_CLOSE_BTN_BOUNDS
- const hoverText = spec.labelZh
+ const hoverText = isEn ? spec.labelEn : spec.labelZh
const padX = 6
const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2)
const boxH = 22
@@ -2506,14 +2598,14 @@ export class WorldPanelsHud {
ctx.drawImage(assets.buySellBtnImg, frameIdx * 32, 0, 32, 32, drawX, drawY, 32, 32)
}
- // 4. D2Client.dll `0x6fb45e5e–0x6fb45f5d`: `buyselltabs.dc6` present tabs + Font16 CHI `.tbl` labels (skipped in gamble mode)
+ // 4. D2Client.dll `0x6fb45e5e–0x6fb45f5d`: `buyselltabs.dc6` present tabs + Font16 `.tbl` labels (skipped in gamble mode)
for (const tab of VENDOR_TABS) {
if (!this.isVendorTabPresent(tab.id)) continue
const selected = this.activeVendorTab === tab.id
const frameIdx = selected ? tab.index : tab.index + 4
ctx.drawImage(assets.vendorTabsImg, frameIdx * 79, 0, 79, 31, ox + tab.xOffset, oy, 79, 31)
if (this.vendorMode === 'trade') {
- font.drawText(ctx, tab.labelZh, ox + tab.centerX, oy + tab.textY, {
+ font.drawText(ctx, isEn ? tab.labelEn : tab.labelZh, ox + tab.centerX, oy + tab.textY, {
font: 'font16',
color: selected ? 'gold' : 'white',
align: 'center',
@@ -2538,8 +2630,12 @@ export class WorldPanelsHud {
if (spec.enabled) {
const hoverText =
btnKind === 'repairAll'
- ? `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}`
- : spec.labelZh
+ ? isEn
+ ? `${spec.labelEn}: ${this.getRepairAllCost(assets.inventory)}`
+ : `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}`
+ : isEn
+ ? spec.labelEn
+ : spec.labelZh
const padX = 6
const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2)
const boxH = 22
@@ -2574,8 +2670,10 @@ export class WorldPanelsHud {
): void {
if (!this.npcMenu) return
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
-
if (this.npcMenu.talking) {
+ const talkHeaderName = this.npcMenu.descriptor
+ ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
+ : getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
@@ -2583,22 +2681,29 @@ export class WorldPanelsHud {
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
- font.drawText(ctx, this.npcMenu.npcName, bx + Math.trunc(boxW / 2), by + 24, {
+ font.drawText(ctx, talkHeaderName, bx + Math.trunc(boxW / 2), by + 24, {
font: 'font16',
color: 'gold',
align: 'center',
})
- const bodyText = this.npcMenu.dialogLines.join('\n')
+ const dialogLines = this.npcMenu.descriptor
+ ? getLocalizedNpcGreetings(this.npcMenu.descriptor, this.lang)
+ : this.npcMenu.dialogLines
+ const bodyText = dialogLines.join('\n')
this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 6)
return
}
+ const headerName =
+ this.lang === 'ENG'
+ ? (this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, 'ENG') : getLocalizedNpcName(this.npcMenu.npcName, 'ENG'))
+ : this.npcMenu.npcName
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
- const layout = computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
+ const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
// 1. D2Client.dll 0x6fb53890 + 0x6fb6e4c0: dark translucent fill + carved stone boxpieces.dc6 border
drawStoneBoxFrame(ctx, layout.left, layout.top, layout.width, layout.height, boxPiecesImg)
@@ -2606,7 +2711,7 @@ export class WorldPanelsHud {
const centerX = layout.left + Math.trunc(layout.width / 2)
// 2. D2Client.dll 0x6faf8c67 + 0x6fb53912: Line 0 NPC Name Header in Font16 Gold (color = 4, step = 21)
- font.drawText(ctx, this.npcMenu.npcName, centerX, layout.headerY, {
+ font.drawText(ctx, headerName, centerX, layout.headerY, {
font: 'font16',
color: 'gold',
align: 'center',
@@ -2618,7 +2723,7 @@ export class WorldPanelsHud {
const opt = opts[i]!
const rowY = layout.optionYs[i]!
const selected = selectedIdx === i
- font.drawText(ctx, opt.labelZh, centerX, rowY, {
+ font.drawText(ctx, this.lang === 'ENG' ? opt.labelEn : opt.labelZh, centerX, rowY, {
font: 'font16',
color: selected ? 'blue' : 'white',
align: 'center',
@@ -2722,7 +2827,7 @@ export class WorldPanelsHud {
const leftPanelOpen = options?.leftPanelOpen ?? false
const rightPanelOpen = options?.rightPanelOpen ?? false
const canvasWidth = options?.canvasWidth ?? 800
- const locale = options?.locale ?? 'en'
+ const locale = options?.locale ?? (this.lang === 'ENG' ? 'en' : 'zh')
const { paddedTitle, titleColor } = resolveMonsterTitleAndColor(monster, locale)
const textWidth = font.measureText(paddedTitle, 'font16')
@@ -2827,6 +2932,7 @@ export class WorldPanelsHud {
): void {
if (!hover) return
const { item } = hover
+ const isEn = this.lang === 'ENG'
const lines: { text: string; color: D2ColorCode; font: 'fontexocet10' | 'font8' }[] = []
const titleColor: D2ColorCode =
@@ -2842,61 +2948,111 @@ export class WorldPanelsHud {
? 'orange'
: 'white'
+ const resolveHeaderZh = (raw: string): string => localizeStarterTooltipLine(raw, false)
+ const resolveBaseName = (): string | undefined => {
+ if (isEn) {
+ if (item.baseNameEn) return item.baseNameEn
+ if (!item.baseNameZh) return undefined
+ return localizeStarterTooltipLine(item.baseNameZh, true)
+ }
+ if (!item.baseNameZh) return undefined
+ return localizeStarterTooltipLine(item.baseNameZh, false)
+ }
+
// Header: Title, Runeword Runes line, Base item name
const unid = isUiItemUnidentified(item)
if (unid) {
- const maskedBaseHeader =
- (item.baseNameZh ? item.baseNameZh.split(' · ')[0] : undefined) || item.nameZh
+ const maskedBaseHeader = isEn
+ ? item.baseNameEn ||
+ (item.baseNameZh ? localizeStarterTooltipLine(item.baseNameZh.split(' · ')[0]!, true) : undefined) ||
+ item.name ||
+ localizeStarterTooltipLine(item.nameZh, true)
+ : (item.baseNameZh ? resolveHeaderZh(item.baseNameZh.split(' · ')[0]!) : undefined) ||
+ resolveHeaderZh(item.nameZh)
lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' })
} else {
- lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
+ const headerText = isEn
+ ? (item.name && !/[\u4e00-\u9fff]/.test(item.name) ? item.name : localizeStarterTooltipLine(item.nameZh, true))
+ : resolveHeaderZh(item.nameZh)
+ lines.push({ text: headerText, color: titleColor, font: 'fontexocet10' })
if (item.runewordRunes) {
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
}
- if (item.baseNameZh) {
- lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
+ const baseLine = resolveBaseName()
+ if (baseLine) {
+ lines.push({ text: baseLine, color: 'white', font: 'font8' })
}
}
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0)
if (item.buyCost !== undefined) {
- const label = item.isGambleMystery ? '赌博价格 (Gamble Cost)' : '购买价格 (Buy Cost)'
- lines.push({ text: `${label}: ${item.buyCost.toLocaleString()} 金币`, color: 'gold', font: 'font8' })
+ const label = item.isGambleMystery
+ ? (isEn ? 'Gamble Cost' : '赌博价格')
+ : (isEn ? 'Buy Cost' : '购买价格')
+ const suffix = isEn ? ' Gold' : ' 金币'
+ lines.push({ text: `${label}: ${item.buyCost.toLocaleString()}${suffix}`, color: 'gold', font: 'font8' })
} else if (vendorOpen && this.activeVendorDescriptor) {
if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) {
const repPrice = this.getPlayerItemRepairPrice(item)
if (repPrice > 0) {
- lines.push({ text: `修理费用 (Repair Cost): ${repPrice.toLocaleString()} 金币`, color: 'gold', font: 'font8' })
+ lines.push({
+ text: isEn
+ ? `Repair Cost: ${repPrice.toLocaleString()} Gold`
+ : `修理费用: ${repPrice.toLocaleString()} 金币`,
+ color: 'gold',
+ font: 'font8',
+ })
}
} else {
const sellPrice = this.getPlayerItemSellPrice(item)
if (sellPrice > 0) {
- lines.push({ text: `出售价格 (Sell Value): ${sellPrice.toLocaleString()} 金币`, color: 'gold', font: 'font8' })
+ lines.push({
+ text: isEn
+ ? `Sell Value: ${sellPrice.toLocaleString()} Gold`
+ : `出售价格: ${sellPrice.toLocaleString()} 金币`,
+ color: 'gold',
+ font: 'font8',
+ })
}
}
}
// Base Stats Block
if (item.defense !== undefined) {
- lines.push({ text: '防御: ' + item.defense, color: 'white', font: 'font8' })
- }
- if (item.damage !== undefined) {
- const dmgText = item.damage.includes('伤害') ? item.damage : ('单手伤害: ' + item.damage)
- lines.push({ text: dmgText, color: 'white', font: 'font8' })
- }
- if (item.durability !== undefined) {
lines.push({
- text: '耐久度: ' + item.durability.current + ' 之 ' + item.durability.max,
+ text: (isEn ? 'Defense: ' : '防御: ') + item.defense,
color: 'white',
font: 'font8',
})
}
- if (item.speedText) {
- lines.push({ text: item.speedText, color: 'white', font: 'font8' })
+ if (item.damage !== undefined) {
+ if (isEn) {
+ const cleanDmg = localizeStarterTooltipLine(item.damage, true)
+ const dmgText = /Damage/i.test(cleanDmg) ? cleanDmg : `One-Hand Damage: ${cleanDmg}`
+ lines.push({ text: dmgText, color: 'white', font: 'font8' })
+ } else {
+ const dmgText = item.damage.includes('伤害') ? item.damage : ('单手伤害: ' + item.damage)
+ lines.push({ text: dmgText, color: 'white', font: 'font8' })
+ }
+ }
+ if (item.durability !== undefined) {
+ lines.push({
+ text: isEn
+ ? `Durability: ${item.durability.current} of ${item.durability.max}`
+ : '耐久度: ' + item.durability.current + ' 之 ' + item.durability.max,
+ color: 'white',
+ font: 'font8',
+ })
+ }
+ const speedLine = isEn
+ ? (item.speedTextEn ?? (item.speedText ? localizeStarterTooltipLine(item.speedText, true) : undefined))
+ : (item.speedText ? localizeStarterTooltipLine(item.speedText, false) : undefined)
+ if (speedLine) {
+ lines.push({ text: speedLine, color: 'white', font: 'font8' })
}
const playerCtx = this.getEffectivePlayerContext()
if (!unid && item.reqClass !== undefined) {
- const classLabelMap: Record = {
+ const classLabelMapZh: Record = {
ama: '(限亚马逊使用)',
sor: '(限法师使用)',
nec: '(限死灵法师使用)',
@@ -2905,7 +3061,16 @@ export class WorldPanelsHud {
dru: '(限德鲁伊使用)',
ass: '(限刺客使用)',
}
- const classText = classLabelMap[item.reqClass]
+ const classLabelMapEn: Record = {
+ ama: '(Amazon Only)',
+ sor: '(Sorceress Only)',
+ nec: '(Necromancer Only)',
+ pal: '(Paladin Only)',
+ bar: '(Barbarian Only)',
+ dru: '(Druid Only)',
+ ass: '(Assassin Only)',
+ }
+ const classText = isEn ? classLabelMapEn[item.reqClass] : classLabelMapZh[item.reqClass]
if (classText) {
lines.push({
text: classText,
@@ -2916,14 +3081,14 @@ export class WorldPanelsHud {
}
if (item.reqDex !== undefined && item.reqDex > 0) {
lines.push({
- text: '需要敏捷: ' + item.reqDex,
+ text: (isEn ? 'Required Dexterity: ' : '需要敏捷: ') + item.reqDex,
color: playerCtx.dex < item.reqDex ? 'red' : 'white',
font: 'font8',
})
}
if (item.reqStr !== undefined) {
lines.push({
- text: '需要力量: ' + item.reqStr,
+ text: (isEn ? 'Required Strength: ' : '需要力量: ') + item.reqStr,
color: playerCtx.str < item.reqStr ? 'red' : 'white',
font: 'font8',
})
@@ -2935,7 +3100,7 @@ export class WorldPanelsHud {
: item.reqLevel
if (displayReqLevel !== undefined) {
lines.push({
- text: '需要等级: ' + displayReqLevel,
+ text: (isEn ? 'Required Level: ' : '需要等级: ') + displayReqLevel,
color: playerCtx.level < displayReqLevel ? 'red' : 'white',
font: 'font8',
})
@@ -2943,40 +3108,72 @@ export class WorldPanelsHud {
if (unid) {
if (item.isGambleMystery) {
- lines.push({ text: '未鉴定 (Unidentified Gamble Item)', color: 'red', font: 'font8' })
+ lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font8' })
} else {
- lines.push({ text: '未辨识 (Unidentified)', color: 'red', font: 'font8' })
+ lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font8' })
if (item.allowedSlots.length > 0) {
- lines.push({ text: '无法使用 (Cannot be Equipped)', color: 'red', font: 'font8' })
+ lines.push({ text: isEn ? 'Cannot be Equipped' : '无法使用', color: 'red', font: 'font8' })
}
if (item.ethereal) {
- lines.push({ text: '无形的 (无法修复) (Ethereal)', color: 'blue', font: 'font8' })
+ lines.push({
+ text: isEn ? 'Ethereal (Cannot be Repaired)' : '无形的 (无法修复)',
+ color: 'blue',
+ font: 'font8',
+ })
}
}
} else {
// Magic Affixes
- for (const st of item.stats) {
- lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
+ const socketableInfo = isEn && !item.statsEn && item.code ? getSocketableInfo(item.code) : undefined
+ if (isEn && socketableInfo) {
+ lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white', font: 'font8' })
+ lines.push({ text: `Weapons: ${socketableInfo.weaponsEn}`, color: 'white', font: 'font8' })
+ lines.push({ text: `Armor: ${socketableInfo.armorEn}`, color: 'white', font: 'font8' })
+ lines.push({ text: `Helms: ${socketableInfo.helmsEn}`, color: 'white', font: 'font8' })
+ lines.push({ text: `Shields: ${socketableInfo.shieldsEn}`, color: 'white', font: 'font8' })
+ } else {
+ const activeStats = isEn ? (item.statsEn ?? item.stats) : item.stats
+ for (const st of activeStats) {
+ lines.push({
+ text: localizeStarterTooltipLine(st.text, isEn),
+ color: st.color ?? 'blue',
+ font: 'font8',
+ })
+ }
}
// Sockets Block
if (
item.sockets !== undefined &&
item.sockets > 0 &&
- !item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
+ !lines.some(s => s.text.includes('凹槽') || s.text.includes('插槽') || s.text.includes('Socketed'))
) {
- lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' })
+ lines.push({
+ text: isEn ? `Socketed (${item.sockets})` : '凹槽 (' + item.sockets + ')',
+ color: 'blue',
+ font: 'font8',
+ })
}
// Set Pieces & Set Bonuses Block
- if (item.setPieces) {
- for (const piece of item.setPieces) {
- lines.push({ text: piece, color: 'gray', font: 'font8' })
+ const activeSetPieces = isEn ? (item.setPiecesEn ?? item.setPieces) : item.setPieces
+ if (activeSetPieces) {
+ for (const piece of activeSetPieces) {
+ lines.push({
+ text: localizeStarterTooltipLine(piece, isEn),
+ color: 'gray',
+ font: 'font8',
+ })
}
}
- if (item.setBonuses) {
- for (const bonus of item.setBonuses) {
- lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
+ const activeSetBonuses = isEn ? (item.setBonusesEn ?? item.setBonuses) : item.setBonuses
+ if (activeSetBonuses) {
+ for (const bonus of activeSetBonuses) {
+ lines.push({
+ text: localizeStarterTooltipLine(bonus.text, isEn),
+ color: bonus.color ?? 'green',
+ font: 'font8',
+ })
}
}
}
@@ -3010,7 +3207,7 @@ export class WorldPanelsHud {
}
/**
- * Draw Area Entry Gothic Banner (`Entering: `).
+ * Draw Area Entry Gothic Banner (`Entering: ` / `进入:`).
*/
drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void {
if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return
@@ -3018,16 +3215,15 @@ export class WorldPanelsHud {
const alpha = remain < 800 ? remain / 800 : 1
ctx.save()
ctx.globalAlpha = Math.max(0, Math.min(1, alpha))
- font.drawText(ctx, `进入:${this.areaBanner.titleZh}`, 400, 142, {
+ const bannerText =
+ this.lang === 'ENG'
+ ? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}`
+ : `进入:${this.areaBanner.titleZh}`
+ font.drawText(ctx, bannerText, 400, 142, {
font: 'font30',
color: 'gold',
align: 'center',
})
- font.drawText(ctx, `Entering ${this.areaBanner.titleEn}`, 400, 166, {
- font: 'fontexocet10',
- color: 'tan',
- align: 'center',
- })
ctx.restore()
}
}
@@ -3135,7 +3331,10 @@ export function resolveMonsterTitleAndColor(
readonly titleColor: D2ColorCode
} {
const rawKey = target.nameKey || target.name
- const baseName = lookupMonsterTblString(rawKey, locale) ?? target.name
+ const baseName =
+ lookupMonsterTblString(rawKey, locale) ??
+ monsterDisplayName(rawKey, locale === 'en' ? 'ENG' : 'CHI') ??
+ target.name
const isGoldRank =
target.rank === 'unique' ||
@@ -3388,7 +3587,12 @@ export function buildMonsterHoverSubtext(
abilityColorPrefix = ''
} else {
const rawSubType = (target.subType ?? '').trim()
- const cleanSubType = rawSubType.toLowerCase() === 'dummy' ? '' : rawSubType
+ const cleanSubType =
+ rawSubType.toLowerCase() === 'dummy'
+ ? ''
+ : rawSubType.includes(' · ')
+ ? (locale === 'en' ? rawSubType.split(' · ')[0]! : rawSubType.split(' · ').slice(-1)[0]!)
+ : rawSubType
abilityPlain = descText || cleanSubType
if (target.rank === 'boss' || target.isBoss) {
abilityColorPrefix = 'ÿc4'
@@ -3439,3 +3643,184 @@ export function buildMonsterHoverSubtext(
}
}
+const STARTER_STAT_EN_EXACT: Record = {
+ '任务宝盒': 'Horadric Artifact',
+ '轻型铁手套': 'Light Gauntlets',
+ '完美宝石': 'Perfect Gem',
+ '宝石': 'Gem',
+ '骷髅': 'Skull',
+ '卷轴': 'Scroll',
+ '右键点击打开合成界面 (3×4 网格)': 'Right-Click to Open Horadric Cube (3x4 Grid)',
+ '右键施放城镇传送门': 'Right-Click to Cast Town Portal',
+ '鉴定未辨识的魔法/暗金装备': 'Right-Click to Identify Unidentified Items',
+ '被击中时有 25% 几率施放等级 1 传送': '25% Chance to Cast Level 1 Teleport When Struck',
+ '击中时有 5% 几率施放等级 10 火风暴': '5% Chance to Cast Level 10 Firestorm on Striking',
+ '升级时有 100% 几率施放等级 29 炽烈之径': '100% Chance to Cast Level 29 Blaze When You Level-Up',
+ '阵亡时有 100% 几率施放等级 37 暴风雪': '100% Chance to Cast Level 37 Blizzard When You Die',
+ '升级时有 100% 几率施放等级 41 新星': '100% Chance to Cast Level 41 Nova When You Level-Up',
+ '化身为吸血鬼巫妖形态 (完整套装)': 'Transforms into Vampire Form (Full Set)',
+ '发射魔法箭 (等级 1)': 'Fires Magic Arrows (Level 1)',
+ '冻结目标时间减半': 'Half Freeze Duration',
+ '无法冰冻': 'Cannot Be Frozen',
+ '忽略目标防御': "Ignore Target's Defense",
+ '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)': 'Prevent Monster Heal',
+ '法球类 - 普通攻击速度': 'Orb Class - Normal Attack Speed',
+ '剑类 - 极快的攻击速度': 'Sword Class - Very Fast Attack Speed',
+ '弓类 - 极快攻击速度 [40]': 'Bow Class - Very Fast Attack Speed [40]',
+ '权杖类 - 极快攻击速度 [40]': 'Scepter Class - Very Fast Attack Speed [40]',
+ '锤类 - 极快攻击速度 [40]': 'Mace Class - Very Fast Attack Speed [40]',
+ '钉头槌类 - 极快攻击速度 [30]': 'Mace Class - Very Fast Attack Speed [30]',
+ '爪类 - 极快攻击速度 [40]': 'Claw Class - Very Fast Attack Speed [40]',
+}
+
+function localizeStarterTooltipLine(text: string, isEn: boolean): string {
+ if (!text) return text
+ if (!isEn) {
+ // CHI mode: strip trailing pure-ASCII parentheticals like `(Harlequin Crest)`, `(Shako)`, `(FCR)`, `(+2 To All Skills)`
+ // and clean up inline English annotations on starter items while keeping authentic Chinese parens like `(完整套装)` / `(数量: 20)`.
+ let zh = text
+ .replace(/^精神\s*\(Spirit\)\s*统盾$/, '精神')
+ .replace(/\s*\(([A-Za-z]+)\s+符文\)$/, '符文')
+ .replace(/\s*\(Rainbow Facet\s*-\s*([^)]+)\)$/, ' ($1)')
+ .replace(/\s*\/\s*每次命中偷取生命\s*\([^)]+\)$/, '')
+ .replace(/\s*\/\s*防止怪物自疗\s*\([^)]+\)$/, '')
+ .replace(/\s*\/\s*战嚎\s*\([^)]+\)$/, '')
+ .replace(/\s*\/\s*生命补满\s*\+\d+\s*\([^)]+\)$/, '')
+ zh = zh.replace(/\s*\([A-Za-z0-9'+\-% /×·,.#:]+\)$/, '')
+ return zh
+ }
+
+ // ENG mode: zero CJK characters allowed
+ if (!/[\u4e00-\u9fff]/.test(text)) return text
+ if (STARTER_STAT_EN_EXACT[text]) return STARTER_STAT_EN_EXACT[text]!
+
+ // If the string has an explicit English parenthetical like `谐角之冠 (Harlequin Crest)` or `+2 所有技能 (+2 To All Skills)`
+ const trailingEnMatch = /\(([A-Za-z0-9'+\-% /,.#:]+)\)$/.exec(text)
+ if (trailingEnMatch) {
+ const inner = trailingEnMatch[1]!.trim()
+ // Only use `inner` directly if it's a full phrase/name (not a short acronym like FCR/FHR/IAS/ED/FRW/MF/CB or number like 40/15)
+ if (
+ inner.length > 3 &&
+ !/^(FCR|FHR|IAS|FRW|MF|ED|CB|\d+\/\d+|#\d+|\d+)$/.test(inner)
+ ) {
+ return inner
+ }
+ }
+
+ let s = text
+ // Strip short acronym parentheticals before pattern replacement
+ .replace(/\s*\((?:FCR|FHR|IAS|FRW|MF|ED|CB)\)/g, '')
+ // Full set / partial set suffixes
+ .replace(/\s*\(完整套装加成\)/g, ' (Full Set Bonus)')
+ .replace(/\s*\(完整套装\)/g, ' (Full Set)')
+ .replace(/\s*\((\d+)\s*件装备\)/g, ' ($1 Items)')
+ .replace(/\s*\(依角色等级而定\)/g, ' (Based on Character Level)')
+ .replace(/\s*\(持续\s*(\d+)\s*秒\)/g, ' (Over $1 Seconds)')
+ // Class restrictions inside skill lines
+ .replace(/\s*\(限法师\)/g, ' (Sorceress Only)')
+ .replace(/\s*\(限死灵法师\)/g, ' (Necromancer Only)')
+ .replace(/\s*\(限圣骑士\)/g, ' (Paladin Only)')
+ .replace(/\s*\(限野蛮人\)/g, ' (Barbarian Only)')
+ .replace(/\s*\(限德鲁伊\)/g, ' (Druid Only)')
+ .replace(/\s*\(限刺客\)/g, ' (Assassin Only)')
+ .replace(/\s*\(限亚马逊\)/g, ' (Amazon Only)')
+ // Specific base/title patterns
+ .replace(/^法术书\s*\(数量[::]\s*(\d+)\)$/, 'Tome (Quantity: $1)')
+ .replace(/^符文\s*\(#(\d+)\)$/, 'Rune (#$1)')
+ .replace(/^带插槽\s*\((\d+)\)$/, 'Socketed ($1)')
+ .replace(/^凹槽\s*\((\d+)\)$/, 'Socketed ($1)')
+ // Stat patterns
+ .replace(/^\+(\d+)\s*所有技能$/, '+$1 to All Skills')
+ .replace(/^\+(\d+)\s*亚马逊技能等级/, '+$1 to Amazon Skill Levels')
+ .replace(/^\+(\d+)\s*法师技能等级/, '+$1 to Sorceress Skill Levels')
+ .replace(/^\+(\d+)\s*死灵法师技能等级/, '+$1 to Necromancer Skill Levels')
+ .replace(/^\+(\d+)\s*圣骑士技能等级/, '+$1 to Paladin Skill Levels')
+ .replace(/^\+(\d+)\s*野蛮人技能等级/, '+$1 to Barbarian Skill Levels')
+ .replace(/^\+(\d+)\s*德鲁伊技能等级/, '+$1 to Druid Skill Levels')
+ .replace(/^\+(\d+)\s*刺客技能等级/, '+$1 to Assassin Skill Levels')
+ .replace(/^\+(\d+)\s*角色技能等级/, '+$1 to Character Skill Levels')
+ .replace(/^\+(\d+)\s*火焰技能/, '+$1 to Fire Skills')
+ .replace(/^\+(\d+)\s*亚马逊被动与魔法技能/, '+$1 to Passive and Magic Skills (Amazon Only)')
+ .replace(/^\+(\d+)\s*亚马逊弓与十字弓技能/, '+$1 to Bow and Crossbow Skills (Amazon Only)')
+ .replace(/^\+(\d+)\s*召唤技能/, '+$1 to Summoning Skills')
+ .replace(/^\+(\d+)\s*毒素与白骨技能/, '+$1 to Poison and Bone Skills')
+ .replace(/^\+(\d+)\s*诅咒技能/, '+$1 to Curses')
+ .replace(/^\+(\d+)\s*防御灵气/, '+$1 to Defensive Auras')
+ .replace(/^\+(\d+)\s*战吼技能/, '+$1 to Warcries')
+ .replace(/^\+(\d+)\s*战斗技能/, '+$1 to Combat Skills')
+ .replace(/^\+(\d+)\s*元素技能/, '+$1 to Elemental Skills')
+ .replace(/^\+(\d+)\s*变形技能/, '+$1 to Shape Shifting Skills')
+ .replace(/^\+(\d+)\s*影子训练技能/, '+$1 to Shadow Disciplines')
+ .replace(/^\+(\d+)\s*支配火焰/, '+$1 to Fire Mastery')
+ .replace(/^\+(\d+)\s*支配闪电/, '+$1 to Lightning Mastery')
+ .replace(/^\+(\d+)\s*支配冰冷/, '+$1 to Cold Mastery')
+ .replace(/^\+(\d+)\s*火球/, '+$1 to Fire Ball')
+ .replace(/^\+(\d+)\s*火墙/, '+$1 to Fire Wall')
+ .replace(/^\+(\d+)\s*陨石/, '+$1 to Meteor')
+ .replace(/^\+(\d+)%\s*快速施法速度/, '+$1% Faster Cast Rate')
+ .replace(/^\+(\d+)%\s*快速再度攻击/, '+$1% Faster Hit Recovery')
+ .replace(/^\+(\d+)%\s*快速格挡率/, '+$1% Faster Block Rate')
+ .replace(/^\+(\d+)%\s*增加格挡几率/, '+$1% Increased Chance of Blocking')
+ .replace(/^\+(\d+)%\s*高速跑步\/行走/, '+$1% Faster Run/Walk')
+ .replace(/^\+(\d+)%\s*提升攻击速度/, '+$1% Increased Attack Speed')
+ .replace(/^\+(\d+)%\s*增强伤害/, '+$1% Enhanced Damage')
+ .replace(/^\+(\d+)%\s*增强防御/, '+$1% Enhanced Defense')
+ .replace(/^\+(\d+)%\s*额外攻击准确率/, '+$1% Bonus to Attack Rating')
+ .replace(/^\+(\d+)%\s*对恶魔的伤害/, '+$1% Damage to Demons')
+ .replace(/^\+(\d+)%\s*对不死生物的伤害/, '+$1% Damage to Undead')
+ .replace(/^\+(\d+)%\s*冰冷技能伤害/, '+$1% to Cold Skill Damage')
+ .replace(/^\+(\d+)%\s*火焰技能伤害/, '+$1% to Fire Skill Damage')
+ .replace(/^\+(\d+)%\s*闪电技能伤害/, '+$1% to Lightning Skill Damage')
+ .replace(/^\+(\d+)%\s*毒素技能伤害/, '+$1% to Poison Skill Damage')
+ .replace(/^-(\d+)%\s*敌人火焰抗性/, '-$1% to Enemy Fire Resistance')
+ .replace(/^-(\d+)%\s*敌人冰冷抗性/, '-$1% to Enemy Cold Resistance')
+ .replace(/^-(\d+)%\s*敌人闪电抗性/, '-$1% to Enemy Lightning Resistance')
+ .replace(/^\+(\d+)%\s*获得经验值/, '+$1% to Experience Gained')
+ .replace(/^(\d+)%\s*压碎性打击/, '$1% Chance of Crushing Blow')
+ .replace(/^(\d+)%\s*每次命中偷取生命/, '$1% Life Stolen per Hit')
+ .replace(/^(\d+)%\s*每次命中偷取法力/, '$1% Mana Stolen per Hit')
+ .replace(/^(\d+)%\s*更佳机会取得魔法装备/, '$1% Better Chance of Getting Magic Items')
+ .replace(/^(\d+)%\s*额外金币从怪物身上取得/, '$1% Extra Gold from Monsters')
+ .replace(/^(\d+)%\s*额外金币取得/, '$1% Extra Gold from Monsters')
+ .replace(/^降低所有(?:商店|商人)价格\s*(\d+)%/, 'Reduces All Vendor Prices $1%')
+ .replace(/^增加法力上限\s*(\d+)%/, 'Increase Maximum Mana $1%')
+ .replace(/^法力(?:再生|回复)速度提高\s*(\d+)%/, 'Regenerate Mana $1%')
+ .replace(/^减慢目标速度\s*(\d+)%/, 'Slows Target by $1%')
+ .replace(/^减慢耐力消耗\s*(\d+)%/, '$1% Slower Stamina Drain')
+ .replace(/^受损生命转化为法力\s*(\d+)%/, '$1% Damage Taken Goes to Mana')
+ .replace(/^物理伤害减少\s*(\d+)%/, 'Damage Reduced by $1%')
+ .replace(/^魔法伤害减少\s*(\d+)/, 'Magic Damage Reduced by $1')
+ .replace(/^需求\s*-(\d+)%/, 'Requirements -$1%')
+ .replace(/^所有抗性\s*\+(\d+)/, 'All Resistances +$1')
+ .replace(/^抗寒\s*\+(\d+)%/, 'Cold Resist +$1%')
+ .replace(/^抗电\s*\+(\d+)%/, 'Lightning Resist +$1%')
+ .replace(/^抗火\s*\+(\d+)%/, 'Fire Resist +$1%')
+ .replace(/^抗毒\s*\+(\d+)%/, 'Poison Resist +$1%')
+ .replace(/^增加\s*(\d+-\d+)\s*火焰伤害/, 'Adds $1 Fire Damage')
+ .replace(/^增加\s*(\d+-\d+)\s*闪电伤害/, 'Adds $1 Lightning Damage')
+ .replace(/^增加\s*(\d+-\d+)\s*冰冷伤害/, 'Adds $1 Cold Damage')
+ .replace(/^增加\s*(\d+)\s*冰冷伤害/, 'Adds $1 Cold Damage')
+ .replace(/^增加\s*(\d+-\d+)\s*魔法伤害/, 'Adds $1 Magic Damage')
+ .replace(/^增加\s*(\d+-\d+)\s*伤害/, 'Adds $1 Damage')
+ .replace(/^\+([\d.]+)\s*生命/, '+$1 to Life')
+ .replace(/^\+([\d.]+)\s*最大生命/, '+$1 to Maximum Life')
+ .replace(/^\+([\d.]+)\s*法力/, '+$1 to Mana')
+ .replace(/^\+(\d+)\s*最大耐力/, '+$1 Maximum Stamina')
+ .replace(/^\+(\d+)\s*力量/, '+$1 to Strength')
+ .replace(/^\+(\d+)\s*敏捷/, '+$1 to Dexterity')
+ .replace(/^\+(\d+)\s*体力/, '+$1 to Vitality')
+ .replace(/^\+(\d+)\s*精力/, '+$1 to Energy')
+ .replace(/^\+(\d+)\s*所有属性/, '+$1 to All Attributes')
+ .replace(/^\+(\d+)\s*防御/, '+$1 Defense')
+ .replace(/^\+(\d+)\s*对远距离攻击的防御/, '+$1 Defense vs. Missile')
+ .replace(/^\+(\d+)\s*攻击准确率/, '+$1 to Attack Rating')
+ .replace(/^\+(\d+)\s*照亮范围/, '+$1 to Light Radius')
+ .replace(/^\+(\d+)\s*每次击杀获得法力/, '+$1 to Mana After Each Kill')
+ .replace(/^\+(\d+)\s*魔法吸收/, '+$1 Magic Absorb')
+ .replace(/^\+(\d+)\s*生命补给/, 'Replenish Life +$1')
+ .replace(/^生命回复\s*\+(\d+)/, 'Replenish Life +$1')
+ .replace(/^攻击者受到伤害\s*(\d+)/, 'Attacker Takes Damage of $1')
+
+ return s
+}
+
diff --git a/tests/challenger-m3-tooltip-stress.test.ts b/tests/challenger-m3-tooltip-stress.test.ts
index 6b56e7a..ee8c831 100644
--- a/tests/challenger-m3-tooltip-stress.test.ts
+++ b/tests/challenger-m3-tooltip-stress.test.ts
@@ -318,7 +318,7 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => {
expect(ctx.fillRects.length).toBe(1)
const texts = font.drawRecords.map(r => r.text)
expect(texts).toContain('特殊符号战斧')
- expect(texts).toContain('ÿc1红色火焰伤害 10-20 (Adds 10-20 Fire Dmg)')
+ expect(texts).toContain('ÿc1红色火焰伤害 10-20')
expect(texts).toContain('Unicode: ★★★ ⚔️ 🛡️ 💀 🔥 ❄️ ⚡ 特殊符号')
})
diff --git a/tests/ground-drop-parity-issues.test.ts b/tests/ground-drop-parity-issues.test.ts
index 647b32f..6ae08e5 100644
--- a/tests/ground-drop-parity-issues.test.ts
+++ b/tests/ground-drop-parity-issues.test.ts
@@ -165,10 +165,10 @@ describe('Issue #479 — Ground Item Drop Spacing, Metadata Parity & Anti-Stacki
const uiInventoryItem = itemToUiInventoryItem(rawDroppedItem, dropTables)
const playerGroundEntity = engine.dropItem(uiInventoryItem, 500, 300)
- // 1. Bilingual nameZh parity (both must be "中文名 (English Name)", NEVER pure English on monster drop)
+ // 1. Authentic Chinese nameZh parity (both must match and NEVER be pure English on monster drop)
expect(monsterGroundEntity.name).toBe(playerGroundEntity.name)
expect(monsterGroundEntity.nameZh).toBe(playerGroundEntity.nameZh)
- expect(monsterGroundEntity.nameZh).toContain('(')
+ expect(monsterGroundEntity.nameZh).toMatch(/[\u4e00-\u9fff]/)
expect(monsterGroundEntity.nameZh).not.toBe(monsterGroundEntity.name)
expect(formatGroundItemLabelText(monsterGroundEntity)).toBe(formatGroundItemLabelText(playerGroundEntity))
diff --git a/tests/issue-503-bilingual-parity.test.ts b/tests/issue-503-bilingual-parity.test.ts
new file mode 100644
index 0000000..ab28a0f
--- /dev/null
+++ b/tests/issue-503-bilingual-parity.test.ts
@@ -0,0 +1,465 @@
+/**
+ * tests/issue-503-bilingual-parity.test.ts
+ *
+ * Comprehensive unit test suite for Issue #503:
+ * Full Chinese (`CHI`) and English (`ENG`) Bilingual Versions of Diablo II v1.13c Web Port.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import {
+ buildLangSwitchHref,
+ detectLangFromUrl,
+ getGameLang,
+ getTblLang,
+ isEn,
+ isEnglish,
+ onTblLangChange,
+ setTblLang,
+} from '../src/i18n/lang.ts'
+import {
+ CANONICAL_CHI_TBL_DICT,
+ CANONICAL_ENG_TBL_DICT,
+ resolveTblKey,
+} from '../src/data/canonical-tbl-strings.ts'
+import {
+ ACT_NAMES_EN,
+ ACT_NAMES_ZH,
+ levelDisplayName,
+ sceneDisplayName,
+ variantDisplayLabel,
+} from '../src/game/level-names-zh.ts'
+import { formatEliteAffixLabel, monsterDisplayName } from '../src/game/monsters.ts'
+import { getLocalizedNpcName } from '../src/game/npc-table.ts'
+import { CharacterSheetPanel } from '../src/ui/character-sheet.ts'
+import { InventoryPanel } from '../src/ui/inventory.ts'
+import { SkillTreePanel } from '../src/ui/skill-tree-panel.ts'
+import { WorldPanelsHud } from '../src/ui/world-panels.ts'
+import { ControlBarHud } from '../src/ui/control-bar.ts'
+import { GlobesHud } from '../src/ui/globes.ts'
+import { SkillHotkeysHud } from '../src/ui/hotkeys.ts'
+import type { D2FontRenderer } from '../src/ui/font.ts'
+
+const CJK_REGEX = /[\u4e00-\u9fff]/
+
+function createMockCanvasAndFont() {
+ const textCalls: string[] = []
+ const ctx = {
+ drawImage: vi.fn(),
+ fillRect: vi.fn(),
+ strokeRect: vi.fn(),
+ fillText: vi.fn((text: string) => {
+ textCalls.push(String(text))
+ }),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ arc: vi.fn(),
+ closePath: vi.fn(),
+ fill: vi.fn(),
+ stroke: vi.fn(),
+ save: vi.fn(),
+ restore: vi.fn(),
+ clip: vi.fn(),
+ rect: vi.fn(),
+ setLineDash: vi.fn(),
+ createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
+ createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
+ measureText: vi.fn((t: string) => ({ width: String(t).length * 8 })),
+ fillStyle: '',
+ strokeStyle: '',
+ lineWidth: 1,
+ globalAlpha: 1,
+ font: '',
+ textAlign: 'left',
+ textBaseline: 'alphabetic',
+ } as unknown as CanvasRenderingContext2D
+
+ const font = {
+ isLoaded: true,
+ measureText: vi.fn((t: string) => String(t).length * 8),
+ drawText: vi.fn((_ctx: CanvasRenderingContext2D, text: string) => {
+ textCalls.push(String(text))
+ }),
+ } as unknown as D2FontRenderer
+
+ return { ctx, font, textCalls }
+}
+
+describe('Issue #503: Bilingual Language Detection & State (src/i18n/lang.ts)', () => {
+ afterEach(() => {
+ setTblLang(null)
+ })
+
+ it('detects ENG vs CHI from URL paths and query parameters', () => {
+ expect(detectLangFromUrl('http://localhost/acts-en.html')).toBe('ENG')
+ expect(detectLangFromUrl('http://localhost/acts-zh.html')).toBe('CHI')
+ expect(detectLangFromUrl('http://localhost/acts.html')).toBe('CHI')
+ expect(detectLangFromUrl('http://localhost/acts.html?lang=en')).toBe('ENG')
+ expect(detectLangFromUrl('http://localhost/acts-zh.html?lang=en')).toBe('ENG')
+ expect(detectLangFromUrl('http://localhost/acts-en.html?lang=zh')).toBe('CHI')
+ })
+
+ it('updates runtime language state and notifies listeners on setTblLang', () => {
+ const observed: string[] = []
+ const unsub = onTblLangChange(l => observed.push(l))
+
+ setTblLang('ENG')
+ expect(getTblLang()).toBe('ENG')
+ expect(getGameLang()).toBe('en')
+ expect(isEn()).toBe(true)
+ expect(isEnglish()).toBe(true)
+
+ setTblLang('CHI')
+ expect(getTblLang()).toBe('CHI')
+ expect(getGameLang()).toBe('zh')
+ expect(isEn()).toBe(false)
+ expect(isEnglish()).toBe(false)
+
+ unsub()
+ expect(observed).toEqual(['ENG', 'CHI'])
+ })
+
+ it('builds language switch href preserving act/level/hero/lighting/density/elite query params', () => {
+ expect(
+ buildLangSwitchHref(
+ 'ENG',
+ '/acts-zh.html',
+ '?act=2&level=40-lut-gholein&hero=pa&lighting=sunset&density=2&elite=4&lang=zh',
+ ),
+ ).toBe('/acts-en.html?act=2&level=40-lut-gholein&hero=pa&lighting=sunset&density=2&elite=4')
+
+ expect(
+ buildLangSwitchHref(
+ 'CHI',
+ '/acts-en.html',
+ '?act=4&level=103-the-pandemonium-fortress&hero=ne',
+ ),
+ ).toBe('/acts-zh.html?act=4&level=103-the-pandemonium-fortress&hero=ne')
+ })
+})
+
+describe('Issue #503: HTML Entry Pages & Zero-CJK Invariant in acts-en.html', () => {
+ it('verifies index.html, acts-zh.html, acts-en.html, and acts.html exist with required bilingual controls', () => {
+ const root = process.cwd()
+ const indexHtml = readFileSync(join(root, 'index.html'), 'utf8')
+ const actsZhHtml = readFileSync(join(root, 'acts-zh.html'), 'utf8')
+ const actsEnHtml = readFileSync(join(root, 'acts-en.html'), 'utf8')
+ const actsHtml = readFileSync(join(root, 'acts.html'), 'utf8')
+
+ expect(indexHtml).toContain('id="enter-zh"')
+ expect(indexHtml).toContain('id="enter-en"')
+ expect(indexHtml).toContain('acts-zh.html')
+ expect(indexHtml).toContain('acts-en.html')
+
+ expect(actsZhHtml).toContain('lang="zh-CN"')
+ expect(actsZhHtml).toContain('id="lang-switch"')
+ expect(actsHtml).toContain('id="lang-switch"')
+
+ expect(actsEnHtml).toContain('lang="en"')
+ expect(actsEnHtml).toContain('id="lang-switch"')
+ expect(CJK_REGEX.test(actsEnHtml)).toBe(false)
+ })
+})
+
+describe('Issue #503: Canonical 1.13c .tbl Dictionary & Domain Lookups', () => {
+ afterEach(() => {
+ setTblLang(null)
+ })
+
+ it('provides comprehensive ENG and CHI .tbl strings for UI, monsters, and NPCs', () => {
+ expect(Object.keys(CANONICAL_ENG_TBL_DICT).length).toBeGreaterThan(3500)
+ expect(Object.keys(CANONICAL_CHI_TBL_DICT).length).toBeGreaterThan(3000)
+
+ expect(resolveTblKey('strBSArmor', 'ENG')).toBe('Armor')
+ expect(resolveTblKey('strBSWeapons', 'ENG')).toBe('Weapons')
+ expect(resolveTblKey('strBSMisc', 'ENG')).toBe('Misc')
+ expect(resolveTblKey('Fallen', 'ENG')).toBe('Fallen')
+ expect(resolveTblKey('Fallen', 'CHI')).toBe('沈淪魔')
+ expect(resolveTblKey('Bishibosh', 'ENG')).toBe('Bishibosh')
+ expect(resolveTblKey('Bishibosh', 'CHI')).toBe('畢須博須')
+ })
+
+ it('resolves level, scene, variant, monster, affix, and NPC names in both ENG and CHI', () => {
+ expect(ACT_NAMES_EN[1]).toBe('Act I')
+ expect(ACT_NAMES_ZH[1]).toBe('第一章')
+
+ expect(levelDisplayName(1, 'ENG')).toBe('Rogue Encampment')
+ expect(levelDisplayName(1, 'CHI')).toBe('罗格营地')
+ expect(sceneDisplayName('1-rogue-encampment', 'Rogue Encampment', 'ENG')).toBe('Rogue Encampment')
+ expect(sceneDisplayName('1-rogue-encampment', 'Rogue Encampment', 'CHI')).toBe('罗格营地')
+ expect(variantDisplayLabel('var1', 'ENG')).toBe('Variant 1')
+ expect(variantDisplayLabel('var1', 'CHI')).toBe('变体 1')
+ expect(variantDisplayLabel('towns1', 'ENG')).toBe('South Gate')
+ expect(variantDisplayLabel('towns1', 'CHI')).toBe('南门')
+
+ expect(monsterDisplayName('Fallen', 'ENG')).toBe('Fallen')
+ expect(monsterDisplayName('Fallen', 'CHI')).toBe('沈淪魔')
+ expect(formatEliteAffixLabel('strong', 'ENG')).toBe('Extra Strong')
+ expect(formatEliteAffixLabel('strong', 'CHI')).toBe('特别强壮')
+
+ expect(getLocalizedNpcName('akara', 'ENG')).toBe('Akara')
+ expect(getLocalizedNpcName('akara', 'CHI')).toBe('阿卡拉')
+ })
+})
+
+describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () => {
+ afterEach(() => {
+ setTblLang(null)
+ })
+
+ it('renders CharacterSheetPanel, InventoryPanel, SkillTreePanel, WorldPanelsHud, ControlBarHud, GlobesHud, and SkillHotkeysHud with zero CJK in ENG mode', () => {
+ setTblLang('ENG')
+ const { ctx, font, textCalls } = createMockCanvasAndFont()
+ const mockImg = { width: 256, height: 256, complete: true, naturalWidth: 256 } as unknown as HTMLImageElement
+
+ const charSheet = new CharacterSheetPanel()
+ charSheet.visible = true
+ charSheet.lang = 'ENG'
+ charSheet.draw(
+ ctx,
+ {
+ hp: 100,
+ maxHp: 100,
+ mana: 50,
+ maxMana: 50,
+ stamina: 80,
+ maxStamina: 80,
+ leftSkillDmg: '45-82',
+ rightSkillDmg: '520-585',
+ },
+ {
+ borderLeftImg: null,
+ charSheetImg: null,
+ buySellBtnImg: null,
+ levelBtnImg: null,
+ },
+ font,
+ )
+
+ const invPanel = new InventoryPanel()
+ invPanel.visible = true
+ invPanel.draw(
+ ctx,
+ {
+ borderRightImg: null,
+ invSheetImg: null,
+ invTab0Img: null,
+ invTab1Img: null,
+ buySellBtnImg: null,
+ goldCoinBtnImg: null,
+ itemsAtlasImg: null,
+ itemRects: {},
+ },
+ font,
+ )
+
+ const skillTree = new SkillTreePanel()
+ skillTree.visible = true
+ skillTree.lang = 'ENG'
+ skillTree.draw(
+ ctx,
+ {
+ borderRightImg: null,
+ skillPointsImg: null,
+ skillTabsImg: null,
+ buySellBtnImg: null,
+ },
+ font,
+ )
+
+ const worldPanels = new WorldPanelsHud()
+ worldPanels.lang = 'ENG'
+ const assets = {
+ borderLeftImg: null,
+ questBgImg: null,
+ waypointBgImg: null,
+ stashBgImg: null,
+ cubeBgImg: null,
+ vendorBgImg: null,
+ vendorTabsImg: null,
+ buySellBtnImg: null,
+ miniConvertImg: null,
+ questsAtlasImg: null,
+ itemsAtlasImg: null,
+ waypointTabsImg: null,
+ waypointIconsImg: null,
+ questTabsImg: null,
+ inventory: invPanel,
+ }
+ worldPanels.drawLeftDockPanel(ctx, 'quest', assets, font)
+ worldPanels.drawLeftDockPanel(ctx, 'waypoint', assets, font)
+ worldPanels.drawLeftDockPanel(ctx, 'stash', assets, font)
+ worldPanels.drawLeftDockPanel(ctx, 'cube', assets, font)
+ worldPanels.openNpcMenuByName('Akara', 400, 220)
+ worldPanels.drawNpcMenu(ctx, font, { boxPiecesImg: mockImg, focus16Img: mockImg })
+
+ const controlBar = new ControlBarHud()
+ controlBar.lang = 'ENG'
+ controlBar.hoveredElement = 'char'
+ controlBar.draw(
+ ctx,
+ {
+ level: 1,
+ xp: 0,
+ currentLevelXp: 0,
+ nextLevelXp: 500,
+ stamina: 80,
+ maxStamina: 80,
+ isRunning: true,
+ unspentStatPoints: 5,
+ unspentSkillPoints: 2,
+ },
+ {
+ ctrlPnlImg: null,
+ runBtnImg: null,
+ menuBtnImg: null,
+ miniPanelImg: null,
+ miniPanelBtnsImg: null,
+ levelBtnImg: null,
+ levelSocketImg: null,
+ },
+ font,
+ )
+
+ const globes = new GlobesHud()
+ globes.lang = 'ENG'
+ globes.lifeHovered = true
+ globes.draw(
+ ctx,
+ {
+ hp: 100,
+ maxHp: 100,
+ mana: 50,
+ maxMana: 50,
+ poisoned: false,
+ },
+ {
+ lifeImg: null,
+ manaImg: null,
+ poisonImg: null,
+ overlapLeftImg: null,
+ overlapRightImg: null,
+ },
+ font,
+ )
+
+ const hotkeys = new SkillHotkeysHud()
+ hotkeys.lang = 'ENG'
+ hotkeys.openPopup = 'right'
+ hotkeys.draw(ctx, null, font, true)
+
+ expect(textCalls.length).toBeGreaterThan(20)
+ const cjkLeaks = textCalls.filter(t => CJK_REGEX.test(t))
+ expect(cjkLeaks).toEqual([])
+ })
+
+ it('renders authentic Chinese strings in CHI mode without mixed English parentheticals on CharacterSheet, SkillTree, ControlBar, and WorldPanels', () => {
+ setTblLang('CHI')
+ const { ctx, font, textCalls } = createMockCanvasAndFont()
+
+ const charSheet = new CharacterSheetPanel()
+ charSheet.visible = true
+ charSheet.lang = 'CHI'
+ charSheet.draw(
+ ctx,
+ {
+ hp: 100,
+ maxHp: 100,
+ mana: 50,
+ maxMana: 50,
+ stamina: 80,
+ maxStamina: 80,
+ leftSkillDmg: '45-82',
+ rightSkillDmg: '520-585',
+ },
+ {
+ borderLeftImg: null,
+ charSheetImg: null,
+ buySellBtnImg: null,
+ levelBtnImg: null,
+ },
+ font,
+ )
+
+ const skillTree = new SkillTreePanel()
+ skillTree.visible = true
+ skillTree.lang = 'CHI'
+ skillTree.draw(
+ ctx,
+ {
+ borderRightImg: null,
+ skillPointsImg: null,
+ skillTabsImg: null,
+ buySellBtnImg: null,
+ },
+ font,
+ )
+
+ const controlBar = new ControlBarHud()
+ controlBar.lang = 'CHI'
+ controlBar.hoveredElement = 'char'
+ controlBar.draw(
+ ctx,
+ {
+ level: 1,
+ xp: 0,
+ currentLevelXp: 0,
+ nextLevelXp: 500,
+ stamina: 80,
+ maxStamina: 80,
+ isRunning: true,
+ unspentStatPoints: 5,
+ unspentSkillPoints: 2,
+ },
+ {
+ ctrlPnlImg: null,
+ runBtnImg: null,
+ menuBtnImg: null,
+ miniPanelImg: null,
+ miniPanelBtnsImg: null,
+ levelBtnImg: null,
+ levelSocketImg: null,
+ },
+ font,
+ )
+
+ const invPanel = new InventoryPanel()
+ const worldPanels = new WorldPanelsHud()
+ worldPanels.lang = 'CHI'
+ const assets = {
+ borderLeftImg: null,
+ questBgImg: null,
+ waypointBgImg: null,
+ stashBgImg: null,
+ cubeBgImg: null,
+ vendorBgImg: null,
+ vendorTabsImg: null,
+ buySellBtnImg: null,
+ miniConvertImg: null,
+ questsAtlasImg: null,
+ itemsAtlasImg: null,
+ waypointTabsImg: null,
+ waypointIconsImg: null,
+ questTabsImg: null,
+ inventory: invPanel,
+ }
+ worldPanels.drawLeftDockPanel(ctx, 'waypoint', assets, font)
+ worldPanels.drawLeftDockPanel(ctx, 'stash', assets, font)
+ worldPanels.drawLeftDockPanel(ctx, 'cube', assets, font)
+
+ expect(textCalls).toContain('力量')
+ expect(textCalls).toContain('剩余技能点')
+ expect(textCalls).toContain('角色属性 (A/C)')
+ expect(textCalls).toContain('罗格营地')
+ expect(textCalls).toContain('私人储物箱')
+ expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)')
+
+ expect(textCalls).not.toContain('角色属性 (Character)')
+ expect(textCalls).not.toContain('技能树 (Skill Tree)')
+ expect(textCalls).not.toContain('传送站 (Waypoints)')
+ expect(textCalls).not.toContain('私人储物箱 (Personal Stash)')
+ expect(textCalls).not.toContain('赫拉迪克方块 (Horadric Cube)')
+ })
+})
diff --git a/tests/item-to-ui-bridge.test.ts b/tests/item-to-ui-bridge.test.ts
index 668245d..4dc1147 100644
--- a/tests/item-to-ui-bridge.test.ts
+++ b/tests/item-to-ui-bridge.test.ts
@@ -78,8 +78,9 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
const uiItem = itemToUiInventoryItem(item, dropTables)
expect(uiItem.quality).toBe('normal')
expect(uiItem.name).toBe('Cap')
- expect(uiItem.nameZh).toBe('帽子 (Cap)')
- expect(uiItem.baseNameZh).toBe('帽子 (Cap)')
+ expect(uiItem.baseNameEn).toBe('Cap')
+ expect(uiItem.nameZh).toBe('帽子')
+ expect(uiItem.baseNameZh).toBe('帽子')
})
it('maps Magic / Blue quality with rolled affixes and bilingual naming', () => {
@@ -96,8 +97,9 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
const uiItem = itemToUiInventoryItem(item, dropTables)
expect(uiItem.quality).toBe('magic')
expect(uiItem.name).toBe('Sturdy Leather Armor')
+ expect(uiItem.baseNameEn).toBe('Leather Armor')
expect(uiItem.nameZh).toContain('皮甲')
- expect(uiItem.baseNameZh).toContain('皮甲 (Leather Armor)')
+ expect(uiItem.baseNameZh).toBe('皮甲')
})
it('maps Rare / Yellow quality with rolled rare affixes and bilingual naming', () => {
@@ -115,8 +117,9 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
const uiItem = itemToUiInventoryItem(item, dropTables)
expect(uiItem.quality).toBe('rare')
expect(uiItem.name).toBe('Doom Gyre')
+ expect(uiItem.baseNameEn).toBe('Ring')
expect(uiItem.nameZh).toContain('毁灭之旋回')
- expect(uiItem.baseNameZh).toContain('戒指 (Ring)')
+ expect(uiItem.baseNameZh).toBe('戒指')
})
it('maps Set / Green quality with set definition, pieces, and bonuses', () => {
@@ -134,8 +137,9 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
const uiItem = itemToUiInventoryItem(item, dropTables)
expect(uiItem.quality).toBe('set')
expect(uiItem.name).toBe("Cathan's Seal")
- expect(uiItem.nameZh).toContain("Cathan's Seal")
- expect(uiItem.baseNameZh).toContain("Cathan's Traps")
+ expect(uiItem.baseNameEn).toContain("Cathan's Traps")
+ expect(uiItem.nameZh).toContain('卡珊的封印')
+ expect(uiItem.baseNameZh).toContain('卡珊的陷阱')
})
it('maps Unique / Gold quality with unique item definition and color dye', () => {
@@ -152,8 +156,9 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
const uiItem = itemToUiInventoryItem(item, dropTables)
expect(uiItem.quality).toBe('unique')
expect(uiItem.name).toBe('Harlequin Crest')
+ expect(uiItem.baseNameEn).toBe('Shako')
expect(uiItem.nameZh).toContain('谐角之冠')
- expect(uiItem.baseNameZh).toContain('军帽 (Shako)')
+ expect(uiItem.baseNameZh).toBe('军帽')
expect(uiItem.invtransform).toBe('cgrn')
})
diff --git a/tests/item-tooltip-color-dye.test.ts b/tests/item-tooltip-color-dye.test.ts
index c62745c..b699ae0 100644
--- a/tests/item-tooltip-color-dye.test.ts
+++ b/tests/item-tooltip-color-dye.test.ts
@@ -327,7 +327,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
expect(calls.length, 'Tooltip must render multiple text lines').toBeGreaterThanOrEqual(3)
// Line 0: Runeword Title in fontexocet10 with gold color
- expect(calls[0]!.text).toBe('战争召唤 (Call to Arms)')
+ expect(calls[0]!.text).toBe('战争召唤')
expect(calls[0]!.options?.font).toBe('fontexocet10')
expect(calls[0]!.options?.color).toBe('gold')
@@ -337,7 +337,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
expect(calls[1]!.options?.color).toBe('gold')
// Line 2: Base Item Name in font8 with white/gray color
- expect(calls[2]!.text).toBe('水晶剑 (Crystal Sword)')
+ expect(calls[2]!.text).toBe('水晶剑')
expect(calls[2]!.options?.font).toBe('font8')
expect(['white', 'gray']).toContain(calls[2]!.options?.color)
})
@@ -691,8 +691,8 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
nameZh: string
baseNameZh: string
}[] = [
- { quality: 'rune', expectedTitleColor: 'gold', nameZh: '战争召唤 (Call to Arms)', baseNameZh: '水晶剑' },
- { quality: 'unique', expectedTitleColor: 'gold', nameZh: '谐角之冠 (Harlequin Crest)', baseNameZh: '军帽' },
+ { quality: 'rune', expectedTitleColor: 'gold', nameZh: '战争召唤', baseNameZh: '水晶剑' },
+ { quality: 'unique', expectedTitleColor: 'gold', nameZh: '谐角之冠', baseNameZh: '军帽' },
{ quality: 'set', expectedTitleColor: 'green', nameZh: "塔·拉夏的守护", baseNameZh: '漆甲' },
{ quality: 'magic', expectedTitleColor: 'blue', nameZh: '工匠之巨鲸的大盾', baseNameZh: '统治者大盾' },
{ quality: 'normal', expectedTitleColor: 'white', nameZh: '超强的超大型巨剑', baseNameZh: '超大型巨剑' },
diff --git a/tests/quest-tbl.test.ts b/tests/quest-tbl.test.ts
index 1d572ae..9aa8df2 100644
--- a/tests/quest-tbl.test.ts
+++ b/tests/quest-tbl.test.ts
@@ -214,7 +214,7 @@ describe('Diablo II v1.13c Quest TBL Ground Truth (Issue #381)', () => {
)
// Parchment title should be drawn around oy + 282 (60 + 282 = 342)
- const titleCall = drawCalls.find(c => c.text.includes('(Den of Evil)'))
+ const titleCall = drawCalls.find(c => c.y === 60 + 282 && c.text.includes('洞穴'))
expect(titleCall).toBeDefined()
expect(titleCall!.y).toBe(60 + 282)
diff --git a/tests/skill-quickbar-tooltip.test.ts b/tests/skill-quickbar-tooltip.test.ts
index 6766142..2c39065 100644
--- a/tests/skill-quickbar-tooltip.test.ts
+++ b/tests/skill-quickbar-tooltip.test.ts
@@ -28,11 +28,15 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
const lines = hud.formatTooltipLines(0, 'left', false)
expect(lines.length).toBeGreaterThanOrEqual(3)
- // Title
- expect(lines[0]!.text).toBe('普通攻击 (Attack)')
+ // Title (CHI)
+ expect(lines[0]!.text).toBe('普通攻击')
expect(lines[0]!.color).toBe('gold')
expect(lines[0]!.font).toBe('fontexocet10')
+ // Title (ENG)
+ const linesEn = hud.formatTooltipLines(0, 'left', false, 'ENG')
+ expect(linesEn[0]!.text).toBe('Attack')
+
// Narrative
const narrativeLine = lines.find(l => l.text.includes('物理攻击'))
expect(narrativeLine).toBeDefined()
@@ -46,8 +50,9 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
it('formats authentic Throw tooltip with throw weapon narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(2, 'left', false)
- expect(lines[0]!.text).toBe('投掷 (Throw)')
+ expect(lines[0]!.text).toBe('投掷')
expect(lines[0]!.color).toBe('gold')
+ expect(hud.formatTooltipLines(2, 'left', false, 'ENG')[0]!.text).toBe('Throw')
const narrativeLine = lines.find(l => l.text.includes('投掷类武器'))
expect(narrativeLine).toBeDefined()
@@ -58,8 +63,9 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
it('formats authentic Unsummon tooltip with minion dismissal narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(3, 'right', false)
- expect(lines[0]!.text).toBe('取消召唤 (Unsummon)')
+ expect(lines[0]!.text).toBe('取消召唤')
expect(lines[0]!.color).toBe('gold')
+ expect(hud.formatTooltipLines(3, 'right', false, 'ENG')[0]!.text).toBe('Unsummon')
const narrativeLine = lines.find(l => l.text.includes('解散'))
expect(narrativeLine).toBeDefined()
@@ -141,8 +147,9 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
const lines = hud.formatTooltipLines(47, 'left', false)
// Title
- expect(lines[0]!.text).toBe('火球 (Fire Ball)')
+ expect(lines[0]!.text).toBe('火球')
expect(lines[0]!.color).toBe('gold')
+ expect(hud.formatTooltipLines(47, 'left', false, 'ENG')[0]!.text).toBe('Fire Ball')
// Level with soft bonus
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 23 (基础 15 + 装备 +8)'))
@@ -179,8 +186,9 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
it('falls back cleanly to catalog data when no TooltipViewModel is supplied', () => {
hud.tooltipResolver = undefined
const lines = hud.formatTooltipLines(47, 'left', false)
- expect(lines[0]!.text).toBe('火球 (Fire Ball)')
+ expect(lines[0]!.text).toBe('火球')
expect(lines[0]!.color).toBe('gold')
+ expect(hud.formatTooltipLines(47, 'left', false, 'ENG')[0]!.text).toBe('Fire Ball')
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 8'))
expect(lvlLine).toBeDefined()
@@ -329,17 +337,20 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
expect(hudMgr.hotkeys.tooltipResolver).toBeDefined()
const sacLines = hudMgr.hotkeys.formatTooltipLines(96, 'left', false)
- expect(sacLines[0]!.text).toContain('Sacrifice')
+ expect(sacLines[0]!.text).toContain('犧牲')
+ expect(hudMgr.hotkeys.formatTooltipLines(96, 'left', false, 'ENG')[0]!.text).toBe('Sacrifice')
expect(sacLines.some(l => l.text.includes('当前技能等级:'))).toBe(true)
const hammerLines = hudMgr.hotkeys.formatTooltipLines(112, 'right', false)
- expect(hammerLines[0]!.text).toContain('Blessed Hammer')
+ expect(hammerLines[0]!.text).toContain('祝福之鎚')
+ expect(hudMgr.hotkeys.formatTooltipLines(112, 'right', false, 'ENG')[0]!.text).toBe('Blessed Hammer')
expect(hammerLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
// Switch to Barbarian: Whirlwind (151)
hudMgr.switchClass('bar')
const wwLines = hudMgr.hotkeys.formatTooltipLines(151, 'right', false)
- expect(wwLines[0]!.text).toContain('Whirlwind')
+ expect(wwLines[0]!.text).toContain('旋風')
+ expect(hudMgr.hotkeys.formatTooltipLines(151, 'right', false, 'ENG')[0]!.text).toBe('Whirlwind')
expect(wwLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
})
})
diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts
index bf7ec94..3fd789e 100644
--- a/tests/ui-hud.test.ts
+++ b/tests/ui-hud.test.ts
@@ -681,7 +681,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
const unlearnedLines = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!)
// Title: Fire Ball in Gold
- expect(unlearnedLines[0]).toEqual({ text: '火球 (Fire Ball)', color: 'gold', font: 'fontexocet10', align: 'center' })
+ expect(unlearnedLines[0]).toEqual({ text: '火球', color: 'gold', font: 'fontexocet10', align: 'center' })
+ const unlearnedLinesEn = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!, 'ENG')
+ expect(unlearnedLinesEn[0]).toEqual({ text: 'Fire Ball', color: 'gold', font: 'fontexocet10', align: 'center' })
// Required Level: red when character level < reqlevel
const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要角色等级: 12'))
expect(reqLevelLine).toBeDefined()
diff --git a/tests/unidentified-drops-identify.test.ts b/tests/unidentified-drops-identify.test.ts
index cd31832..c454ed1 100644
--- a/tests/unidentified-drops-identify.test.ts
+++ b/tests/unidentified-drops-identify.test.ts
@@ -294,17 +294,27 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System'
const texts = fontCalls.map(c => c.text)
expect(texts[0]).toContain('军帽')
expect(texts.some(t => t.includes('Harlequin Crest') || t.includes('谐角之冠'))).toBe(false)
- expect(texts).toContain('未辨识 (Unidentified)')
- expect(texts).toContain('无法使用 (Cannot be Equipped)')
+ expect(texts).toContain('未辨识')
+ expect(texts).toContain('无法使用')
+
+ // Verify ENG unidentified tooltip
+ wp.lang = 'ENG'
+ fontCalls.length = 0
+ wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false)
+ const textsEn = fontCalls.map(c => c.text)
+ expect(textsEn[0]).toBe('Shako')
+ expect(textsEn).toContain('Unidentified')
+ expect(textsEn).toContain('Cannot be Equipped')
+ wp.lang = 'CHI'
// Hover tooltip after identification
identifyUiItem(uiShako)
fontCalls.length = 0
wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false)
const identifiedTexts = fontCalls.map(c => c.text)
- expect(identifiedTexts[0]).toContain('Harlequin Crest')
- expect(identifiedTexts).not.toContain('未辨识 (Unidentified)')
- expect(identifiedTexts).not.toContain('无法使用 (Cannot be Equipped)')
+ expect(identifiedTexts[0]).toContain('谐角之冠')
+ expect(identifiedTexts).not.toContain('未辨识')
+ expect(identifiedTexts).not.toContain('无法使用')
expect(identifiedTexts.length).toBeGreaterThan(texts.length)
})
})
diff --git a/vite.config.ts b/vite.config.ts
index 52a4839..5cacb21 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -17,36 +17,16 @@ export default defineConfig({
port: 5173,
strictPort: true,
},
- plugins: [
- {
- name: 'root-redirect',
- configureServer(server) {
- server.middlewares.use((req, res, next) => {
- if (req.url === '/' || req.url === '/index.html') {
- res.writeHead(302, { Location: '/acts.html' })
- res.end()
- return
- }
- next()
- })
- },
- generateBundle() {
- this.emitFile({
- type: 'asset',
- fileName: 'index.html',
- source:
- 'd2web跳转至暗黑破坏神 2 游戏主界面\n',
- })
- },
- },
- ],
build: {
target: 'es2022',
sourcemap: true,
rollupOptions: {
input: {
- acts: new URL('./acts.html', import.meta.url).pathname,
- arena: new URL('./arena.html', import.meta.url).pathname,
+ index: resolve(__dirname, 'index.html'),
+ acts: resolve(__dirname, 'acts.html'),
+ 'acts-zh': resolve(__dirname, 'acts-zh.html'),
+ 'acts-en': resolve(__dirname, 'acts-en.html'),
+ arena: resolve(__dirname, 'arena.html'),
loot: resolve(__dirname, 'loot.html'),
boss: resolve(__dirname, 'boss.html'),
skills: resolve(__dirname, 'skills.html'),
@@ -54,3 +34,4 @@ export default defineConfig({
},
},
})
+