287 lines
9.0 KiB
TypeScript
287 lines
9.0 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
|