diff --git a/docs/I18N.md b/docs/I18N.md index 9c6f3efc..7b3dcfbf 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -24,25 +24,32 @@ t('还剩 {n} 秒', { n }); // params: {name}; arrays fill {0}, t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }); tc('toggle', '关闭'); // context: the key 'toggle::关闭' ("Off"), else '关闭' ("Close") const TABLE = { a: N_('独立模拟') }; // module level: N_ only marks it; call t(TABLE.a) where it is shown +html`${tParts('第 {r} 回合 · 最终攻势', { r: html`${r}` })}`; // markup inside a sentence ``` - **Placeholders** are the same in the msgid and the translation: `{name}`, positional `{0}`, and one plural form for English, `{n|one|other}` (`'{n} {n|round|rounds}'`). A value that is an array renders as a list (`、` / `, `). A missing value leaves the placeholder as it is (official templates such as `{0}博士…` pass through untouched). +- **Markup inside a sentence** (a number in ``, a ``): `tParts(msgid, params)` returns the translation split at + its placeholders, with a param that is markup (a vnode, or an array of vnodes) kept as it is — the translator gets the + whole sentence and its word order, the Chinese pieces are the text around the markup exactly as before. Do not split + such a sentence into fragments (`第` … `回合`). - **Game-data names** inside a message are marked `dn(name)` (`{ dn: '…' }`) and translated through the localized data (`tName(name)` does the same for a bare name). **Player names are never translated** — pass them as plain strings. - **Data texts are not msgids**: `getChess(id).name`, `getMode(id).desc`, `data.get('config').tips` … already come in the current language (§2). Do not wrap them in `t()`. - **Re-rendering**: `main.js` App subscribes with `useLang()` (`public/js/ui/lang.js`), so the whole tree re-renders on a switch. A component that caches text outside rendering (a `useMemo` returning text, a module-level string) must - depend on the language (`useLang()`) or move the `t()` call into rendering. + depend on the language (`useLang()`) or move the `t()` call into rendering; a memo over data records (the roster, the + strategy list, `useGameData()`) lists `data.locale()` among its dependencies, so the game texts follow the switch. - Font samples, data keys and other Chinese that is not UI text can carry `// i18n-ignore` on the line. ### Translating Edit `public/i18n/en.json` (`"msgid": "English"`, two-space JSON; `_about` is metadata). Keep every `{placeholder}`; -`test/i18n.test.js` checks that, that no Chinese is left in a value, and that every msgid of the converted screens is -present. Use the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts): +`test/i18n.test.js` checks that, that no Chinese is left in a value, and that every msgid the code uses is present. A +translation may use more params than its msgid (`'{n} {count|unit|units}'` with `count` passed beside a vnode `n`). Use +the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts): | 中文 | English | 中文 | English | |---|---|---|---| @@ -59,8 +66,9 @@ present. Use the official EN terms of the game (the EN client's own wording, e.g ### Tools: `node tools/i18n.mjs` -- `extract [paths] [--list]` — per file: the msgids in `t()` / `tc()` / `N_()` / `msg()`, and the Chinese literals still - shown untranslated; the total says how many of those already have a translation (ready for the codemod). +- `extract [paths] [--list]` — per file: the msgids in `t()` / `tc()` / `tParts()` / `N_()` / `msg()`, and the Chinese + literals still shown untranslated; the total says how many of those already have a translation (ready for the + codemod). - `check [paths] [--strict] [--stale]` — msgids the code uses that `public/i18n/en.json` lacks (`--strict` exits 1; `--stale` lists entries no code uses yet). - `codemod [--write]` — wraps a file's Chinese literals: html`` text and attribute values → `${t('…')}`, strings → @@ -74,7 +82,15 @@ present. Use the official EN terms of the game (the EN client's own wording, e.g code uses (`--all`: also the literals not wrapped yet, under the msgid the codemod will give them). Server code is scanned too: the first argument of `msg()` and a Chinese literal handed to `m.toast` / `ctx.toast` / -`tickerText` are msgids (`server/sim` only for those — its own `t()` helpers are not translation calls). +`tickerText` are msgids (`server/sim` only for those — its own `t()` helpers are not translation calls). Not player text, +so neither wrapped nor counted: `console.*` / logger calls (`log.warn(…)`, `this.m.log?.info?.(…)`), `Error` messages, +the `detail` of an error result (`{ error: 'BAD_TARGET', detail: '…' }`, `ev.detail = '…'` — players see `ERR_TEXT`), a +line marked `// i18n-ignore` and a file marked `i18n-ignore-file` in a comment (the developer invariant reports +`server/match/audit.js` / `invariants.js`, the bilingual HTTP error pages `server/http/*`). An aliased import +(`import { t as tr }`, where `t` is a local variable) is read as the function it names. + +`test/i18n.test.js` scans what `extract` / `check` scan (public/js, shared, server) and fails on a Chinese literal left +unwrapped or a msgid missing from `public/i18n/en.json` — `extract` 0 and `check --strict` are part of the tests. ## 2. Game texts: `data/i18n/en.json` @@ -132,21 +148,38 @@ protocol is backward compatible both ways (new fields are optional). this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) })); ``` -## 4. Phase 2 (after the 0.2.0 feature screens land) +Game texts the server copies into `m.public` / `m.private` in Chinese — a 机变 card's name and description, the draft's +name, an effect entry of the effects column, a reward offer's label, a result title — are shown from the localized data +on the client: names through `tName()` (every record name is in the overlay's `names`), texts through +`ui/lang.js sentText(sent, raw, local)`, which takes the localized record's text when the sent text is that record's own +Chinese text (`data.lookupRaw` / `data.getRaw`), else the sent text (a line the server reworded — the two-battle text of a +multi-round 悬赏, `server/match/choices.js bountyText` — stays Chinese). In Chinese both return what the server sent. + +## 4. Coverage of the UI strings (phase 2, 0.2.0) Phase 1 converted the title screen, the lobby, the room, the settings panel (with the language switch), the app shell -(main.js: toasts, close reasons, the crash screen), `ui/toasts.js`, `DifficultyTag` and the server messages. Phase 2: +(main.js: toasts, close reasons, the crash screen), `ui/toasts.js`, `DifficultyTag` and the server messages. Phase 2 +(2026-10-06) converted the rest: of the 1 128 Chinese literals `extract` listed, 1 056 go through `t()` / `tParts()` / +`N_()` — the briefing, the band draft, the game screen and its overlays, 干员调配 / 干员持有, the result (275), the HUD, +shop bar, detail card, bond strip, 机变 overlay, enemy drawer, team panel, guide, emotes, components and the client +models (579), the phase banners, refusal reasons and formats of `ui/gameLogic` (106), the canvas / fallback-field / facing +labels (16) and the msgid tables of `shared/constants.js` (80) — and 72 are marked as no player text (above). Now +`node tools/i18n.mjs extract` reports 0, `check` 1 002 msgids, all in `public/i18n/en.json` (1 015 entries). The Chinese +output did not change: a render of every converted file in Chinese (each `t()` call put back as the string it renders, +html`` parsed with htm, text runs compared) equals the code before the sweep; the few rewrites it cannot follow (a +variable folded into whole sentences, a list param in place of `.join('、')`, `fmtNum`'s English branch) were checked by +hand. Numbers: `fmtNum` writes 万 / 亿 in Chinese and K / M / B in English. -1. `node tools/i18n.mjs extract` lists the rest (2026-10-05: 1 033 literals in 56 files — briefing, band draft, game - screen and its HUD / panels / overlays, loadout, result, guide, connection banner, components …; 964 of them already - have a translation seeded from PR #70). -2. Per file (one stream per file group to avoid conflicts): `node tools/i18n.mjs codemod --write`, review the - diff (module-level tables: `t()` at the use site; join split sentences; `tc()` where one Chinese text needs two - English ones), then `node tools/i18n.mjs check` and translate what is missing, in the official terms above. -3. Texts the server puts in `m.public` / `m.private` (机变 card names and descriptions, effect names in views) should be - looked up by id on the client (`getEffect` / `data.lookup`) instead of shown as sent. -4. `public/js/ui/guide.js` chapter / page titles, the `GuideButton` / `LoadoutButton` default labels and tooltips. -5. When the sweep is complete: `node tools/i18n.mjs check --strict` in CI, and `extract` reporting 0. +What stays Chinese in English, and why: + +- player names, including the AI seats' (`AI·华法琳` …, `server/lobby.js BOT_NAMES`) — a name is never translated; +- a server text the client cannot match to a data record (the reworded multi-round 悬赏 text above); +- game texts the official EN client does not have yet (§2: some 自选 operators, newer modules); +- the 玩法说明 pages, which are the official Chinese pictures (their titles are translated). + +Not player text, never translated: developer details and logs (`detail`, `log.*`, the invariant reports), the data keys +(`shared/highGround.js` reads the trait text 「可以放置于远程位」), the development STUB match, and the HTTP error pages, +which are bilingual on purpose. ## Credits diff --git a/public/i18n/en.json b/public/i18n/en.json index 6fc41f91..325e6795 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -183,10 +183,7 @@ "战场": "Battlefield", "本局禁用": "Disabled this match", "{skip};本局不限时": "{skip}; no time limit this match", - "每位博士有 {0} 秒": "each Doctor has {0} s", "每位博士限时决策": "each Doctor has a time limit", - "当前选中的": "the currently highlighted ", - "已选择「{0}」{1}": "Selected \"{0}\"{1}", ",等待其他博士": ", waiting for other Doctors", "轮到你决策": "Your turn to decide", "{turnName} 决策中": "{turnName} is deciding", @@ -211,8 +208,6 @@ "战场随机(共{poolN}张)": "Random Battlefield ({poolN} total)", "确认本局信息": "Confirm Match Info", "ENEMY LEADER // 敌方领袖": "ENEMY LEADER // Enemy Leader", - "第": "Round", - "回合 · 最终攻势": "· Final Assault", "特训敌人": "Tactical Training Enemies", "未知领袖": "Unknown Leader", "本局没有特训敌人": "No Tactical Training Enemies this match", @@ -228,11 +223,9 @@ "当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)", "已配发的装备无法销毁": "Equipped Equipment cannot be destroyed", "销毁道具": "Destroy Item", - "道具无法出售。确定要销毁「{0}」吗?": "Items cannot be sold. Destroy \"{0}\"?", "道具": "Item", "销毁": "Destroy", "正在查看": "Viewing", - "的阵地(只读)": "'s Field (read-only)", "返回自己": "Back to Mine", "你已被淘汰 · 可继续观战队友": "You have been eliminated · You can keep spectating your Teammates", "已调整": "Customized", @@ -274,7 +267,6 @@ "按盟约筛选": "Filter by Alliance", "全部盟约": "All Alliances", "仅看已调整": "Customized Only", - "{t}阶": "Tier {t}", "保存中…": "Saving…", "同步中…": "Syncing…", "已同步": "Synced", @@ -308,7 +300,6 @@ "把导出的内容粘贴到下方,或点「选择文件」。": "Paste exported data below, or tap [Choose File]. ", "覆盖": "overwrite", "在此粘贴导出的调配内容…": "Paste the exported loadout here…", - "调整干员携带的技能与模组": "Change the Skills and Modules your Operators carry", "造成伤害": "Damage Dealt", "击倒敌人": "Enemies Defeated", "领袖伤害": "Leader Damage", @@ -335,8 +326,6 @@ "防线已被突破": "The defense line has been breached", "敌方领袖": "Enemy Leader", "隐秘核心": "Hidden Core", - "本局耗时": "Match duration:", - "分钟": "min", "返回大厅": "Back to Lobby", "暂无结算数据": "No results data", "服务器会话已重置,上一局模拟已结束": "Server session was reset. The previous simulation has ended.", @@ -349,8 +338,6 @@ "(含调和 +{harmony})": " (incl. Harmony +{harmony})", "层数叠加已禁用": "Stack accumulation disabled", "调和": "Harmony", - "{0} 在场": "{0} on field", - "{harmonyName}已激活": "{harmonyName} active", "层数": "Stacks", "盟约效果": "Alliance Effect", "成员": "Members", @@ -358,12 +345,9 @@ "盟约:{name}": "Alliance: {name}", "CORE BOND // 核心盟约": "CORE BOND // Core Alliance", "ADD-ON BOND // 附加盟约": "ADD-ON BOND // Add-on Alliance", - "的盟约": "'s Alliance", "在场": "On field", "(含整备区)": "(incl. Bench)", "(含{harmonyName} +{harmony})": "(incl. {harmonyName} +{harmony})", - "已激活{0}": "Active{0}", - " · {tier} 阶": " · Tier {tier}", "未激活": "Inactive", "本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)": "This Alliance never activates in this mode (its Operators may still appear through their other Alliances)", "名及以下": " or fewer", @@ -440,7 +424,6 @@ "无": "None", "攻击范围": "Attack Range", "全场": "Whole field", - "基础 {0}": "Base {0}", "当前作战中的实时数值(绿色为增益,红色为减益)": "Live values in the current battle (green: buffed, red: debuffed)", "下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)": "Values at the start of the next battle: Equipment, Alliance stacks, Attributes, Strategy and Improv effects included (skills and temporary in-battle effects excluded)", "实时": "Live", @@ -496,7 +479,6 @@ "作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)": "Deploys once on its placed tile when the battle starts, then reappears each time its Operator activates a skill (does not appear if not placed)", "所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)": "Appears on its placed tile only when its Operator activates a skill (does not appear if not placed)", "出售精锐干员": "Sell Elite Operator", - "确定要出售精锐干员「{0}」吗?出售后获得 {1} 资金。": "Sell the Elite Operator \"{0}\"? You will get {1} Funds.", "详情": "Details", "退出全屏": "Exit Fullscreen", "全屏": "Fullscreen", @@ -509,8 +491,6 @@ "表情主题": "Emote themes", "悬赏": "Bounty", "护卫": "Escort", - "回合 · 即将迎击": "·", - "名敌人": "incoming enemies", "暂无敌方情报": "No enemy intel yet", "本局禁用干员情况": "Banned Operators this match", "我的策略": "My Strategy", @@ -522,22 +502,16 @@ "该干员": "This Operator", "将被销毁": "Will be destroyed", "替换装备": "Replace Equipment", - "」的装备栏已满(2/2)。选择一件装备进行替换,": "]'s Equipment slots are full (2/2). Choose a piece of Equipment to replace.", "被替换的装备将被销毁": "The replaced Equipment will be destroyed", "新装备": "New Equipment", "选择被替换的装备": "Choose Equipment to replace", - "将销毁「": "Destroy [", - "」并配发「": "] and equip [", "点击选择要替换的装备": "Tap to choose the Equipment to replace", "上": "Up", "右": "Right", "下": "Down", "左": "Left", - "选择{0}朝向": "Choose facing{0}", - "「{name}」的": " for [{name}]", "点击取消": "Tap to cancel", "拖回中心区域取消": "Drag back to center to cancel", - "朝向:{0}": "Facing: {0}", "干员": "Operator", "整备区": "Bench", "召唤": "Summon", @@ -546,7 +520,6 @@ "已淘汰": "Eliminated", "已离开": "Left", "{name}:本局禁用(该盟约不会激活)": "{name}: Disabled this match (this Alliance will not activate)", - "本局禁用{0}盟约,此策略效果可能无法发挥": "{0} Alliance disabled this match; this Strategy's effect may not work", "行动中": "Acting", "作战中": "In Combat", "联防中": "Uniting", @@ -561,7 +534,6 @@ "作战开始": "Battle Start", "各自行动阶段": "Individual action phase", "联防阶段": "Unite Phase", - "联防:{0}": "Unite: {0}", "完美作战的博士迎战突破防线的敌人": "Doctors with a flawless battle take on the enemies that broke through", "最终攻势": "Final Assault", "击败敌方领袖": "Defeat the Enemy Leader", @@ -626,28 +598,16 @@ "下一页": "Next page", "页码": "Pages", "联防": "Unite", - "{name} 漏过的敌人": "Enemies that got past {name}", - "你漏过的敌人": "Enemies that got past you", - "队友正在迎战": "teammates are engaging", - "{who}已全部被击倒": "{who}: all defeated", "剩余敌人 {v}": "Enemies remaining: {v}", "剩余不足 {cap} 个后,队友每击倒一个少扣 1 点": "once fewer than {cap} remain, each one your teammates defeat costs 1 less", "队友每击倒一个就少扣 1 点": "each one your teammates defeat costs 1 less", "目标生命值 {lp}:联防中,你漏过的敌人还剩 {left} 个,{each};按现在结算扣除 {pending} 点(每回合至多 {cap} 点)": "LP {lp}: Unite in progress, {left} enemies that got past you remain; {each}. Settling now would cost {pending} LP (max {cap} per round)", "目标生命值 {lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 {pending} 点)": "LP {lp}: Unite in progress, teammates are engaging the enemies that got past you; the loss at settlement depends on the enemies left after Unite (max {pending} LP)", - "{cap} 个以上": "{cap}+", - "{pending} 个": "{pending}", "放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁": "Move them to the Bench or Battlefield, equip or use them before you can Ready; units still on the Temporary Bench when the Rest Phase ends will be destroyed", - "临时整备区还有 {count} 个单位:{TEMP_RULE}": "{count} unit(s) still on the Temporary Bench: {TEMP_RULE}", "取消准备": "Cancel Ready", - "临时整备区": "Temporary Bench:", - "个单位待处理": "unit(s) pending", "本局信息(策略 / 禁用盟约 / 干员)": "Match Info (Strategies / Banned Alliances / Operators)", "查看即将迎击的敌方单位": "View the enemies you are about to face", "休整期可以查看即将迎击的敌方单位": "Upcoming enemies can be viewed during the Rest Phase", - "秒后全队生命值开始流失": "s", - "超时 · 生命值": "Overtime · LP", - "/秒": "/s", "已流失": "Lost", "暂停": "Pause", "继续作战(Space)": "Resume Battle (Space)", @@ -667,15 +627,12 @@ "这不是干员调配的数据": "This is not Operator Loadout data", "里面没有有效的调配条目": "It contains no valid loadout entries", "弹药": "Ammo", - "{0}秒": "{0}s", "本局的干员调配已锁定,修改将在下一局生效": "Operator loadout is locked for this match; changes will apply next match", "独立模拟的休整期没有时间限制,你可以随时继续。": "The Rest Phase in Solo Simulation has no time limit. You can continue at any time.", "放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。": "Abandoning the simulation ends this match immediately and returns you to the lobby. Progress will not be kept and no results will be tallied.", "离开模拟": "Leave Simulation", "暂离(AI 托管)": "Step Away (AI Autopilot)", "暂离": "Step Away", - ":由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。": ": AI takes over your seat (auto deploy, ready-up and choices). You can return at any time.", - ":离开后无法返回本局,你的干员将回到共享卡池。": ": You cannot return to this match after leaving, and your Operators go back to the shared pool.", "AI 托管中": "AI Autopilot active", "AUTOPILOT // AI 托管": "AUTOPILOT // AI AUTOPILOT", "AI 正在代为操作你的席位": "AI is playing your seat for you", @@ -697,7 +654,6 @@ ",再次点击确认": ", tap again to confirm", "已拥有 {copies}/{need}": "Owned {copies}/{need}", "可晋升": "Promotable", - "技能{0}:{1}{2}{3}": "Skill{0}: {1}{2}{3}", "(已调配)": " (Assigned)", " · 模组:{name}": " · Module: {name}", " · 未装备模组": " · No Module equipped", @@ -719,23 +675,17 @@ "解冻": "Unfreeze", "刷新商店 · R": "Refresh Shop · R", "免费 ×{free}": "Free ×{free}", - "联防中,": "In Unite; ", - "至多": "up to ", "查看 {name} 的战场": "View {name}'s Battlefield", "查看自己的阵地": "View your Field", "查看 {name} 的阵地": "View {name}'s Field", "连接已断开": "Disconnected", "前往查看": "Go View", - "{0} 操作": "{0} actions", "单位": "Unit", "撤退": "Retreat", "撤退至整备区": "Retreat to Bench", "出售,获得 {sell} 资金": "Sell for {sell} Funds", "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)": "Units that arrived after Ready are kept until the next Rest Phase; anything still here then will be destroyed (cancel Ready and they are destroyed at the end of this Rest Phase)", "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁": "Move to the Bench or Battlefield, equip or use them before you can Ready; anything still here at the end of the Rest Phase will be destroyed", - "件道具": "item(s)", - "个单位": "unit(s)", - "{what}待处理": "{what} pending", "结算": "Settlement", "模拟结束": "Simulation Over", "表情套组:卫戍协议": "Emote Set: Stronghold Protocol", @@ -745,7 +695,7 @@ "谢谢": "Thanks", "思考": "Thinking", "合作愉快": "Nice teamwork", - "表情套组:虫动": "Emote Set: Slug Moves", + "表情套组:虫动": "Emote Set: Slug Style", "合作愉快!": "Nice teamwork!", "谢谢!": "Thanks!", "对不起!": "Sorry!", @@ -778,20 +728,6 @@ "维维美 4": "Wiwime 4", "维维美 5": "Wiwime 5", "维维美 6": "Wiwime 6", - "\n{status} · 卫戍协议:盟约

