feat(i18n): 装备与词条中英文多语言切换支持 EN/ZH/双语 (fixes #122)

This commit is contained in:
troytt 2026-09-19 12:05:24 +00:00
parent ea6913950e
commit 7f7b769840
5 changed files with 3593 additions and 62 deletions

View File

@ -526,6 +526,65 @@
gap: 16px;
}
/* Showcase Toolbar & Language Switcher (Issue #122) */
.showcase-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: #0d0906;
border: 1px solid #2a1c11;
border-radius: 6px;
padding: 8px 14px;
margin-bottom: 4px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-icon {
font-size: 14px;
}
.toolbar-title {
font-size: 12px;
font-weight: 700;
color: var(--gold-bright);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.lang-switch-row {
display: flex;
align-items: center;
gap: 6px;
}
.lang-label {
font-size: 11px;
color: var(--text-dim);
margin-right: 4px;
}
.lang-pill {
background: #18110b;
border: 1px solid #3d2a1b;
color: #aa9c8d;
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.lang-pill:hover {
background: #24170e;
border-color: var(--border-gold);
color: var(--gold-bright);
}
.lang-pill.active {
background: linear-gradient(180deg, #44260d 0%, #2e1806 100%);
border-color: #ff9800;
color: #ffe082;
box-shadow: 0 0 8px rgba(255, 152, 0, 0.35);
}
/* Diablo II Authentic Tooltip / Item Card */
.d2-item-card {
background: #080604;
@ -822,6 +881,20 @@
</div>
</div>
<!-- Showcase Toolbar / Language Switcher -->
<div class="showcase-toolbar">
<div class="toolbar-left">
<span class="toolbar-icon">💎</span>
<span class="toolbar-title">战利品展示面板</span>
</div>
<div class="lang-switch-row">
<span class="lang-label">装备语言:</span>
<button type="button" class="lang-pill active" data-lang="en">EN 原版</button>
<button type="button" class="lang-pill" data-lang="zh">中文</button>
<button type="button" class="lang-pill" data-lang="bilingual">EN / ZH 双语</button>
</div>
</div>
<!-- Loot Grid -->
<div class="loot-grid" id="loot-items-grid">
<!-- Rendered by src/boss.ts -->

View File

@ -13,6 +13,7 @@
import { getEmbeddedDropTables } from './game/embedded-drop-tables.ts'
import { executeDropPipeline, type DropTables } from './game/drop-pipeline.ts'
import { formatItemTooltip, type FormattedItemTooltip } from './game/item-tooltip.ts'
import { getTooltipLocale, setTooltipLocale, type TooltipLocale } from './game/tooltip-i18n.ts'
import type { Item } from './game/items.ts'
import { computeScaledNoDrop, computeEffectivePlayers } from './game/treasure-engine.ts'
@ -148,6 +149,7 @@ interface DropSessionRecord {
}
const sessionHistory: DropSessionRecord[] = []
let latestDroppedItems: Item[] = []
let totalKills = 0
let totalItemsDropped = 0
let totalUniques = 0
@ -171,6 +173,7 @@ export function initBossApp(): void {
bindDifficultySelector()
bindSettings()
bindActionButtons()
bindLanguageSelector()
bindKeyboardShortcuts()
// 3. Initial UI update
@ -369,11 +372,25 @@ function bindActionButtons(): void {
if (clearBtn) {
clearBtn.addEventListener('click', () => {
latestDroppedItems = []
renderLootShowcase([])
})
}
}
function bindLanguageSelector(): void {
const pills = document.querySelectorAll<HTMLButtonElement>('.lang-pill')
pills.forEach(pill => {
pill.addEventListener('click', () => {
const lang = pill.dataset.lang as TooltipLocale | undefined
if (!lang) return
setTooltipLocale(lang)
pills.forEach(p => p.classList.toggle('active', p === pill))
renderLootShowcase(latestDroppedItems)
})
})
}
function bindKeyboardShortcuts(): void {
window.addEventListener('keydown', (e: KeyboardEvent) => {
// Don't trigger if typing in an input
@ -494,15 +511,19 @@ function renderKillBanner(times: number, dropCount: number, durationMs: string):
}
function renderLootShowcase(items: Item[]): void {
latestDroppedItems = items
const container = document.getElementById('loot-items-grid')
if (!container) return
const locale = getTooltipLocale()
if (items.length === 0) {
const isZh = locale === 'zh' || locale === 'bilingual'
container.innerHTML = `
<div class="empty-loot-placeholder">
<div class="placeholder-icon">⚔️</div>
<div class="placeholder-title">尚未击杀首领</div>
<div class="placeholder-desc">点击左侧「模拟击杀 Boss」或按下 [空格键] 体验 1.13c 真实掉落与词缀属性生成</div>
<div class="placeholder-title">${isZh ? '尚未击杀首领' : 'No Boss Slain Yet'}</div>
<div class="placeholder-desc">${isZh ? '点击左侧「模拟击杀 Boss」或按下 [空格键] 体验 1.13c 真实掉落与词缀属性生成' : 'Click "Kill Boss" on the left sidebar or press [Space] to simulate authentic 1.13c drops & affixes.'}</div>
</div>
`
return
@ -510,69 +531,80 @@ function renderLootShowcase(items: Item[]): void {
// Render Item Cards
container.innerHTML = items.map((item, idx) => {
const tooltip = formatItemTooltip(item)
return renderSingleItemCard(tooltip, idx + 1)
const tooltip = formatItemTooltip(item, locale)
return renderSingleItemCard(tooltip, idx + 1, locale)
}).join('')
}
function renderSingleItemCard(t: FormattedItemTooltip, index: number): string {
function renderSingleItemCard(t: FormattedItemTooltip, index: number, locale: TooltipLocale = getTooltipLocale()): string {
const linesHtml = t.lines.map(l => {
const colorClass = `line-${l.color || 'blue'}`
return `<div class="tooltip-stat-line ${colorClass}">${escapeHtml(l.text)}</div>`
}).join('')
const isZh = locale === 'zh' || locale === 'bilingual'
const lblDefense = isZh ? '防御: ' : 'Defense: '
const lblOneHand = isZh ? '单手伤害: ' : 'One-Hand Damage: '
const lblTwoHand = isZh ? '双手伤害: ' : 'Two-Hand Damage: '
const lblDurability = isZh ? '耐久度: ' : 'Durability: '
const lblReqLevel = isZh ? '需要等级: ' : 'Required Level: '
const lblReqStr = isZh ? '需要力量: ' : 'Required Strength: '
const lblReqDex = isZh ? '需要敏捷: ' : 'Required Dexterity: '
const wordOf = isZh ? ' / ' : ' of '
const wordTo = isZh ? ' 至 ' : ' to '
const baseStatsHtml: string[] = []
if (t.defense !== undefined) {
const defText =
t.baseDefense !== undefined && t.baseDefense !== t.defense
? `<span class="val-blue">${t.defense}</span> <span class="val-base">(${t.baseDefense})</span>`
: `<span class="val-white">${t.defense}</span>`
baseStatsHtml.push(`<div class="base-stat-item">Defense: ${defText}</div>`)
baseStatsHtml.push(`<div class="base-stat-item">${lblDefense}${defText}</div>`)
}
if (t.oneHandDamage) {
const d = t.oneHandDamage
const dmgText =
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
? `<span class="val-blue">${d.min} to ${d.max}</span> <span class="val-base">(${d.baseMin} to ${d.baseMax})</span>`
: `<span class="val-white">${d.min} to ${d.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">One-Hand Damage: ${dmgText}</div>`)
? `<span class="val-blue">${d.min}${wordTo}${d.max}</span> <span class="val-base">(${d.baseMin}${wordTo}${d.baseMax})</span>`
: `<span class="val-white">${d.min}${wordTo}${d.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">${lblOneHand}${dmgText}</div>`)
}
if (t.twoHandDamage) {
const d = t.twoHandDamage
const dmgText =
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
? `<span class="val-blue">${d.min} to ${d.max}</span> <span class="val-base">(${d.baseMin} to ${d.baseMax})</span>`
: `<span class="val-white">${d.min} to ${d.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">Two-Hand Damage: ${dmgText}</div>`)
? `<span class="val-blue">${d.min}${wordTo}${d.max}</span> <span class="val-base">(${d.baseMin}${wordTo}${d.baseMax})</span>`
: `<span class="val-white">${d.min}${wordTo}${d.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">${lblTwoHand}${dmgText}</div>`)
}
if (t.durability) {
const dur = t.durability
const durText =
dur.isEnhanced && dur.baseMax !== undefined
? `<span class="val-blue">${dur.current} of ${dur.max}</span> <span class="val-base">(${dur.baseMax})</span>`
: `<span class="val-white">${dur.current} of ${dur.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">Durability: ${durText}</div>`)
? `<span class="val-blue">${dur.current}${wordOf}${dur.max}</span> <span class="val-base">(${dur.baseMax})</span>`
: `<span class="val-white">${dur.current}${wordOf}${dur.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">${lblDurability}${durText}</div>`)
}
if (t.reqLevel !== undefined) {
const lvlText =
t.baseReqLevel !== undefined && t.baseReqLevel !== t.reqLevel
? `<span class="val-blue">${t.reqLevel}</span> <span class="val-base">(${t.baseReqLevel})</span>`
: `<span class="val-white">${t.reqLevel}</span>`
baseStatsHtml.push(`<div class="base-stat-item req">Required Level: ${lvlText}</div>`)
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqLevel}${lvlText}</div>`)
}
if (t.reqStr !== undefined) {
const strText =
t.baseReqStr !== undefined && t.baseReqStr !== t.reqStr
? `<span class="val-blue">${t.reqStr}</span> <span class="val-base">(${t.baseReqStr})</span>`
: `<span class="val-white">${t.reqStr}</span>`
baseStatsHtml.push(`<div class="base-stat-item req">Required Strength: ${strText}</div>`)
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqStr}${strText}</div>`)
}
if (t.reqDex !== undefined) {
const dexText =
t.baseReqDex !== undefined && t.baseReqDex !== t.reqDex
? `<span class="val-blue">${t.reqDex}</span> <span class="val-base">(${t.baseReqDex})</span>`
: `<span class="val-white">${t.reqDex}</span>`
baseStatsHtml.push(`<div class="base-stat-item req">Required Dexterity: ${dexText}</div>`)
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqDex}${dexText}</div>`)
}
return `
@ -581,7 +613,7 @@ function renderSingleItemCard(t: FormattedItemTooltip, index: number): string {
<div class="item-title" style="color: ${t.qualityColor};">${escapeHtml(t.title)}</div>
${t.subTitle ? `<div class="item-subtitle">${escapeHtml(t.subTitle)}</div>` : ''}
<div class="item-tags">
<span class="item-quality-pill" style="border-color: ${t.qualityColor}; color: ${t.qualityColor};">${t.quality.toUpperCase()}</span>
<span class="item-quality-pill" style="border-color: ${t.qualityColor}; color: ${t.qualityColor};">${escapeHtml(t.quality.toUpperCase())}</span>
<span class="item-ilvl-pill">ilvl ${t.ilvl}</span>
</div>
</div>

View File

@ -29,6 +29,23 @@ import {
type ItemStatCostTable,
type ItemStatCostRecord,
} from './properties.ts'
import {
getTooltipLocale,
setTooltipLocale,
translateItemName,
translateMagicName,
translateRareName,
formatPropertyCodeI18n,
RUNE_INFO_ZH,
type TooltipLocale,
} from './tooltip-i18n.ts'
export {
getTooltipLocale,
setTooltipLocale,
translateItemName,
type TooltipLocale,
} from './tooltip-i18n.ts'
export interface FormattedStatLine {
text: string
@ -439,7 +456,11 @@ export function formatPropertyCode(
minOrMax?: number,
maxOrPar?: number | string,
maybePar?: string | number,
locale?: TooltipLocale,
): string {
if (locale && locale !== 'en') {
return formatPropertyCodeI18n(code, valueOrMin, minOrMax, maxOrPar, maybePar, locale)
}
let value: number
let min: number
let max: number
@ -1965,7 +1986,7 @@ function getAffixEffectiveLevelReq(aff: RolledAffix, itemClass: string): number
/**
* Generates formatted tooltip structure for any dropped Diablo II Item.
*/
export function formatItemTooltip(item: Item): FormattedItemTooltip {
export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltipLocale()): FormattedItemTooltip {
const base = item.base
const quality = String(item.rarity || item.quality || 'normal').toLowerCase()
const ilvl = item.level ?? item.ilvl ?? 1
@ -1982,18 +2003,35 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
const runeData = isRune && runeKey ? RUNE_INFO[runeKey] : undefined
if (runeData) {
const rZh = runeKey ? RUNE_INFO_ZH[runeKey] : undefined
if (locale === 'zh') {
title = (rZh ? rZh.nameZh : runeData.nameEn) + '符文'
subTitle = `符文 (${runeData.num})`
qualityColor = '#ff8c00'
lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white' })
lines.push({ text: `武器: ${rZh ? rZh.weaponZh : runeData.weapon}`, color: 'blue' })
lines.push({ text: `防具/头盔/盾牌: ${rZh ? rZh.armorZh : runeData.armor}`, color: 'blue' })
} else if (locale === 'bilingual') {
title = `${runeData.nameEn} (${rZh ? rZh.nameZh : ''})`
subTitle = `Rune (${runeData.num})`
qualityColor = '#ff8c00'
lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white' })
lines.push({ text: `武器: ${rZh ? rZh.weaponZh : runeData.weapon}`, color: 'blue' })
lines.push({ text: `防具/头盔/盾牌: ${rZh ? rZh.armorZh : runeData.armor}`, color: 'blue' })
} else {
title = runeData.nameEn
subTitle = `Rune (${runeData.num})`
qualityColor = '#ff8c00'
lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white' })
lines.push({ text: `Weapons: ${runeData.weapon}`, color: 'blue' })
lines.push({ text: `Armor/Helms/Shields: ${runeData.armor}`, color: 'blue' })
}
return {
title,
subTitle,
quality: 'Rune',
quality: locale === 'zh' ? '符文' : 'Rune',
qualityColor,
baseType: 'Rune',
baseType: locale === 'zh' ? '符文' : 'Rune',
ilvl,
reqLevel: runeData.lvl > 0 ? runeData.lvl : undefined,
lines,
@ -2003,13 +2041,13 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
// Check if this is Gold
if (base.id === 'gld' || (base.kind !== 'weapon' && base.kind !== 'armor' && base.name.trim().toLowerCase() === 'gold')) {
const amount = (item.stats.amount ?? item.value ?? 100)
title = `${amount.toLocaleString()} Gold`
title = locale === 'zh' ? `${amount.toLocaleString()} 金币` : `${amount.toLocaleString()} Gold`
qualityColor = '#ffd700'
return {
title,
quality: 'Normal',
quality: locale === 'zh' ? '普通' : 'Normal',
qualityColor,
baseType: 'Gold',
baseType: locale === 'zh' ? '金币' : 'Gold',
ilvl: 1,
lines: [],
}
@ -2028,8 +2066,18 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
qualityColor = '#c8a15a'
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
if (uniqDef) {
if (locale === 'zh') {
title = translateItemName(uniqDef.index)
subTitle = translateItemName(base.name)
} else if (locale === 'bilingual') {
const uZh = translateItemName(uniqDef.index)
const bZh = translateItemName(base.name)
title = `${uniqDef.index} (${uZh})`
subTitle = `${base.name} (${bZh})`
} else {
title = uniqDef.index
subTitle = base.name
}
if (uniqDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, uniqDef.levelreq)
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
@ -2047,14 +2095,25 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
}
}
} else {
subTitle = base.name
subTitle = locale === 'zh' ? translateItemName(base.name) : locale === 'bilingual' ? `${base.name} (${translateItemName(base.name)})` : base.name
}
} else if (quality === 'set') {
qualityColor = '#00e600'
const setDef = item.setItemDef as SetItem | undefined
if (setDef) {
if (locale === 'zh') {
title = translateItemName(setDef.index)
subTitle = `${translateItemName(base.name)} · [${translateItemName(setDef.set)}]`
} else if (locale === 'bilingual') {
const sZh = translateItemName(setDef.index)
const bZh = translateItemName(base.name)
const setZh = translateItemName(setDef.set)
title = `${setDef.index} (${sZh})`
subTitle = `${base.name} (${bZh}) · [${setDef.set} (${setZh})]`
} else {
title = setDef.index
subTitle = `${base.name} · [${setDef.set}]`
}
if (setDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, setDef.levelreq)
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
@ -2072,21 +2131,30 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
}
}
} else {
subTitle = base.name
subTitle = locale === 'zh' ? translateItemName(base.name) : locale === 'bilingual' ? `${base.name} (${translateItemName(base.name)})` : base.name
}
} else if (quality === 'rare') {
qualityColor = '#ffff60'
subTitle = base.name
const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined
if (rareDef) {
if (rareDef.name) title = rareDef.name
if (rareDef.affixes) {
const enName = rareDef?.name || item.name
if (locale === 'zh') {
title = translateRareName(enName)
subTitle = translateItemName(base.name)
} else if (locale === 'bilingual') {
const rZh = translateRareName(enName)
const bZh = translateItemName(base.name)
title = `${enName} (${rZh})`
subTitle = `${base.name} (${bZh})`
} else {
title = enName
subTitle = base.name
}
if (rareDef && rareDef.affixes) {
for (const aff of rareDef.affixes) {
const effReq = getAffixEffectiveLevelReq(aff, itemClass)
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
}
}
}
if (item.rolledProps && item.rolledProps.length > 0) {
for (const p of item.rolledProps) {
const keepRange = NON_RANDOM_RANGE_CODES.has(p.code.toLowerCase()) || (p.min === 0 && p.max === 0)
@ -2114,10 +2182,21 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
}
} else if (quality === 'magic') {
qualityColor = '#4880ff'
subTitle = base.name
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
const enName = magicDef?.name || item.name
if (locale === 'zh') {
title = translateMagicName(enName, base.name)
subTitle = translateItemName(base.name)
} else if (locale === 'bilingual') {
const mZh = translateMagicName(enName, base.name)
const bZh = translateItemName(base.name)
title = `${enName} (${mZh})`
subTitle = `${base.name} (${bZh})`
} else {
title = enName
subTitle = base.name
}
if (magicDef) {
if (magicDef.name) title = magicDef.name
if (magicDef.prefix) {
const effReq = getAffixEffectiveLevelReq(magicDef.prefix, itemClass)
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
@ -2172,7 +2251,14 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
}
} else if (quality === 'superior') {
qualityColor = '#ffffff'
title = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${base.name}`
const enName = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${base.name}`
if (locale === 'zh') {
title = translateItemName(enName)
} else if (locale === 'bilingual') {
title = `${enName} (${translateItemName(enName)})`
} else {
title = enName
}
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
} else if (base.kind === 'weapon') {
@ -2182,13 +2268,27 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
}
} else if (quality === 'low') {
qualityColor = '#a0a0a0'
title = item.name && item.name !== base.name ? item.name : `Cracked ${base.name}`
const enName = item.name && item.name !== base.name ? item.name : `Cracked ${base.name}`
if (locale === 'zh') {
title = translateItemName(enName)
} else if (locale === 'bilingual') {
title = `${enName} (${translateItemName(enName)})`
} else {
title = enName
}
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
}
} else {
qualityColor = '#e0d5c1'
title = base.name
const enName = base.name
if (locale === 'zh') {
title = translateItemName(enName)
} else if (locale === 'bilingual') {
title = `${enName} (${translateItemName(enName)})`
} else {
title = enName
}
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
}
@ -2198,7 +2298,7 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
const mergedProps = aggregateAndSortProperties(rawProps)
for (const prop of mergedProps) {
lines.push({
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param, locale),
color: 'blue',
isMagic: true,
})
@ -2243,11 +2343,13 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
const explicitSockets = Number((item as any).sockets || item.totalSockets || rolledSockets || 0)
if (isEthereal && !lines.some(l => l.text.includes('Ethereal (Cannot be Repaired)'))) {
lines.push({ text: 'Ethereal (Cannot be Repaired)', color: 'blue', isMagic: true })
if (isEthereal && !lines.some(l => l.text.includes('Ethereal') || l.text.includes('无形'))) {
const ethText = locale === 'zh' || locale === 'bilingual' ? '无形 (无法修复)' : 'Ethereal (Cannot be Repaired)'
lines.push({ text: ethText, color: 'blue', isMagic: true })
}
if (explicitSockets > 0 && !lines.some(l => l.text.includes('Socketed ('))) {
lines.push({ text: `Socketed (${explicitSockets})`, color: 'blue', isMagic: true })
if (explicitSockets > 0 && !lines.some(l => l.text.includes('Socketed') || l.text.includes('凹槽'))) {
const sockText = locale === 'zh' || locale === 'bilingual' ? `凹槽 (${explicitSockets})` : `Socketed (${explicitSockets})`
lines.push({ text: sockText, color: 'blue', isMagic: true })
}
// 1. Defense (Enhanced / Low Quality / Ethereal vs Base)
@ -2365,13 +2467,13 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
const sockets = explicitSockets > 0 ? explicitSockets : (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined
const displayQuality =
quality === 'unique' ? 'Unique' :
quality === 'set' ? 'Set' :
quality === 'rare' ? 'Rare' :
quality === 'magic' ? 'Magic' :
quality === 'superior' ? 'Superior' :
quality === 'low' ? 'Low Quality' :
isRune ? 'Rune' : 'Normal'
quality === 'unique' ? (locale === 'zh' ? '暗金' : 'Unique') :
quality === 'set' ? (locale === 'zh' ? '套装' : 'Set') :
quality === 'rare' ? (locale === 'zh' ? '亮金' : 'Rare') :
quality === 'magic' ? (locale === 'zh' ? '魔法' : 'Magic') :
quality === 'superior' ? (locale === 'zh' ? '超强' : 'Superior') :
quality === 'low' ? (locale === 'zh' ? '劣质' : 'Low Quality') :
isRune ? (locale === 'zh' ? '符文' : 'Rune') : (locale === 'zh' ? '普通' : 'Normal')
return {
title,

3116
src/game/tooltip-i18n.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,208 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
formatItemTooltip,
formatPropertyCode,
getTooltipLocale,
setTooltipLocale,
translateItemName,
} from '../src/game/item-tooltip.ts'
import {
translateSkillName,
translateRareName,
translateMagicName,
formatPropertyCodeI18n,
ITEM_NAMES_ZH,
SKILL_NAMES_ZH,
CANONICAL_SKILL_TABS_ZH,
RUNE_INFO_ZH,
} from '../src/game/tooltip-i18n.ts'
import type { Item } from '../src/game/items.ts'
describe('Issue #122: Equipment & Affix Chinese Localization (EN / ZH / Bilingual)', () => {
beforeEach(() => {
// Ensure default is always English
setTooltipLocale('en')
})
describe('1. Locale State & Switching', () => {
it('defaults to en (1.13c English original)', () => {
expect(getTooltipLocale()).toBe('en')
})
it('can switch between en, zh, and bilingual', () => {
setTooltipLocale('zh')
expect(getTooltipLocale()).toBe('zh')
setTooltipLocale('bilingual')
expect(getTooltipLocale()).toBe('bilingual')
setTooltipLocale('en')
expect(getTooltipLocale()).toBe('en')
})
})
describe('2. Item & Skill Name Dictionaries', () => {
it('translates base equipment names correctly', () => {
expect(translateItemName('Cap')).toBe('帽子')
expect(translateItemName('Shako')).toBe('军帽')
expect(translateItemName('Archon Plate')).toBe('执政官铠甲')
expect(translateItemName('Colossus Blade')).toBe('巨神之刃')
expect(translateItemName('Ring')).toBe('戒指')
expect(translateItemName('Amulet')).toBe('项链')
})
it('translates unique item names correctly', () => {
expect(translateItemName('The Stone of Jordan')).toBe('乔丹之石')
expect(translateItemName('Harlequin Crest')).toBe('谐角之冠')
expect(translateItemName('Stormshield')).toBe('暴风之盾')
expect(translateItemName('The Oculus')).toBe('眼球')
})
it('translates set item names and set names correctly', () => {
expect(translateItemName("Tal Rasha's Wrappings")).toBe('塔·拉夏的外袍')
expect(translateItemName("Tal Rasha's Guardianship")).toBe('塔·拉夏的守护')
expect(translateItemName("Immortal King's Soul Cage")).toBe('不朽之王的灵魂牢笼')
expect(translateItemName("Griswold's Legacy")).toBe('格瑞斯华尔德的传奇')
})
it('translates superior and low quality prefixes', () => {
expect(translateItemName('Superior Cap')).toBe('超强的帽子')
expect(translateItemName('Cracked Ring Mail')).toBe('劣质的锁环甲')
expect(translateItemName('Crude Short Sword')).toBe('粗糙的短剑')
expect(translateItemName('Low Quality Breast Plate')).toBe('低品质的胸甲')
})
it('translates classic and LoD skills correctly', () => {
expect(translateSkillName('Teleport')).toBe('传送')
expect(translateSkillName('Fire Ball')).toBe('火球')
expect(translateSkillName('Blizzard')).toBe('暴风雪')
expect(translateSkillName('Whirlwind')).toBe('旋风')
expect(translateSkillName('Blessed Hammer')).toBe('祝福之锤')
// Expansion Druid & Assassin skills
expect(translateSkillName('Hurricane')).toBe('飓风')
expect(translateSkillName('Phoenix Strike')).toBe('凤凰击')
expect(translateSkillName('Poison Creeper')).toBe('剧毒蔓藤')
})
it('translates rare and magic names correctly', () => {
expect(translateRareName('Beast Edge')).toBe('野兽之刃')
expect(translateRareName('Shadow Brow')).toBe('阴影之额头')
expect(translateMagicName('Sturdy Leather Armor', 'Leather Armor')).toBe('结实之皮甲')
})
})
describe('3. Property Code Formatting in Chinese vs English', () => {
it('formats skills in Chinese', () => {
expect(formatPropertyCodeI18n('allskills', 2, undefined, undefined, undefined, 'zh')).toBe('+2 所有技能')
expect(formatPropertyCodeI18n('ama', 1, undefined, undefined, undefined, 'zh')).toBe('+1 亚马逊技能等级')
expect(formatPropertyCodeI18n('skilltab', 2, undefined, undefined, 3, 'zh')).toBe('+2 火焰技能 (限法师使用)')
expect(formatPropertyCodeI18n('skill', 3, undefined, undefined, 'Teleport', 'zh')).toBe('+3 传送')
})
it('formats attributes & life/mana in Chinese', () => {
expect(formatPropertyCodeI18n('str', 20, undefined, undefined, undefined, 'zh')).toBe('+20 力量')
expect(formatPropertyCodeI18n('dex', 15, undefined, undefined, undefined, 'zh')).toBe('+15 敏捷')
expect(formatPropertyCodeI18n('all-stats', 10, undefined, undefined, undefined, 'zh')).toBe('+10 所有属性')
expect(formatPropertyCodeI18n('hp', 50, undefined, undefined, undefined, 'zh')).toBe('+50 生命')
expect(formatPropertyCodeI18n('mana', 40, undefined, undefined, undefined, 'zh')).toBe('+40 法力')
})
it('formats combat speeds and resistances in Chinese', () => {
expect(formatPropertyCodeI18n('fcr', 20, undefined, undefined, undefined, 'zh')).toBe('+20% 快速施法速度')
expect(formatPropertyCodeI18n('fhr', 30, undefined, undefined, undefined, 'zh')).toBe('+30% 快速打击恢复')
expect(formatPropertyCodeI18n('ias', 20, undefined, undefined, undefined, 'zh')).toBe('+20% 增加攻击速度')
expect(formatPropertyCodeI18n('res-all', 15, undefined, undefined, undefined, 'zh')).toBe('所有抗性 +15')
expect(formatPropertyCodeI18n('res-fire', 30, undefined, undefined, undefined, 'zh')).toBe('抗火 +30%')
})
it('formats per-level scaling and repair rate in Chinese', () => {
expect(formatPropertyCodeI18n('hp/lvl', 12, undefined, undefined, undefined, 'zh')).toBe('+1.5 生命 (依角色等级提升)')
expect(formatPropertyCodeI18n('rep-dur', 0, 0, undefined, 33, 'zh')).toBe('每 3 秒恢复 1 点耐久度')
})
})
describe('4. formatItemTooltip: EN, ZH, and Bilingual Modes', () => {
const sampleHarlequin = {
id: 'item-shako-unique',
name: 'Harlequin Crest',
rarity: 'unique',
level: 87,
base: {
id: 'uap',
name: 'Shako',
kind: 'armor',
defense: 141,
durability: 12,
reqstr: 50,
levelreq: 62,
},
stats: {},
uniqueItemDef: {
index: 'Harlequin Crest',
level: 69,
levelreq: 62,
code: 'uap',
rarity: 1,
props: [
{ code: 'allskills', min: 2, max: 2 },
{ code: 'all-stats', min: 2, max: 2 },
{ code: 'hp/lvl', min: 12, max: 12 },
{ code: 'mana/lvl', min: 12, max: 12 },
{ code: 'dmag', min: 10, max: 10 },
{ code: 'mag%', min: 50, max: 50 },
],
},
} as unknown as Item
it('formats in pure 1.13c English by default (locale === "en")', () => {
const tt = formatItemTooltip(sampleHarlequin, 'en')
expect(tt.title).toBe('Harlequin Crest')
expect(tt.subTitle).toBe('Shako')
expect(tt.quality).toBe('Unique')
expect(tt.lines.some(l => l.text === '+2 to All Skills')).toBe(true)
expect(tt.lines.some(l => l.text === '+2 to All Attributes')).toBe(true)
expect(tt.lines.some(l => l.text === '50% Better Chance of Getting Magic Items')).toBe(true)
})
it('formats in Simplified Chinese when locale === "zh"', () => {
const tt = formatItemTooltip(sampleHarlequin, 'zh')
expect(tt.title).toBe('谐角之冠')
expect(tt.subTitle).toBe('军帽')
expect(tt.quality).toBe('暗金')
expect(tt.lines.some(l => l.text === '+2 所有技能')).toBe(true)
expect(tt.lines.some(l => l.text === '+2 所有属性')).toBe(true)
expect(tt.lines.some(l => l.text === '50% 获得魔法装备几率')).toBe(true)
})
it('formats in Bilingual mode when locale === "bilingual"', () => {
const tt = formatItemTooltip(sampleHarlequin, 'bilingual')
expect(tt.title).toBe('Harlequin Crest (谐角之冠)')
expect(tt.subTitle).toBe('Shako (军帽)')
expect(tt.lines.some(l => l.text === '+2 所有技能')).toBe(true)
expect(tt.lines.some(l => l.text === '+2 所有属性')).toBe(true)
})
it('formats Runes correctly across all 3 modes', () => {
const sampleRune = {
id: 'item-rune-01',
name: 'El Rune',
code: 'r01',
rarity: 'normal',
base: { id: 'r01', name: 'El Rune', kind: 'misc' },
stats: {},
} as unknown as Item
const ttEn = formatItemTooltip(sampleRune, 'en')
expect(ttEn.title).toBe('El Rune')
expect(ttEn.subTitle).toBe('Rune (1)')
expect(ttEn.lines[0]?.text).toBe('Can be Inserted into Socketed Items')
const ttZh = formatItemTooltip(sampleRune, 'zh')
expect(ttZh.title).toBe('艾尔符文')
expect(ttZh.subTitle).toBe('符文 (1)')
expect(ttZh.lines[0]?.text).toBe('可镶嵌进有凹槽的装备')
const ttBi = formatItemTooltip(sampleRune, 'bilingual')
expect(ttBi.title).toBe('El Rune (艾尔)')
expect(ttBi.subTitle).toBe('Rune (1)')
expect(ttBi.lines[0]?.text).toBe('可镶嵌进有凹槽的装备')
})
})
})