feedback5 WI: UI strings through t() with an English switch on the title screen and in 设置; pilot screens converted
public/i18n/en.json: Chinese msgid -> English, seeded from PR #70 (@YuriRestia, credited) for every Chinese literal of the current code (≈830 entries; 966 of the 1 036 literals left for phase 2 already translated), completed by hand in the official EN terms. public/js/ui/lang.js: boot language (?lang=en|zh, then localStorage sp.pref.lang, else Chinese; the browser language is not consulted), switchLang, useLang, LangToggle; main.js renders after initLang and re-renders in place on a switch, and translates server texts (m.toast / m.ticker frames, room.closed reasons, error codes via ui/toasts.js describeError). Converted: title screen (English title, wordmark hidden), lobby, room, settings (语言 row, On/Off, quality, hotkeys), the shell (crash screen, toasts) and DifficultyTag. tools/i18n.mjs: extract (msgids + untranslated literals per file), check (msgids missing from en.json), codemod (html`` text / attributes / strings / template literals -> t(), nested strings, N_ for module level, i18n-ignore lines), seed (fill en.json from a msgid map).
This commit is contained in:
1 parent
e596478bef
commit
a12a539e4d
13 files changed
+2102
-171
No files matched your search
@@ -15,6 +15,7 @@ import { h, Fragment } from '../../vendor/preact.module.js';
|
||||
import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import htm from '../../vendor/htm.module.js';
|
||||
import { DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { serverNow } from '../store.js';
|
||||
import { data, useData, localAsset } from '../data.js';
|
||||
import { uiUrl } from './assetUrls.js';
|
||||
@@ -781,7 +782,7 @@ export function DifficultyIcon({ difficulty, class: cls }) {
|
||||
* @param {{ difficulty: string, size?: 'sm'|'md'|'lg', class?: string, code?: string }} props
|
||||
*/
|
||||
export function DifficultyTag({ difficulty, size = 'md', class: cls, code }) {
|
||||
const name = DIFFICULTY_NAMES[difficulty] || difficulty || '—';
|
||||
const name = DIFFICULTY_NAMES[difficulty] ? t(DIFFICULTY_NAMES[difficulty]) : difficulty || '—';
|
||||
const color = DIFFICULTY_COLORS[difficulty] || 'var(--text-lo)';
|
||||
return html`<span class=${cx('dtag', `dtag--${size}`, cls)} style=${`--d-color:${color}`}>
|
||||
<${DifficultyIcon} difficulty=${difficulty} class="dtag__icon" />
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Language switch of the client (docs/I18N.md): picks the language at boot, loads the UI translations
|
||||
// (public/i18n/<lang>.json, shared/i18n.js) and the localized game data (data/i18n/<lang>.json through data.js), keeps
|
||||
// the choice, and translates what the server sends (m.toast / m.ticker / error codes).
|
||||
//
|
||||
// Chinese is the default; English is a switch (the owner's decision of 2026-10-05) — the browser's language is not
|
||||
// consulted. Order at boot: `?lang=en|zh` in the URL (then removed from the address bar and kept as the choice), the
|
||||
// stored choice (localStorage `sp.pref.lang`), else Chinese. A switch re-renders the app in place (main.js App
|
||||
// subscribes with useLang) — no reload; the game texts follow as soon as the data overlay has downloaded (data.js
|
||||
// notifies its subscribers).
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { LANGS, DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire } from '../../../shared/i18n.js';
|
||||
import { loadPref, savePref } from '../store.js';
|
||||
import { data } from '../data.js';
|
||||
import { html } from './components.js';
|
||||
|
||||
/** Labels of the language switch (each in its own language). */
|
||||
export const LANG_LABELS = Object.freeze({ zh: '中文', en: 'English' });
|
||||
const PREF_KEY = 'lang';
|
||||
/** How long boot waits for the UI translations before rendering in Chinese anyway (they apply when they arrive). */
|
||||
const BOOT_WAIT_MS = 2500;
|
||||
|
||||
/** @type {Map<string, Promise<boolean>>} */
|
||||
const uiLoads = new Map();
|
||||
|
||||
/**
|
||||
* Download (once) the UI translations of a language.
|
||||
* @param {string} lang
|
||||
* @param {typeof fetch} [doFetch]
|
||||
* @returns {Promise<boolean>} false when unavailable (the UI stays Chinese)
|
||||
*/
|
||||
export function loadUiMessages(lang, doFetch = (...a) => globalThis.fetch(...a)) {
|
||||
if (lang === DEFAULT_LANG) return Promise.resolve(true);
|
||||
if (uiLoads.has(lang)) return uiLoads.get(lang);
|
||||
const p = (async () => {
|
||||
try {
|
||||
const res = await doFetch(`/i18n/${lang}.json`, { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
|
||||
addMessages(lang, await res.json());
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(`[i18n] /i18n/${lang}.json unavailable (${err?.message || err}); the interface stays Chinese`);
|
||||
uiLoads.delete(lang);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
uiLoads.set(lang, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language to start in: the URL's `?lang=`, then the stored choice, then Chinese.
|
||||
* @param {string} [search] location.search
|
||||
* @param {(key: string, fallback: any) => any} [load]
|
||||
* @returns {{ lang: string, fromUrl: boolean }}
|
||||
*/
|
||||
export function initialLang(search = globalThis.location?.search || '', load = loadPref) {
|
||||
let fromUrl = null;
|
||||
try { fromUrl = normalizeLang(new URLSearchParams(search).get('lang')); } catch { /* ignore */ }
|
||||
if (fromUrl) return { lang: fromUrl, fromUrl: true };
|
||||
let stored = null;
|
||||
try { stored = normalizeLang(load(PREF_KEY, null)); } catch { /* ignore */ }
|
||||
return { lang: stored || DEFAULT_LANG, fromUrl: false };
|
||||
}
|
||||
|
||||
function stripLangParam() {
|
||||
try {
|
||||
const url = new URL(globalThis.location.href);
|
||||
if (!url.searchParams.has('lang')) return;
|
||||
url.searchParams.delete('lang');
|
||||
globalThis.history?.replaceState(globalThis.history.state, '', url.pathname + (url.search || '') + url.hash);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** <html lang> and the tab title follow the language. */
|
||||
function applyDocument(lang) {
|
||||
const doc = globalThis.document;
|
||||
if (!doc) return;
|
||||
doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
|
||||
doc.documentElement.dataset.lang = lang;
|
||||
doc.title = lang === 'en' ? 'Stronghold Protocol: Alliance · Web Simulation' : '卫戍协议:盟约 · STRONGHOLD PROTOCOL';
|
||||
}
|
||||
|
||||
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
|
||||
setNameResolver((name) => data.localeName(name));
|
||||
|
||||
let wired = false;
|
||||
function wire() {
|
||||
if (wired) return;
|
||||
wired = true;
|
||||
onLangChange((lang) => { applyDocument(lang); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot: choose the language and load its UI translations (waits at most BOOT_WAIT_MS) before the first render; the
|
||||
* game-data overlay downloads in the background.
|
||||
* @returns {Promise<string>} the language in effect
|
||||
*/
|
||||
export async function initLang() {
|
||||
wire();
|
||||
const { lang, fromUrl } = initialLang();
|
||||
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
|
||||
applyDocument(getLang());
|
||||
if (lang === DEFAULT_LANG) return getLang();
|
||||
const ok = await Promise.race([loadUiMessages(lang), new Promise((r) => setTimeout(() => r(null), BOOT_WAIT_MS))]);
|
||||
if (ok === null) loadUiMessages(lang).then((loaded) => { if (loaded && normalizeLang(loadPref(PREF_KEY, null)) === lang) setLang(lang); });
|
||||
else if (ok) setLang(lang);
|
||||
data.setLocale(lang).catch(() => {});
|
||||
return getLang();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the language (the switch on the title screen and in 设置): keeps the choice, loads the translations, then
|
||||
* re-renders; the game texts follow when their overlay has downloaded.
|
||||
* @param {string} lang
|
||||
* @returns {Promise<string>} the language in effect
|
||||
*/
|
||||
export async function switchLang(lang) {
|
||||
wire();
|
||||
const want = normalizeLang(lang) || DEFAULT_LANG;
|
||||
savePref(PREF_KEY, want);
|
||||
if (want !== DEFAULT_LANG && !(await loadUiMessages(want))) return getLang();
|
||||
setLang(want);
|
||||
data.setLocale(want).catch(() => {});
|
||||
return getLang();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preact hook: the current language; the component re-renders when it changes.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function useLang() {
|
||||
const [lang, setState] = useState(getLang);
|
||||
useEffect(() => {
|
||||
setState(getLang());
|
||||
return onLangChange((l) => setState(l));
|
||||
}, []);
|
||||
return lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language switch (segmented 中文 | English).
|
||||
* @param {{ class?: string }} props
|
||||
*/
|
||||
export function LangToggle({ class: cls }) {
|
||||
const lang = useLang();
|
||||
return html`<div class=${`set-seg lang-toggle${cls ? ` ${cls}` : ''}`} role="radiogroup" aria-label="Language / 语言" data-testid="lang-toggle">
|
||||
${LANGS.map((l) => html`<button key=${l} type="button" role="radio" lang=${l === 'zh' ? 'zh-CN' : 'en'} aria-checked=${lang === l ? 'true' : 'false'}
|
||||
class=${lang === l ? 'is-on' : ''} data-lang=${l} onClick=${() => { if (lang !== l) switchLang(l); }}>${LANG_LABELS[l]}</button>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of an m.ticker frame in the current language. A config.broadcasts line (`id` + `args`, server ≥ 0.2.0) is
|
||||
* rebuilt from the localized broadcast template — `{0}` is the player's name (never translated), other args are
|
||||
* game-data names or numbers; any other line goes through translateWire (msgid + params, or its text as a msgid).
|
||||
* @param {{ text?: string, id?: string|null, args?: unknown[], msgid?: string, params?: any }} msg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tickerText(msg) {
|
||||
if (getLang() !== DEFAULT_LANG && typeof msg?.id === 'string' && Array.isArray(msg.args)) {
|
||||
const list = data.get('config')?.broadcasts;
|
||||
const b = Array.isArray(list) ? list.find((x) => x && x.id === msg.id) : null;
|
||||
if (b && typeof b.text === 'string') {
|
||||
const args = msg.args.map((a, i) => (i === 0 ? String(a ?? '') : tName(String(a ?? ''))));
|
||||
return format(b.text, args);
|
||||
}
|
||||
}
|
||||
return translateWire(msg);
|
||||
}
|
||||
+24
-15
@@ -1,6 +1,6 @@
|
||||
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
|
||||
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
|
||||
// the settings modal.
|
||||
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`).
|
||||
|
||||
import { useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||
@@ -9,6 +9,8 @@ import { sanitizeSettings } from './gameLogic.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { openGuide } from './guide.js';
|
||||
import { detectFeatures } from './device.js';
|
||||
import { LangToggle } from './lang.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
|
||||
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
|
||||
@@ -41,11 +43,14 @@ function Toggle({ label, micro, value, onChange }) {
|
||||
return html`<div class="set-row">
|
||||
<span class="set-row__label">${label}<${MicroLabel}>${micro}<//></span>
|
||||
<button type="button" class=${`set-toggle${value ? ' is-on' : ''}`} role="switch" aria-checked=${value ? 'true' : 'false'}
|
||||
onClick=${() => onChange(!value)}><i></i><span>${value ? '开启' : '关闭'}</span></button>
|
||||
onClick=${() => onChange(!value)}><i></i><span>${value ? tc('toggle', '开启') : tc('toggle', '关闭')}</span></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const QUALITY = [['high', '高'], ['medium', '中'], ['low', '低']];
|
||||
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
|
||||
/** Desktop shortcuts of the hint line: key → action (msgids). */
|
||||
const SHORTCUTS = [['R', N_('刷新')], ['F', N_('冻结')], ['D', N_('升级')], ['Q', N_('撤退选中干员')], ['X', N_('出售选中干员')],
|
||||
['Space', N_('准备就绪')], ['Esc', N_('关闭弹窗')]];
|
||||
|
||||
/**
|
||||
* Settings modal.
|
||||
@@ -55,25 +60,29 @@ export function SettingsModal({ open, onClose }) {
|
||||
const s = useSettings();
|
||||
const [tested, setTested] = useState(false);
|
||||
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
|
||||
return html`<${Modal} open=${open} onClose=${onClose} title="设置" micro="SETTINGS" width="7.4rem"
|
||||
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>玩法说明<//>
|
||||
<${Button} variant="primary" icon="check" onClick=${onClose}>完成<//>`}>
|
||||
return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
|
||||
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
|
||||
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
|
||||
<div class="set-list">
|
||||
<${Slider} label="背景音乐" micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
|
||||
<${Slider} label="音效" micro="SFX" icon="signal" value=${s.sfx}
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label="静音" micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
<${Toggle} label="显示伤害数字" micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">画面质量<${MicroLabel}>QUALITY<//></span>
|
||||
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
|
||||
<${LangToggle} class="set-lang" />
|
||||
</div>
|
||||
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
|
||||
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
|
||||
<div class="set-seg" role="radiogroup">
|
||||
${QUALITY.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.quality === id ? 'true' : 'false'}
|
||||
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${label}</button>`)}
|
||||
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
${touchUi
|
||||
? html`<p class="set-hint">触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向</p>`
|
||||
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Q</kbd> 撤退选中干员 · <kbd>X</kbd> 出售选中干员 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
|
||||
? html`<p class="set-hint">${t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向')}</p>`
|
||||
: html`<p class="set-hint">${t('快捷键:')}${SHORTCUTS.map(([key, label]) => html`<kbd>${key}</kbd> ${t(label)} · `)}${t('右键查看详情')}</p>`}
|
||||
</div>
|
||||
<//>`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { h } from '../../vendor/preact.module.js';
|
||||
import { useEffect, useReducer } from '../../vendor/hooks.module.js';
|
||||
import htm from '../../vendor/htm.module.js';
|
||||
import { ERR_TEXT } from '../../../shared/constants.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
|
||||
function hFresh(type, props, ...children) {
|
||||
@@ -87,17 +88,18 @@ export function dismissToast(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Text for an error-ish value: NetError / {code,msg} / Error / string.
|
||||
* Text for an error-ish value: NetError / {code,msg} / Error / string — in the current language (the Chinese texts are
|
||||
* msgids: ERR_TEXT, net.js CLIENT_ERR_TEXT, a server `msg`; anything else is shown as it is).
|
||||
* @param {any} err
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeError(err) {
|
||||
if (!err) return '发生未知错误';
|
||||
if (typeof err === 'string') return ERR_TEXT[err] || err;
|
||||
if (err.code && ERR_TEXT[err.code]) return ERR_TEXT[err.code];
|
||||
if (typeof err.message === 'string' && err.message) return err.message;
|
||||
if (typeof err.msg === 'string' && err.msg) return err.msg;
|
||||
return '发生未知错误';
|
||||
if (!err) return t('发生未知错误');
|
||||
if (typeof err === 'string') return t(Object.hasOwn(ERR_TEXT, err) ? ERR_TEXT[err] : err);
|
||||
if (err.code && Object.hasOwn(ERR_TEXT, err.code)) return t(ERR_TEXT[err.code]);
|
||||
if (typeof err.message === 'string' && err.message) return t(err.message);
|
||||
if (typeof err.msg === 'string' && err.msg) return t(err.msg);
|
||||
return t('发生未知错误');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user