{status}

{2}

\n{3}

返回首页 · Back to home

": "\n{status} · Stronghold Protocol: Alliance

{status}

{2}

\n{3}

Back to home

", - "页面不存在 · Not found": "Not found", - "禁止访问 · Forbidden": "Forbidden", - "请求地址过长 · URI too long": "URI too long", - "请求地址无效 · Bad request": "Bad request", - "不支持的请求方法 · Method not allowed": "Method not allowed", - "服务器内部错误 · Internal error": "Internal error", - "AI·华法琳": "AI·Warfarin", - "AI·阿米娅": "AI·Amiya", - "AI·惊蛰": "AI·Leizi", - "AI·杜宾": "AI·Dobermann", - "AI·凯尔希": "AI·Kal'tsit", - "AI·可露希尔": "AI·Closure", - "对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。": "Match core not implemented (STUB): the match settles once everyone confirms or the countdown ends.", "精打细算玩偶": "Budget-Savvy Doll", "悬赏决策": "Bounty Decision", "道具补给": "Item Supply", @@ -952,5 +888,131 @@ "下一局生效": "Applies next match", "这份数据来自更新的版本(v{v}),请先更新游戏": "This data comes from a newer version (v{v}). Update the game first.", "这不是自选编队的数据": "This is not Custom Squad data", - "里面没有自选编队的数据": "It holds no Custom Squad data" + "里面没有自选编队的数据": "It holds no Custom Squad data", + "本局禁用{names}盟约,此策略效果可能无法发挥": "{names} disabled this match: this Strategy may not take full effect", + "【{name}】": "[{name}]", + "每位博士有 {n} 秒": "each Doctor has {n} {n|second|seconds}", + "{skip};{clock}": "{skip}; {clock}", + "{skip};{clock},超时将自动选择当前选中的「{autoName}」": "{skip}; {clock}. When time runs out, the highlighted \"{autoName}\" is picked for you", + "{skip};{clock},超时将自动选择「{autoName}」": "{skip}; {clock}. When time runs out, \"{autoName}\" is picked for you", + "已选择「{name}」": "Selected \"{name}\"", + "跳过": "Skip", + "第 {r} 回合 · 最终攻势": "Round {r} · Final Assault", + "道具无法出售。确定要销毁「{name}」吗?": "Items cannot be sold. Destroy \"{name}\"?", + "展开盟约": "Expand Alliances", + "收起盟约": "Collapse Alliances", + "正在查看 {name} 的阵地(只读)": "Viewing {name}'s field (read-only)", + "观战中 · 点击左侧成员头像切换查看": "Spectating · tap a member's avatar on the left to switch", + "未持有(干员持有):由替补干员上场": "Not owned (Operator Roster): its stand-in takes the field", + "未持有": "Not owned", + "{name} 调配": "{name} Loadout", + "干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效": "Marked as not owned in the Operator Roster: this chess takes the field as its stand-in {name}, with a fixed skill and module (a stand-in's skill cannot be changed); the loadout here applies once it is set back to \"Owned\"", + "干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效": "Marked as not owned in the Operator Roster: this chess takes the field as its stand-in, with a fixed skill and module (a stand-in's skill cannot be changed); the loadout here applies once it is set back to \"Owned\"", + "全部持有": "Own All", + "将 {nNotOwned} 名未持有的干员恢复为持有(由本人上场)?": "Set {nNotOwned} not-owned {nNotOwned|Operator|Operators} back to owned (fielded as themselves)?", + "将 {nChanged} 名干员的技能与模组恢复为默认配置?": "Reset the skills and modules of {nChanged} {nChanged|Operator|Operators} to the default loadout?", + "文件过大,请选择「导出」下载的干员持有文件": "The file is too large. Choose an Operator Roster file downloaded with \"Export\".", + "已导入:{applied} 名干员未持有(另有 {dropped} 项无效,未导入)": "Imported: {applied} {applied|Operator|Operators} not owned ({dropped} invalid {dropped|entry|entries} skipped)", + "已导入:{applied} 名干员未持有": "Imported: {applied} {applied|Operator|Operators} not owned", + "已导入:全部持有": "Imported: all owned", + "已导入 {applied} 名干员(另有 {dropped} 项未导入)": "Imported {applied} {applied|Operator|Operators} ({dropped} {dropped|entry|entries} not imported)", + "已导入 {applied} 名干员的调配": "Imported the loadout of {applied} {applied|Operator|Operators}", + "干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效": "The Operator Roster is set outside matches: this match keeps the roster it started with; changes apply from the next match", + "局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有": "Set outside matches, applies from the next match · in co-op it only affects your own chess · everything is owned by default", + "标记未持有的干员:由官方指定的替补干员上场": "Mark the Operators you do not own: their official stand-ins take the field", + "干员持有": "Operator Roster", + "{nNotOwned} 名未持有": "{nNotOwned} not owned", + "导出干员持有(可复制或下载)": "Export the Operator Roster (copy or download)", + "导入干员持有(粘贴或选择文件)": "Import an Operator Roster (paste or choose a file)", + "导出干员持有": "Export Operator Roster", + "导入干员持有": "Import Operator Roster", + "共 {n} 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。": "{n} {count|Operator is|Operators are} not owned. Copy or download this data to import it on another device or browser.", + "把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会{overwrite}当前的干员持有设置。": "Paste exported Operator Roster data below, or click \"Choose File\". Importing will {overwrite} the current Operator Roster.", + "共 {n} 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。": "{n} {count|Operator has|Operators have} a custom loadout. Copy or download this data to import it on another device or browser.", + "导入会{overwrite}当前的 {nChanged} 名干员调配。": "Importing will {overwrite} the current loadout of {nChanged} {nChanged|Operator|Operators}.", + "在此粘贴导出的干员持有内容…": "Paste the exported Operator Roster here…", + "调整干员携带的技能与模组 · 干员持有": "Change the skills and modules your Operators carry · Operator Roster", + "{n} 名干员已调整": "{n} {n|Operator|Operators} customized", + "{off} 名干员未持有,由替补干员上场": "{off} {off|Operator|Operators} not owned, fielded as {off|its|their} stand-in", + "{off} 名干员未持有(由替补干员上场)": "{off} {off|Operator|Operators} not owned (fielded as {off|its|their} stand-in)", + "{name}:持有": "{name}: owned", + "{name}:未持有,由 {standIn} 上场": "{name}: not owned, {standIn} takes the field", + "替补干员": "the stand-in", + "点击标记为未持有:由 {standIn} 上场": "Click to mark as not owned: {standIn} takes the field", + "点击恢复为持有": "Click to set back to owned", + "无替补数据": "No stand-in data", + "持有": "Owned", + "仅看未持有": "Not owned only", + "全部持有:没有由替补干员上场的棋子": "Everything is owned: no chess takes the field as a stand-in", + "没有可下掉的干员": "No Operators can be marked", + "{name}({note})": "{name} ({note})", + "本局耗时 {n} 分钟": "Duration: {n} {mins|minute|minutes}", + "{tip} · {n} 名成员": "{tip} · {n} {n|member|members}", + "{names} 在场:核心盟约激活人数 +{harmony}": "{names} on the field: Core Alliance head count +{harmony}", + "{harmonyName}已激活:核心盟约激活人数 +{harmony}": "{harmonyName} active: Core Alliance head count +{harmony}", + "层数不显示": "Stacks not shown", + "已激活 · {tier} 阶": "Active · Tier {tier}", + "已激活": "Active", + "bond-threshold::名": "+", + "({note})": " ({note})", + "观战中,可点击左侧成员头像前往查看": "Spectating · tap a member's avatar on the left to view their field", + "基础 {v}": "Base {v}", + "{items} → 【{name}】": "{items} → [{name}]", + "list::、": ", ", + "与{morph}一同装备时,携带者视为【{bond}】成员": "Equipped together with {morph}, the wearer counts as a member of [{bond}]", + ":在场 {count}/{cap}": ": {count}/{cap} on the field", + ":在场 {count}": ": {count} on the field", + "拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)": "Drag onto an Operator to equip (up to 2 per Operator; equipped Equipment cannot be removed)", + "调度中心不出售 · 获取途径:{source}": "Not sold in the Dispatch Center · Source: {source}", + "免疫:{list}": "Immune to: {list}", + "确定要出售精锐干员「{name}」吗?出售后获得 {price} 资金。": "Sell the Elite Operator \"{name}\"? You get {price} Funds.", + "之后的每场作战": "every later battle", + "还剩 {left} 场作战": "{left} {left|battle|battles} left", + "第 {r} 回合 · 即将迎击 {n} 名敌人": "Round {r} · {n} incoming {count|enemy|enemies}", + "特训敌人·": "Tactical Training/", + "{bandOwner} 的策略": "{bandOwner}'s Strategy", + "确认替换": "Replace", + "「{name}」的装备栏已满(2/2)。选择一件装备进行替换,{warn}。": "\"{name}\" has no free Equipment slot (2/2). Choose a piece of Equipment to replace: {warn}.", + "将销毁「{old}」并配发「{item}」": "Destroys \"{old}\" and equips \"{item}\"", + "{name} 漏过的敌人还剩 {n} 个(联防中)": "{n} {n|enemy|enemies} that got past {name} still standing (Unite in progress)", + "你漏过的敌人还剩 {n} 个(队友正在迎战)": "{n} {n|enemy|enemies} that got past you still standing (your teammates are engaging)", + "{name} 漏过的敌人已全部被击倒": "Every enemy that got past {name} has been defeated", + "你漏过的敌人已全部被击倒": "Every enemy that got past you has been defeated", + "目标生命值 {lp}:本回合已有 {cap} 个以上敌人进入蓝门,结算时扣除 {pending} 点(每回合至多 {cap} 点)": "LP {lp}: {cap}+ enemies reached the blue gate this round; settlement costs {pending} LP (max {cap} per round)", + "目标生命值 {lp}:本回合已有 {pending} 个敌人进入蓝门,结算时扣除 {pending} 点(每回合至多 {cap} 点)": "LP {lp}: {pending} {pending|enemy|enemies} reached the blue gate this round; settlement costs {pending} LP (max {cap} per round)", + "临时整备区还有 {count} 个单位:{rule}": "{count} {count|unit is|units are} still on the Temporary Bench: {rule}", + "临时整备区 {n} 个单位待处理": "Temporary Bench: {n} {count|unit|units} pending", + "{n} 秒后全队生命值开始流失": "Team LP starts draining in {n} {count|second|seconds}", + "超时 · 生命值 {n}/秒": "Overtime · LP {n}/s", + "这份调配来自更新的版本(v{v}),请先更新游戏": "This loadout comes from a newer version (v{v}). Update the game first.", + "{n}秒": "{n}s", + "干员持有是局外设置,修改将在下一局生效": "The Operator Roster is set outside matches; changes apply from the next match", + "离开观战": "Stop Spectating", + "离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。": "Leave the spectator seat and return to the lobby; the simulation goes on. While a spectator seat is free you can watch again with the Alliance Key.", + "{away}:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。": "{away}: the AI takes over your seat (deploying, readying and picking); you can come back at any time.", + "{quit}:离开后无法返回本局,你的干员将回到共享卡池。": "{quit}: you cannot come back to this match, and your Operators return to the shared pool.", + "灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活)": "Gray: some of the Alliance's Operators cannot appear (it can still be activated by Operators of other Alliances or by Equipment)", + "这不是干员持有的数据": "This is not Operator Roster data", + "里面没有干员持有的数据": "It holds no Operator Roster data", + "{name},价格 {price}": "{name}, price {price}", + "技能 {slot}:{name}": "Skill {slot}: {name}", + "技能:{name}": "Skill: {name}", + "目标生命值 {lp},联防中:漏过的敌人还剩 {left} 个,按现在结算扣除 {pending} 点(每回合至多 {cap} 点)": "LP {lp}, Unite in progress: {left} {left|enemy|enemies} that got through still standing; settling now would cost {pending} LP (max {cap} per round)", + "目标生命值 {lp},联防中,结算时扣除至多 {pending} 点": "LP {lp}, Unite in progress: settlement costs up to {pending} LP", + "目标生命值 {lp},结算时扣除 {pending} 点": "LP {lp}: settlement costs {pending} LP", + "{name} 操作": "{name} actions", + "撤退至整备区(Q)": "Retreat to Bench (Q)", + "撤退[Q]": "Retreat [Q]", + "出售(+{sell} 资金,X)": "Sell (+{sell} Funds, X)", + "出售[X]": "Sell [X]", + "临时整备区 {n} 件道具待处理": "Temporary Bench: {n} {count|item|items} pending", + "{name}:部分盟约所含干员阵容不完整({bannedN} 名干员无法出现)": "{name}: some of the Alliance's Operators cannot appear ({bannedN} {bannedN|Operator|Operators} banned)", + "{name}:部分盟约所含干员阵容不完整": "{name}: some of the Alliance's Operators cannot appear", + "联防:{names}": "Unite: {names}", + "剩余资金": "Funds Left", + "继续整备": "Keep Preparing", + "还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?": "You still have {funds} unspent Funds. The round's leftover Funds are cleared when the Rest Phase ends. Ready anyway?", + "选择「{name}」的朝向": "Choose {name}'s facing", + "选择朝向": "Choose a facing", + "朝向:{dir}": "Facing: {dir}" } diff --git a/public/js/battle/observe.js b/public/js/battle/observe.js index 7b4112a8..f18708bd 100644 --- a/public/js/battle/observe.js +++ b/public/js/battle/observe.js @@ -10,6 +10,7 @@ import { PHASE } from '../../../shared/constants.js'; import { data } from '../data.js'; +import { t } from '../../../shared/i18n.js'; const isObj = (v) => !!v && typeof v === 'object'; const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]); @@ -27,7 +28,7 @@ export function fieldOf(pub, playerId) { /** Display name of a player id ('队友' when unknown). */ export function nameOf(pub, playerId) { - return players(pub).find((p) => p.playerId === playerId)?.name || '队友'; + return players(pub).find((p) => p.playerId === playerId)?.name || t('队友'); } /** @@ -39,20 +40,20 @@ export function nameOf(pub, playerId) { * (its result is on the way to the server) */ export function observeTarget(p, pub, myId, { observing = false, ownDone = false } = {}) { - if (!isObj(p)) return { reason: '无效的目标' }; + if (!isObj(p)) return { reason: t('无效的目标') }; if (p.playerId === myId) return observing ? { back: true } : { reason: null }; - if (p.alive === false || p.status === 'left') return { reason: '该队友已被淘汰,无法查看' }; + if (p.alive === false || p.status === 'left') return { reason: t('该队友已被淘汰,无法查看') }; const phase = pub?.phase; const me = players(pub).find((x) => x.playerId === myId) || null; const meAlive = me ? me.alive !== false : true; if (!COMBAT.has(phase)) return { fieldId: `n:${p.playerId}` }; const target = fieldOf(pub, p.playerId); - if (!target) return { reason: '该队友当前没有战场' }; + if (!target) return { reason: t('该队友当前没有战场') }; const own = fieldOf(pub, myId); if (!meAlive || !own) return { fieldId: target.fieldId }; - if (own.fieldId === target.fieldId) return { reason: '队友与你在同一战场,使用 ‹ › 切换视角' }; - if (target.kind === 'boss' || target.kind === 'hidden') return { reason: '无法查看另一组队友的战场' }; - if (own.kind === 'normal' && own.live !== false && !ownDone) return { reason: '作战中无法查看队友,作战结束后可前往查看' }; + if (own.fieldId === target.fieldId) return { reason: t('队友与你在同一战场,使用 ‹ › 切换视角') }; + if (target.kind === 'boss' || target.kind === 'hidden') return { reason: t('无法查看另一组队友的战场') }; + if (own.kind === 'normal' && own.live !== false && !ownDone) return { reason: t('作战中无法查看队友,作战结束后可前往查看') }; return { fieldId: target.fieldId }; } @@ -91,7 +92,7 @@ export function teammateProgress(pub, myId) { const p = players(pub).find((x) => x.playerId === pid); const pr = isObj(f.progress) ? f.progress : null; out.push({ - playerId: pid, name: p?.name || '队友', isBot: !!p?.isBot, + playerId: pid, name: p?.name || t('队友'), isBot: !!p?.isBot, killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null, done: f.live === false || !!pr?.done, }); @@ -114,13 +115,13 @@ export function cameraLayers(field, pub, myId) { if (!isObj(field) || (field.kind !== 'unite' && field.kind !== 'boss' && field.kind !== 'hidden')) return []; const sides = sidesOf(field); const at = (side) => Object.keys(sides).find((pid) => sides[pid] === side) || null; - const label = (pid) => (!pid ? '无人在家' : pid === myId ? '你自己' : nameOf(pub, pid)); + const label = (pid) => (!pid ? t('无人在家') : pid === myId ? t('你自己') : nameOf(pub, pid)); const left = at('L'); const right = at('R'); if ((field.kind === 'boss' || field.kind === 'hidden') && (!left || !right)) return []; return [ { key: 'L', label: label(left), self: left === myId, watch: !!left && left !== myId }, - { key: 'ALL', label: '全景', self: false, watch: false }, + { key: 'ALL', label: t('全景'), self: false, watch: false }, { key: 'R', label: label(right), self: right === myId, watch: !!right && right !== myId }, ]; } diff --git a/public/js/data.js b/public/js/data.js index e4c519ae..71630939 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -305,6 +305,17 @@ export function createDataStore(opts = {}) { if (id == null) return null; return index(name)?.get(String(id)) ?? null; }, + /** + * Record by id from a loaded file exactly as downloaded (its Chinese texts), whatever the locale — what the server + * copied into a message, to compare with what it sent (ui/lang.js sentText). + */ + lookupRaw(name, id) { + if (id == null) return null; + const e = entries.get(name); + if (!e || e.status !== 'ready') return null; + if (!e.index) e.index = buildIndex(name, e.value); + return e.index.get(String(id)) ?? null; + }, /** All records of a loaded file as an array (empty when not loaded). */ list: (name) => [...(index(name)?.values() ?? [])], /** Drop a cached file and refetch it now (subscribers are notified when it settles). */ diff --git a/public/js/net.js b/public/js/net.js index ce2d362f..b921a444 100644 --- a/public/js/net.js +++ b/public/js/net.js @@ -31,6 +31,7 @@ import { PROTOCOL_VERSION, ERR_TEXT } from '../../shared/constants.js'; import { validateC2S } from '../../shared/protocol.js'; +import { N_ } from '../../shared/i18n.js'; export const REQUEST_TIMEOUT_MS = 8000; export const HELLO_TIMEOUT_MS = 8000; @@ -40,12 +41,12 @@ export const BACKOFF = Object.freeze({ base: 500, factor: 2, max: 10000, jitter: /** Client-side error codes (in addition to shared ERR codes). */ export const CLIENT_ERR_TEXT = Object.freeze({ - TIMEOUT: '请求超时,请重试', - OFFLINE: '未连接到服务器', - DISCONNECTED: '连接已断开,请重试', - CLOSED: '连接已关闭', - REPLACED: '该身份已在其他页面登录', - VERSION: '客户端版本与服务器不一致,请刷新页面', + TIMEOUT: N_('请求超时,请重试'), + OFFLINE: N_('未连接到服务器'), + DISCONNECTED: N_('连接已断开,请重试'), + CLOSED: N_('连接已关闭'), + REPLACED: N_('该身份已在其他页面登录'), + VERSION: N_('客户端版本与服务器不一致,请刷新页面'), }); /** Server close code: the session was taken over by another socket (server/net.js CLOSE.REPLACED). */ @@ -62,7 +63,7 @@ const QUIET_SWAP_MIN_AGE_MS = 5000; * @returns {string} */ export function errorText(code, msg) { - return ERR_TEXT[code] || CLIENT_ERR_TEXT[code] || (typeof msg === 'string' && msg) || String(code || '未知错误'); + return ERR_TEXT[code] || CLIENT_ERR_TEXT[code] || (typeof msg === 'string' && msg) || String(code || N_('未知错误')); } /** Error thrown/rejected by requests. `code` is an ERR code or a CLIENT_ERR_TEXT key. */ diff --git a/public/js/render/app.js b/public/js/render/app.js index 60657f37..a1d4ad3c 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -134,6 +134,7 @@ import { CAMERA_MS, BOARD3D_STABLE_MS, BOARD3D_RETRY_MS, PEN_CAMERA_MS, RANGE_GR import { boardPreference, switchableBox, bandFor, fieldRows, boardArea, viewKind, penShown, leaderShown } from './app/view.js'; import { renderInfo, FORCED_EXIT, showsDeathFx } from './app/info.js'; import { resolveAssets, makeData, withTimeout, QUALITY_RES, BOARD_RES, releaseGl } from './app/host.js'; +import { t } from '../../../shared/i18n.js'; export { ensurePixi } from './app/pixi.js'; export { PEN_CAMERA_MS, LEADER_HIT_STYLE, DRAG_HOLD_TILES } from './app/tune.js'; @@ -185,7 +186,7 @@ export async function createFieldView(host, options = {}) { canvas.style.height = '100%'; canvas.style.touchAction = 'none'; canvas.style.userSelect = 'none'; - canvas.setAttribute('aria-label', '战场'); + canvas.setAttribute('aria-label', t('战场')); canvas.style.position = 'relative'; canvas.style.zIndex = '1'; host.appendChild(canvas); diff --git a/public/js/screens/bandDraft.js b/public/js/screens/bandDraft.js index 76a84f2f..46d9e791 100644 --- a/public/js/screens/bandDraft.js +++ b/public/js/screens/bandDraft.js @@ -29,18 +29,19 @@ import { useStore } from '../store.js'; import { data } from '../data.js'; import { audio } from '../audio.js'; import { modeOffBonds, bandOffBonds, bandOffLine } from '../ui/gameLogic.js'; +import { t, tParts, tName } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); /** 本局禁用 on a strategy card whose bonds `names` the mode switches off (bandOffBonds); nothing otherwise. */ export function BandOffTag({ names = [] }) { - return names.length ? html`本局禁用` : null; + return names.length ? html`${t('本局禁用')}` : null; } /** The detail pane's note for such a strategy: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥" (the bond names struck through). */ export function BandOffNote({ names = [] }) { if (!names.length) return null; - return html`

<${Icon} name="info" />本局禁用${names.map((n, i) => html`【${n}】`)}盟约,此策略效果可能无法发挥

`; + return html`

<${Icon} name="info" />${tParts('本局禁用{names}盟约,此策略效果可能无法发挥', { names: names.map((n, i) => html`${tParts('【{name}】', { name: html`${n}` })}`) })}

`; } /** @@ -125,11 +126,12 @@ export function autoPickBand(sel, { bands, taken, myPick = null, defaultId = DEF * selected: the auto pick is the highlighted band (not the default standing in for a band a teammate holds) */ export function draftTip({ timed, turnSeconds = null, autoName = null, selected = true }) { - const skip = '联合模拟在选择策略时可以进行一次跳过'; - if (!timed) return `${skip};本局不限时`; - const clock = Number(turnSeconds) > 0 ? `每位博士有 ${Math.round(turnSeconds)} 秒` : '每位博士限时决策'; - if (!autoName) return `${skip};${clock}`; - return `${skip};${clock},超时将自动选择${selected ? '当前选中的' : ''}「${autoName}」`; + const skip = t('联合模拟在选择策略时可以进行一次跳过'); + if (!timed) return t('{skip};本局不限时', { skip }); + const clock = Number(turnSeconds) > 0 ? t('每位博士有 {n} 秒', { n: Math.round(turnSeconds) }) : t('每位博士限时决策'); + if (!autoName) return t('{skip};{clock}', { skip, clock }); + return selected ? t('{skip};{clock},超时将自动选择当前选中的「{autoName}」', { skip, clock, autoName }) + : t('{skip};{clock},超时将自动选择「{autoName}」', { skip, clock, autoName }); } /** @@ -154,11 +156,11 @@ export function draftClock(pub) { * @returns {{ text: string, secs: number|null, tone: 'mint'|'gold'|'warn'|'dim' }} */ export function draftInfoStatus({ myPick = null, pickName = null, myTurn, turnName = null, secs = null, waiting = false }) { - if (myPick) return { text: `已选择「${pickName || ''}」${waiting ? ',等待其他博士' : ''}`, secs: null, tone: 'mint' }; + if (myPick) return { text: `${t('已选择「{name}」', { name: pickName || '' })}${waiting ? t(',等待其他博士') : ''}`, secs: null, tone: 'mint' }; const s = Number.isFinite(secs) ? Math.max(0, Math.round(secs)) : null; const tone = s != null && s <= 10 ? 'warn' : 'gold'; - if (myTurn) return { text: '轮到你决策', secs: s, tone }; - return { text: turnName ? `${turnName} 决策中` : '等待轮到你', secs: s, tone: s != null ? tone : 'dim' }; + if (myTurn) return { text: t('轮到你决策'), secs: s, tone }; + return { text: turnName ? t('{turnName} 决策中', { turnName }) : t('等待轮到你'), secs: s, tone: s != null ? tone : 'dim' }; } /** BAND_DRAFT screen. */ @@ -177,7 +179,7 @@ export function BandDraftScreen() { const mode = gd.config?.modes?.[pub?.modeId]; const offBonds = modeOffBonds(mode); // the bonds this mode never activates (标准: 10 of 23) const solo = roomSolo || mode?.type === 'SINGLE' || String(pub?.modeId || '').includes('single'); - const bands = useMemo(() => allowedBands(gd.list('bands'), mode?.type || (solo ? 'SINGLE' : 'MULTI')), [gd.ready, mode?.type, solo]); + const bands = useMemo(() => allowedBands(gd.list('bands'), mode?.type || (solo ? 'SINGLE' : 'MULTI')), [gd.ready, mode?.type, solo, data.locale()]); const players = sortedPlayers(pub); const draft = normalizeDraft(pub?.draft, players); const myPick = draft.picks.get(myId) || priv?.bandId || null; @@ -221,7 +223,7 @@ export function BandDraftScreen() { // what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — match/phases.js timeoutBand) const autoId = autoPickBand(sel, { bands, taken, myPick, defaultId }); - const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || '华法琳'; + const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || tName('华法琳'); const band = sel ? gd.band(sel) : null; const selTaken = !!band && taken.has(band.bandId); @@ -249,11 +251,11 @@ export function BandDraftScreen() { return html`
- <${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${clock ? { ...pub, deadline: clock.deadline } : { ...pub, deadline: 0 }} + <${StepHeader} step=${2} of=${2} title=${t('选择策略')} micro="STRATEGY // BAND CHECK" pub=${clock ? { ...pub, deadline: clock.deadline } : { ...pub, deadline: 0 }} total=${clock ? clock.total : null} onExit=${() => setExit(true)} />
`; })} <${Button} variant="secondary" icon="search" block=${true} class="draft-order__info" data-testid="match-info-open" - aria-haspopup="dialog" onClick=${() => setInfoOpen(true)}>查看禁用盟约与干员 + aria-haspopup="dialog" onClick=${() => setInfoOpen(true)}>${t('查看禁用盟约与干员')} ${!solo ? html`

${draftTip({ timed, turnSeconds: turnLen, autoName: myPick ? null : autoName, selected: autoId === sel })}

` : null} -
+
${bands.map((b) => { const who = pickers.get(b.bandId) || []; const isTaken = taken.has(b.bandId); const offNames = bandOffBonds(b, offBonds).map((id) => gd.bond(id)?.name || id); // 本局禁用 (still selectable) return html``; })}
@@ -302,22 +304,22 @@ export function BandDraftScreen() {
<${BandIcon} bandId=${band.bandId} size="xl" />
-
初始生命值<${LpTower} value=${band.totalHp} size="lg" />
+
${t('初始生命值')}<${LpTower} value=${band.totalHp} size="lg" />

${band.name}

<${BandOffNote} names=${bandOffBonds(band, offBonds).map((id) => gd.bond(id)?.name || id)} />
<${MicroLabel} tone="mint">EFFECT ${band.effectName || ''} <${RichText} as="p" text=${band.descRaw || band.desc} class="draft-detail__desc" /> -
` : html`

选择一个策略查看详情

`} + ` : html`

${t('选择一个策略查看详情')}

`}
- ${myPick ? html`

<${Icon} name="check" />已选择「${gd.band(myPick)?.name || ''}」${!solo && !draft.done ? ',等待其他博士' : ''}

` - : selTaken ? html`

<${Icon} name="close" />队友已选,请选择其他策略

` - : !myTurn ? html`

<${Icon} name="hourglass" />${turnName ? `${turnName} 正在决策…` : '等待轮到你'}

` : null} + ${myPick ? html`

<${Icon} name="check" />${t('已选择「{name}」', { name: gd.band(myPick)?.name || '' })}${!solo && !draft.done ? t(',等待其他博士') : ''}

` + : selTaken ? html`

<${Icon} name="close" />${t('队友已选,请选择其他策略')}

` + : !myTurn ? html`

<${Icon} name="hourglass" />${turnName ? t('{turnName} 正在决策…', { turnName }) : t('等待轮到你')}

` : null}
${!solo ? html`<${Button} variant="secondary" size="lg" icon="chevrons" disabled=${!canSkip} loading=${busy === 'skip'} onClick=${skip} - title=${skipsLeft > 0 ? '跳过本轮,稍后再选' : '跳过次数已用完'}>跳过${skipsLeft > 0 ? '' : '(已用)'}` : null} - <${Button} variant="primary" size="lg" icon="check" disabled=${!myTurn || !band || selTaken} loading=${busy === 'pick'} onClick=${confirm}>${selTaken ? '队友已选' : '确认选择'} + title=${skipsLeft > 0 ? t('跳过本轮,稍后再选') : t('跳过次数已用完')}>${t('跳过')}${skipsLeft > 0 ? '' : t('(已用)')}` : null} + <${Button} variant="primary" size="lg" icon="check" disabled=${!myTurn || !band || selTaken} loading=${busy === 'pick'} onClick=${confirm}>${selTaken ? t('队友已选') : t('确认选择')}
diff --git a/public/js/screens/briefing.js b/public/js/screens/briefing.js index 0e6ff775..582bac3e 100644 --- a/public/js/screens/briefing.js +++ b/public/js/screens/briefing.js @@ -16,6 +16,7 @@ import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic. import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js'; import { useStore } from '../store.js'; import { data } from '../data.js'; +import { t, tParts } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -41,7 +42,7 @@ export function BriefingScreen() { const stage = pub.stageId ? gd.stage(pub.stageId) : null; // the mode's battlefield pool (config.json modes[].stages): 标准 is always 战场#01, 险境+ draw one at random const poolN = Array.isArray(mode?.stages) ? mode.stages.length : 0; - const pool = poolN > 1 ? `战场随机(共${poolN}张)` : poolN === 1 ? '战场固定' : ''; + const pool = poolN > 1 ? t('战场随机(共{poolN}张)', { poolN }) : poolN === 1 ? t('战场固定') : ''; const types = factionTypes(pub.factions); const factions = gd.factions?.types || {}; const m = gd.m; @@ -55,20 +56,20 @@ export function BriefingScreen() { return html`
- <${StepHeader} step=${1} of=${2} title="确认本局信息" micro="BRIEFING // INFO CHECK" pub=${pub} + <${StepHeader} step=${1} of=${2} title=${t('确认本局信息')} micro="BRIEFING // INFO CHECK" pub=${pub} total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
- <${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖 + <${MicroLabel} tone="gold">${t('ENEMY LEADER // 敌方领袖')}
${bossEnemy ? html`<${Img} src=${enemyIconUrl(m, boss.enemyKey)} class="brief-boss__sil" fallback=${html`?`} />` : html`?`}
-

${boss?.name || bossEnemy?.name || '未知领袖'}

- 第 ${mode?.bossRound ?? pub.lastRound ?? 14} 回合 · 最终攻势 +

${boss?.name || bossEnemy?.name || t('未知领袖')}

+ ${tParts('第 {r} 回合 · 最终攻势', { r: html`${mode?.bossRound ?? pub.lastRound ?? 14}` })} ${Array.isArray(boss?.abilities) && boss.abilities.length ? html`
    ${boss.abilities.slice(0, 3).map((a, i) => html`
  • <${RichText} text=${a} />
  • `)}
` : null} @@ -79,11 +80,11 @@ export function BriefingScreen() { <${Icon} name="rook" />${stage?.name || pub.stageId || '—'}
-

特训敌人<${MicroLabel}>SPECIAL ENEMIES

+

${t('特训敌人')}<${MicroLabel}>SPECIAL ENEMIES

${types.length ? types.map((t) => html`
<${Img} src=${factionIconUrl(m, factions[t]?.icon)} /> ${factions[t]?.name || t}${factions[t]?.desc || ''} -
`) : html`

本局没有特训敌人

`} +
`) : html`

${t('本局没有特训敌人')}

`}
@@ -93,11 +94,11 @@ export function BriefingScreen() {
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
- 已就绪 ${readyN}/${players.length} + ${t('已就绪')} ${readyN}/${players.length} ${players.map((p) => html`<${Icon} name="user" />`)}
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy} - disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : me ? '准备就绪' : '观战中'} + disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
`; diff --git a/public/js/screens/game.js b/public/js/screens/game.js index d5e62705..43563c64 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -115,6 +115,7 @@ import { useDocClass, FullscreenButton } from '../ui/device.js'; import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js'; import { keepEarly } from './game/early.js'; import { MatchEnded, PausedOverlay } from './game/overlays.js'; +import { t, tParts } from '../../../shared/i18n.js'; /** Router for the in-match screens. */ export function GameScreen() { @@ -127,7 +128,7 @@ export function GameScreen() { if (!pub || !gd.ready) { return html`
<${Spinner} size="lg" label=${pub ? 'LOADING DATA' : 'ENTERING SIMULATION'} /> -

${pub ? '正在载入模拟数据…' : '正在进入模拟…'}

+

${pub ? t('正在载入模拟数据…') : t('正在进入模拟…')}

`; } const mode = phaseMode(pub.phase); @@ -562,7 +563,7 @@ function MatchScreen() { let asked = null; try { asked = new URLSearchParams(globalThis.location?.search || '').get('render'); } catch { asked = null; } if (asked === 'fallback' || globalThis.__SP_RENDER__ === 'fallback') return; - toast('当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)', 'info', { ttl: 5000 }); + toast(t('当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)'), 'info', { ttl: 5000 }); }, [viewKind]); // phase changes: banners, sounds, resets @@ -678,9 +679,9 @@ function MatchScreen() { const closeIt = () => { setDetail((d) => (d?.kind === 'piece' && d.uid === piece.uid ? null : d)); setSel((x) => (x && x.uid === piece.uid ? null : x)); }; if (piece.kind === 'item') { // an equipped item is locked (the server refuses g.destroy): replacing it is the equip-replace dialog's job - if (!itemDestroyable(live.current.placeCtx, piece.uid)) { toast('已配发的装备无法销毁', 'warn'); audio.sfx('error', { volume: 0.5 }); return false; } + if (!itemDestroyable(live.current.placeCtx, piece.uid)) { toast(t('已配发的装备无法销毁'), 'warn'); audio.sfx('error', { volume: 0.5 }); return false; } const it = data.lookup('items', piece.id); - const ok = await confirmDialog({ title: '销毁道具', text: `道具无法出售。确定要销毁「${it?.name || '道具'}」吗?`, okText: '销毁', danger: true }); + const ok = await confirmDialog({ title: t('销毁道具'), text: t('道具无法出售。确定要销毁「{name}」吗?', { name: it?.name || t('道具') }), okText: t('销毁'), danger: true }); if (ok && await actions.destroy(piece.uid)) { closeIt(); return true; } return false; } @@ -1021,7 +1022,7 @@ function MatchScreen() { const L = live.current; const uid = L.sel?.uid; const to = uid != null ? retreatSlot(L.placeCtx, uid) : null; - if (!to) { toast('整备区已满', 'warn'); audio.sfx('error', { volume: 0.5 }); return; } + if (!to) { toast(t('整备区已满'), 'warn'); audio.sfx('error', { volume: 0.5 }); return; } setSelBusy(true); if (await actions.move(uid, to)) { setSel(null); @@ -1043,7 +1044,7 @@ function MatchScreen() { const u = (Array.isArray(field?.units) ? field.units : []).find((x) => x && x.id === detail.unitId); return u ? { ...detail, unit: u } : detail; }, [detail, field]); - const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces, { priv, backups: gd.backups }), [detailTarget, placeCtx, gd.ready]); + const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces, { priv, backups: gd.backups }), [detailTarget, placeCtx, gd.ready, data.locale()]); useEffect(() => { if (detail && !resolved && detail.kind === 'piece') setDetail(null); }, [resolved]); const snapHp = (() => { const id = resolved?.unitId; @@ -1174,9 +1175,9 @@ function MatchScreen() { const stripFid = strip.fieldId; const liveLayers = (combat || settleMode) && battleState?.bondLayers ? battleState.bondLayers : null; // the observing pill names the player whose bonds the strip shows (the same teammate as the strip's "👁 name" tag) - const observingName = cc && combat && watchedFid ? (!strip.self && stripFid === watchedFid ? strip.name : (players.find((p) => p.fieldId === watchedFid || ownFieldId(p.playerId) === watchedFid)?.name || '队友')) : null; + const observingName = cc && combat && watchedFid ? (!strip.self && stripFid === watchedFid ? strip.name : (players.find((p) => p.fieldId === watchedFid || ownFieldId(p.playerId) === watchedFid)?.name || t('队友'))) : null; const watchedP = watchingOther ? (players.find((p) => p.playerId !== myId && (watching === ownFieldId(p.playerId) || (watching === p.fieldId && String(watching).startsWith('n:')))) || null) : null; - const watchedName = watchingOther ? (watchedP?.name || players.find((p) => watching === p.fieldId)?.name || '队友') : null; + const watchedName = watchingOther ? (watchedP?.name || players.find((p) => watching === p.fieldId)?.name || t('队友')) : null; const stripBonds = strip.bonds; // a popup opened from the strip closes when the strip changes hands (another teammate scouted / a ‹ › half / back to // the own bonds); one opened from a card's chip keeps its unit owner (it carries its own player either way) @@ -1269,7 +1270,7 @@ function MatchScreen() {
+ }}><${Icon} name=${bondsCollapsed ? 'chevronRight' : 'chevronLeft'} />${bondsCollapsed ? t('盟约') : t('收起')} `; } const m = data.get('assets'); const shown = sorted.slice(0, max); const strip = html`
+ aria-label=${owner ? t('{owner} 的盟约', { owner }) : t('我的盟约')} data-owner=${owner || null}> ${owner ? html`<${OwnerTag} owner=${owner} />` : null} ${shown.map((b) => { const rec = data.lookup('bonds', b.bondId); @@ -64,8 +64,8 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null class=${cx('bslot', 'is-off', openId === b.bondId && 'is-open')}> <${BondDisc} name=${name} icon=${bondIconUrl(m, b.bondId)} tier=${0} maxTier=${Math.max(1, th.length)} active=${false} disabled=${true} size="sm" showName=${true} layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)} - title=${`${briefingBondTip(name, 'off')} · ${b.count ?? 0} 名成员`} /> - 本局禁用 + title=${t('{tip} · {n} 名成员', { tip: briefingBondTip(name, 'off'), n: b.count ?? 0 })} /> + ${t('本局禁用')}
`; } const next = nextThreshold(b.count ?? 0, th); @@ -74,13 +74,13 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null <${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${rec?.noStack ? undefined : b.layers ?? 0} tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true} layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)} - title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? `(含调和 +${b.harmony})` : ''}`} /> + title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? t('(含调和 +{harmony})', { harmony: b.harmony }) : ''}`} /> ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}
`; })} ${sorted.length > shown.length ? html`+${sorted.length - shown.length}` : null} `; - return layersDisabled ? html`<${Tooltip} text="层数叠加已禁用" placement="bottom">${strip}` : strip; + return layersDisabled ? html`<${Tooltip} text=${t('层数叠加已禁用')} placement="bottom">${strip}` : strip; } /** @@ -122,38 +122,39 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, const si = memberStandIn(priv, rawChess(x.id), data.get('backups'), rawChess); return si ? { ...x, name: si.name } : x; }) : []; - const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || '调和'; - const harmonyText = `${harmonyBy.length ? `${harmonyBy.map((x) => x.name).join('、')} 在场` : `${harmonyName}已激活`}:核心盟约激活人数 +${harmony}`; + const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || tName('调和'); + const harmonyText = harmonyBy.length ? t('{names} 在场:核心盟约激活人数 +{harmony}', { names: harmonyBy.map((x) => x.name), harmony }) + : t('{harmonyName}已激活:核心盟约激活人数 +{harmony}', { harmonyName, harmony }); return html`