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
This commit is contained in:
troytt 2026-09-28 05:07:27 +00:00
parent 44ec4124b9
commit 6fa9c7784e
34 changed files with 5816 additions and 658 deletions

112
acts-en.html Normal file
View File

@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>d2web · Diablo II v1.13c</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
border-radius: 6px; backdrop-filter: blur(3px); }
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
a { color: #c8a15a; }
#hud { color: #9c8d78; }
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
#controls label { color: #9c8d78; }
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
select.non-default, .non-default {
border: 1px solid #e74c3c !important;
color: #ff6b6b !important;
}
optgroup { background: #1c1813; color: #c8a15a; }
option { background: #1c1813; }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
#status:empty { display: none; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
.hint { color: #6d5a34; }
.lang-btn {
display: inline-block;
padding: 2px 7px;
background: #1c1813;
color: #c8a15a;
border: 1px solid #5c4628;
border-radius: 4px;
text-decoration: none;
font-size: 11px;
cursor: pointer;
}
.lang-btn:hover {
border-color: #e2be78;
color: #f0d596;
}
</style>
</head>
<body>
<header class="overlay">
<h1>d2web · Diablo II</h1>
<span id="hud">—</span>
</header>
<div id="controls" class="overlay">
<label>Class <select id="hero" disabled>
<option value="so">Sorceress</option>
<option value="am">Amazon</option>
<option value="ne">Necromancer</option>
<option value="pa">Paladin</option>
<option value="ba">Barbarian</option>
<option value="dz">Druid</option>
<option value="ai">Assassin</option>
</select></label>
<label>Act <select id="act" disabled>
<option value="1">Act I</option>
<option value="2">Act II</option>
<option value="3">Act III</option>
<option value="4">Act IV</option>
<option value="5">Act V</option>
</select></label>
<label>Scene <select id="scene" disabled><option>Loading scenes…</option></select></label>
<label>Variant <select id="variant" disabled></select></label>
<label>Lighting <select id="lighting">
<option value="auto">Auto (1.13c Default)</option>
<option value="noon">Noon (Bright Daylight)</option>
<option value="morning">Morning (Warm Dawn)</option>
<option value="sunset">Sunset (Dusk Glow)</option>
<option value="night">Midnight (Blue Moonlight)</option>
<option value="cave">Abyssal Dungeon (Torchlight)</option>
<option value="hell">Blood Moon (Act IV Crimson)</option>
<option value="eclipse">Solar Eclipse (Dark Curse)</option>
<option value="off">Unlit (Full Brightness)</option>
</select></label>
<label>Density <select id="density">
<option value="0.5">0.5x</option>
<option value="1" selected>Density: 1x (1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="8">8x</option>
</select></label>
<label>Elite <select id="elite">
<option value="0.5">0.5x</option>
<option value="1" selected>Elite: 1x (1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="all">All Elite</option>
</select></label>
<a id="lang-switch" class="lang-btn" href="./acts-zh.html">Lang: ZH</a>
<span class="hint" style="margin:0">I Inventory · A Character · T Skills · Q Quests · V Waypoints · ~ Belt · R Run/Walk · W Swap</span>
</div>
<div id="status" class="overlay">Loading map…</div>
<div id="dialog" class="overlay" hidden></div>
<div id="labels" style="position: fixed; inset: 0; pointer-events: none; z-index: 2;"></div>
<canvas id="view" width="960" height="560"></canvas>
<canvas id="d2-hud-canvas" width="800" height="600"></canvas>
<script type="module" src="/src/scene/act-scene.ts"></script>
</body>
</html>

112
acts-zh.html Normal file
View File

@ -0,0 +1,112 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>d2web · 暗黑破坏神 2 v1.13c</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
border-radius: 6px; backdrop-filter: blur(3px); }
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
a { color: #c8a15a; }
#hud { color: #9c8d78; }
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
#controls label { color: #9c8d78; }
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
select.non-default, .non-default {
border: 1px solid #e74c3c !important;
color: #ff6b6b !important;
}
optgroup { background: #1c1813; color: #c8a15a; }
option { background: #1c1813; }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
#status:empty { display: none; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
.hint { color: #6d5a34; }
.lang-btn {
display: inline-block;
padding: 2px 7px;
background: #1c1813;
color: #c8a15a;
border: 1px solid #5c4628;
border-radius: 4px;
text-decoration: none;
font-size: 11px;
cursor: pointer;
}
.lang-btn:hover {
border-color: #e2be78;
color: #f0d596;
}
</style>
</head>
<body>
<header class="overlay">
<h1>d2web · 暗黑破坏神 2</h1>
<span id="hud">—</span>
</header>
<div id="controls" class="overlay">
<label>职业 <select id="hero" disabled>
<option value="so">法师</option>
<option value="am">亚马逊</option>
<option value="ne">死灵法师</option>
<option value="pa">圣骑士</option>
<option value="ba">野蛮人</option>
<option value="dz">德鲁伊</option>
<option value="ai">刺客</option>
</select></label>
<label>章节 <select id="act" disabled>
<option value="1">第一章</option>
<option value="2">第二章</option>
<option value="3">第三章</option>
<option value="4">第四章</option>
<option value="5">第五章</option>
</select></label>
<label>场景 <select id="scene" disabled><option>正在读取场景列表…</option></select></label>
<label>细分场景 <select id="variant" disabled></select></label>
<label>光照 <select id="lighting">
<option value="auto">自动 (原版 1.13c)</option>
<option value="noon">正午 (明亮日光)</option>
<option value="morning">清晨 (晨曦暖金)</option>
<option value="sunset">黄昏 (暮色紫金)</option>
<option value="night">午夜 (幽暗蓝月)</option>
<option value="cave">深渊地牢 (火炬暗黑)</option>
<option value="hell">地狱血月 (第四幕猩红)</option>
<option value="eclipse">蚀日之咒 (幽蓝暗蚀)</option>
<option value="off">关闭光照 (全亮无光影)</option>
</select></label>
<label>密度 <select id="density">
<option value="0.5">0.5x</option>
<option value="1" selected>密度: 1x (原版 1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="8">8x</option>
</select></label>
<label>精英 <select id="elite">
<option value="0.5">0.5x</option>
<option value="1" selected>精英: 1x (原版 1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="all">全精英</option>
</select></label>
<a id="lang-switch" class="lang-btn" href="./acts-en.html">English</a>
<span class="hint" style="margin:0">I背包 · A角色 · T技能 · Q任务 · V小站 · ~腰带 · R奔跑 · W换手</span>
</div>
<div id="status" class="overlay">正在加载地图…</div>
<div id="dialog" class="overlay" hidden></div>
<div id="labels" style="position: fixed; inset: 0; pointer-events: none; z-index: 2;"></div>
<canvas id="view" width="960" height="560"></canvas>
<canvas id="d2-hud-canvas" width="800" height="600"></canvas>
<script type="module" src="/src/scene/act-scene.ts"></script>
</body>
</html>

View File

@ -3,13 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>d2web · 暗黑破坏神 2 地图与法师</title>
<title>d2web · 暗黑破坏神 2 v1.13c</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
/* 画布铺满窗口;HUD 与控制器浮在上面 */
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
border-radius: 6px; backdrop-filter: blur(3px); }
@ -18,9 +17,8 @@
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
a { color: #c8a15a; }
#hud { color: #9c8d78; }
/* 状态与地图选择控件紧凑停靠在顶部,把屏幕底部完整让给暗黑2原版 800CtrlPnl7 控制栏与血蓝球 */
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
align-items: center; flex-wrap: wrap; max-width: 72vw; font-size: 12px; z-index: 4; }
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
#controls label { color: #9c8d78; }
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
@ -35,6 +33,21 @@
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
.hint { color: #6d5a34; }
.lang-btn {
display: inline-block;
padding: 2px 7px;
background: #1c1813;
color: #c8a15a;
border: 1px solid #5c4628;
border-radius: 4px;
text-decoration: none;
font-size: 11px;
cursor: pointer;
}
.lang-btn:hover {
border-color: #e2be78;
color: #f0d596;
}
</style>
</head>
<body>
@ -44,13 +57,13 @@
</header>
<div id="controls" class="overlay">
<label>职业 <select id="hero" disabled>
<option value="so">法师 (Sorceress)</option>
<option value="am">亚马逊 (Amazon)</option>
<option value="ne">死灵法师 (Necromancer)</option>
<option value="pa">圣骑士 (Paladin)</option>
<option value="ba">野蛮人 (Barbarian)</option>
<option value="dz">德鲁伊 (Druid)</option>
<option value="ai">刺客 (Assassin)</option>
<option value="so">法师</option>
<option value="am">亚马逊</option>
<option value="ne">死灵法师</option>
<option value="pa">圣骑士</option>
<option value="ba">野蛮人</option>
<option value="dz">德鲁伊</option>
<option value="ai">刺客</option>
</select></label>
<label>章节 <select id="act" disabled>
<option value="1">第一章</option>
@ -74,18 +87,19 @@
</select></label>
<label>密度 <select id="density">
<option value="0.5">0.5x</option>
<option value="1" selected>Density: 1x (1.13c)</option>
<option value="1" selected>密度: 1x (原版 1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="8">8x</option>
</select></label>
<label>精英 <select id="elite">
<option value="0.5">0.5x</option>
<option value="1" selected>Elite: 1x (1.13c)</option>
<option value="1" selected>精英: 1x (原版 1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="all">All Elite</option>
<option value="all">全精英</option>
</select></label>
<a id="lang-switch" class="lang-btn" href="./acts-en.html">English</a>
<span class="hint" style="margin:0">I背包 · A角色 · T技能 · Q任务 · V小站 · ~腰带 · R奔跑 · W换手</span>
</div>
<div id="status" class="overlay">正在加载地图…</div>

183
index.html Normal file
View File

@ -0,0 +1,183 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Diablo II Web · 暗黑破坏神 II 网页版 (v1.13c)</title>
<style>
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 50% 24%, rgba(168, 92, 24, 0.22) 0%, rgba(18, 12, 8, 0.92) 56%, #070503 100%);
color: #e8dcc8;
font-family: 'Exocet', 'Cinzel', 'Georgia', 'Songti SC', 'SimSun', serif;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.sanctuary-frame {
width: min(840px, 100%);
background: linear-gradient(180deg, rgba(24, 19, 14, 0.96) 0%, rgba(11, 9, 6, 0.98) 100%);
border: 2px solid #5c4628;
box-shadow:
0 0 0 1px #1e160d,
0 0 48px rgba(0, 0, 0, 0.92),
inset 0 0 32px rgba(200, 161, 90, 0.08);
padding: 42px 36px 34px;
text-align: center;
position: relative;
}
.sanctuary-frame::before {
content: '';
position: absolute;
inset: 6px;
border: 1px solid rgba(200, 161, 90, 0.28);
pointer-events: none;
}
.emblem {
display: inline-block;
padding: 4px 14px;
border: 1px solid #6e5430;
background: rgba(34, 24, 14, 0.85);
color: #c8a15a;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: 16px;
}
h1 {
margin: 0 0 8px;
font-size: clamp(26px, 4vw, 38px);
color: #d8b46a;
letter-spacing: 0.08em;
text-shadow: 0 2px 12px rgba(216, 140, 40, 0.35), 0 1px 2px #000;
}
.subtitle {
margin: 0 0 28px;
color: #a7967d;
font-size: 15px;
letter-spacing: 0.05em;
}
.lang-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.lang-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 26px 22px;
background: linear-gradient(180deg, #1d1711 0%, #110d09 100%);
border: 1px solid #6a5230;
color: #e8dcc8;
text-decoration: none;
transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease;
box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.8);
cursor: pointer;
}
.lang-card:hover,
.lang-card:focus-visible {
transform: translateY(-2px);
border-color: #e2be78;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.75),
0 0 16px rgba(200, 161, 90, 0.25),
inset 0 0 20px rgba(200, 161, 90, 0.14);
outline: none;
}
.lang-badge {
font-size: 11px;
letter-spacing: 0.16em;
color: #9c8458;
text-transform: uppercase;
margin-bottom: 8px;
}
.lang-title {
font-size: 22px;
font-weight: 700;
color: #f0d596;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.lang-desc {
font-size: 13px;
color: #9c8d78;
line-height: 1.5;
}
.lang-cta {
margin-top: 16px;
padding: 6px 18px;
background: rgba(200, 161, 90, 0.14);
border: 1px solid #8c6e3e;
color: #e8c478;
font-size: 12px;
letter-spacing: 0.08em;
}
.footer-meta {
font-size: 12px;
color: #6d5e4b;
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<main class="sanctuary-frame">
<div class="emblem">Diablo II: Lord of Destruction · Patch 1.13c</div>
<h1>DIABLO II WEB · 暗黑破坏神 II</h1>
<p class="subtitle">Select Language Edition · 请选择游戏语言版本</p>
<div class="lang-grid">
<a id="enter-zh" class="lang-card" href="./acts-zh.html" data-target="./acts-zh.html">
<span class="lang-badge">Simplified Chinese · CHI.tbl</span>
<span class="lang-title">中文版 (简体中文)</span>
<span class="lang-desc">暗黑破坏神 II:毁灭之王 v1.13c<br />完整原版中文字典、任务日志、技能树与装备词缀</span>
<span class="lang-cta">进入中文版 · Enter Chinese</span>
</a>
<a id="enter-en" class="lang-card" href="./acts-en.html" data-target="./acts-en.html">
<span class="lang-badge">Canonical English · ENG.tbl</span>
<span class="lang-title">English Version</span>
<span class="lang-desc">Diablo II: Lord of Destruction v1.13c<br />Authentic 1.13c English String Tables, Quests, Skills &amp; Item Affixes</span>
<span class="lang-cta">Enter Sanctuary · English</span>
</a>
</div>
<div class="footer-meta">
136 Canonical Levels · 7 Hero Classes · Authentic 1.13c DRLG, Combat &amp; .tbl Localization
</div>
</main>
<script>
;(function () {
var suffix = (window.location.search || '') + (window.location.hash || '')
var links = document.querySelectorAll('a[data-target]')
for (var i = 0; i < links.length; i++) {
var a = links[i]
var base = a.getAttribute('data-target')
if (base) {
a.setAttribute('href', base + suffix)
a.addEventListener('click', function (e) {
var target = e.currentTarget.getAttribute('data-target')
if (target) {
e.preventDefault()
window.location.href = target + (window.location.search || '') + (window.location.hash || '')
}
})
}
}
})()
</script>
</body>
</html>

View File

@ -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<Record<string, string>> = {
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<Record<string, string>> = ${JSON.s
export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = ${JSON.stringify(chiOut, null, 2)}
export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = ${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')

View File

@ -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<void>(resolve => setTimeout(resolve, ms))
interface CdpSession {
evalJs: <T = any>(expression: string) => Promise<T>
saveScreenshot: (filename: string) => Promise<void>
waitForGameReady: () => Promise<void>
}
async function withChromePage<T>(
url: string,
matchSubstr: string,
debugPort: number,
fn: (session: CdpSession) => Promise<T>,
): Promise<T> {
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<void>(resolve => {
ws.onopen = () => resolve()
})
let idCounter = 1
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
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 <R = any>(expression: string): Promise<R> => {
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<void> => {
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<void> => {
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`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<void> {
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<string, string>
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)
})

View File

@ -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": "高级符文与完美/无瑕宝石掉落",

File diff suppressed because it is too large Load Diff

View File

@ -147,46 +147,45 @@ export function resolveItemQualityString(quality: any, code?: string, item?: any
}
/**
* Formats an item's display name into the canonical bilingual Chinese+English format
* (`"中文名 (English Name)"`) matching `itemToUiInventoryItem` (`src/ui/item-bridge.ts`),
* while preserving any pre-localized `item.nameZh` that already differs from `name`.
* Formats an item's Chinese display name into pure Chinese matching `itemToUiInventoryItem` (`src/ui/item-bridge.ts`),
* without appending `(${name})`.
*/
export function formatBilingualGroundItemName(item: any, name: string, quality: string): string {
if (typeof item?.nameZh === 'string' && item.nameZh.trim().length > 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)

View File

@ -14,6 +14,16 @@ export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
5: '第五章',
}
/** Canonical English Act names (1..5). */
export const ACT_NAMES_EN: Readonly<Record<number, string>> = {
1: 'Act I',
2: 'Act II',
3: 'Act III',
4: 'Act IV',
5: 'Act V',
}
/** `Levels.txt` 的 `Id` → 中文场景名。 */
export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
// Act 1
@ -170,6 +180,8 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
* (`TownN1`、`CourtW`、`LutN`…)。**只白名单这几个**,不做"末尾字母是方位"的猜测:
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
*/
import { isEn } from '../i18n/lang.ts'
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
townn1: '北门',
towne1: '东门',
@ -182,6 +194,18 @@ const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
lutw: '西门',
}
const DIRECTION_SUFFIXES_EN: Readonly<Record<string, string>> = {
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<Record<number, string>> = {
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<string, string>, key: string): string | undefined {
if (tbl instanceof Map) {
return tbl.get(key)

View File

@ -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.
*/

View File

@ -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<Record<string, string>> = {
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<Record<string, readonly string[]>> = {
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<Record<string, string>> = {
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');
}

151
src/i18n/lang.ts Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<Record<BaseStatKey, { x: number; y: nu
export class CharacterSheetPanel {
visible = false
lang: TblLang = getTblLang()
attrs: CharacterAttributes = {
name: 'Isendra',
classNameZh: '女法师',
@ -142,6 +144,7 @@ export class CharacterSheetPanel {
font: D2FontRenderer,
): void {
if (!this.visible) return
const isEn = this.lang === 'ENG'
// 1. Draw 800x600 Left Gothic Border (`800BorderFrame.dc6` frames 0..4) at (0, 0)
if (assets.borderLeftImg) {
@ -164,28 +167,28 @@ export class CharacterSheetPanel {
color: 'gold',
align: 'center',
})
font.drawText(ctx, `${this.attrs.classNameZh} (${this.attrs.classNameEn})`, ox + 236, oy + 24, {
font.drawText(ctx, isEn ? this.attrs.classNameEn : this.attrs.classNameZh, ox + 236, oy + 24, {
font: 'fontexocet10',
color: 'white',
align: 'center',
})
// 4. Level, Experience, Next Level
font.drawText(ctx, '等级', ox + 42, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 42, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 64, { font: 'fontexocet10', color: 'white', align: 'center' })
font.drawText(ctx, '当前经验值', ox + 135, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 135, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 64, { font: 'font8', color: 'white', align: 'center' })
font.drawText(ctx, '升级所需经验', ox + 245, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 245, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 64, { font: 'font8', color: 'white', align: 'center' })
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [
{ key: 'str', label: '力量 (Str)', y: 91, val: this.attrs.str },
{ key: 'dex', label: '敏捷 (Dex)', y: 153, val: this.attrs.dex },
{ key: 'vit', label: '体力 (Vit)', y: 239, val: this.attrs.vit },
{ key: 'ene', label: '精力 (Ene)', y: 301, val: this.attrs.ene },
{ key: 'str', label: isEn ? 'Strength' : '力量', y: 91, val: this.attrs.str },
{ key: 'dex', label: isEn ? 'Dexterity' : '敏捷', y: 153, val: this.attrs.dex },
{ key: 'vit', label: isEn ? 'Vitality' : '体力', y: 239, val: this.attrs.vit },
{ key: 'ene', label: isEn ? 'Energy' : '精力', y: 301, val: this.attrs.ene },
]
for (const r of statRows) {
font.drawText(ctx, r.label, ox + 18, oy + r.y, { font: 'font8', color: 'tan' })
@ -208,7 +211,7 @@ export class CharacterSheetPanel {
// Remaining Stat Points
if (this.attrs.statPoints > 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' })

View File

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

View File

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

View File

@ -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 '物品'
}

View File

@ -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',

View File

@ -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<CharacterClassCode, ClassProfileState>()
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',
)
},
},

View File

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

View File

@ -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 } : {}),

View File

@ -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<CharacterClassCode, readonly SkillTreeTabInfo[]> = {
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<CharacterClassCode, readonly SkillNodeDe
export const CLASS_SKILL_TREES: Record<CharacterClassCode, readonly SkillNodeDef[]> = 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: • <Name>: 每个有效技能点增加 <X>% 伤害 (当前 +<Y>%)
// 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)

View File

@ -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<D2FontRenderer, 'measureText'> | 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<CharacterClassCode, string> = {
const classLabelMapZh: Record<CharacterClassCode, string> = {
ama: '(限亚马逊使用)',
sor: '(限法师使用)',
nec: '(限死灵法师使用)',
@ -2905,7 +3061,16 @@ export class WorldPanelsHud {
dru: '(限德鲁伊使用)',
ass: '(限刺客使用)',
}
const classText = classLabelMap[item.reqClass]
const classLabelMapEn: Record<CharacterClassCode, string> = {
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: <Level Name>`).
* Draw Area Entry Gothic Banner (`Entering: <Level Name>` / `进入:<Level Name>`).
*/
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<string, string> = {
'任务宝盒': '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
}

View File

@ -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: ★★★ ⚔️ 🛡️ 💀 🔥 ❄️ ⚡ 特殊符号')
})

View File

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

View File

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

View File

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

View File

@ -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: '超大型巨剑' },

View File

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

View File

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

View File

@ -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()

View File

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

View File

@ -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:
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=./acts.html"><title>d2web</title><script>location.replace("./acts.html" + location.search + location.hash)</script></head><body><a href="./acts.html">跳转至暗黑破坏神 2 游戏主界面</a></body></html>\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({
},
},
})