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:
sganggs committed 2026-10-05 20:19:58 +08:00
1 parent e596478bef
commit a12a539e4d
13 files changed
+2102 -171

No files matched your search

+27 -16
View File
@@ -23,6 +23,9 @@
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
// Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang).
// Server texts are translated on arrival: m.toast / m.ticker frames (msgid + params, or the text as a msgid; a
// config.broadcasts line from its id + args), error codes (ui/toasts.js describeError) and room.closed reasons.
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
import './ui/compat.js';
@@ -46,6 +49,8 @@ import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { installLoadoutSync } from './ui/loadoutSync.js';
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -92,7 +97,7 @@ function schedulePendingJoin() {
const code = s.ui.pendingJoin;
if (!code || joinInFlight || !s.session.entered || net.status !== 'online') return;
if (s.room) {
if (s.room.code !== code) toast('你已在其他同盟中,请先离开当前同盟', 'warn');
if (s.room.code !== code) toast(t('你已在其他同盟中,请先离开当前同盟'), 'warn');
clearPendingJoin();
return;
}
@@ -146,7 +151,7 @@ function onWelcome(msg) {
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
const notice = sessionResetNotice(prev, msg.playerId);
backToLobby();
if (notice) toast(notice, 'warn', { ttl: 7000 });
if (notice) toast(t(notice), 'warn', { ttl: 7000 });
} else if (prev.room || prev.match.public) {
// Resumed session: the server re-pushes room/match state; drop whatever it doesn't.
store.patch('ui', { restoring: true });
@@ -170,7 +175,7 @@ function onRoomState(msg) {
const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
// We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
if (store.get().room) toast('你已不在该同盟中', 'warn');
if (store.get().room) toast(t('你已不在该同盟中'), 'warn');
store.set({ room: null, match: emptyMatch() });
return;
}
@@ -184,8 +189,8 @@ function onRoomState(msg) {
const CLOSE_REASON = {
// 'timeout' = this player was removed after staying disconnected past the lobby grace (server/lobby.js)
host_left: '创建者已离开,同盟已解散', timeout: '由于长时间断开连接,你已离开同盟', empty: '同盟已解散',
kicked: '你已被移出同盟', ended: '模拟已结束', expired: '同盟已过期', shutdown: '服务器维护中,同盟已关闭',
host_left: N_('创建者已离开,同盟已解散'), timeout: N_('由于长时间断开连接,你已离开同盟'), empty: N_('同盟已解散'),
kicked: N_('你已被移出同盟'), ended: N_('模拟已结束'), expired: N_('同盟已过期'), shutdown: N_('服务器维护中,同盟已关闭'),
};
function wireNet() {
@@ -201,12 +206,13 @@ function wireNet() {
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
net.on('replaced', () => toast('该身份已在其他页面登录,本页已断开', 'warn', { ttl: 6000 }));
net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
net.on('room.closed', (msg) => {
backToLobby();
toast(CLOSE_REASON[msg.reason] || (typeof msg.reason === 'string' && msg.reason.length < 60 ? `同盟已关闭:${msg.reason}` : '同盟已关闭'), 'warn');
const known = Object.hasOwn(CLOSE_REASON, String(msg.reason)) ? CLOSE_REASON[msg.reason] : null;
toast(known ? t(known) : typeof msg.reason === 'string' && msg.reason.length < 60 ? t('同盟已关闭:{reason}', { reason: msg.reason }) : t('同盟已关闭'), 'warn');
});
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
@@ -214,17 +220,19 @@ function wireNet() {
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
toast(msg.text, kind);
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
toast(translateWire(msg), kind);
});
net.on('m.ticker', (msg) => {
if (typeof msg.text !== 'string') return;
const text = tickerText(msg);
// type, player + the round it came in: a BOSS_HIT line is dropped once its boss round is over and superseded by the
// same player's next one (ui/ticker.js tickerLineLive / tickerSupersedes)
const type = typeof msg.type === 'string' ? msg.type : null;
const playerId = typeof msg.playerId === 'string' ? msg.playerId : null;
// its broadcast priority: the strip plays the highest first (ui/ticker.js enqueueTickerLines)
const priority = Number.isFinite(msg.priority) ? msg.priority : 0;
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
});
net.on('m.emote', (msg) => {
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
@@ -257,15 +265,16 @@ function ScreenCrashed({ error, reset }) {
return html`<div class="screen crash">
<div class="crash__box brackets">
<${MicroLabel} tone="mint">SYSTEM FAULT<//>
<h2>界面发生错误</h2>
<h2>${t('界面发生错误')}</h2>
<p class="t-lo">${String(error?.message || error).slice(0, 200)}</p>
<${Button} variant="primary" icon="refresh" onClick=${reset}>重新加载界面<//>
<${Button} variant="primary" icon="refresh" onClick=${reset}>${t('重新加载界面')}<//>
</div>
</div>`;
}
function App() {
const route = useStore(selectRoute);
useLang(); // a language switch re-renders the whole tree in place
const [error, resetError] = useErrorBoundary((err) => console.error('[ui] screen crashed', err));
const Screen = SCREENS[route] || LobbyScreen;
return html`<div class="app-root">
@@ -285,8 +294,8 @@ async function waitForFonts(ms) {
const fonts = document.fonts;
if (!fonts || typeof fonts.load !== 'function') return;
const loads = [
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'),
fonts.load('700 1em "Noto Sans SC"', '开始'),
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'), // i18n-ignore (font sample)
fonts.load('700 1em "Noto Sans SC"', '开始'), // i18n-ignore (font sample)
fonts.load('700 1em Bender', '0123456789'),
fonts.load('700 1em Rajdhani', '0123456789'),
].map((p) => p.catch(() => null));
@@ -301,7 +310,7 @@ function installGlobalErrorHandlers() {
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
if (err instanceof NetError) toastError(err);
else toast(`发生意外错误:${describeError(err)}`.slice(0, 120), 'error');
else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
@@ -342,7 +351,9 @@ async function boot() {
if (entered) net.setName(savedName);
else net.connect();
});
await Promise.all([waitForFonts(1200), connectWhenReady]);
// the language (and its UI translations) before the first render: no Chinese flash for an English player
const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err));
await Promise.all([waitForFonts(1200), connectWhenReady, langReady]);
const root = document.getElementById('app');
render(html`<${App} />`, root);
@@ -369,5 +380,5 @@ async function boot() {
boot().catch((err) => {
console.error('[app] boot failed', err);
const el = document.getElementById('boot-err');
if (el) el.textContent = '启动失败,请刷新页面重试';
if (el) el.textContent = t('启动失败,请刷新页面重试');
});
+69 -61
View File
@@ -8,6 +8,8 @@
// screen is complete before data is generated. Rounds: solo 标准 = 9, everything else 14 (+R15
// hidden core on 险境+), per research 00-INDEX §2. Battlefield pool (`modes[].stages`): 标准 always
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
// config.json mode texts come localized from data.js.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
@@ -18,20 +20,21 @@ import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
import { getConfig, getMode, getStage, useData } from '../data.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
/** Official mode texts (activity_table act2autochess.modeDataDict), fallback when config.json is absent. */
export const MODE_TEXT = {
single: {
FUNNY: { code: 'AC-1', desc: '时长较短的模拟训练', effects: ['可以快速完成作战', '常规奖励'] },
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
FUNNY: { code: 'AC-1', desc: N_('时长较短的模拟训练'), effects: [N_('可以快速完成作战'), N_('常规奖励')] },
NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
},
multi: {
FUNNY: { code: 'AC-1', desc: '敌方攻击强度较低的模拟训练', effects: ['作战环境较为温和', '常规奖励'] },
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
FUNNY: { code: 'AC-1', desc: N_('敌方攻击强度较低的模拟训练'), effects: [N_('作战环境较为温和'), N_('常规奖励')] },
NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
},
};
@@ -40,13 +43,17 @@ export const STAGE_POOL = { FUNNY: ['act1autochess_m01'], NORMAL: 8, HARD: 7, AB
/**
* Display name of a stage: stages.json when it is loaded, else derived from the id (act1 m0N → 战场#0N, act2 m0N → 战场#0(N+4)).
* From the data name only its 战场#NN part (with a (上半) / (下半) mark) — 'Battlefield #NN (First Half)' in English.
* @param {string} id e.g. 'act1autochess_m01'
*/
export function stageLabel(id) {
const rec = getStage(id);
if (rec && typeof rec.name === 'string' && rec.name) return rec.name.split(/\s+/)[0];
if (rec && typeof rec.name === 'string' && rec.name) {
const m = rec.name.match(/^(.*?#\d+(?:\s*[((][^))]*[))])?)/);
return m ? m[1] : rec.name.split(/\s+/)[0];
}
const m = String(id || '').match(/^act(\d)autochess_m(\d+)$/);
return m ? `战场#${String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0')}` : '';
return m ? t('战场#{no}', { no: String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0') }) : '';
}
/**
@@ -58,22 +65,22 @@ export function stageLabel(id) {
export function stageNote(stages) {
if (Array.isArray(stages)) {
const ids = stages.filter((s) => typeof s === 'string' && s);
if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? `战场固定为 ${name}` : '战场固定'; }
return ids.length > 1 ? `战场随机(共${ids.length}张)` : '';
if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? t('战场固定为 {name}', { name }) : t('战场固定'); }
return ids.length > 1 ? t('战场随机(共{n}张)', { n: ids.length }) : '';
}
return Number.isInteger(stages) && stages > 1 ? `战场随机(共${stages}张)` : '';
return Number.isInteger(stages) && stages > 1 ? t('战场随机(共{stages}张)', { stages }) : '';
}
const MODE_CARDS = [
{
id: 'solo', name: '独立模拟', en: 'SOLO SIMULATION', icon: 'user',
desc: '独自调配资金与干员,以自己的节奏完成整场模拟。',
points: ['1 名博士', '休整期与机变阶段不限时'],
id: 'solo', name: N_('独立模拟'), en: 'SOLO SIMULATION', icon: 'user',
desc: N_('独自调配资金与干员,以自己的节奏完成整场模拟。'),
points: [N_('1 名博士'), N_('休整期与机变阶段不限时')],
},
{
id: 'coop', name: '同盟模拟', en: 'ALLIANCE SIMULATION', icon: 'users',
desc: `与至多 ${MAX_SEATS - 1} 名博士组成同盟,共享干员池,联防协作抵御敌潮。`,
points: [`1–${MAX_SEATS} 名博士 · 可由 AI 队友补位`, '联防阶段 · 最终攻势合并生命值'],
id: 'coop', name: N_('同盟模拟'), en: 'ALLIANCE SIMULATION', icon: 'users',
desc: N_('与至多 {n} 名博士组成同盟,共享干员池,联防协作抵御敌潮。'), params: { n: MAX_SEATS - 1 },
points: [N_('1–{n} 名博士 · 可由 AI 队友补位'), N_('联防阶段 · 最终攻势合并生命值')], pointParams: { n: MAX_SEATS },
},
];
@@ -89,11 +96,11 @@ export function difficultyInfo(roomMode, difficulty) {
const m = DIFFICULTIES.includes(difficulty) ? getMode(modeIdFor(roomMode, difficulty)) : null;
const effects = Array.isArray(m?.effectDescList)
? m.effectDescList.map((e) => String(e).replace(/^[·•\s]+/, '')).filter(Boolean)
: fallback.effects;
: fallback.effects.map((e) => t(e));
const rounds = Number.isFinite(m?.lastRound) ? m.lastRound : roomMode === 'solo' && difficulty === 'FUNNY' ? 9 : 14;
return {
code: typeof m?.code === 'string' ? m.code : fallback.code,
desc: typeof m?.desc === 'string' ? m.desc : fallback.desc,
desc: typeof m?.desc === 'string' ? m.desc : t(fallback.desc),
effects,
rounds,
hidden: difficulty !== 'FUNNY',
@@ -160,20 +167,21 @@ export function rememberRoom(code) {
}
const FALLBACK_TIPS = [
'联合模拟在选择策略时可以进行一次跳过',
'调度中心即使冻结,依然可以主动刷新',
'两件同名装备可以合成一件更强力的装备',
'只有达成完美作战的队友可以进行联防',
N_('联合模拟在选择策略时可以进行一次跳过'),
N_('调度中心即使冻结,依然可以主动刷新'),
N_('两件同名装备可以合成一件更强力的装备'),
N_('只有达成完美作战的队友可以进行联防'),
];
const TIP_ROTATE_MS = 5000; // matchingTipRotateInterval
/** Rotating tactical tips (config.json `tips`, weighted list of { tip, weight }). */
function TipsPanel() {
const cfg = getConfig();
const fallback = FALLBACK_TIPS.map((x) => t(x));
const tips = Array.isArray(cfg?.tips)
? cfg.tips.map((t) => (typeof t === 'string' ? t : t?.tip)).filter((t) => typeof t === 'string' && t)
: FALLBACK_TIPS;
const list = tips.length ? tips : FALLBACK_TIPS;
? cfg.tips.map((x) => (typeof x === 'string' ? x : x?.tip)).filter((x) => typeof x === 'string' && x)
: fallback;
const list = tips.length ? tips : fallback;
const [idx, setIdx] = useState(() => Math.floor(Math.random() * list.length));
useEffect(() => {
const id = setInterval(() => setIdx((i) => i + 1), TIP_ROTATE_MS);
@@ -183,11 +191,11 @@ function TipsPanel() {
return html`<div class="tips brackets">
<div class="tips__head">
<${Icon} name="info" />
<span>作战提示</span>
<span>${t('作战提示')}</span>
<${MicroLabel}>TACTICAL TIPS<//>
<span class="tips__idx num">${String(i + 1).padStart(2, '0')}<span class="t-dim">/${String(list.length).padStart(2, '0')}</span></span>
<button type="button" class="tips__nav" onClick=${() => setIdx(i - 1 + list.length)} aria-label="上一条"><${Icon} name="chevronLeft" /></button>
<button type="button" class="tips__nav" onClick=${() => setIdx(i + 1)} aria-label="下一条"><${Icon} name="chevronRight" /></button>
<button type="button" class="tips__nav" onClick=${() => setIdx(i - 1 + list.length)} aria-label=${t('上一条')}><${Icon} name="chevronLeft" /></button>
<button type="button" class="tips__nav" onClick=${() => setIdx(i + 1)} aria-label=${t('下一条')}><${Icon} name="chevronRight" /></button>
</div>
<p key=${i} class="tips__text">${list[i]}</p>
</div>`;
@@ -200,11 +208,11 @@ function ModeCard({ card, selected, onSelect }) {
<span class="mode-card__icon"><${Icon} name=${card.icon} /></span>
<span class="mode-card__text">
<${MicroLabel} tone=${selected ? 'mint' : undefined}>${card.en}<//>
<span class="mode-card__name">${card.name}</span>
<span class="mode-card__desc">${card.desc}</span>
<span class="mode-card__points">${card.points.map((p) => html`<span key=${p}>${p}</span>`)}</span>
<span class="mode-card__name">${t(card.name)}</span>
<span class="mode-card__desc">${t(card.desc, card.params)}</span>
<span class="mode-card__points">${card.points.map((p) => html`<span key=${p}>${t(p, card.pointParams)}</span>`)}</span>
</span>
<span class="mode-card__check" aria-hidden="true"><${Icon} name="check" />已选定</span>
<span class="mode-card__check" aria-hidden="true"><${Icon} name="check" />${t('已选定')}</span>
</button>`;
}
@@ -215,15 +223,15 @@ function DifficultyCard({ roomMode, difficulty, selected, onSelect }) {
<span class="diff-card__bar" aria-hidden="true"></span>
<span class="diff-card__head">
<${DifficultyIcon} difficulty=${difficulty} class="diff-card__glyph" />
<span class="diff-card__name">${DIFFICULTY_NAMES[difficulty]}</span>
<span class="diff-card__name">${t(DIFFICULTY_NAMES[difficulty])}</span>
<span class="diff-card__code num">${info.code}</span>
<span class="diff-card__meta">
<span class="num">${info.rounds}</span> 回合${info.hidden ? html`<span class="diff-card__hidden">+ 隐秘核心</span>` : null}
<span class="num">${info.rounds}</span> ${tc('rounds', '回合')}${info.hidden ? html`<span class="diff-card__hidden">${t('+ 隐秘核心')}</span>` : null}
</span>
</span>
<span class="diff-card__desc">${info.desc}</span>
<span class="diff-card__effects">${info.effects.map((e) => html`<span key=${e}>${e}</span>`)}${info.stageNote ? html`<span key="stage" class="diff-card__stage"><${Icon} name="rook" />${info.stageNote}</span>` : null}</span>
<span class="diff-card__check" aria-hidden="true"><${Icon} name="check" /><span>已选定</span></span>
<span class="diff-card__check" aria-hidden="true"><${Icon} name="check" /><span>${t('已选定')}</span></span>
</button>`;
}
@@ -252,7 +260,7 @@ export function LobbyScreen() {
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast('尚未连接到服务器,请稍候', 'warn'); return; }
if (!online) { toast(t('尚未连接到服务器,请稍候'), 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
@@ -265,23 +273,23 @@ export function LobbyScreen() {
// `onClick=${join}` hands the click EVENT as the first argument, and a default parameter only applies to
// `undefined` — codeArg keeps an event target out of the key and falls back to the input field
const k = codeArg(c, code);
if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
run('join', () => net.request('room.join', { code: k }));
};
// a spectator seat: no player seat taken, nothing to do but watch (also a match already running)
const spectate = (c = code) => {
// same guard as join: `onClick=${spectate}` passes the click event, not a code
const k = codeArg(c, code);
if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
run('spectate', () => net.request('room.spectate', { code: k }).catch((err) => {
// Clearer than the bare ERR_TEXT: the usual cause is a code that is not the host's (a remembered one from an
// earlier room, or another machine's) — the server can only answer "no such room".
if (err?.code === ERR.ROOM_NOT_FOUND) {
toast(`没有找到密钥 ${k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)`, 'warn');
toast(t('没有找到密钥 {k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)', { k }), 'warn');
return;
}
if (err?.code === ERR.ALREADY) {
toast('你已经是该同盟的博士:先离开同盟,才能以观战身份进入', 'warn');
toast(t('你已经是该同盟的博士:先离开同盟,才能以观战身份进入'), 'warn');
return;
}
throw err;
@@ -295,20 +303,20 @@ export function LobbyScreen() {
return html`<div class="screen lobby-screen">
<header class="topbar">
<div class="topbar__left">
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title="返回标题">返回<//>
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}<//>
<${PingPill} ms=${conn.ping} online=${online} />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
<h1 class="topbar__title">选择模拟协议</h1>
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
</div>
<div class="topbar__right">
<${GuideButton} class="lobby-guide" variant="secondary" />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" />
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<div class="me-chip">
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
<div class="me-chip__text">
<span class="me-chip__name">${me.name || '博士'}</span>
<span class="me-chip__name">${me.name || t('博士')}</span>
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
</div>
</div>
@@ -317,45 +325,45 @@ export function LobbyScreen() {
<div class="lobby-body screen__scroll">
<section class="lobby-left">
<div class="section-label"><span class="section-label__idx num">01</span>模拟方式<${MicroLabel}>MODE<//></div>
<div class="section-label"><span class="section-label__idx num">01</span>${t('模拟方式')}<${MicroLabel}>MODE<//></div>
<div class="mode-cards">
${MODE_CARDS.map((c) => html`<${ModeCard} key=${c.id} card=${c} selected=${roomMode === c.id} onSelect=${pickMode} />`)}
</div>
<div class="section-label"><span class="section-label__idx num">03</span>加入同盟<${MicroLabel}>JOIN WITH ALLIANCE KEY<//></div>
<div class="section-label"><span class="section-label__idx num">03</span>${t('加入同盟')}<${MicroLabel}>JOIN WITH ALLIANCE KEY<//></div>
<${Panel} class="join-panel" tone="amber">
<div class="join-row">
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
<${TextField} size="code" icon="key" value=${code} placeholder=${t('输入同盟密钥 / 粘贴邀请链接')}
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟<//>
<${Tooltip} text=${`以观战者身份进入:不占博士席位,只能观看(每个同盟最多 ${MAX_SPECTATORS} 名,模拟进行中也可进入)`}>
<${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>观战<//>
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>${t('加入同盟')}<//>
<${Tooltip} text=${t('以观战者身份进入:不占博士席位,只能观看(每个同盟最多 {MAX_SPECTATORS} 名,模拟进行中也可进入)', { MAX_SPECTATORS })}>
<${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>${t('观战')}<//>
<//>
</div>
<div class="join-foot">
${recent.length ? html`<span class="t-lo">最近的同盟</span>
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" title="填入密钥(不会直接加入)"
${recent.length ? html`<span class="t-lo">${t('最近的同盟')}</span>
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" title=${t('填入密钥(不会直接加入)')}
onClick=${() => setCode(c)}>${c}</button>`)}`
: html`<span class="t-dim">向同伴索取 ${ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接</span>`}
: html`<span class="t-dim">${t('向同伴索取 {ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接', { ROOM_CODE_LEN })}</span>`}
</div>
<//>
<${TipsPanel} />
</section>
<section class="lobby-right">
<div class="section-label"><span class="section-label__idx num">02</span>模拟难度<${MicroLabel}>DIFFICULTY<//></div>
<div class="section-label"><span class="section-label__idx num">02</span>${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></div>
<div class="diff-list">
${DIFFICULTIES.map((d) => html`<${DifficultyCard} key=${d} roomMode=${roomMode} difficulty=${d} selected=${difficulty === d} onSelect=${pickDifficulty} />`)}
</div>
<div class="create-box">
<${Tooltip} block=${true} text=${online ? null : '正在连接服务器…'}>
<${Tooltip} block=${true} text=${online ? null : t('正在连接服务器…')}>
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" loading=${busy === 'create'} disabled=${!online} onClick=${create}>
${roomMode === 'solo' ? '开始独立模拟' : '创建同盟'}
${roomMode === 'solo' ? t('开始独立模拟') : t('创建同盟')}
<//>
<//>
<div class="create-box__hint">
${online
? html`<span>${roomMode === 'solo' ? '创建后即可开始模拟' : '创建后可邀请好友或添加 AI 队友'}</span>`
? html`<span>${roomMode === 'solo' ? t('创建后即可开始模拟') : t('创建后可邀请好友或添加 AI 队友')}</span>`
: html`<${Spinner} size="sm" label="CONNECTING" />`}
</div>
</div>
+57 -55
View File
@@ -9,6 +9,7 @@
// Spectator seats (community report #26, a remake feature): a co-op room with spectators shows the 观战席 strip under
// the seats — names, offline marks, the host's ✕ (room.removeSpectator) — and a spectator's own view swaps the ready
// button for 观战中 and offers 入座 (room.join of the room) while a player seat is free.
// Texts go through t() (docs/I18N.md).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS, MAX_SPECTATORS } from '../../../shared/constants.js';
@@ -22,6 +23,7 @@ import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
import { difficultyInfo } from './lobby.js';
import { t, tc } from '../../../shared/i18n.js';
/**
* Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record.
@@ -90,13 +92,13 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
<header class="seat__head"><span class="seat__no num">P${index + 1}</span><${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//></header>
<div class="seat__art seat__art--empty">
<div class="seat__radar" aria-hidden="true"></div>
<span class="seat__wait">等待博士加入</span>
<span class="seat__wait">${t('等待博士加入')}</span>
<${MicroLabel}>AWAITING DOCTOR<//>
</div>
<footer class="seat__foot">
${canAdd
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>添加 AI 队友<//>`
: html`<span class="seat__state t-dim">空位</span>`}
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>${t('添加 AI 队友')}<//>`
: html`<span class="seat__state t-dim">${t('空位')}</span>`}
</footer>
</article>`;
}
@@ -110,30 +112,30 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
<header class="seat__head">
<span class="seat__no num">P${index + 1}</span>
<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//>
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />创建者</span>` : null}
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />${t('创建者')}</span>` : null}
</header>
<div class="seat__art">
<div class="seat__stripes" aria-hidden="true"></div>
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} />
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />AI 队友</span>` : null}
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />${t('AI 队友')}</span>` : null}
</div>
<div class="seat__who">
<span class="seat__name">${seat.name || '博士'}</span>
${isMe ? html`<span class="seat__you">你</span>` : null}
<span class="seat__name">${seat.name || t('博士')}</span>
${isMe ? html`<span class="seat__you">${t('你')}</span>` : null}
</div>
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}<//>
<footer class="seat__foot">
<span class=${`seat__state seat__state--${state}`}>
${state === 'ready' ? html`<${Icon} name="check" />已就绪`
: state === 'offline' ? html`<${Icon} name="wifiOff" />连接中断`
: state === 'host' ? html`<${Icon} name="crown" />待命中`
: html`<${Icon} name="hourglass" />准备中`}
${state === 'ready' ? html`<${Icon} name="check" />${t('已就绪')}`
: state === 'offline' ? html`<${Icon} name="wifiOff" />${t('连接中断')}`
: state === 'host' ? html`<${Icon} name="crown" />${t('待命中')}`
: html`<${Icon} name="hourglass" />${t('准备中')}`}
</span>
${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" />
${seat.isBot && facts.isHost ? html`<${Tooltip} text=${t('移除该 AI 队友')}>
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label=${t('移除 AI 队友')} />
<//>` : null}
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text="将该博士移出同盟">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label="移出该博士" />
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text=${t('将该博士移出同盟')}>
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label=${t('移出该博士')} />
<//>` : null}
</footer>
</article>`;
@@ -142,29 +144,29 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
/** 观战席: the room's spectators (host: ✕ frees a seat), and 入座 for a spectator while a player seat is free. */
function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
if (!facts.spectators.length) return null;
return html`<section class="specbar" aria-label="观战席">
<span class="specbar__label"><${Icon} name="eye" />观战席<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
return html`<section class="specbar" aria-label=${t('观战席')}>
<span class="specbar__label"><${Icon} name="eye" />${t('观战席')}<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
${facts.spectators.map((s) => html`<span key=${s.playerId} class=${`specbar__who${s.playerId === myId ? ' is-me' : ''}${s.connected === false ? ' is-offline' : ''}`}>
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`<span class="seat__you">你</span>` : null}
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || t('博士')}${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`}
onClick=${() => onRemove(s.playerId)} aria-label=${`移出观战者 ${s.name || ''}`} title="移出该观战者" />` : null}
onClick=${() => onRemove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
</span>`)}
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座<//>` : null}
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>${t('入座')}<//>` : null}
</section>`;
}
function InviteBox({ code }) {
const copy = async (what) => {
const ok = await copyText(what === 'code' ? code : inviteLink(code));
if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success');
else toast('复制失败,请手动复制', 'warn');
if (ok) toast(what === 'code' ? t('已复制同盟密钥 {code}', { code }) : t('已复制邀请链接'), 'success');
else toast(t('复制失败,请手动复制'), 'warn');
};
return html`<div class="invite brackets">
<div class="invite__label"><${Icon} name="key" /><span>同盟密钥</span><${MicroLabel}>ALLIANCE KEY<//></div>
<div class="invite__code num selectable" aria-label=${`同盟密钥 ${code}`}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
<div class="invite__label"><${Icon} name="key" /><span>${t('同盟密钥')}</span><${MicroLabel}>ALLIANCE KEY<//></div>
<div class="invite__code num selectable" aria-label=${t('同盟密钥 {code}', { code })}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
<div class="invite__btns">
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>复制密钥<//>
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>复制链接<//>
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>${t('复制密钥')}<//>
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>${t('复制链接')}<//>
</div>
</div>`;
}
@@ -173,14 +175,14 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
if (!isHost) {
return html`<div class="dpick dpick--ro">
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} />
<span class="t-dim">由创建者选择</span>
<span class="t-dim">${t('由创建者选择')}</span>
</div>`;
}
return html`<div class="dpick" role="radiogroup" aria-label="模拟难度">
return html`<div class="dpick" role="radiogroup" aria-label=${t('模拟难度')}>
${DIFFICULTIES.map((d) => html`<button key=${d} type="button" role="radio" aria-checked=${room.difficulty === d ? 'true' : 'false'}
class=${`dpick__opt${room.difficulty === d ? ' is-active' : ''}`} style=${`--d-color:${DIFFICULTY_COLORS[d]}`}
disabled=${!!busy} onClick=${() => room.difficulty !== d && onPick(d)}>
<${DifficultyIcon} difficulty=${d} />${DIFFICULTY_NAMES[d].replace('模拟', '')}
<${DifficultyIcon} difficulty=${d} />${t(DIFFICULTY_NAMES[d].replace('模拟', ''))}
</button>`)}
</div>`;
}
@@ -204,7 +206,7 @@ export function RoomScreen() {
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast('连接中断,请稍候重试', 'warn'); return; }
if (!online) { toast(t('连接中断,请稍候重试'), 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
@@ -221,7 +223,7 @@ export function RoomScreen() {
// confirmed player's id goes along: if they left and someone else took the seat meanwhile, the server refuses it.
const kick = async (seat, name, playerId) => {
if (inFlight.current) return;
const ok = await confirmDialog({ title: '移出同盟', text: `确定将「${name || '博士'}」移出同盟吗?对方可以凭同盟密钥重新加入。`, okText: '移出', danger: true });
const ok = await confirmDialog({ title: t('移出同盟'), text: t('确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。', { name: name || t('博士') }), okText: t('移出'), danger: true });
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
@@ -232,7 +234,7 @@ export function RoomScreen() {
if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
if (facts.isHost && othersHere) {
const ok = await confirmDialog({ title: '离开同盟', text: '你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?', okText: '离开', danger: true });
const ok = await confirmDialog({ title: t('离开同盟'), text: t('你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?'), okText: t('离开'), danger: true });
if (!ok) return;
}
inFlight.current = true;
@@ -250,37 +252,37 @@ export function RoomScreen() {
};
const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
? html`<span class="t-orange"><${Icon} name="wifiOff" />${t('连接中断,正在重连…')}</span>`
: facts.spectating
? html`<span class="t-lo"><${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士</span>`
? html`<span class="t-lo"><${Icon} name="eye" />${t('观战中 · 不占博士席位,模拟开始后可切换观看各位博士')}</span>`
: !coop
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
? html`<span class="t-mint">${t('*模拟协议已就绪,准许进入模拟')}</span>`
: facts.isHost
? facts.canStart
? html`<span class="t-mint">*同盟人数达标,准许进入模拟</span>`
: html`<span class="t-lo">等待所有博士准备就绪</span>`
? html`<span class="t-mint">${t('*同盟人数达标,准许进入模拟')}</span>`
: html`<span class="t-lo">${t('等待所有博士准备就绪')}</span>`
: myReady
? html`<span class="t-mint">已就绪 · 等待创建者开始模拟</span>`
: html`<span class="t-lo">准备就绪后,创建者即可开始模拟</span>`;
? html`<span class="t-mint">${t('已就绪 · 等待创建者开始模拟')}</span>`
: html`<span class="t-lo">${t('准备就绪后,创建者即可开始模拟')}</span>`;
return html`<div class="screen room-screen">
<header class="topbar">
<div class="topbar__left">
<${Tooltip} text="离开同盟" placement="bottom">
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label="离开同盟" />
<${Tooltip} text=${t('离开同盟')} placement="bottom">
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label=${t('离开同盟')} />
<//>
<div class="room-ping">
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>当前延迟<//>
<${MicroLabel}>${t('当前延迟')}<//>
</div>
<${GuideButton} class="room-guide" variant="secondary" />
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}<//>
<h1 class="topbar__title">${coop ? '同盟模拟' : '独立模拟'}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
<h1 class="topbar__title">${coop ? t('同盟模拟') : t('独立模拟')}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
</div>
<div class="topbar__right">
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>仅限 1 名博士</span></div>`}
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>${t('仅限 1 名博士')}</span></div>`}
</div>
</header>
@@ -289,12 +291,12 @@ export function RoomScreen() {
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)}
${coop ? null : html`<aside class="solo-brief brackets">
<${MicroLabel} tone="mint">BRIEFING<//>
<h2>${DIFFICULTY_NAMES[room.difficulty] || ''}<span class="num t-dim"> ${info.code}</span></h2>
<h2>${DIFFICULTY_NAMES[room.difficulty] ? t(DIFFICULTY_NAMES[room.difficulty]) : ''}<span class="num t-dim"> ${info.code}</span></h2>
<p>${info.desc}</p>
<ul>
${info.effects.map((e) => html`<li key=${e}>${e}</li>`)}
<li>共 <b class="num">${info.rounds}</b> 回合${info.hidden ? ',满足条件时进入隐秘核心' : ''}</li>
<li>独立模拟中休整期与机变阶段不限时</li>
<li>${t('共')} <b class="num">${info.rounds}</b> ${tc('rounds', '回合')}${info.hidden ? t(',满足条件时进入隐秘核心') : ''}</li>
<li>${t('独立模拟中休整期与机变阶段不限时')}</li>
</ul>
</aside>`}
</main>
@@ -302,12 +304,12 @@ export function RoomScreen() {
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">模拟难度<${MicroLabel}>DIFFICULTY<//></span>
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
<span class="t-lo">已就绪</span>
<span class="t-lo">${t('已就绪')}</span>
<b class="num">${facts.readyHumans}</b><span class="num t-dim">/${facts.humans.length}</span>
<span class="ready-count__icons" aria-hidden="true">
${facts.humans.map((s) => html`<${Icon} key=${s.playerId} name="user" class=${facts.isReady(s) ? 'is-on' : ''} />`)}
@@ -316,15 +318,15 @@ export function RoomScreen() {
<div class="room-bar__status">${statusLine}</div>
</div>
<div class="room-bar__right">
<${LoadoutButton} from="room" size="lg" class="room-loadout" />
<${LoadoutButton} from="room" size="lg" class="room-loadout" label=${t('干员调配')} />
${facts.isHost
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//>
? html`<${Tooltip} text=${facts.canStart ? null : t('仍有博士未准备就绪')}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>${t('开始模拟')}<//>
<//>`
: facts.spectating
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>观战中<//>`
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>${t('观战中')}<//>`
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? t('已就绪') : t('准备就绪')}<//>`}
</div>
</footer>
</div>`;
+23 -16
View File
@@ -1,4 +1,5 @@
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始.
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language switch
// (中文 | English, ui/lang.js; in English the big title is the English one and the small wordmark above it hides).
//
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
@@ -17,6 +18,8 @@ import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
// the client accepts is never rejected by the server's hello validation.
@@ -174,14 +177,15 @@ function Ridges() {
}
const STATUS_TEXT = {
idle: '准备连接', connecting: '正在连接服务器', connected: '已连接服务器', handshaking: '正在验证身份',
online: '已连接服务器', reconnecting: '连接中断,正在重连', closed: '连接已关闭',
idle: N_('准备连接'), connecting: N_('正在连接服务器'), connected: N_('已连接服务器'), handshaking: N_('正在验证身份'),
online: N_('已连接服务器'), reconnecting: N_('连接中断,正在重连'), closed: N_('连接已关闭'),
};
/** Title screen component. */
export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin);
const lang = useLang();
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const assetsSettled = useData('assets');
const assets = data.get('assets');
@@ -199,7 +203,7 @@ export function TitleScreen() {
const valid = isValidName(name);
const start = () => {
if (!valid) { toast('请输入博士代号', 'warn'); return; }
if (!valid) { toast(t('请输入博士代号'), 'warn'); return; }
enterSession(name);
};
@@ -232,39 +236,42 @@ export function TitleScreen() {
<div><${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE<//><br /><${MicroLabel}>TACTICAL CO-OP NODE · 02<//></div>
</div>
<div class="title-corner title-corner--tr">
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
<div>
<${LangToggle} class="title-lang" />
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
</div>
</div>
<main class="title-main">
<${Emblem} />
<div class="title-en">
${lang === 'en' ? null : html`<div class="title-en">
<span class="title-en__a">STRONGHOLD PROTOCOL</span>
<span class="title-en__b">ALLIANCE</span>
</div>
<h1 class="title-cn">卫戍协议<span class="title-cn__colon">:</span><em>盟约</em></h1>
<p class="title-tag">调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。</p>
</div>`}
<h1 class=${`title-cn${lang === 'en' ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${lang === 'en' ? ': ' : ':'}</span><em>${t('盟约')}</em></h1>
<p class="title-tag">${t('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}</p>
<div class="title-login">
${pendingJoin ? html`<div class="title-invite">
<${Icon} name="key" />
<span>收到同盟邀请</span><b class="num">${pendingJoin}</b><span class="t-lo">· 输入代号后将自动加入</span>
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 输入代号后将自动加入')}</span>
</div>` : null}
<${TextField} label="博士代号" micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
placeholder="输入你的代号(最多 ${NAME_MAX_LEN} 字)" autoFocus=${!touchUi}
<${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi}
onInput=${setName} onEnter=${start} />
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>开始<//>
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${t('开始')}<//>
<div class="title-conn">
<span class=${`status-dot ${dotClass}`}></span>
<span>${STATUS_TEXT[conn.status] || conn.status}</span>
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
<${GuideButton} class="title-guide" />
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
<${FullscreenButton} class="title-fs" />
</div>
</div>
</main>
<footer class="title-foot">
<span>非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有</span>
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
</footer>
</div>`;
+2 -1
View File
@@ -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" />
+170
View File
@@ -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
View File
@@ -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>
<//>`;
}
+9 -7
View File
@@ -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('发生未知错误');
}
/**