diablo2-web/src/client/i18n/lang.ts

287 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Centralized language detection & runtime state (`CHI` / `'zh'` vs `ENG` / `'en'`)
* for Diablo II v1.13c web port (Issue #503).
*/
import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts'
import {
setDefaultTblLang,
setDefaultTblLangProvider,
toGameLang,
toTblLang,
type GameLang,
type TblLang,
} from '../../common/formats/tbl.ts'
export { toGameLang, toTblLang, type GameLang, type TblLang }
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>()
const gameLangListeners = new Set<(lang: GameLang) => void>()
setDefaultTblLangProvider(
() => getTblLang(),
(tbl) => {
overrideLang = tbl === 'ENG' ? 'en' : 'zh'
},
)
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') ||
path.endsWith('/bnet.html') ||
path.endsWith('bnet.html') ||
path.endsWith('/play-en.html') ||
path.endsWith('play-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') ||
path.endsWith('/bnet.html') ||
path.endsWith('bnet.html') ||
path.endsWith('/play-en.html') ||
path.endsWith('play-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 (path.endsWith('/play.html') || path.endsWith('play.html')) {
try {
const raw = window.localStorage?.getItem('d2web.settings.v1')
if (raw) {
const parsed = JSON.parse(raw) as { lang?: unknown }
if (parsed && (parsed.lang === 'en' || parsed.lang === 'zh')) {
return parsed.lang
}
}
} catch {
// Ignore storage errors
}
}
}
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 getLang(): GameLang {
return getGameLang()
}
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 gameLang = getGameLang()
const tbl = gameLang === 'en' ? 'ENG' : 'CHI'
setDefaultTblLang(tbl)
for (const fn of listeners) {
try {
fn(tbl)
} catch {
// ignore listener errors
}
}
for (const fn of gameLangListeners) {
try {
fn(gameLang)
} 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 setLang(lang: GameLang | TblLang | null): void {
setGameLang(lang)
}
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 onLangChange(listener: (lang: GameLang) => void): () => void {
gameLangListeners.add(listener)
return () => {
gameLangListeners.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
}
export type ToolbarI18nKey =
| 'toolbar.status.disconnected'
| 'toolbar.status.connecting'
| 'toolbar.status.bncs'
| 'toolbar.status.mcp'
| 'toolbar.status.d2gs'
| 'toolbar.status.error'
| 'toolbar.server.label'
| 'toolbar.server.placeholder'
| 'toolbar.server.apply'
| 'toolbar.server.confirmReconnect'
| 'toolbar.lang.label'
| 'toolbar.lighting.label'
| 'toolbar.automap.label'
| 'toolbar.automap.off'
| 'toolbar.automap.level'
| 'toolbar.automap.act'
| 'toolbar.viewport.label'
| 'toolbar.inspector.toggle'
| 'toolbar.diag.quotaExceeded'
| 'toolbar.diag.tlsError'
| 'toolbar.diag.bareIpHttps'
export const TOOLBAR_I18N: Readonly<Record<ToolbarI18nKey, { readonly zh: string; readonly en: string }>> = {
'toolbar.status.disconnected': { zh: '未连接', en: 'Offline' },
'toolbar.status.connecting': { zh: '连接中...', en: 'Connecting...' },
'toolbar.status.bncs': { zh: '战网大厅 (BNCS)', en: 'Lobby (BNCS)' },
'toolbar.status.mcp': { zh: '角色选择 (MCP)', en: 'Characters (MCP)' },
'toolbar.status.d2gs': { zh: '游戏中 (D2GS)', en: 'In-Game (D2GS)' },
'toolbar.status.error': { zh: '连接异常', en: 'Conn Error' },
'toolbar.server.label': { zh: '服务器', en: 'Server' },
'toolbar.server.placeholder': { zh: 'www.laiseek.xyz', en: 'www.laiseek.xyz' },
'toolbar.server.apply': { zh: '连接', en: 'Apply' },
'toolbar.server.confirmReconnect': {
zh: '当前正在联机会话中,修改服务器地址将断开当前连接并重连,是否继续?',
en: 'Changing server host while connected will disconnect the current session and reconnect. Continue?',
},
'toolbar.lang.label': { zh: '语言', en: 'Lang' },
'toolbar.lighting.label': { zh: '光照', en: 'Light' },
'toolbar.automap.label': { zh: '全图', en: 'Map' },
'toolbar.automap.off': { zh: '关闭', en: 'Off' },
'toolbar.automap.level': { zh: '本层', en: 'Level' },
'toolbar.automap.act': { zh: '全幕', en: 'Act' },
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
'toolbar.diag.quotaExceeded': {
zh: '单 IP 桥接配额已满(上限 5 个并发连接),请关闭多余标签页后重试。',
en: 'Bridge connection quota reached (max 5 connections per IP). Close extra tabs and retry.',
},
'toolbar.diag.tlsError': {
zh: 'WSS TLS 握手失败:请确保使用有效的域名(如 www.laiseek.xyz)而非裸 IP。',
en: 'WSS TLS handshake failed: ensure serverHost uses a valid TLS hostname (e.g. www.laiseek.xyz).',
},
'toolbar.diag.bareIpHttps': {
zh: 'HTTPS 页面下无法对裸 IP 发起安全 WSS 连接,请使用域名(如 www.laiseek.xyz)。',
en: 'Cannot open WSS to a bare IP address from an HTTPS page. Use a TLS hostname (e.g. www.laiseek.xyz).',
},
}
export function tToolbar(key: ToolbarI18nKey, lang: GameLang = getGameLang()): string {
const entry = TOOLBAR_I18N[key]
return lang === 'en' ? entry.en : entry.zh
}