feedback5: merge W5S — i18n phase 2: every player-facing Chinese literal in the client, shared and server goes through t() / tParts() / N_() (1 128 → 0; en.json 1 015 entries), server-sent data texts show the English record, a language switch refreshes cached names; test/i18n.test.js enforces extract 0 / check --strict over the whole tree; Chinese output unchanged (per-file render proof); golden unchanged

This commit is contained in:
sganggs committed 2026-10-06 18:39:35 +08:00
commit 3ac88dea43
73 files changed
+1280 -903

No files matched your search

+51 -18
View File
@@ -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`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${r}</b>` })}</span>`; // 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 `<b>`, a `<kbd>`): `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 <files> [--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 <file> --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
+128 -66
View File
@@ -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",
"<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>{status} · 卫戍协议:盟约</title><style>\n:root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111614;color:#d8e3de;font:16px/1.6 \"Noto Sans SC\",system-ui,sans-serif}\nmain{border:1px solid #2c3a35;padding:32px 40px;max-width:520px;text-align:center}h1{margin:0;color:#4ed8af;font-size:56px;letter-spacing:4px}\np{margin:8px 0}a{color:#4ed8af}</style></head><body><main><h1>{status}</h1><p>{2}</p>\n{3}<p><a href=\"/\">返回首页 · Back to home</a></p></main></body></html>": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>{status} · Stronghold Protocol: Alliance</title><style>\n:root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111614;color:#d8e3de;font:16px/1.6 \"Noto Sans SC\",system-ui,sans-serif}\nmain{border:1px solid #2c3a35;padding:32px 40px;max-width:520px;text-align:center}h1{margin:0;color:#4ed8af;font-size:56px;letter-spacing:4px}\np{margin:8px 0}a{color:#4ed8af}</style></head><body><main><h1>{status}</h1><p>{2}</p>\n{3}<p><a href=\"/\">Back to home</a></p></main></body></html>",
"页面不存在 · 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}"
}
+11 -10
View File
@@ -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 },
];
}
+11
View File
@@ -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). */
+8 -7
View File
@@ -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. */
+2 -1
View File
@@ -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);
+31 -29
View File
@@ -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`<span class="dband__off" title=${bandOffLine(names)}>本局禁用</span>` : null;
return names.length ? html`<span class="dband__off" title=${bandOffLine(names)}>${t('本局禁用')}</span>` : 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`<p class="draft-detail__off" role="note" aria-label=${bandOffLine(names)}><${Icon} name="info" /><span>本局禁用${names.map((n, i) => html`<span key=${i}>【<s class="draft-detail__offname">${n}</s>】</span>`)}盟约,此策略效果可能无法发挥</span></p>`;
return html`<p class="draft-detail__off" role="note" aria-label=${bandOffLine(names)}><${Icon} name="info" /><span>${tParts('本局禁用{names}盟约,此策略效果可能无法发挥', { names: names.map((n, i) => html`<span key=${i}>${tParts('【{name}】', { name: html`<s class="draft-detail__offname">${n}</s>` })}</span>`) })}</span></p>`;
}
/**
@@ -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`<div class="screen draft">
<div class="brief__bg" aria-hidden="true"></div>
<${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)} />
<main class="draft__main">
<aside class="draft-order">
<h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
<h3 class="brief-h"><span>${solo ? t('独立模拟') : t('决策顺序')}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
${(solo ? players.filter((p) => p.playerId === myId) : draft.order.map((pid) => players.find((p) => p.playerId === pid)).filter(Boolean)).map((p, i) => {
const picked = draft.picks.get(p.playerId) || (p.playerId === myId ? myPick : p.bandId) || null;
const cur = !picked && (solo || draft.turnPid === p.playerId);
@@ -262,10 +264,10 @@ export function BandDraftScreen() {
${!solo ? html`<span class="dorder__idx num">${i + 1}</span>` : null}
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
<div class="dorder__text">
<b class="dorder__name">${p.name || '博士'}${p.isBot ? html`<span class="dorder__ai">AI</span>` : null}</b>
<span class="dorder__state">${picked ? html`<span class="t-mint">${pband?.name || '已选择'}</span>`
: cur ? html`<span class="t-gold"><${Icon} name="hourglass" />决策中${turnSecs != null ? html`<b class="num dorder__secs">${turnSecs}s</b>` : null}</span>`
: html`<span class="t-dim"><${Icon} name="dots" />等待中</span>`}</span>
<b class="dorder__name">${p.name || t('博士')}${p.isBot ? html`<span class="dorder__ai">AI</span>` : null}</b>
<span class="dorder__state">${picked ? html`<span class="t-mint">${pband?.name || t('已选择')}</span>`
: cur ? html`<span class="t-gold"><${Icon} name="hourglass" />${t('决策中')}${turnSecs != null ? html`<b class="num dorder__secs">${turnSecs}s</b>` : null}</span>`
: html`<span class="t-dim"><${Icon} name="dots" />${t('等待中')}</span>`}</span>
</div>
<span class="dorder__box">
${picked ? html`<${BandIcon} bandId=${picked} size="sm" /><span class="dorder__check"><${Icon} name="check" /></span>`
@@ -275,24 +277,24 @@ export function BandDraftScreen() {
</div>`;
})}
<${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`<p class="draft-order__tip" data-testid="draft-tip">${draftTip({ timed, turnSeconds: turnLen, autoName: myPick ? null : autoName, selected: autoId === sel })}</p>` : null}
</aside>
<section class="draft-grid" role="listbox" aria-label="策略">
<section class="draft-grid" role="listbox" aria-label=${t('策略')}>
${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`<button key=${b.bandId} type="button" role="option" aria-selected=${sel === b.bandId ? 'true' : 'false'} data-band=${b.bandId}
aria-disabled=${isTaken ? 'true' : 'false'} title=${isTaken ? '队友已选' : offNames.length ? bandOffLine(offNames) : undefined}
aria-disabled=${isTaken ? 'true' : 'false'} title=${isTaken ? t('队友已选') : offNames.length ? bandOffLine(offNames) : undefined}
class=${cx('dband', sel === b.bandId && 'is-sel', myPick === b.bandId && 'is-mine', isTaken && 'is-taken', offNames.length && 'is-off')} onClick=${() => { setSel(b.bandId); audio.sfx('tab', { volume: 0.5 }); }}>
<${BandIcon} bandId=${b.bandId} size="lg" />
<span class="dband__name">${b.name}</span>
<span class="dband__lp num"><i></i>${b.totalHp}</span>
<${BandOffTag} names=${offNames} />
${who.length ? html`<span class="dband__who">${who.slice(0, 4).map((p) => html`<${PlayerAvatar} key=${p.playerId} player=${p} size="sm" />`)}</span>` : null}
${isTaken ? html`<span class="dband__taken">队友已选</span>` : null}
${isTaken ? html`<span class="dband__taken">${t('队友已选')}</span>` : null}
</button>`;
})}
</section>
@@ -302,22 +304,22 @@ export function BandDraftScreen() {
<div class="draft-detail__art">
<${BandIcon} bandId=${band.bandId} size="xl" />
</div>
<div class="draft-detail__hp"><span>初始生命值</span><${LpTower} value=${band.totalHp} size="lg" /></div>
<div class="draft-detail__hp"><span>${t('初始生命值')}</span><${LpTower} value=${band.totalHp} size="lg" /></div>
<h2 class="draft-detail__name">${band.name}</h2>
<${BandOffNote} names=${bandOffBonds(band, offBonds).map((id) => gd.bond(id)?.name || id)} />
<div class="draft-detail__eff">
<${MicroLabel} tone="mint">EFFECT</${MicroLabel}>
<b>${band.effectName || ''}</b>
<${RichText} as="p" text=${band.descRaw || band.desc} class="draft-detail__desc" />
</div>` : html`<p class="t-dim">选择一个策略查看详情</p>`}
</div>` : html`<p class="t-dim">${t('选择一个策略查看详情')}</p>`}
<div class="draft-detail__actions">
${myPick ? html`<p class="draft-detail__status t-mint"><${Icon} name="check" />已选择「${gd.band(myPick)?.name || ''}」${!solo && !draft.done ? ',等待其他博士' : ''}</p>`
: selTaken ? html`<p class="draft-detail__status draft-detail__status--taken"><${Icon} name="close" />队友已选,请选择其他策略</p>`
: !myTurn ? html`<p class="draft-detail__status"><${Icon} name="hourglass" />${turnName ? `${turnName} 正在决策…` : '等待轮到你'}</p>` : null}
${myPick ? html`<p class="draft-detail__status t-mint"><${Icon} name="check" />${t('已选择「{name}」', { name: gd.band(myPick)?.name || '' })}${!solo && !draft.done ? t(',等待其他博士') : ''}</p>`
: selTaken ? html`<p class="draft-detail__status draft-detail__status--taken"><${Icon} name="close" />${t('队友已选,请选择其他策略')}</p>`
: !myTurn ? html`<p class="draft-detail__status"><${Icon} name="hourglass" />${turnName ? t('{turnName} 正在决策…', { turnName }) : t('等待轮到你')}</p>` : null}
<div class="draft-detail__btns">
${!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('确认选择')}<//>
</div>
</div>
</aside>
+10 -9
View File
@@ -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`<div class="screen brief">
<div class="brief__bg" aria-hidden="true"></div>
<${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)} />
<main class="brief__main">
<section class="brief__left">
<div class="brief-boss brackets">
<${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖</${MicroLabel}>
<${MicroLabel} tone="gold">${t('ENEMY LEADER // 敌方领袖')}</${MicroLabel}>
<div class="brief-boss__art">
<div class="brief-boss__ring" aria-hidden="true"></div>
${bossEnemy ? html`<${Img} src=${enemyIconUrl(m, boss.enemyKey)} class="brief-boss__sil" fallback=${html`<span class="brief-boss__q">?</span>`} />`
: html`<span class="brief-boss__q">?</span>`}
</div>
<div class="brief-boss__text">
<h2 class="brief-boss__name">${boss?.name || bossEnemy?.name || '未知领袖'}</h2>
<span class="brief-boss__when">第 <b class="num">${mode?.bossRound ?? pub.lastRound ?? 14}</b> 回合 · 最终攻势</span>
<h2 class="brief-boss__name">${boss?.name || bossEnemy?.name || t('未知领袖')}</h2>
<span class="brief-boss__when">${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${mode?.bossRound ?? pub.lastRound ?? 14}</b>` })}</span>
${Array.isArray(boss?.abilities) && boss.abilities.length ? html`<ul class="brief-boss__abil">
${boss.abilities.slice(0, 3).map((a, i) => html`<li key=${i}><${RichText} text=${a} /></li>`)}
</ul>` : null}
@@ -79,11 +80,11 @@ export function BriefingScreen() {
<span class="brief-stage__name"><${Icon} name="rook" />${stage?.name || pub.stageId || '—'}</span>
</div>
<div class="brief-factions">
<h3 class="brief-h"><span>特训敌人</span><${MicroLabel}>SPECIAL ENEMIES</${MicroLabel}></h3>
<h3 class="brief-h"><span>${t('特训敌人')}</span><${MicroLabel}>SPECIAL ENEMIES</${MicroLabel}></h3>
${types.length ? types.map((t) => html`<div key=${t} class="brief-faction">
<span class="brief-faction__icon"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} /></span>
<span class="brief-faction__text"><b>${factions[t]?.name || t}</b><span>${factions[t]?.desc || ''}</span></span>
</div>`) : html`<p class="t-dim">本局没有特训敌人</p>`}
</div>`) : html`<p class="t-dim">${t('本局没有特训敌人')}</p>`}
</div>
</section>
<section class="brief__right">
@@ -93,11 +94,11 @@ export function BriefingScreen() {
<footer class="brief__foot">
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
<div class="brief-ready">
<span class="brief-ready__txt">已就绪 <b class="num">${readyN}</b><span class="num">/${players.length}</span></span>
<span class="brief-ready__txt">${t('已就绪')} <b class="num">${readyN}</b><span class="num">/${players.length}</span></span>
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
</div>
<${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('观战中')}<//>
</footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
+17 -16
View File
@@ -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`<div class="screen gload">
<${Spinner} size="lg" label=${pub ? 'LOADING DATA' : 'ENTERING SIMULATION'} />
<p class="t-lo">${pub ? '正在载入模拟数据…' : '正在进入模拟…'}</p>
<p class="t-lo">${pub ? t('正在载入模拟数据…') : t('正在进入模拟…')}</p>
</div>`;
}
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() {
<div class="gm__bonds">
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
aria-label=${bondsCollapsed ? '展开盟约' : '收起盟约'} title=${bondsCollapsed ? '展开盟约' : '收起盟约'}
aria-label=${bondsCollapsed ? t('展开盟约') : t('收起盟约')} title=${bondsCollapsed ? t('展开盟约') : t('收起盟约')}
onKeyDown=${(e) => {
// Keep native Space activation here without also firing the global ready / pause shortcut.
if (e.key === ' ') e.stopPropagation();
@@ -1277,7 +1278,7 @@ function MatchScreen() {
onClick=${() => {
if (!bondsCollapsed && bondOpen?.from === 'strip') setBondOpen(null);
setBondsCollapsed(!bondsCollapsed);
}}><${Icon} name=${bondsCollapsed ? 'chevronRight' : 'chevronLeft'} /><span>${bondsCollapsed ? '盟约' : '收起'}</span></button>
}}><${Icon} name=${bondsCollapsed ? 'chevronRight' : 'chevronLeft'} /><span>${bondsCollapsed ? t('盟约') : t('收起')}</span></button>
<div id="match-bond-strip" class="gm__bond-list" hidden=${bondsCollapsed}>
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
@@ -1291,8 +1292,8 @@ function MatchScreen() {
<div class="gm__effects"><${EffectsList} effects=${watchingOther && field ? (field.effects ?? null) : priv?.effects} /></div>
${watchingOther && !combat ? html`<div class="gm__watching" role="status">
<${GIcon} name="eye" /><span>正在查看 <b>${watchedName}</b> 的阵地(只读)</span>
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>返回自己<//>`}
<${GIcon} name="eye" /><span>${tParts('正在查看 {name} 的阵地(只读)', { name: html`<b>${watchedName}</b>` })}</span>
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
</div>` : null}
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
@@ -1312,15 +1313,15 @@ function MatchScreen() {
client=${cc ? { progress, observing: observingName ? { name: observingName } : null, onBack: alive ? backHome : null, layers, layer, onLayer: setLayer } : null} />` : null}
${showDeadPill(alive, phase) ? (spectator
? html`<div class="gm__dead gm__dead--spectator" role="status"><${GIcon} name="eye" />观战中 · 点击左侧成员头像切换查看</div>`
: html`<div class="gm__dead" role="status"><${Icon} name="close" />你已被淘汰 · 可继续观战队友</div>`) : null}
? html`<div class="gm__dead gm__dead--spectator" role="status"><${GIcon} name="eye" />${t('观战中 · 点击左侧成员头像切换查看')}</div>`
: html`<div class="gm__dead" role="status"><${Icon} name="close" />${t('你已被淘汰 · 可继续观战队友')}</div>`) : null}
<${Ticker} />
<div class="gm__corner">
${spectator ? null : html`<${EmoteWheel} open=${emoteOpen} onToggle=${setEmoteOpen} onSend=${(id) => actions.emote(id)} disabled=${conn.status !== 'online'} />`}
<button type="button" class="gm__gear" aria-label="设置" title="设置" onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
<button type="button" class="gm__gear gm__guide" aria-label="玩法说明" title="玩法说明" onClick=${() => openGuide(0)}><${Icon} name="book" /></button>
<button type="button" class="gm__gear" aria-label=${t('设置')} title=${t('设置')} onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
<button type="button" class="gm__gear gm__guide" aria-label=${t('玩法说明')} title=${t('玩法说明')} onClick=${() => openGuide(0)}><${Icon} name="book" /></button>
<${FullscreenButton} class="gm__gear gm__fs" />
</div>
+10 -9
View File
@@ -3,15 +3,16 @@
import { Button, MicroLabel, html } from '../../ui/components.js';
import { emptyMatch, store } from '../../store.js';
import { localAsset } from '../../data.js';
import { t } from '../../../../shared/i18n.js';
/** The room went back to its lobby without a result (match aborted): offer the way back. */
export function MatchEnded() {
return html`<div class="awayov" role="dialog" aria-label="模拟已结束">
return html`<div class="awayov" role="dialog" aria-label=${t('模拟已结束')}>
<div class="awayov__box brackets">
<${MicroLabel} tone="mint">SIMULATION CLOSED</${MicroLabel}>
<h2>本局模拟已结束</h2>
<p class="t-lo">同盟已返回等待室</p>
<${Button} variant="primary" size="lg" icon="chevronLeft" onClick=${() => store.set({ match: emptyMatch() })}>返回同盟<//>
<h2>${t('本局模拟已结束')}</h2>
<p class="t-lo">${t('同盟已返回等待室')}</p>
<${Button} variant="primary" size="lg" icon="chevronLeft" onClick=${() => store.set({ match: emptyMatch() })}>${t('返回同盟')}<//>
</div>
</div>`;
}
@@ -19,16 +20,16 @@ export function MatchEnded() {
/** Solo pause (m.public.paused): the field dims under the 暂停中 plate; 继续作战 resumes (g.pause off). */
export function PausedOverlay({ canResume, busy, onResume, onExit }) {
const plate = localAsset('ui/battle', 'matte_pause');
return html`<div class="pauseov" role="dialog" aria-label="暂停中" data-testid="paused">
return html`<div class="pauseov" role="dialog" aria-label=${t('暂停中')} data-testid="paused">
<div class="pauseov__box">
<div class="pauseov__plate" style=${plate ? `--pause-plate:url("${plate}")` : ''}>
<span class="pauseov__micro">PAUSED</span>
<h2>暂停中</h2>
<h2>${t('暂停中')}</h2>
</div>
<p class="pauseov__note">作战已暂停,计时与敌人行动均已停止</p>
<p class="pauseov__note">${t('作战已暂停,计时与敌人行动均已停止')}</p>
<div class="pauseov__btns">
${onExit ? html`<${Button} variant="secondary" size="lg" icon="exit" onClick=${onExit}>放弃模拟<//>` : null}
${canResume ? html`<${Button} variant="primary" size="lg" icon="play" loading=${busy} onClick=${onResume} data-autofocus>继续作战<//>` : null}
${onExit ? html`<${Button} variant="secondary" size="lg" icon="exit" onClick=${onExit}>${t('放弃模拟')}<//>` : null}
${canResume ? html`<${Button} variant="primary" size="lg" icon="play" loading=${busy} onClick=${onResume} data-autofocus>${t('继续作战')}<//>` : null}
</div>
</div>
</div>`;
+115 -112
View File
@@ -36,7 +36,7 @@ import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNE
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
import { DiyPanel, diyData } from './diy.js';
import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
import { t } from '../../../shared/i18n.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
import { copyText } from '../ui/clipboard.js';
import { toast } from '../ui/toasts.js';
@@ -155,15 +155,15 @@ function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = fa
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
</span>
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
${choice.changed ? html`<span class="lo-card__flag" aria-label="已调整"></span>` : null}
${notOwned ? html`<span class="lo-card__sub" title="未持有(干员持有):由替补干员上场" aria-label="未持有">替补</span>` : null}
${choice.changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')} aria-label=${t('未持有')}>${t('替补')}</span>` : null}
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__kit">
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
<${SkillIcon} m=${m} rec=${skillRec} index=${choice.skill} size="xs" />
<b class="num">${skillLabel(choice.skill)}</b>
</span>
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : '不装备模组'}>
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : t('不装备模组')}>
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
</span>` : null}
</span>
@@ -182,16 +182,16 @@ function SkillOption({ m, opt, on, level, onPick }) {
<span class="lo-skill__body">
<span class="lo-skill__head">
<span class="lo-skill__slot num">${skillLabel(opt.index)}</span>
<b class="lo-skill__name">${rec?.name || '未知技能'}</b>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">默认</span>` : null}
${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />已装备</span>` : null}
<b class="lo-skill__name">${rec?.name || t('未知技能')}</b>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">${t('默认')}</span>` : null}
${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${t('已装备')}</span>` : null}
</span>
<span class="lo-skill__tags">
<span class=${cx('lo-sp', `lo-sp--${tags.spKind}`)}>${tags.sp}</span>
${tags.init != null ? html`<span class="lo-tag">初始 <b class="num">${tags.init}</b></span>` : null}
${tags.cost != null ? html`<span class="lo-tag">消耗 <b class="num">${tags.cost}</b></span>` : null}
${tags.duration ? html`<span class="lo-tag">持续 <b class="num">${tags.duration}</b></span>` : null}
${tags.charges ? html`<span class="lo-tag">充能 <b class="num">${tags.charges}</b></span>` : null}
${tags.init != null ? html`<span class="lo-tag">${t('初始')} <b class="num">${tags.init}</b></span>` : null}
${tags.cost != null ? html`<span class="lo-tag">${t('消耗')} <b class="num">${tags.cost}</b></span>` : null}
${tags.duration ? html`<span class="lo-tag">${t('持续')} <b class="num">${tags.duration}</b></span>` : null}
${tags.charges ? html`<span class="lo-tag">${t('充能')} <b class="num">${tags.charges}</b></span>` : null}
</span>
<${RichText} as="span" class="lo-skill__desc" text=${rec?.descRaw || rec?.desc || ''} />
</span>
@@ -204,8 +204,8 @@ function ModuleInfo({ m, golden, opt }) {
if (!rec) {
const traitBase = golden.traitBase || null;
return html`<div class="lo-minfo lo-minfo--none">
<p class="lo-minfo__lead">不装备模组:精锐干员以基础属性、特性与天赋作战。</p>
${traitBase?.desc ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${traitBase.descRaw || traitBase.desc} /></div>` : null}
<p class="lo-minfo__lead">${t('不装备模组:精锐干员以基础属性、特性与天赋作战。')}</p>
${traitBase?.desc ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${traitBase.descRaw || traitBase.desc} /></div>` : null}
</div>`;
}
const rows = attrRows(rec.attr);
@@ -214,14 +214,14 @@ function ModuleInfo({ m, golden, opt }) {
const talents = (Array.isArray(rec.talentChanges) ? rec.talentChanges : []).filter((t) => t && (t.name || t.desc) && !t.hidden);
return html`<div class="lo-minfo">
<div class="lo-minfo__title"><${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" /><span class="lo-minfo__type num">${rec.typeName || ''}</span><b>${rec.name || rec.uniEquipId}</b>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">默认</span>` : null}</div>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">${t('默认')}</span>` : null}</div>
<div class="lo-minfo__row">
<span class="lo-minfo__k">属性</span>
<span class="lo-minfo__v lo-attrs">${rows.length ? rows.map((r) => html`<span key=${r.key} class=${cx('lo-attr', r.positive ? 'is-up' : 'is-down')}>${r.label}<b class="num">${r.text}</b></span>`) : html`<span class="t-dim">无属性加成</span>`}</span>
<span class="lo-minfo__k">${t('属性')}</span>
<span class="lo-minfo__v lo-attrs">${rows.length ? rows.map((r) => html`<span key=${r.key} class=${cx('lo-attr', r.positive ? 'is-up' : 'is-down')}>${r.label}<b class="num">${r.text}</b></span>`) : html`<span class="t-dim">${t('无属性加成')}</span>`}</span>
</div>
${traitText ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${traitText} /></div>` : null}
${talents.map((t, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">天赋</span>
<span class="lo-minfo__v">${t.name ? html`<b class="lo-minfo__tname">${t.name}</b>` : null}<${RichText} text=${t.descRaw || t.desc || ''} /></span></div>`)}
${traitText ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${traitText} /></div>` : null}
${talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
<span class="lo-minfo__v">${tal.name ? html`<b class="lo-minfo__tname">${tal.name}</b>` : null}<${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
</div>`;
}
@@ -260,21 +260,21 @@ export function statsPreview(base, golden, entries, level, getChess) {
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) {
const pv = statsPreview(base, golden, entries, level, getChess);
if (!pv) return null;
return html`<section class="lo-sec lo-sec--stats" aria-label="局内数值" data-variant=${pv.elite ? 'elite' : 'normal'}>
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
<header class="lo-sec__head">
<h3>局内数值<${MicroLabel}>STATS<//></h3>
<div class="lo-seg" role="tablist" aria-label="数值版本">
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>普通</button>
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>精锐</button>
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
</div>
</header>
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
${pv.talents.map((t, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">天赋</span>
<span class="lo-minfo__v"><b class="lo-minfo__tname">${t.name}</b><${RichText} text=${t.descRaw || t.desc || ''} /></span></div>`)}
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
</div>` : null}
<p class="lo-stats__cap">${pv.elite ? '数值含所选模组;' : golden ? '普通干员没有模组,所选模组在「精锐」中生效;' : ''}不含技能发动、装备、盟约等局内加成</p>
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
</section>`;
}
@@ -283,40 +283,40 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
const bodyRef = useRef(null);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">没有符合条件的干员</p></aside>`;
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">${t('没有符合条件的干员')}</p></aside>`;
const opt = chessOptions(chess, golden);
const choice = effectiveChoice(entries, chess, golden);
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
const lv = (c) => c?.status?.skillLevel ?? '—';
return html`<aside class="lo-detail" aria-label=${`${chess.name} 调配`}>
return html`<aside class="lo-detail" aria-label=${t('{name} 调配', { name: chess.name })}>
<div class="lo-dhead">
<div class=${cx('lo-dhead__art', `lo-dhead__art--t${chess.tier}`)}>
<${Img} src=${chessPortraitUrl(m, golden || chess)} fallback=${html`<${UnitThumb} kind="chess" id=${chess.chessId} size="lg" />`} />
</div>
<div class="lo-dhead__info">
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
${choice.changed ? html`<span class="lo-badge lo-badge--changed">已调整</span>` : html`<span class="lo-badge lo-badge--plain">默认配置</span>`}</div>
${choice.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
<h2 class="lo-dhead__name">${chess.name}</h2>
<span class="lo-dhead__en">${chess.appellation || ''}</span>
<span class="lo-dhead__class">
<${Img} src=${profGlyphUrl(m, chess.profession)} class="lo-dhead__prof lo-profglyph" />${PROF_NAME[chess.profession] || ''}
<${Img} src=${profGlyphUrl(m, chess.profession)} class="lo-dhead__prof lo-profglyph" />${t(PROF_NAME[chess.profession] || '')}
<i class="lo-sep"></i><${Img} src=${subProfIconUrl(m, chess)} class="lo-dhead__prof" />${chess.subProfessionName || ''}
</span>
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
</div>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>恢复默认<//>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
</div>
<div class="lo-detail__body" ref=${bodyRef}>
<section class="lo-sec">
<header class="lo-sec__head">
<h3>技能<${MicroLabel}>SKILL<//></h3>
<div class="lo-seg" role="tablist" aria-label="技能等级">
<button type="button" role="tab" aria-selected=${level === 'normal' ? 'true' : 'false'} class=${cx(level === 'normal' && 'is-on')} onClick=${() => setLevel('normal')}>普通 <span class="num">Lv.${lv(chess)}</span></button>
<button type="button" role="tab" aria-selected=${level === 'elite' ? 'true' : 'false'} class=${cx(level === 'elite' && 'is-on')} disabled=${!golden} onClick=${() => setLevel('elite')}>精锐 <span class="num">Lv.${lv(golden)}</span></button>
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
<div class="lo-seg" role="tablist" aria-label=${t('技能等级')}>
<button type="button" role="tab" aria-selected=${level === 'normal' ? 'true' : 'false'} class=${cx(level === 'normal' && 'is-on')} onClick=${() => setLevel('normal')}>${t('普通')} <span class="num">Lv.${lv(chess)}</span></button>
<button type="button" role="tab" aria-selected=${level === 'elite' ? 'true' : 'false'} class=${cx(level === 'elite' && 'is-on')} disabled=${!golden} onClick=${() => setLevel('elite')}>${t('精锐')} <span class="num">Lv.${lv(golden)}</span></button>
</div>
</header>
<div class="lo-skills" role="radiogroup" aria-label="选择技能">
<div class="lo-skills" role="radiogroup" aria-label=${t('选择技能')}>
${opt.skillOptions.map((s) => html`<${SkillOption} key=${s.index} m=${m} opt=${s} level=${level} on=${s.index === choice.skill}
onPick=${(i) => onChange({ skill: i })} />`)}
</div>
@@ -324,25 +324,26 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} />
${golden ? html`<section class="lo-sec lo-sec--mod">
<header class="lo-sec__head">
<h3>模组<${MicroLabel}>MODULE<//></h3>
<span class="lo-sec__note">仅精锐干员装备 · 模组等级 <b class="num">${golden.status?.equipLevel ?? 1}</b></span>
<h3>${t('模组')}<${MicroLabel}>MODULE<//></h3>
<span class="lo-sec__note">${t('仅精锐干员装备 · 模组等级')} <b class="num">${golden.status?.equipLevel ?? 1}</b></span>
</header>
<div class="lo-mods" role="radiogroup" aria-label="选择模组">
<div class="lo-mods" role="radiogroup" aria-label=${t('选择模组')}>
${opt.moduleOptions.map((mo) => html`<button key=${mo.id} type="button" role="radio" aria-checked=${mo.id === choice.module ? 'true' : 'false'}
data-module=${mo.id} class=${cx('lo-mod', mo.id === choice.module && 'is-on', mo.id === MODULE_NONE && 'lo-mod--none')}
onClick=${() => onChange({ module: mo.id })}>
<${ModuleGlyph} m=${m} rec=${mo.rec} id=${mo.id} />
<span class="lo-mod__text">
<span class="lo-mod__type num">${mo.id === MODULE_NONE ? 'NONE' : mo.rec?.typeName || ''}</span>
<b class="lo-mod__name">${mo.id === MODULE_NONE ? '不装备' : mo.rec?.name || mo.id}</b>
<b class="lo-mod__name">${mo.id === MODULE_NONE ? t('不装备') : mo.rec?.name || mo.id}</b>
</span>
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">默认</span>` : null}
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">${t('默认')}</span>` : null}
</button>`)}
</div>
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
</section>` : null}
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />干员持有中标记为未持有:此棋子由替补干员${standInName(chess) ? ` ${standInName(chess)} ` : ''}上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效</p>` : null}
${locked ? html`<p class="lo-locknote"><${Icon} name="info" />本局的调配已锁定,修改将在下一局生效</p>` : null}
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />${standInName(chess) ? t('干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效', { name: standInName(chess) })
: t('干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效')}</p>` : null}
${locked ? html`<p class="lo-locknote"><${Icon} name="info" />${t('本局的调配已锁定,修改将在下一局生效')}</p>` : null}
</div>
</aside>`;
}
@@ -353,29 +354,29 @@ function Filters({ m, filters, onFilters, bonds }) {
const set = (patch) => onFilters({ ...filters, ...patch });
return html`<div class="lo-filters">
<div class="lo-frow">
<div class="lo-chips" role="group" aria-label="阶级">
<button type="button" class=${cx('lo-chip', !filters.tier && 'is-on')} onClick=${() => set({ tier: null })}>全部</button>
${[1, 2, 3, 4, 5, 6].map((t) => html`<button key=${t} type="button" class=${cx('lo-chip', 'lo-chip--tier', `lo-chip--t${t}`, filters.tier === t && 'is-on')}
aria-pressed=${filters.tier === t ? 'true' : 'false'} title=${`${t}阶`} onClick=${() => set({ tier: filters.tier === t ? null : t })}><span class="num">${ROMAN[t]}</span></button>`)}
<div class="lo-chips" role="group" aria-label=${t('阶级')}>
<button type="button" class=${cx('lo-chip', !filters.tier && 'is-on')} onClick=${() => set({ tier: null })}>${t('全部')}</button>
${[1, 2, 3, 4, 5, 6].map((tier) => html`<button key=${tier} type="button" class=${cx('lo-chip', 'lo-chip--tier', `lo-chip--t${tier}`, filters.tier === tier && 'is-on')}
aria-pressed=${filters.tier === tier ? 'true' : 'false'} title=${t('{tier}阶', { tier })} onClick=${() => set({ tier: filters.tier === tier ? null : tier })}><span class="num">${ROMAN[tier]}</span></button>`)}
</div>
<${TextField} size="sm" icon="search" value=${filters.query} placeholder="搜索干员 / 职业 / 盟约" class="lo-search"
<${TextField} size="sm" icon="search" value=${filters.query} placeholder=${t('搜索干员 / 职业 / 盟约')} class="lo-search"
onInput=${(v) => set({ query: String(v).slice(0, 24) })} />
</div>
<div class="lo-frow">
<div class="lo-chips lo-chips--prof" role="group" aria-label="职业">
<div class="lo-chips lo-chips--prof" role="group" aria-label=${t('职业')}>
${PROF_ORDER.map((p) => html`<button key=${p} type="button" class=${cx('lo-chip', 'lo-chip--prof', filters.prof === p && 'is-on')}
aria-pressed=${filters.prof === p ? 'true' : 'false'} title=${PROF_NAME[p]} onClick=${() => set({ prof: filters.prof === p ? null : p })}>
<${Img} src=${profGlyphUrl(m, p)} class="lo-chip__icon lo-profglyph" fallback=${html`<span>${PROF_NAME[p][0]}</span>`} /><span class="lo-chip__lbl">${PROF_NAME[p]}</span></button>`)}
aria-pressed=${filters.prof === p ? 'true' : 'false'} title=${t(PROF_NAME[p])} onClick=${() => set({ prof: filters.prof === p ? null : p })}>
<${Img} src=${profGlyphUrl(m, p)} class="lo-chip__icon lo-profglyph" fallback=${html`<span>${t(PROF_NAME[p])[0]}</span>`} /><span class="lo-chip__lbl">${t(PROF_NAME[p])}</span></button>`)}
</div>
<label class="lo-select">
<span class="lo-select__k">盟约</span>
<select value=${filters.bond || ''} onChange=${(e) => set({ bond: e.currentTarget.value || null })} aria-label="按盟约筛选">
<option value="">全部盟约</option>
<span class="lo-select__k">${t('盟约')}</span>
<select value=${filters.bond || ''} onChange=${(e) => set({ bond: e.currentTarget.value || null })} aria-label=${t('按盟约筛选')}>
<option value="">${t('全部盟约')}</option>
${bonds.map((b) => html`<option key=${b.bondId} value=${b.bondId}>${b.name}</option>`)}
</select>
</label>
<button type="button" class=${cx('lo-toggle', filters.changedOnly && 'is-on')} aria-pressed=${filters.changedOnly ? 'true' : 'false'}
onClick=${() => set({ changedOnly: !filters.changedOnly })}><i class="lo-toggle__box"><${Icon} name="check" /></i>仅看已调整</button>
onClick=${() => set({ changedOnly: !filters.changedOnly })}><i class="lo-toggle__box"><${Icon} name="check" /></i>${t('仅看已调整')}</button>
</div>
</div>`;
}
@@ -383,11 +384,11 @@ function Filters({ m, filters, onFilters, bonds }) {
// ---- screen -------------------------------------------------------------------------------------------------------------
const SYNC_TEXT = {
idle: ['', ''], pending: ['保存中…', 'is-busy'], sending: ['同步中…', 'is-busy'], synced: ['已同步', 'is-ok'],
locked: ['本局已锁定 · 下一局生效', 'is-warn'], error: ['同步失败', 'is-bad'],
idle: ['', ''], pending: [N_('保存中…'), 'is-busy'], sending: [N_('同步中…'), 'is-busy'], synced: [N_('已同步'), 'is-ok'],
locked: [N_('本局已锁定 · 下一局生效'), 'is-warn'], error: [N_('同步失败'), 'is-bad'],
};
/** The 干员持有 tab's status line: the setting never applies to a running match. */
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] };
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: [N_('下一局生效'), 'is-warn'] };
/** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */
const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT };
@@ -402,7 +403,7 @@ function LoadoutScreen({ st }) {
const m = data.get('assets');
const getChess = (id) => data.lookup('chess', id);
const getBond = (id) => data.lookup('bonds', id);
const roster = useMemo(() => rosterOf(data.list('chess')), [ready]);
const roster = useMemo(() => rosterOf(data.list('chess')), [ready, data.locale()]); // (names follow a language switch)
const bonds = useMemo(() => {
const used = new Set(roster.flatMap((c) => c.bonds || []));
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
@@ -434,7 +435,7 @@ function LoadoutScreen({ st }) {
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
const ownAll = async () => {
if (!nNotOwned) return;
const ok = await confirmDialog({ title: '全部持有', text: `将 ${nNotOwned} 名未持有的干员恢复为持有(由本人上场)?`, okText: '全部持有' });
const ok = await confirmDialog({ title: t('全部持有'), text: t('将 {nNotOwned} 名未持有的干员恢复为持有(由本人上场)?', { nNotOwned }), okText: t('全部持有') });
if (ok) setNotOwned([]);
};
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
@@ -442,7 +443,7 @@ function LoadoutScreen({ st }) {
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
const resetAll = async () => {
if (!nChanged) return;
const ok = await confirmDialog({ title: '全部恢复默认', text: `将 ${nChanged} 名干员的技能与模组恢复为默认配置?`, okText: '恢复默认', danger: true });
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能与模组恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
if (ok) setEntries({});
};
@@ -458,7 +459,7 @@ function LoadoutScreen({ st }) {
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
const ioCopy = async () => {
const ok = await copyText(ioText);
toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn');
toast(ok ? t('已复制到剪贴板') : t('复制失败,请在文本框中手动全选复制'), ok ? 'success' : 'warn');
};
const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : ioDiy ? 'diy' : 'loadout'), ioText);
const ioPick = () => fileRef.current?.click();
@@ -468,12 +469,12 @@ function LoadoutScreen({ st }) {
if (!f) return;
// refuse a huge pick before reading it into memory (a real payload is a few KB)
if (ioDiy && f.size > DIY_IMPORT_MAX_BYTES) { toast(t('文件过大,请选择「导出」下载的自选编队文件'), 'error'); return; }
if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(`文件过大,请选择「导出」下载的${ioOwn ? '干员持有' : '调配'}文件`, 'error'); return; }
try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(ioOwn ? t('文件过大,请选择「导出」下载的干员持有文件') : t('文件过大,请选择「导出」下载的调配文件'), 'error'); return; }
try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast(t('读取文件失败'), 'error'); }
};
const ioApply = () => {
// an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; }
if (!ready) { toast(t('干员数据仍在载入,请稍候再导入'), 'warn'); return; }
if (ioDiy) {
const r = parseDiyImport(ioText);
if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; }
@@ -484,21 +485,22 @@ function LoadoutScreen({ st }) {
}
if (ioOwn) {
const r = parseOwnershipImport(ioText);
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; }
if (!r.ok) { toast(t('导入失败:{error}', { error: r.error }), 'error'); return; }
const { applied, dropped } = applyOwnershipImport(r.notOwned, getChess);
setIo(null);
toast(applied ? `已导入:${applied} 名干员未持有${dropped ? `(另有 ${dropped} 项无效,未导入)` : ''}` : '已导入:全部持有', dropped ? 'warn' : 'success');
toast(applied ? (dropped ? t('已导入:{applied} 名干员未持有(另有 {dropped} 项无效,未导入)', { applied, dropped }) : t('已导入:{applied} 名干员未持有', { applied }))
: t('已导入:全部持有'), dropped ? 'warn' : 'success');
return;
}
const res = parseImport(ioText);
if (!res.ok) { toast(`导入失败:${res.error}`, 'error'); return; }
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
if (!applied) { toast('导入失败:这份数据在当前版本没有可用的调配,未做任何改动', 'error'); return; }
if (!applied) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
setIo(null);
toast(dropped
? `已导入 ${applied} 名干员(另有 ${dropped} 项未导入)`
: `已导入 ${applied} 名干员的调配`, dropped ? 'warn' : 'success');
? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
};
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
@@ -532,28 +534,28 @@ function LoadoutScreen({ st }) {
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,干员等级不可调整');
// 干员持有 is out of match: a running match keeps the list its seat had at its start
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
const ownText = ownLocked ? t('干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效')
: t('局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有');
const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效')
: t('局外设置,下一局生效 · 联机时只进入你自己的商店');
return html`<${Fragment}>
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
<div class="lo" role="dialog" aria-modal="true" aria-label=${t('干员调配')}>
<div class="lo__bg" aria-hidden="true"></div>
<header class="lo-top">
<div class="lo-top__left">
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回<//>
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label=${t('返回')} title=${t('返回 (Esc)')}>${t('返回')}<//>
</div>
<div class="lo-top__center">
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : tab === 'diy' ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'}<//>
<div class="lo-tabs" role="tablist" aria-label=${t('干员调配 / 干员持有 / 自选编队')}>
<button type="button" role="tab" aria-selected=${tab === 'loadout' ? 'true' : 'false'} data-tab="loadout" class=${cx('lo-tab', tab === 'loadout' && 'is-on')} onClick=${() => setTab('loadout')}>
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</button>
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />${t('干员调配')}</button>
<button type="button" role="tab" aria-selected=${tab === 'ownership' ? 'true' : 'false'} data-tab="ownership" class=${cx('lo-tab', tab === 'ownership' && 'is-on')} onClick=${() => setTab('ownership')}
title="标记未持有的干员:由官方指定的替补干员上场">干员持有${nNotOwned ? html`<span class="lo-tab__n num" aria-label=${`${nNotOwned} 名未持有`}>${nNotOwned}</span>` : null}</button>
title=${t('标记未持有的干员:由官方指定的替补干员上场')}>${t('干员持有')}${nNotOwned ? html`<span class="lo-tab__n num" aria-label=${t('{nNotOwned} 名未持有', { nNotOwned })}>${nNotOwned}</span>` : null}</button>
<button type="button" role="tab" aria-selected=${tab === 'diy' ? 'true' : 'false'} data-tab="diy" class=${cx('lo-tab', tab === 'diy' && 'is-on')} onClick=${() => setTab('diy')}
title=${t('5阶、6阶的自选名额:持有的 6★ 干员或原型干员')}>${t('自选编队')}${nDiy ? html`<span class="lo-tab__n num" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })}>${nDiy}</span>` : null}</button>
</div>
@@ -565,67 +567,68 @@ function LoadoutScreen({ st }) {
<${Button} variant="ghost" size="sm" data-testid="diy-import" disabled=${!ready} onClick=${openImport} title=${t('导入自选编队(粘贴或选择文件)')}>${t('导入')}<//>
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-reset" disabled=${!nDiy} onClick=${clearDiy}>${t('全部清空')}<//>
</div>` : tab === 'ownership' ? html`<div class="lo-top__right">
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="ownership-sync">${syncText}</span>` : null}
<span class="lo-count">未持有 <b class="num">${nNotOwned}</b><span class="num t-dim">/${ownRoster.length}</span></span>
<${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出<//>
<${Button} variant="ghost" size="sm" data-testid="ownership-import" disabled=${!ready} onClick=${openImport} title="导入干员持有(粘贴或选择文件)">导入<//>
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="ownership-reset" disabled=${!nNotOwned} onClick=${ownAll}>全部持有<//>
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="ownership-sync">${t(syncText)}</span>` : null}
<span class="lo-count">${t('未持有')} <b class="num">${nNotOwned}</b><span class="num t-dim">/${ownRoster.length}</span></span>
<${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title=${t('导出干员持有(可复制或下载)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="ownership-import" disabled=${!ready} onClick=${openImport} title=${t('导入干员持有(粘贴或选择文件)')}>${t('导入')}<//>
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="ownership-reset" disabled=${!nNotOwned} onClick=${ownAll}>${t('全部持有')}<//>
</div>` : html`<div class="lo-top__right">
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null}
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${syncText}</span>` : null}
<span class="lo-count">已调整 <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title="导出当前调配(可复制或下载)">导出<//>
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title="导入调配(粘贴或选择文件)">导入<//>
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label=${t('调配截止')} class="lo-deadline" />` : null}
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${t(syncText)}</span>` : null}
<span class="lo-count">${t('已调整')} <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title=${t('导入调配(粘贴或选择文件)')}>${t('导入')}<//>
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>${t('全部恢复默认')}<//>
</div>`}
</header>
${tab === 'diy'
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${diyText}</p>`
: tab === 'ownership'
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${ownText}</p>`
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>`}
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : tab === 'diy'
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}</p>`}
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}</div>` : tab === 'diy'
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
: tab === 'ownership'
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster">
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
<div class="lo-grid" role="listbox" aria-label="干员列表" ref=${gridRef}>
<div class="lo-grid" role="listbox" aria-label=${t('干员列表')} ref=${gridRef}>
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">没有符合条件的干员</p>`}
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
</div>
</section>
<div class="lo-detail-wrap">
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />干员列表</button>
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />${t('干员列表')}</button>
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
</div>
</main>`}
</div>
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
title=${ioDiy ? t(io.mode === 'export' ? '导出自选编队' : '导入自选编队') : `${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`}
title=${ioDiy ? t(io.mode === 'export' ? '导出自选编队' : '导入自选编队')
: io.mode === 'export' ? (ioOwn ? t('导出干员持有') : t('导出干员调配')) : (ioOwn ? t('导入干员持有') : t('导入干员调配'))}
micro=${ioOwn ? 'OPERATOR ROSTER' : ioDiy ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'}
actions=${io.mode === 'export'
? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭<//>
<${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制<//>
<${Button} variant="primary" data-testid="loadout-io-download" onClick=${ioDownload}>下载文件<//>`
: html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消<//>
<${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件<//>
<${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入<//>`}>
? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>${t('关闭')}<//>
<${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>${t('复制')}<//>
<${Button} variant="primary" data-testid="loadout-io-download" onClick=${ioDownload}>${t('下载文件')}<//>`
: html`<${Button} variant="ghost" onClick=${() => setIo(null)}>${t('取消')}<//>
<${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>${t('选择文件')}<//>
<${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>${t('导入')}<//>`}>
<p class="lo-io__hint">${ioDiy
? (io.mode === 'export'
? t('共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。', { n: nDiy })
: t('把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。'))
: ioOwn
? (io.mode === 'export'
? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。`
: html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`)
? html`${tParts('共 {n} 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。', { n: html`<b class="num">${nNotOwned}</b>`, count: nNotOwned })}`
: html`${tParts('把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会{overwrite}当前的干员持有设置。', { overwrite: html`<strong>${t('覆盖')}</strong>` })}`)
: io.mode === 'export'
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
? html`${tParts('共 {n} 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。', { n: html`<b class="num">${nChanged}</b>`, count: nChanged })}`
: html`${t('把导出的内容粘贴到下方,或点「选择文件」。')}${nChanged ? html`${tParts('导入会{overwrite}当前的 {nChanged} 名干员调配。', { overwrite: html`<strong>${t('覆盖')}</strong>`, nChanged })}` : null}`}</p>
<textarea class="lo-io__text" data-testid="loadout-io-text" spellcheck=${false} readOnly=${io.mode === 'export'} value=${ioText}
placeholder=${io.mode === 'export' ? '' : ioDiy ? t('在此粘贴导出的自选编队内容…') : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'}
placeholder=${io.mode === 'export' ? '' : ioDiy ? t('在此粘贴导出的自选编队内容…') : ioOwn ? t('在此粘贴导出的干员持有内容…') : t('在此粘贴导出的调配内容…')}
onInput=${(e) => setIo({ ...io, text: e.currentTarget.value })}></textarea>
<input type="file" accept=".json,application/json,text/plain" class="lo-io__file" ref=${fileRef} onChange=${ioFile} />
<//>` : null}
@@ -675,7 +678,7 @@ export function badgeCount(entries, getChess) {
* Entry button (lobby / room / briefing).
* @param {{ from: 'lobby'|'room'|'briefing', size?: string, variant?: string, class?: string, label?: string }} props
*/
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = '干员调配' }) {
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = t('干员调配') }) {
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
@@ -686,11 +689,11 @@ export function LoadoutButton({ from, size = 'md', variant = 'secondary', class:
// 0.2.0 自选编队: how many DIY slots the player filled
const nDiy = diyCount(diy);
return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组 · 干员持有">
onClick=${() => openLoadout(from)} title=${t('调整干员携带的技能与模组 · 干员持有')}>
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-entry__icon" fallback=${html`<${Icon} name="edit" class="btn__icon" />`} />
<span class="btn__label">${label}</span>
${n ? html`<span class="lo-entry__n num" aria-label=${`${n} 名干员已调整`}>${n}</span>` : null}
${off ? html`<span class="lo-entry__own" aria-label=${`${off} 名干员未持有,由替补干员上场`} title=${`${off} 名干员未持有(由替补干员上场)`}>替补 <b class="num">${off}</b></span>` : null}
${n ? html`<span class="lo-entry__n num" aria-label=${t('{n} 名干员已调整', { n })}>${n}</span>` : null}
${off ? html`<span class="lo-entry__own" aria-label=${t('{off} 名干员未持有,由替补干员上场', { off })} title=${t('{off} 名干员未持有(由替补干员上场)', { off })}>${t('替补')} <b class="num">${off}</b></span>` : null}
${nDiy ? html`<span class="lo-entry__own lo-entry__diy" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })} title=${t('{n} 个自选名额已选择', { n: nDiy })}>${t('自选')} <b class="num">${nDiy}</b></span>` : null}
</button>`;
}
+11 -11
View File
@@ -16,14 +16,14 @@ import { chessAvatarUrl } from '../ui/assetUrls.js';
import { data } from '../data.js';
import { PROF_NAME } from '../ui/loadoutModel.js';
import { ownershipRoster, rosterByTier, isOwned, standInSummary } from '../ui/ownershipModel.js';
import { t } from '../../../shared/i18n.js';
import { t, tParts } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
/** The roster of the tab (data.list('chess'), memoised on the data being ready). */
export function useOwnershipRoster(ready) {
return useMemo(() => ownershipRoster(data.list('chess')), [ready]);
return useMemo(() => ownershipRoster(data.list('chess')), [ready, data.locale()]);
}
/** One chess: the operator → its stand-in, and the 持有 switch (the whole card is the switch). */
@@ -31,15 +31,15 @@ export function OwnCard({ m, chess, backups, owned, onToggle }) {
const si = standInSummary(chess, backups);
const skill = si?.skill;
const slot = skill && Number.isInteger(skill.index) ? `S${skill.index + 1}` : '';
const label = `${chess.name}:${owned ? '持有' : `未持有,由 ${si?.name || '替补干员'} 上场`}`;
const label = owned ? t('{name}:持有', { name: chess.name }) : t('{name}:未持有,由 {standIn} 上场', { name: chess.name, standIn: si?.name || t('替补干员') });
return html`<button type="button" role="switch" aria-checked=${owned ? 'true' : 'false'} aria-label=${label} data-chess=${chess.chessId}
class=${cx('own-card', `own-card--t${chess.tier}`, !owned && 'is-off')} onClick=${() => onToggle(chess.chessId, !owned)}
title=${owned ? `点击标记为未持有:由 ${si?.name || '替补干员'} 上场` : '点击恢复为持有'}>
title=${owned ? t('点击标记为未持有:由 {standIn} 上场', { standIn: si?.name || t('替补干员') }) : t('点击恢复为持有')}>
<span class="own-card__op">
<span class="own-card__ava"><${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<b>${[...(chess.name || '?')][0]}</b>`} /></span>
<span class="own-card__txt">
<b class="own-card__name">${chess.name}</b>
<small>${PROF_NAME[chess.profession] || ''}${chess.subProfessionName ? ` · ${chess.subProfessionName}` : ''}</small>
<small>${t(PROF_NAME[chess.profession] || '')}${chess.subProfessionName ? ` · ${chess.subProfessionName}` : ''}</small>
</span>
</span>
<span class="own-card__arrow" aria-hidden="true"><${Icon} name="chevronRight" /></span>
@@ -47,10 +47,10 @@ export function OwnCard({ m, chess, backups, owned, onToggle }) {
<span class="own-card__ava own-card__ava--sub">${si ? html`<${Img} src=${chessAvatarUrl(m, si.record)} fallback=${html`<b>${[...(si.name || '?')][0]}</b>`} />` : null}</span>
<span class="own-card__txt">
<b class="own-card__name">${si ? si.name : '—'}</b>
<small>${si ? `${PROF_NAME[si.profession] || ''}${slot ? ` · ${slot} ${skill.name || ''}` : ''}` : '无替补数据'}</small>
<small>${si ? `${t(PROF_NAME[si.profession] || '')}${slot ? ` · ${slot} ${skill.name || ''}` : ''}` : t('无替补数据')}</small>
</span>
</span>
<span class="own-card__state"><i class="own-switch" aria-hidden="true"><i></i></i>${owned ? '持有' : '未持有'}</span>
<span class="own-card__state"><i class="own-switch" aria-hidden="true"><i></i></i>${owned ? t('持有') : t('未持有')}</span>
</button>`;
}
@@ -67,15 +67,15 @@ export function OwnershipPanel({ m, roster, notOwned, onToggle }) {
<div class="own__bar">
<p class="own__lead">${t('未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。')}</p>
<button type="button" class=${cx('lo-toggle', offOnly && 'is-on')} aria-pressed=${offOnly ? 'true' : 'false'} onClick=${() => setOffOnly(!offOnly)}>
<i class="lo-toggle__box"><${Icon} name="check" /></i>仅看未持有</button>
<i class="lo-toggle__box"><${Icon} name="check" /></i>${t('仅看未持有')}</button>
</div>
<div class="own__list">
${groups.length ? groups.map((g) => html`<section key=${g.tier} class="own-tier" data-tier=${g.tier} aria-label=${`${g.tier}阶`}>
<h3 class="own-tier__head"><${TierChip} tier=${g.tier} size="sm" /><span class="num">${ROMAN[g.tier]}</span><span>阶</span><span class="num t-dim">${g.list.length}</span></h3>
${groups.length ? groups.map((g) => html`<section key=${g.tier} class="own-tier" data-tier=${g.tier} aria-label=${t('{tier}阶', { tier: g.tier })}>
<h3 class="own-tier__head"><${TierChip} tier=${g.tier} size="sm" />${tParts('{tier}阶', { tier: html`<span class="num">${ROMAN[g.tier]}</span>` })}<span class="num t-dim">${g.list.length}</span></h3>
<div class="own-grid">
${g.list.map((c) => html`<${OwnCard} key=${c.chessId} m=${m} chess=${c} backups=${backups} owned=${isOwned(notOwned, c.chessId)} onToggle=${onToggle} />`)}
</div>
</section>`) : html`<p class="lo-empty t-dim">${offOnly ? '全部持有:没有由替补干员上场的棋子' : '没有可下掉的干员'}</p>`}
</section>`) : html`<p class="lo-empty t-dim">${offOnly ? t('全部持有:没有由替补干员上场的棋子') : t('没有可下掉的干员')}</p>`}
</div>
</main>`;
}
+25 -21
View File
@@ -23,15 +23,17 @@ import { data } from '../data.js';
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js';
import { sentText } from '../ui/lang.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
// The first seven present are shown (+ remaining LP = a 4×2 grid); the title (评语) stats come first.
// `gold` is the funds a player SPENT (server/match/player/economy.js spend(); the 挥金如土 title stat).
const STAT_ROWS = [
['dmgDealt', '造成伤害'], ['kills', '击倒敌人'], ['bossDamage', '领袖伤害'], ['activatedLayers', '盟约层数'],
['merges', '晋升次数'], ['itemsEquipped', '配发装备'], ['gold', '消耗资金'], ['perfectRounds', '完美作战'],
['refreshes', '刷新次数'], ['leaks', '未击倒'], ['lpLost', '损失生命'],
['dmgDealt', N_('造成伤害')], ['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['activatedLayers', N_('盟约层数')],
['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')],
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
];
/**
@@ -43,13 +45,15 @@ export function LineupThumb({ u, gd }) {
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si}
title=${si ? `${si.name}(${standInForText(chess.name)})` : undefined} />`;
title=${si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : undefined} />`;
}
function PlayerCard({ p, myId, titles, best, solo = false }) {
const gd = useGameData();
const titleRec = p.title ? titles.find((t) => t.id === p.title.id) || null : null;
const titleName = p.title?.name || titleRec?.name || null;
const titleRec = p.title ? titles.find((x) => x.id === p.title.id) || null : null;
// (the server sends the title's Chinese name: the localized config.titles record when it is that record's)
const rawTitle = p.title ? (data.getRaw('config')?.titles || []).find((x) => x && x.id === p.title.id) || null : null;
const titleName = sentText(p.title?.name, rawTitle?.name, titleRec?.name) || titleRec?.name || null;
const stats = STAT_ROWS.filter(([k]) => Number.isFinite(p.stats[k])).slice(0, 7);
const band = p.bandId ? gd.band(p.bandId) : null;
const lineup = p.lineup.slice(0, 10);
@@ -58,28 +62,28 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
<header class="rcard__head">
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
<div class="rcard__who">
<b class="rcard__name">${p.name}${p.isBot ? html`<span class="rcard__ai">AI</span>` : null}${p.playerId === myId ? html`<span class="rcard__you">你</span>` : null}</b>
<b class="rcard__name">${p.name}${p.isBot ? html`<span class="rcard__ai">AI</span>` : null}${p.playerId === myId ? html`<span class="rcard__you">${t('你')}</span>` : null}</b>
<span class="rcard__band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : '—'}</span>
</div>
<div class="rcard__mid">
<div class="rcard__lineup">
${lineup.length ? lineup.map((u, i) => html`<${LineupThumb} key=${i} u=${u} gd=${gd} />`)
: html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
: html`<span class="rcard__noinfo">${p.alive === false ? t('阵容已撤离') : 'NO INFO'}</span>`}
</div>
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>
<${BondGlyph} bondId=${b.bondId} /><b class="num">${b.layers ?? 0}</b></span>`)}</div>` : null}
</div>
<div class="rcard__round"><${MicroLabel}>ROUNDS</${MicroLabel}><b class="num">${p.roundsPassed}</b>
${p.trophies > 0 || p.reward > 0 ? html`<span class="rcard__gain">${p.trophies > 0 ? html`<span title="获得奖杯"><${Icon} name="crown" /><b class="num">+${p.trophies}</b></span>` : null}${p.reward > 0 ? html`<span title="卫戍认证"><${Icon} name="shield" /><b class="num">+${p.reward}</b></span>` : null}</span>` : null}
${p.trophies > 0 || p.reward > 0 ? html`<span class="rcard__gain">${p.trophies > 0 ? html`<span title=${t('获得奖杯')}><${Icon} name="crown" /><b class="num">+${p.trophies}</b></span>` : null}${p.reward > 0 ? html`<span title=${t('卫戍认证')}><${Icon} name="shield" /><b class="num">+${p.reward}</b></span>` : null}</span>` : null}
</div>
${titleName ? html`<div class="rcard__title" title=${titleRec?.text || p.title?.text || ''}>
<${Img} src=${titleIconUrl(gd.m, titleRec?.picId || p.title?.picId || String(p.title.id || '').replace('comment_', 'comment_icon_'))} class="rcard__ticon" fallback=${html`<${Icon} name="crown" />`} />
<span><${MicroLabel} tone="gold">评语</${MicroLabel}><b>${titleName}</b></span>
<span><${MicroLabel} tone="gold">${t('评语')}</${MicroLabel}><b>${titleName}</b></span>
</div>` : html`<span class="rcard__title rcard__title--none" aria-hidden="true"></span>`}
</header>
<div class="rcard__stats">
${stats.map(([k, label]) => html`<div key=${k} class=${cx('rstat', best[k] === p.playerId && 'is-best')}><span>${label}</span><b class="num">${fmtNum(p.stats[k])}</b></div>`)}
${Number.isFinite(p.lp) ? html`<div class="rstat" title=${p.lpShared && !solo ? '最终攻势起全队共享目标生命值' : ''}><span>${p.lpShared && !solo ? '同盟剩余生命' : '剩余生命'}</span><${LpTower} value=${p.lp} size="sm" /></div>` : null}
${stats.map(([k, label]) => html`<div key=${k} class=${cx('rstat', best[k] === p.playerId && 'is-best')}><span>${t(label)}</span><b class="num">${fmtNum(p.stats[k])}</b></div>`)}
${Number.isFinite(p.lp) ? html`<div class="rstat" title=${p.lpShared && !solo ? t('最终攻势起全队共享目标生命值') : ''}><span>${p.lpShared && !solo ? t('同盟剩余生命') : t('剩余生命')}</span><${LpTower} value=${p.lp} size="sm" /></div>` : null}
</div>
</article>`;
}
@@ -114,30 +118,30 @@ export function ResultScreen() {
<section class="result__hero">
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
<h1 class="result__headline">${r.victory ? '模拟完成' : '模拟失败'}</h1>
<p class="result__sub">${r.victory ? '成功卫戍 · 敌方领袖已被击败' : '防线已被突破'}</p>
<h1 class="result__headline">${r.victory ? t('模拟完成') : t('模拟失败')}</h1>
<p class="result__sub">${r.victory ? t('成功卫戍 · 敌方领袖已被击败') : t('防线已被突破')}</p>
<div class="result__rounds">
<span class="result__rlabel">通过回合</span>
<span class="result__rlabel">${t('通过回合')}</span>
<b class="result__rnum num">${r.roundsPassed}</b>
${r.roundsPassed <= r.lastRound ? html`<span class="result__rof num">/${r.lastRound}</span>` : null}
</div>
<div class="result__medals">
${boss ? html`<div class=${cx('medal', r.victory && 'is-done')} title=${boss.name}>
<${Img} src=${enemyIconUrl(gd.m, boss.enemyKey)} /><span class="medal__check">${r.victory ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
<span class="medal__label">敌方领袖</span></div>` : null}
<span class="medal__label">${t('敌方领袖')}</span></div>` : null}
${hidden ? html`<div class=${cx('medal', 'medal--hidden', r.hiddenCleared && 'is-done')} title=${hidden.name}>
<${Img} src=${enemyIconUrl(gd.m, hidden.enemyKey)} /><span class="medal__check">${r.hiddenCleared ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
<span class="medal__label">隐秘核心</span></div>` : null}
<span class="medal__label">${t('隐秘核心')}</span></div>` : null}
</div>
${mins ? html`<p class="result__time t-lo">本局耗时 <b class="num">${mins}</b> 分钟</p>` : null}
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
<footer class="result__foot">
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? '返回同盟' : '返回大厅'}<//>
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')}<//>
</footer>
</section>
<section class="result__players">
<h2 class="brief-h"><span>同盟成员</span><${MicroLabel}>ALLIANCE REPORT</${MicroLabel}></h2>
<h2 class="brief-h"><span>${t('同盟成员')}</span><${MicroLabel}>ALLIANCE REPORT</${MicroLabel}></h2>
${r.players.length ? r.players.map((p) => html`<${PlayerCard} key=${p.playerId} p=${p} myId=${myId} titles=${titles} best=${best} solo=${r.players.length < 2} />`)
: html`<p class="t-dim">暂无结算数据</p>`}
: html`<p class="t-dim">${t('暂无结算数据')}</p>`}
</section>
</main>
</div>`;
+3 -2
View File
@@ -21,6 +21,7 @@
import { useLayoutEffect, useReducer, useRef } from '../vendor/hooks.module.js';
import { PHASE } from '../../shared/constants.js';
import { t } from '../../shared/i18n.js';
/**
* Create an observable store.
@@ -125,8 +126,8 @@ export function isSpectating(room, playerId) {
export function sessionResetNotice(prev, playerId) {
const prevId = prev?.me?.playerId;
if (prevId == null || prevId === playerId) return null;
if (prev?.match?.public || prev?.room?.inMatch) return '服务器会话已重置,上一局模拟已结束';
if (prev?.room) return '服务器会话已重置,已返回大厅';
if (prev?.match?.public || prev?.room?.inMatch) return t('服务器会话已重置,上一局模拟已结束');
if (prev?.room) return t('服务器会话已重置,已返回大厅');
return null;
}
+25 -24
View File
@@ -28,13 +28,13 @@ import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMON
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
import { t, tc, tParts, tName } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** The "👁 name" tag of a teammate's strip (DESIGN §20.15). */
function OwnerTag({ owner }) {
return html`<span class="bstrip__owner" title=${`正在查看 ${owner} 的盟约`} data-owner=${owner}>
return html`<span class="bstrip__owner" title=${t('正在查看 {owner} 的盟约', { owner })} data-owner=${owner}>
<${GIcon} name="eye" class="bstrip__eye" /><${MicroLabel}>${owner}</${MicroLabel}>
</span>`;
}
@@ -47,12 +47,12 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
const sorted = sortBonds(bonds, (id) => data.lookup('bonds', id));
if (!sorted.length) {
return html`<div class=${cx('bstrip', 'bstrip--empty', owner && 'is-other')} data-owner=${owner || null}>
${owner ? html`<${OwnerTag} owner=${owner} />` : html`<${MicroLabel}>BONDS</${MicroLabel}>`}<span>${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}</span></div>`;
${owner ? html`<${OwnerTag} owner=${owner} />` : html`<${MicroLabel}>BONDS</${MicroLabel}>`}<span>${owner ? t('{owner} 尚未激活盟约', { owner }) : t('部署干员以激活盟约')}</span></div>`;
}
const m = data.get('assets');
const shown = sorted.slice(0, max);
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list"
aria-label=${owner ? `${owner} 的盟约` : '我的盟约'} data-owner=${owner || null}>
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} 名成员`} />
<span class="bslot__count bslot__off">本局禁用</span>
title=${t('{tip} · {n} 名成员', { tip: briefingBondTip(name, 'off'), n: b.count ?? 0 })} />
<span class="bslot__count bslot__off">${t('本局禁用')}</span>
</div>`;
}
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 }) : ''}`} />
<span class=${cx('bslot__count', 'num', next == null && 'is-max')}>${b.count ?? 0}<small>/${next ?? th[th.length - 1] ?? '-'}</small></span>
</div>`;
})}
${sorted.length > shown.length ? html`<span class="bstrip__more num">+${sorted.length - shown.length}</span>` : null}
</div>`;
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`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over', owner && 'is-other')} data-place=${at} data-owner=${owner || null}
role="dialog" aria-label=${owner ? `${owner} 的盟约:${b.name}` : `盟约:${b.name}`}>
<button type="button" class="bpop__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
role="dialog" aria-label=${owner ? t('{owner} 的盟约:{name}', { owner, name: b.name }) : t('盟约:{name}', { name: b.name })}>
<button type="button" class="bpop__close" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
<header class="bpop__head">
<div class=${cx('bpop__disc', active && 'is-active')}><${BondGlyph} bondId=${bondId} /></div>
<div class="bpop__titles">
<${MicroLabel} tone="mint">${b.isCore ? 'CORE BOND // 核心盟约' : 'ADD-ON BOND // 附加盟约'}</${MicroLabel}>
${owner ? html`<span class="bpop__owner"><${GIcon} name="eye" /><b>${owner}</b> 的盟约</span>` : null}
<${MicroLabel} tone="mint">${b.isCore ? t('CORE BOND // 核心盟约') : t('ADD-ON BOND // 附加盟约')}</${MicroLabel}>
${owner ? html`<span class="bpop__owner"><${GIcon} name="eye" />${tParts('{owner} 的盟约', { owner: html`<b>${owner}</b>` })}</span>` : null}
<h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts">
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}${harmony ? html`<small class="bpop__hnote" data-harmony=${harmony}>(含${harmonyName} +${harmony})</small>` : null}</span>`}
${b.noStack ? html`<span>层数不显示</span>` : html`<span>层数 <b class="num t-mint">${layers}</b></span>`}
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span>
${off ? null : html`<span>${t('在场')} <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>${t('(含整备区)')}</small>` : null}${harmony ? html`<small class="bpop__hnote" data-harmony=${harmony}>${t('(含{harmonyName} +{harmony})', { harmonyName, harmony })}</small>` : null}</span>`}
${b.noStack ? html`<span>${t('层数不显示')}</span>` : html`<span>${t('层数')} <b class="num t-mint">${layers}</b></span>`}
<span class=${active ? 't-mint' : 't-lo'}>${active ? (th.length > 1 ? t('已激活 · {tier} 阶', { tier }) : t('已激活')) : off ? t('本局禁用') : t('未激活')}</span>
</div>
</div>
</header>
${off ? html`<p class="bpop__off" role="note">本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)</p>` : null}
${off ? html`<p class="bpop__off" role="note">${t('本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)')}</p>` : null}
<div class="bpop__tiers">
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? t('名及以下') : tc('bond-threshold', '名')}</small></span>`)}
</div>
${hasNow ? html`<section class="bpop__sec bpop__sec--now">
<h4>当前效果 ${b.noStack ? null : html`<small class="num">(${layers} 层)</small>`}</h4>
<h4>${t('当前效果')} ${b.noStack ? null : html`<small class="num">${t('({layers} 层)', { layers })}</small>`}</h4>
<${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
</section>` : null}
<section class="bpop__sec">
<h4>盟约效果</h4>
<h4>${t('盟约效果')}</h4>
<${RichText} as="p" text=${b.descRaw || b.desc} class="bpop__desc" />
</section>
<section class="bpop__sec">
<h4>成员 <small>${memberHeadCount(members, countsHand)}/${members.length}</small></h4>
<h4>${t('成员')} <small>${memberHeadCount(members, countsHand)}/${members.length}</small></h4>
${harmony ? (harmonyBy.length
? html`<button type="button" class="bpop__harmony" data-harmony=${harmony} title=${harmonyText} onClick=${() => onMember?.(harmonyBy[0].id, null)}>
<${BondGlyph} bondId=${HARMONY_BOND} /><b>${harmonyName} +${harmony}</b><span>${harmonyText}</span></button>`
@@ -167,11 +168,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
return html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted', si && 'is-standin')}
onClick=${() => (si ? onMember?.(mb.id, items, si.standInFor) : onMember?.(mb.id, items))}
data-granted=${mb.granted ? '1' : null} data-standin=${si ? si.charId : null}
title=${`${name}${si ? `(${standInForText(mb.name)})` : ''}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
title=${`${name}${si ? t('({note})', { note: standInForText(mb.name) }) : ''}${mb.granted ? t('(变形同构体:视为本盟约成员)') : ''}${mb.banned ? t('(本局禁用)') : mb.onBoard ? t('(在场)') : mb.owned ? t('(整备区)') : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} rec=${si || (mb.diy ? mb.rec : null)} title=${name} />
<span class="bpop__mname">${name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">${t('同构')}</span>` : null}
${mb.diy ? html`<span class="bpop__iso bpop__diy" data-diy="1" aria-hidden="true">${t('自选')}</span>` : null}
</button>`;
})}
+30 -24
View File
@@ -23,6 +23,8 @@ import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
import { richTextPlain } from './richText.js';
import { sortedPlayers } from './gameLogic.js';
import { data } from '../data.js';
import { t, tName } from '../../../shared/i18n.js';
import { sentText } from './lang.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -31,12 +33,14 @@ const bare = (t) => String(t || '').replace(/\s+/g, '');
/**
* A card's effect text: the card's own rich text, else the data's rich text (highlighted numbers, 下场作战, set bonuses,
* line breaks — the detail card's text) while it says what the server's plain `desc` says, else that plain text (a
* server-side wording the data does not have wins).
* server-side wording the data does not have wins). The server's texts are Chinese: `rawRich` is the record's Chinese
* rich text they are compared with, `rich` the same record in the current language (the two are one text in Chinese).
* @param {{ descRaw?: string, desc?: string }} card @param {string} rich items.json / effects.json descRaw
* @param {string} [rawRich] its Chinese text (data.lookupRaw)
*/
export function cardText(card, rich) {
if (card.descRaw) return card.descRaw;
if (rich && (!card.desc || bare(richTextPlain(rich)) === bare(card.desc))) return rich;
export function cardText(card, rich, rawRich = rich) {
if (card.descRaw) return rich && card.descRaw === rawRich ? rich : card.descRaw;
if (rich && (!card.desc || bare(richTextPlain(rawRich || rich)) === bare(card.desc))) return rich;
return card.desc || rich || '';
}
@@ -52,6 +56,7 @@ export function resolveSpCard(card, family) {
const eff = effectId ? data.lookup('effects', effectId) : null;
const itemId = card.itemId || (!effectId && typeof card.id === 'string' && data.lookup('items', card.id) ? card.id : null);
const item = itemId ? data.lookup('items', itemId) : null;
const rawRich = (itemId && data.lookupRaw('items', itemId)?.descRaw) || (effectId && data.lookupRaw('effects', effectId)?.descRaw) || '';
const bounty = byEffect(choices?.cards?.bounty, effectId);
const tactic = byEffect(choices?.cards?.tactic, effectId);
const m = data.get('assets');
@@ -66,8 +71,8 @@ export function resolveSpCard(card, family) {
else icon = uiUrl(m, `buffIcon/${team ? 'icon_team_buff' : 'icon_player_buff'}`);
return {
kind,
name: card.name || item?.name || eff?.name || bounty?.name || tactic?.name || '机变',
desc: cardText(card, item?.descRaw || eff?.descRaw || '') || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '',
name: tName(card.name) || item?.name || eff?.name || bounty?.name || tactic?.name || t('机变'),
desc: cardText(card, item?.descRaw || eff?.descRaw || '', rawRich) || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '',
tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null,
icon,
team: !!team,
@@ -155,10 +160,11 @@ export function ChoiceOverlay(props) {
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null;
const rawFam = data.getRaw('choices')?.families?.[sp.family] || null;
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
const myTurn = solo || sp.turnPid === myId;
const mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || '队友';
const turnName = players.get(sp.turnPid)?.name || t('队友');
const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order;
const timed = !solo && !sp.untimed;
@@ -171,25 +177,25 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm();
};
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label="机变阶段" onPointerDown=${onDown}>
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${t('机变阶段')} onPointerDown=${onDown}>
<div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner">
<header class="spov__head">
<div class="spov__titles">
<${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || '机变'}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || '选择一项'} /></span></h2>
<p class="spov__sub">${timed ? '倒计时结束后仍未选定将自动分配' : '选择一项(无时间限制)'}${mine == null && myTurn ? ' · 点击卡牌选中,再次点击确认' : ''}</p>
<${MicroLabel} tone="mint">${t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sentText(sp.name, rawFam?.name, fam?.name) || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sentText(sp.desc, rawFam?.desc, fam?.desc) || fam?.desc || t('选择一项')} /></span></h2>
<p class="spov__sub">${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
</div>
<div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>`
: html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></span>`}
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">${t('当前轮到你决策')}</span>`
: html`<span class="spov__turntxt">${t('{turnName} 正在决策…', { turnName })}<${Icon} name="hourglass" /></span>`}
${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
title=${armedName ? `确认选择「${armedName}」(再次点击卡牌亦可)` : '确认选择'} onClick=${onConfirm}>确认选择<//>` : null}
title=${armedName ? t('确认选择「{armedName}」(再次点击卡牌亦可)', { armedName }) : t('确认选择')} onClick=${onConfirm}>${t('确认选择')}<//>` : null}
${timed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
</div>
</header>
${order.length ? html`<div class="spov__order" aria-label="决策顺序">
${order.length ? html`<div class="spov__order" aria-label=${t('决策顺序')}>
${order.map((pid, i) => {
const p = players.get(pid);
const picked = sp.pickOf.has(pid);
@@ -198,7 +204,7 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
return html`<div key=${pid} class=${cx('spov__who', cur && 'is-cur', picked && 'is-done', pid === myId && 'is-self')}>
<span class="spov__idx num">${i + 1}</span>
<${PlayerAvatar} player=${p || { name: '?' }} size="sm" self=${pid === myId} />
<span class="spov__wname">${p?.name || '博士'}</span>
<span class="spov__wname">${p?.name || t('博士')}</span>
<span class="spov__wstate">${left ? html`<${Icon} name="exit" />` : picked ? html`<${Icon} name="check" />` : cur ? html`<${Icon} name="hourglass" />` : html`<${Icon} name="dots" />`}</span>
</div>`;
})}
@@ -210,26 +216,26 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const can = cardPickable(sp, card, { myId, solo, busyIdx });
const busy = pickBusy(busyIdx, card, mine);
const isArmed = can && armed === card.idx;
const takerName = taker ? (card.takenBy === myId ? '你' : taker.name) : null;
const takerName = taker ? (card.takenBy === myId ? t('你') : taker.name) : null;
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined} disabled=${!can} onClick=${() => can && onTap(card.idx)}
aria-label=${isArmed ? `${r.name},已选中,再次点击确认` : takerName ? `${r.name},${takerName}已选择` : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
aria-label=${isArmed ? t('{name},已选中,再次点击确认', { name: r.name }) : takerName ? t('{name},{takerName}已选择', { name: r.name, takerName }) : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span>
<span class="spcard__head">
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
<span class="spcard__title">
<b class="spcard__name">${r.name}</b>
${r.team || (r.kind === 'bounty' && r.coin) ? html`<span class="spcard__tags">
${r.team ? html`<span class="spcard__tag spcard__tag--team">全队获得</span>` : null}
${r.kind === 'bounty' && r.coin ? html`<span class="spcard__tag spcard__tag--coin">赏金 ${r.coin}</span>` : null}
${r.team ? html`<span class="spcard__tag spcard__tag--team">${t('全队获得')}</span>` : null}
${r.kind === 'bounty' && r.coin ? html`<span class="spcard__tag spcard__tag--coin">${t('赏金 {coin}', { coin: r.coin })}</span>` : null}
</span>` : null}
</span>
</span>
<${RichText} text=${r.desc} class="spcard__desc" />
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /></span>` : null}
${isArmed ? html`<span class="spcard__confirm" role="status"><b>确认选择</b><small>再次点击</small></span>` : null}
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
${taker ? html`<span class="spcard__taker" title=${t('{name} 已选择', { name: taker.name })}><${PlayerAvatar} player=${taker} size="sm" /></span>` : null}
${isArmed ? html`<span class="spcard__confirm" role="status"><b>${t('确认选择')}</b><small>${t('再次点击')}</small></span>` : null}
${busy ? html`<span class="spcard__busy" role="status">${t('选择中')}</span>` : null}
</button>`;
})}
</div>
+16 -15
View File
@@ -13,17 +13,18 @@ import { html, Icon } from './components.js';
import { DpCounter } from './hud.js';
import { GIcon } from './gameComponents.js';
import { switcherLabel, cycleField } from './gameLogic.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Teammates' progress under the waiting pill (css/screens/game.css .chud__progress / .chud__prog). */
function ProgressList({ list }) {
if (!Array.isArray(list) || !list.length) return null;
return html`<div class="chud__progress" role="list" aria-label="队友作战进度">
return html`<div class="chud__progress" role="list" aria-label=${t('队友作战进度')}>
${list.map((p) => html`<span key=${p.playerId} role="listitem" class=${cx('chud__prog', p.done && 'is-done')}>
<b>${p.name}</b>
${p.done
? html`<span class="chud__prog__ok" aria-label="作战结束">✓</span>`
? html`<span class="chud__prog__ok" aria-label=${t('作战结束')}>✓</span>`
: html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
</span>`)}
</div>`;
@@ -44,14 +45,14 @@ export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectatin
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>` : null}
${spectating ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>观战中</span></div>`
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,正在观战</span></div>`) : null}
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>${t('作战结束,等待队友完成作战')}</span></div>` : null}
${spectating ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>${t('观战中')}</span></div>`
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>${t('你已被淘汰,正在观战')}</span></div>`) : null}
${fields.length ? html`<div class=${cx('vswitch', !canCycle && 'is-single')}>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="上一个战场"
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label=${t('上一个战场')}
onClick=${() => { const n = cycleField(fields, watching, -1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">${label}</span>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="下一个战场"
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label=${t('下一个战场')}
onClick=${() => { const n = cycleField(fields, watching, 1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronRight" /></button>
</div>` : null}
</div>
@@ -72,22 +73,22 @@ function ClientHud({ hud, myDone, spectating, spectator = false, client }) {
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>${t('作战结束,等待队友完成作战')}</span></div>
<${ProgressList} list=${client.progress} />` : null}
${spectating && !observing ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>观战中,可点击左侧成员头像前往查看</span></div>`
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>`) : null}
${spectating && !observing ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>${t('观战中,可点击左侧成员头像前往查看')}</span></div>`
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>${t('你已被淘汰,可点击队友头像前往查看')}</span></div>`) : null}
${observing && !halves ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span>
${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
<span class="btn__label">${t('返回战场')}</span></button>` : null}
</div>` : null}
${halves ? html`<div class=${cx('vswitch', 'chud__layers', observing && 'is-observing')}>
<button type="button" class="vswitch__arrow" disabled=${idx <= 0} aria-label="左侧战场" onClick=${() => step(-1)}><${Icon} name="chevronLeft" /></button>
<button type="button" class="vswitch__arrow" disabled=${idx <= 0} aria-label=${t('左侧战场')} onClick=${() => step(-1)}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">
${cur && cur.watch ? html`<${GIcon} name="eye" />` : null}<span>${cur ? cur.label : '全景'}</span></span>
<button type="button" class="vswitch__arrow" disabled=${idx >= layers.length - 1} aria-label="右侧战场" onClick=${() => step(1)}><${Icon} name="chevronRight" /></button>
${cur && cur.watch ? html`<${GIcon} name="eye" />` : null}<span>${cur ? cur.label : t('全景')}</span></span>
<button type="button" class="vswitch__arrow" disabled=${idx >= layers.length - 1} aria-label=${t('右侧战场')} onClick=${() => step(1)}><${Icon} name="chevronRight" /></button>
${observing && client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
<span class="btn__label">${t('返回战场')}</span></button>` : null}
</div>` : null}
</div>
</div>`;
+11 -11
View File
@@ -194,12 +194,12 @@ export function tierChipUrl(t) {
* @param {{ tier: number, golden?: boolean, size?: 'sm'|'md'|'lg', class?: string }} props
*/
export function TierChip({ tier, golden = false, size = 'md', class: cls }) {
const t = Math.max(1, Math.min(6, Number(tier) | 0 || 1));
const src = tierChipUrl(t);
const n = Math.max(1, Math.min(6, Number(tier) | 0 || 1));
const src = tierChipUrl(n);
const [bad, setBad] = useState(null);
const img = !!src && bad !== src;
return html`<span class=${cx('tier', `tier--${t}`, `tier--${size}`, golden && 'tier--golden', img && 'tier--img', cls)} aria-label=${`${t}阶`}>
${img ? html`<img src=${src} alt="" draggable=${false} onError=${() => setBad(src)} />` : roman(t)}
return html`<span class=${cx('tier', `tier--${n}`, `tier--${size}`, golden && 'tier--golden', img && 'tier--img', cls)} aria-label=${t('{tier}阶', { tier: n })}>
${img ? html`<img src=${src} alt="" draggable=${false} onError=${() => setBad(src)} />` : roman(n)}
</span>`;
}
@@ -240,7 +240,7 @@ export function BondDisc({
: html`<span class="bond__glyph">${glyph}</span>`}
</span>
${stack != null ? html`<span class="bond__count">${stack}</span>` : null}
${disabled ? html`<span class="bond__ban" aria-label="禁用"><${Icon} name="close" /></span>` : null}
${disabled ? html`<span class="bond__ban" aria-label=${t('禁用')}><${Icon} name="close" /></span>` : null}
</span>
${showName && name ? html`<span class="bond__name">${name}</span>` : null}
<//>`;
@@ -373,7 +373,7 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
// untimed phase: no timer on screen (hooks above run unconditionally, so the component may toggle freely)
if (remain == null) return null;
return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? '无倒计时' : `剩余${remain}秒`}>
return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? t('无倒计时') : t('剩余{remain}秒', { remain })}>
<div class="countdown__main">
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} />
${label ? html`<span class="countdown__label">${label}</span>` : null}
@@ -489,7 +489,7 @@ export function DialogHost() {
}, []);
const d = dialogs[0];
if (!d) return null;
const { title = '确认', text, okText = '确认', cancelText = '取消', tone, danger, micro = 'CONFIRMATION' } = d.opts;
const { title = t('确认'), text, okText = t('确认'), cancelText = t('取消'), tone, danger, micro = 'CONFIRMATION' } = d.opts;
const isConfirm = d.kind === 'confirm';
// Enter confirms unless a specific button has focus (then the native click decides).
const onKey = (e) => {
@@ -670,7 +670,7 @@ export function Spinner({ size = 'md', label, tone = 'mint', class: cls }) {
<polygon class="spinner__track" points="25,3 44,14 44,36 25,47 6,36 6,14" />
<polygon class="spinner__arc" points="25,3 44,14 44,36 25,47 6,36 6,14" />
</svg>
${label ? html`<span class="spinner__label">${label}</span>` : html`<span class="sr-only">加载中</span>`}
${label ? html`<span class="spinner__label">${label}</span>` : html`<span class="sr-only">${t('加载中')}</span>`}
</span>`;
}
@@ -695,8 +695,8 @@ export function AvatarFrame({ name = '', src, size = 'md', seat = 0, host, bot,
: bot ? html`<${Icon} name="robot" class="avatar__bot" />`
: html`<span class="avatar__glyph">${glyph}</span>`}
</div>
${host ? html`<span class="avatar__badge avatar__badge--host" title="创建者"><${Icon} name="crown" /></span>` : null}
${self ? html`<span class="avatar__badge avatar__badge--self" title="你"><${Icon} name="user" /></span>` : null}
${host ? html`<span class="avatar__badge avatar__badge--host" title=${t('创建者')}><${Icon} name="crown" /></span>` : null}
${self ? html`<span class="avatar__badge avatar__badge--self" title=${t('你')}><${Icon} name="user" /></span>` : null}
${bot && !empty ? html`<span class="avatar__tag">AI</span>` : null}
</div>`;
}
@@ -755,7 +755,7 @@ export function doctorNo(id) {
export function PingPill({ ms, online = true, class: cls }) {
const ok = online && Number.isFinite(ms);
const tier = !ok ? 'off' : ms < 60 ? 'low' : ms < 200 ? 'medium' : 'high';
return html`<span class=${cx('ping', `ping--${tier}`, cls)} title=${ok ? `当前延迟 ${ms}ms` : '未连接'}>
return html`<span class=${cx('ping', `ping--${tier}`, cls)} title=${ok ? t('当前延迟 {ms}ms', { ms }) : t('未连接')}>
<${Icon} name=${ok ? 'signal' : 'wifiOff'} class="ping__icon" />
<span class="ping__value">${ok ? Math.min(9999, Math.round(ms)) : '--'}</span><span class="ping__unit">ms</span>
</span>`;
+13 -12
View File
@@ -9,6 +9,7 @@ import { html, Button, Icon, useTicker } from './components.js';
import { net, CLIENT_ERR_TEXT } from '../net.js';
import { useStore, shallowEqual } from '../store.js';
import { useDocClass } from './device.js';
import { t } from '../../../shared/i18n.js';
/** Whether the banner shows for this connection state (mirrors the early returns below). */
export function bannerVisible(conn, entered, restoring, buildStale = false) {
@@ -28,15 +29,15 @@ export function ConnectionBanner() {
if (!entered) return null;
if (conn.status === 'online' && !restoring && !buildStale) return null;
if (conn.status === 'online' && restoring) {
return html`<div class="conn-banner" role="status"><${Icon} name="refresh" /><span>正在同步同盟状态…</span></div>`;
return html`<div class="conn-banner" role="status"><${Icon} name="refresh" /><span>${t('正在同步同盟状态…')}</span></div>`;
}
if (conn.status === 'online' && buildStale) {
// the server has a newer build than this page: the guard reloads by itself once the match is over, the button is
// for a player who would rather do it now
return html`<div class="conn-banner" role="alert">
<${Icon} name="refresh" />
<span>服务器已更新,本局结束后刷新</span>
<${Button} size="sm" variant="secondary" icon="refresh" onClick=${() => location.reload()}>刷新页面<//>
<span>${t('服务器已更新,本局结束后刷新')}</span>
<${Button} size="sm" variant="secondary" icon="refresh" onClick=${() => location.reload()}>${t('刷新页面')}<//>
</div>`;
}
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return null;
@@ -47,18 +48,18 @@ export function ConnectionBanner() {
// Short transitional states (a rename re-sends hello on the live socket) only show if they linger.
const transient = conn.status === 'connecting' || conn.status === 'handshaking' || (conn.status === 'connected' && !rejected);
const text = conn.status === 'reconnecting'
? '与服务器的连接已中断,正在重连'
: replaced ? '该身份已在其他页面登录'
: conn.status === 'closed' ? '连接已关闭'
: rejected ? conn.lastError.text : '正在连接服务器';
const action = conn.status === 'reconnecting' ? { label: '立即重连', run: () => net.retryNow() }
: conn.status === 'closed' ? { label: replaced ? '在此页面继续' : '重新连接', run: () => net.connect() }
: versionMismatch ? { label: '刷新页面', run: () => location.reload() }
: rejected ? { label: '重试', run: () => net.reconnectNow() } : null;
? t('与服务器的连接已中断,正在重连')
: replaced ? t('该身份已在其他页面登录')
: conn.status === 'closed' ? t('连接已关闭')
: rejected ? t(conn.lastError.text) : t('正在连接服务器');
const action = conn.status === 'reconnecting' ? { label: t('立即重连'), run: () => net.retryNow() }
: conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: () => net.connect() }
: versionMismatch ? { label: t('刷新页面'), run: () => location.reload() }
: rejected ? { label: t('重试'), run: () => net.reconnectNow() } : null;
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
<${Icon} name="wifiOff" />
<span>${text}</span>
${conn.status === 'reconnecting' ? html`<span class="conn-banner__sub">第 ${conn.attempt} 次 · ${secs}s</span>` : null}
${conn.status === 'reconnecting' ? html`<span class="conn-banner__sub">${t('第 {attempt} 次 · {secs}s', { attempt: conn.attempt, secs })}</span>` : null}
${action ? html`<${Button} size="sm" variant="secondary" icon="refresh" onClick=${action.run}>${action.label}<//>` : null}
</div>`;
}
+92 -92
View File
@@ -41,18 +41,18 @@ import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
import { t } from '../../../shared/i18n.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const PROF_NAME = { PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种', TOKEN: '召唤物' };
const SP_TYPE = { INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动' };
const SKILL_TYPE = { MANUAL: '自动触发', AUTO: '自动触发', PASSIVE: '被动' };
const PROF_NAME = { PIONEER: N_('先锋'), WARRIOR: N_('近卫'), TANK: N_('重装'), SNIPER: N_('狙击'), CASTER: N_('术师'), MEDIC: N_('医疗'), SUPPORT: N_('辅助'), SPECIAL: N_('特种'), TOKEN: N_('召唤物') };
const SP_TYPE = { INCREASE_WITH_TIME: N_('自动回复'), INCREASE_WHEN_ATTACK: N_('攻击回复'), INCREASE_WHEN_TAKEN_DAMAGE: N_('受击回复'), ON_DEPLOY: N_('被动') };
const SKILL_TYPE = { MANUAL: N_('自动触发'), AUTO: N_('自动触发'), PASSIVE: N_('被动') };
/** Fallback type icon by trigger, for a garrison record without its official `eventTypeIcon` (garrisonTypeIconKey). */
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_bond', SERVER_PREP_FIN: 's_icon_bond', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
const RANK = { NORMAL: '普通', ELITE: '精英', BOSS: '领袖' };
const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' };
const RANK = { NORMAL: N_('普通'), ELITE: N_('精英'), BOSS: N_('领袖') };
const DMG = { phys: N_('物理'), arts: N_('法术'), heal: N_('治疗'), true: N_('真实'), none: N_('无') };
/** A range grid of at least this many tiles covers the field (纯烬艾雅法拉 S3 "攻击范围扩大至整个战场"): named, not drawn. */
export const FIELD_WIDE_CELLS = 400;
@@ -87,7 +87,7 @@ export function cardRangeGrid(live, rec, chess) {
/** Mini range map. */
export function RangeGrid({ grid, class: cls }) {
if (Array.isArray(grid) && grid.length >= FIELD_WIDE_CELLS) return html`<span class=${cx('rgrid-all', cls)} aria-label="攻击范围">全场</span>`;
if (Array.isArray(grid) && grid.length >= FIELD_WIDE_CELLS) return html`<span class=${cx('rgrid-all', cls)} aria-label=${t('攻击范围')}>${t('全场')}</span>`;
const box = rangeGridBox(grid);
if (!box.cells.size) return html`<span class="t-dim">—</span>`;
const cells = [];
@@ -97,7 +97,7 @@ export function RangeGrid({ grid, class: cls }) {
cells.push(html`<i key=${`${r},${c}`} class=${cx(box.cells.has(tileKey(r, c)) && 'on', self && 'self')}></i>`);
}
}
return html`<div class=${cx('rgrid', cls)} style=${rangeGridStyle(box)} aria-label="攻击范围">${cells}</div>`;
return html`<div class=${cx('rgrid', cls)} style=${rangeGridStyle(box)} aria-label=${t('攻击范围')}>${cells}</div>`;
}
function Stat({ k, v, sub, tone = null, title }) {
@@ -140,15 +140,15 @@ export function liveStat(live, key, fallback, fmt = fmtNum) {
const d = cur - base;
sub = key === 'interval' ? `${d > 0 ? '+' : '−'}${Math.abs(d).toFixed(2)}` : `${d > 0 ? '+' : '−'}${key === 'res' || key === 'moveSpeed' ? Math.round(Math.abs(d) * 10) / 10 : fmtNum(Math.abs(d))}`;
}
return { v: fmt(cur), tone, sub, title: base != null ? `基础 ${fmt(base)}` : undefined };
return { v: fmt(cur), tone, sub, title: base != null ? t('基础 {v}', { v: fmt(base) }) : undefined };
}
/** The tag of a live stats block: 实时 (battle) / 开战时 (the prep preview). */
function LiveTag({ live }) {
if (!live) return null;
const battle = live.src === 'battle';
return html`<span class=${cx('dstats__tag', battle && 'is-battle')} title=${battle ? '当前作战中的实时数值(绿色为增益,红色为减益)'
: '下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)'}>${battle ? '实时' : '开战时'}</span>`;
return html`<span class=${cx('dstats__tag', battle && 'is-battle')} title=${battle ? t('当前作战中的实时数值(绿色为增益,红色为减益)')
: t('下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)')}>${battle ? t('实时') : t('开战时')}</span>`;
}
const fmtInterval = (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(2)}s` : '—');
@@ -181,16 +181,16 @@ export function MorphPairings({ off = null, carried = null }) {
if (!rows.length) return null;
const wearer = Array.isArray(carried);
return html`<div class=${cx('dmorph', wearer && 'is-wearer')}>
<p class="dmorph__lead">搭配以下装备时,携带者视为对应盟约的成员:</p>
<ul class="dmorph__list" aria-label="变形同构体对应关系">
<p class="dmorph__lead">${t('搭配以下装备时,携带者视为对应盟约的成员:')}</p>
<ul class="dmorph__list" aria-label=${t('变形同构体对应关系')}>
${rows.map((r) => html`<li key=${r.bondId} class=${cx('dmorph__row', r.off && 'is-off', r.worn && 'is-worn')} data-bond=${r.bondId}
title=${`${r.items.map((it) => it.name).join('、')} → 【${r.name}】${r.off ? '(本局禁用)' : ''}`}>
<span class="dmorph__bond">【${r.name}】</span>
<span class="dmorph__items">${r.items.map((it, i) => html`<span key=${it.id} class=${cx('dmorph__item', it.worn && 'is-worn')}>${i ? '、' : ''}${it.name}</span>`)}${r.worn
? html`<span class="dmorph__tag is-on">${r.off ? '已搭配' : '生效中'}</span>` : null}${r.off ? html`<span class="dmorph__tag is-off">本局禁用</span>` : null}</span>
title=${`${t('{items} → 【{name}】', { items: r.items.map((it) => it.name), name: r.name })}${r.off ? t('(本局禁用)') : ''}`}>
<span class="dmorph__bond">${t('【{name}】', { name: r.name })}</span>
<span class="dmorph__items">${r.items.map((it, i) => html`<span key=${it.id} class=${cx('dmorph__item', it.worn && 'is-worn')}>${i ? tc('list', '、') : ''}${it.name}</span>`)}${r.worn
? html`<span class="dmorph__tag is-on">${r.off ? t('已搭配') : t('生效中')}</span>` : null}${r.off ? html`<span class="dmorph__tag is-off">${t('本局禁用')}</span>` : null}</span>
</li>`)}
</ul>
${wearer && !rows.some((r) => r.worn) ? html`<p class="dmorph__none">暂未生效:需与上表中的一件装备一同携带</p>` : null}
${wearer && !rows.some((r) => r.worn) ? html`<p class="dmorph__none">${t('暂未生效:需与上表中的一件装备一同携带')}</p>` : null}
</div>`;
}
@@ -207,8 +207,8 @@ export function MorphGrantLine({ item, off = null, carried = null }) {
const isOff = isOffIn(off, item.giveBondId);
const worn = grantedBonds(carried, (id) => data.lookup('items', id)).includes(item.giveBondId);
return html`<p class=${cx('dhint', 'dhint--morph', worn && 'is-worn', isOff && 'is-off')} data-bond=${item.giveBondId}>
<${Icon} name="info" /><span>与${morph.name}一同装备时,携带者视为【${bond.name}】成员${isOff ? html`<span class="dmorph__tag is-off">本局禁用</span>` : null}${worn
? html`<span class="dmorph__tag is-on">${isOff ? '已搭配' : '生效中'}</span>` : null}</span>
<${Icon} name="info" /><span>${t('与{morph}一同装备时,携带者视为【{bond}】成员', { morph: morph.name, bond: bond.name })}${isOff ? html`<span class="dmorph__tag is-off">${t('本局禁用')}</span>` : null}${worn
? html`<span class="dmorph__tag is-on">${isOff ? t('已搭配') : t('生效中')}</span>` : null}</span>
</p>`;
}
@@ -233,7 +233,7 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
if (!ids.length) return null;
const iso = new Set(Array.isArray(granted) ? granted : []);
const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b]));
return html`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约">
return html`<div class="dbonds dbonds--top" role="list" aria-label=${t('所属盟约')}>
${ids.map((id) => {
const rec = data.lookup('bonds', id);
// a bond this mode never activates (gameLogic modeOffBonds): 本局禁用, no count or tier pips
@@ -245,21 +245,21 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
const active = !isOff && (e ? !!e.active : tier > 0);
const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null;
const isoTag = iso.has(id) ? '(变形同构体)' : '';
const isoTag = iso.has(id) ? t('(变形同构体)') : '';
// the count holds 调和's +1 (the server's bond entry says so, DESIGN §21.26)
const harmonyTag = !isOff && Number.isInteger(e?.harmony) && e.harmony > 0 ? `(含调和 +${e.harmony})` : '';
const harmonyTag = !isOff && Number.isInteger(e?.harmony) && e.harmony > 0 ? t('(含调和 +{harmony})', { harmony: e.harmony }) : '';
const label = isOff ? briefingBondTip(rec?.name || id, 'off')
: `${rec?.name || id}${isoTag}:在场 ${count}${cap != null ? `/${cap}` : ''}${harmonyTag}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
: `${rec?.name || id}${isoTag}${cap != null ? t(':在场 {count}/{cap}', { count, cap }) : t(':在场 {count}', { count })}${harmonyTag}${active ? t(',已激活 {tier} 阶', { tier }) : t(',未激活')}`;
const body = isOff
? html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span>
${iso.has(id) ? html`<span class="dbond__iso">同构</span>` : null}
<span class="dbond__off">本局禁用</span>`
${iso.has(id) ? html`<span class="dbond__iso">${t('同构')}</span>` : null}
<span class="dbond__off">${t('本局禁用')}</span>`
: html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span>
${iso.has(id) ? html`<span class="dbond__iso">同构</span>` : null}
${iso.has(id) ? html`<span class="dbond__iso">${t('同构')}</span>` : null}
<span class=${cx('dbond__count', 'num', next == null && count > 0 && 'is-max')}>${count}${cap != null ? html`<small>/${cap}</small>` : null}</span>
${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`;
const cls = cx('dbond', active && 'is-active', isOff && 'is-off', rec?.isCore && 'is-core', iso.has(id) && 'is-granted');
@@ -306,9 +306,9 @@ export function garrisonTypeIconKey(garrison) {
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
function GarrisonBlock({ garrison, m }) {
return html`<section class="dgarrison" aria-label="特质" data-garrison=${garrison.garrisonId || ''}>
return html`<section class="dgarrison" aria-label=${t('特质')} data-garrison=${garrison.garrisonId || ''}>
<div class="dgarrison__head">
<span class="dgarrison__k">特质</span>
<span class="dgarrison__k">${t('特质')}</span>
<span class="dgarrison__type">
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${garrisonTypeIconKey(garrison)}`)} class="dgarrison__icon" />
${garrison.eventTypeDesc || ''}
@@ -344,16 +344,16 @@ export function chessStatsBlock({ rec, chess, live = null }) {
<div key="stats" class=${cx('dstats-wrap', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<div class="dstats">
<${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} />
<${Stat} k="攻击" ...${st.atk} />
<${Stat} k="防御" ...${st.def} />
<${Stat} k="法术抗性" ...${st.res} />
<${Stat} k="攻击间隔" ...${st.interval} />
<${Stat} k="阻挡数" ...${st.blockCnt} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
<${Stat} k=${t('生命上限')} ...${st.maxHp} />
<${Stat} k=${t('攻击')} ...${st.atk} />
<${Stat} k=${t('防御')} ...${st.def} />
<${Stat} k=${t('法术抗性')} ...${st.res} />
<${Stat} k=${t('攻击间隔')} ...${st.interval} />
<${Stat} k=${t('阻挡数')} ...${st.blockCnt} />
<${Stat} k=${t('部署费用')} v=${s.cost ?? '—'} />
<${Stat} k=${t('再部署')} v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div>
<div class="drange"><span class="dstat__k">攻击范围</span><${RangeGrid} grid=${cardRangeGrid(live, rec, chess)} /></div>
<div class="drange"><span class="dstat__k">${t('攻击范围')}</span><${RangeGrid} grid=${cardRangeGrid(live, rec, chess)} /></div>
</div>`;
}
@@ -396,8 +396,8 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<div class="dhead__info">
<div class="dhead__chips">
<${TierChip} tier=${c.tier} golden=${golden} size="lg" />
${golden ? html`<span class="dtag-elite">精锐</span>` : null}
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</span>` : null}
${golden ? html`<span class="dtag-elite">${t('精锐')}</span>` : null}
${piece?.kind === 'token' ? html`<span class="dtag-token">${t('召唤物')}</span>` : null}
${si ? html`<span class="dtag-standin" data-standin=${si.charId} title=${standInTip(si, c.name)}>${standInLabel(si)}</span>` : null}
${diy ? html`<span class="dtag-diy" data-diy=${c.charId || ''} title=${t('自选编队:所选技能与模组,没有特质,盟约按所属阵营分配')}>${t('自选')}</span>` : null}
</div>
@@ -405,11 +405,11 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
${si ? html`<span class="dhead__for" data-for=${c.chessId}>${standInForText(c.name)}</span>` : html`<span class="dhead__en">${c.appellation || ''}</span>`}
<div class="dhead__class">
<${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" />
<span>${PROF_NAME[body.profession] || body.profession || ''}</span>
<span>${t(PROF_NAME[body.profession]) || body.profession || ''}</span>
<i class="sep"></i>
<${Img} src=${subProfIconUrl(m, body)} class="dhead__sub" />
<span>${body.subProfessionName || ''}</span>
<span class="dhead__pos">${body.position === 'MELEE' ? '近战位' : '远程位'}</span>
<span class="dhead__pos">${body.position === 'MELEE' ? t('近战位') : t('远程位')}</span>
</div>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
<${BondChips} bondIds=${bondIds} bonds=${bonds} off=${offBonds} onBond=${onBond} granted=${grantedIds} />
@@ -419,49 +419,49 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
blocks.trait = body.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(body, golden, lo)} /></p>` : null;
// (`fr` is the body the unit fights with — a stand-in's record for a 补位 chess; `chess` is only the range's fallback)
blocks.stats = chessStatsBlock({ rec: fr, chess: c, live });
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
blocks.skill = sk ? html`<${Section} key="skill" title=${t('技能')} micro="SKILL" class="dsec--skill">
<div class="dskill" data-skill=${sk.skillId || ''}>
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
<div class="dskill__meta">
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill && !si ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${t('技能 {skSlot}', { skSlot })}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill && !si ? html`<span class="dtag-loadout" title=${t('干员调配中选择的技能')}>${t('已调配')}</span>` : null}</b>
<div class="dskill__tags">
<span class="dsp dsp--${sk.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : sk.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time'}">${SP_TYPE[sk.spType] || '技力'}</span>
<span class="dsp dsp--trig">${SKILL_TYPE[sk.skillType] || '自动触发'}</span>
${sk.spType !== 'ON_DEPLOY' && sk.skillType !== 'PASSIVE' ? html`<span class="dsp__num"><${GIcon} name="bolt" />初始 <b class="num">${sk.initSp ?? 0}</b> · 消耗 <b class="num">${sk.spCost ?? 0}</b></span>` : null}
${sk.duration > 0 ? html`<span class="dsp__num">持续 <b class="num">${sk.duration}</b>s</span>` : null}
${sk.maxChargeTime > 1 ? html`<span class="dsp__num">充能 <b class="num">${sk.maxChargeTime}</b></span>` : null}
<span class="dsp dsp--${sk.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : sk.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time'}">${t(SP_TYPE[sk.spType]) || t('技力')}</span>
<span class="dsp dsp--trig">${t(SKILL_TYPE[sk.skillType]) || t('自动触发')}</span>
${sk.spType !== 'ON_DEPLOY' && sk.skillType !== 'PASSIVE' ? html`<span class="dsp__num"><${GIcon} name="bolt" />${t('初始')} <b class="num">${sk.initSp ?? 0}</b> ${t('· 消耗')} <b class="num">${sk.spCost ?? 0}</b></span>` : null}
${sk.duration > 0 ? html`<span class="dsp__num">${t('持续')} <b class="num">${sk.duration}</b>s</span>` : null}
${sk.maxChargeTime > 1 ? html`<span class="dsp__num">${t('充能')} <b class="num">${sk.maxChargeTime}</b></span>` : null}
</div>
</div>
</div>
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
<//>` : null;
blocks.module = golden && lo?.module ? html`<${Section} key="module" title="模组" micro="MODULE" class="dsec--module">
blocks.module = golden && lo?.module ? html`<${Section} key="module" title=${t('模组')} micro="MODULE" class="dsec--module">
<div class=${cx('dmodule', lo.module.none && 'is-none')} data-module=${lo.module.id}>
${!lo.module.none && lo.module.typeName ? html`<span class="dmodule__icon" data-type=${lo.module.typeName}>
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
<b class="dmodule__name">${lo.module.name}</b>
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
${!lo.defaultModule && !si ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
${!lo.defaultModule && !si ? html`<span class="dtag-loadout" title=${t('干员调配中选择的模组')}>${t('已调配')}</span>` : null}
</div>
<//>` : null;
// (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`)
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} carried=${items} off=${offBonds} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title=${t('装备')} micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} carried=${items} off=${offBonds} />`) : html`<p class="t-dim dempty">${t('拖拽装备至该干员以配发(最多 2 件)')}</p>`}
<//>`
// no own piece (a teammate's unit, a bond popup's 变形同构体 row): what it carries, read-only
: !piece && carried.length ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${carried.length}/2`} class="dsec--equip">
: !piece && carried.length ? html`<${Section} key="equip" title=${t('装备')} micro=${`EQUIP ${carried.length}/2`} class="dsec--equip">
${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} carried=${carried} off=${offBonds} />`)}
<//>` : null;
const talents = chessTalents(fr);
blocks.talents = talents.length ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
blocks.talents = talents.length ? html`<${Section} key="talents" title=${t('天赋')} micro="TALENT" class="dsec--talent">
${talents.map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
<//>` : null;
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, si || c)}>出售<span class="dsell num">+${sell}</span><//>
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, si || c)}>${t('出售')}<span class="dsell num">+${sell}</span><//>
</div>` : null;
const out = CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
// a merge-completing shop / reward card: where the elite goes (shopBar mergeHint), right under the header
if (hint) out.splice(1, 0, html`<p key="merge" class="dhint dhint--merge"><${Icon} name="info" />可晋升:${hint}</p>`);
if (hint) out.splice(1, 0, html`<p key="merge" class="dhint dhint--merge"><${Icon} name="info" />${t('可晋升:{hint}', { hint })}</p>`);
return out;
}
@@ -478,21 +478,21 @@ export function ItemDetail({ item, piece, editable, onDestroy, offBonds = null }
<div class="dhead dhead--item">
<div class=${cx('dhead__icon', item.isGolden && 'is-golden')}><${Img} src=${itemIconUrl(m, item)} fallback=${html`<${GIcon} name="bolt" />`} /></div>
<div class="dhead__info">
<div class="dhead__chips"><${TierChip} tier=${item.tier} golden=${item.isGolden} size="lg" />${item.isGolden ? html`<span class="dtag-elite">进阶</span>` : null}
<span class="dtag-kind">${item.itemType === 'MAGIC' ? '奇术' : '装备'}</span></div>
<div class="dhead__chips"><${TierChip} tier=${item.tier} golden=${item.isGolden} size="lg" />${item.isGolden ? html`<span class="dtag-elite">${t('进阶')}</span>` : null}
<span class="dtag-kind">${item.itemType === 'MAGIC' ? t('奇术') : t('装备')}</span></div>
<h3 class="dhead__name">${item.name}</h3>
${item.flavor ? html`<span class="dhead__flavor">${item.flavor}</span>` : null}
</div>
</div>
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" /><//>
${item.canGiveBond ? html`<${Section} title="天赋" micro="TALENT" class="dsec--morph"><${MorphPairings} off=${offBonds} /><//>` : null}
<${Section} title=${t('效果')} micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" /><//>
${item.canGiveBond ? html`<${Section} title=${t('天赋')} micro="TALENT" class="dsec--morph"><${MorphPairings} off=${offBonds} /><//>` : null}
${!item.canGiveBond && item.giveBondId ? html`<${MorphGrantLine} item=${item} off=${offBonds} />` : null}
${item.note ? html`<p class="dhint dhint--rule"><${Icon} name="info" />${item.note}</p>` : null}
${item.itemType === 'MAGIC'
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`}
${item.shopExcluded ? html`<p class="dhint dhint--source"><${Icon} name="info" />调度中心不出售 · 获取途径:${item.shopExcludedBy || '效果获得'}</p>` : null}
${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具<//></div>` : null}`;
? html`<p class="dhint"><${Icon} name="info" />${t('将其拖拽至战场上的格子使用')}</p>`
: html`<p class="dhint"><${Icon} name="info" />${t('拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)')}${item.mergeable ? t(';2 件相同装备自动合成进阶装备') : ''}</p>`}
${item.shopExcluded ? html`<p class="dhint dhint--source"><${Icon} name="info" />${t('调度中心不出售 · 获取途径:{source}', { source: item.shopExcludedBy || t('效果获得') })}</p>` : null}
${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>${t('销毁道具')}<//></div>` : null}`;
}
function EnemyDetail({ enemy, snapHp, count, live = null }) {
@@ -500,7 +500,7 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
const s = enemy.stats || {};
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
const factions = data.get('factions')?.types || {};
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k));
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: t('晕眩'), silence: t('沉默'), sleep: t('沉睡'), frozen: t('冻结'), levitate: t('浮空') }[k] || k));
const interval = attackInterval(s.bat, s.aspd);
const hp = hpOf(live, snapHp);
// a battle enemy: its live stats against its spawned ones (the round's multipliers included — unitStatsEntry base)
@@ -516,8 +516,8 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
</div>
<div class="dhead__info">
<div class="dhead__chips">
<span class=${cx('drank', `drank--${(enemy.rank || 'NORMAL').toLowerCase()}`)}>${RANK[enemy.rank] || '普通'}</span>
<span class="dtag-kind">${s.motion === 'FLY' ? '空中' : '地面'}</span>
<span class=${cx('drank', `drank--${(enemy.rank || 'NORMAL').toLowerCase()}`)}>${t(RANK[enemy.rank]) || t('普通')}</span>
<span class="dtag-kind">${s.motion === 'FLY' ? t('空中') : t('地面')}</span>
${count ? html`<span class="dtag-kind num">×${count}</span>` : null}
</div>
<h3 class="dhead__name">${enemy.name}</h3>
@@ -527,19 +527,19 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
</div>
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'battle' : undefined}>
<${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} />
<${Stat} k="攻击" ...${st.atk} sub=${st.atk.sub || DMG[s.dmgType] || ''} />
<${Stat} k="防御" ...${st.def} />
<${Stat} k="法术抗性" ...${st.res} />
<${Stat} k="移动速度" ...${st.moveSpeed} />
<${Stat} k="攻击间隔" ...${st.interval} />
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} />
<${Stat} k="目标价值" v=${s.lpr ?? 1} />
<${Stat} k=${t('生命上限')} ...${st.maxHp} />
<${Stat} k=${t('攻击')} ...${st.atk} sub=${st.atk.sub || t(DMG[s.dmgType]) || ''} />
<${Stat} k=${t('防御')} ...${st.def} />
<${Stat} k=${t('法术抗性')} ...${st.res} />
<${Stat} k=${t('移动速度')} ...${st.moveSpeed} />
<${Stat} k=${t('攻击间隔')} ...${st.interval} />
<${Stat} k=${t('攻击范围')} v=${s.rangeRadius > 0 ? s.rangeRadius : t('近战')} />
<${Stat} k=${t('目标价值')} v=${s.lpr ?? 1} />
</div>
${imm.length ? html`<p class="dhint"><${Icon} name="shield" />免疫:${imm.join('、')}</p>` : null}
${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title="能力" micro="ABILITIES">
${imm.length ? html`<p class="dhint"><${Icon} name="shield" />${t('免疫:{list}', { list: imm })}</p>` : null}
${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title=${t('能力')} micro="ABILITIES">
<ul class="dabil">${abilityRows(enemy.abilities, !!live?.silenced).map((a, i) => html`<li key=${i} class=${a.off ? 'is-off' : null}><${RichText} text=${a.text} /></li>`)}</ul>
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title=${t('说明')}><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
}
/**
@@ -553,10 +553,10 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY) {
if (!token || token.kind !== 'summon' || token.placeable !== true) return null;
const talent = Object.values(token.variants || {}).some((v) => (v?.sources || []).includes('talent'));
if (talent) return '作战开始时在摆放的位置部署';
if (talent) return t('作战开始时在摆放的位置部署');
return startDeploy
? '作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)'
: '所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)';
? t('作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)')
: t('所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)');
}
/**
@@ -599,20 +599,20 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
<div class="dhead dhead--item">
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
<div class="dhead__info">
<div class="dhead__chips"><span class="dtag-token">召唤物</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div>
<div class="dhead__chips"><span class="dtag-token">${t('召唤物')}</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div>
<h3 class="dhead__name">${token.name}</h3>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
</div>
</div>
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} /><${Stat} k="攻击" ...${st.atk} />
<${Stat} k="防御" ...${st.def} /><${Stat} k="阻挡数" ...${st.blockCnt} />
<${Stat} k=${t('生命上限')} ...${st.maxHp} /><${Stat} k=${t('攻击')} ...${st.atk} />
<${Stat} k=${t('防御')} ...${st.def} /><${Stat} k=${t('阻挡数')} ...${st.blockCnt} />
</div>
${hint ? html`<p class="dhint"><${Icon} name="info" />${hint}</p>` : null}
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}
${skill ? html`<${Section} title="技能"><p class="dtext"><b>${skill.name}</b> ${skill.desc}</p><//>` : null}
${talents.length ? html`<${Section} title="天赋">${talents.map((t, i) => html`<p class="dtext" key=${i}><b>${t.name}</b> ${t.desc}</p>`)}<//>` : null}`;
${token.descRaw || token.desc ? html`<${Section} title=${t('说明')}><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}
${skill ? html`<${Section} title=${t('技能')}><p class="dtext"><b>${skill.name}</b> ${skill.desc}</p><//>` : null}
${talents.length ? html`<${Section} title=${t('天赋')}>${talents.map((t, i) => html`<p class="dtext" key=${i}><b>${t.name}</b> ${t.desc}</p>`)}<//>` : null}`;
}
/**
@@ -741,18 +741,18 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
const sellIt = async (piece, chess) => {
const golden = piece.golden || chess?.isGolden;
if (golden) {
const ok = await confirmDialog({ title: '出售精锐干员', text: `确定要出售精锐干员「${chess?.name || ''}」吗?出售后获得 ${chess?.sellPrice ?? 1} 资金。`, okText: '出售', danger: true });
const ok = await confirmDialog({ title: t('出售精锐干员'), text: t('确定要出售精锐干员「{name}」吗?出售后获得 {price} 资金。', { name: chess?.name || '', price: chess?.sellPrice ?? 1 }), okText: t('出售'), danger: true });
if (!ok) return;
}
onSell(piece);
};
const destroyIt = async (piece, item) => {
const ok = await confirmDialog({ title: '销毁道具', text: `道具无法出售。确定要销毁「${item?.name || ''}」吗?`, okText: '销毁', danger: true });
const ok = await confirmDialog({ title: t('销毁道具'), text: t('道具无法出售。确定要销毁「{name}」吗?', { name: item?.name || '' }), okText: t('销毁'), danger: true });
if (ok) onDestroy(piece);
};
return html`<aside class=${cx('dpanel', 'brackets', `dpanel--${detail.type}`, side === 'right' && 'dpanel--right', side === 'right' && shopOpen && 'is-shop')} role="dialog" aria-label="详情"
return html`<aside class=${cx('dpanel', 'brackets', `dpanel--${detail.type}`, side === 'right' && 'dpanel--right', side === 'right' && shopOpen && 'is-shop')} role="dialog" aria-label=${t('详情')}
data-side=${side === 'right' ? 'right' : 'left'}>
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<button type="button" class="dpanel__close" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
+2 -1
View File
@@ -21,6 +21,7 @@
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon } from './components.js';
import { t } from '../../../shared/i18n.js';
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
export const LONG_PRESS_MS = 520;
@@ -154,7 +155,7 @@ export function FullscreenButton({ class: cls = '' }) {
return () => { d.removeEventListener('fullscreenchange', upd); d.removeEventListener('webkitfullscreenchange', upd); };
}, []);
if (!ok) return null;
const label = on ? '退出全屏' : '全屏';
const label = on ? t('退出全屏') : t('全屏');
return html`<button type="button" class=${`fsbtn tapx ${cls}`} aria-label=${label} title=${label} aria-pressed=${on ? 'true' : 'false'}
onClick=${() => fullscreen.toggle()}>
<${Icon} name=${on ? 'collapse' : 'expand'} />
+8 -7
View File
@@ -16,6 +16,7 @@
import { DIY_LIMITS, checkDiyPicks } from '../../../shared/protocol.js';
import { diySlotIds, diySlot, diyPool, isPrototypePick, lockedSelection, isDiyModule, diyRecord } from '../../../shared/diy.js';
import { unitForm } from '../../../shared/standIn.js';
import { N_ } from '../../../shared/i18n.js';
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const DIY_PREF = 'diy';
@@ -186,16 +187,16 @@ export const serializeDiy = (picks, opts) => JSON.stringify(exportDiy(picks, opt
export function parseDiyImport(input) {
let raw = input;
if (typeof raw === 'string') {
if (raw.length > DIY_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' };
if (raw.length > DIY_IMPORT_MAX_BYTES) return { ok: false, error: N_('内容过长,无法导入') };
const text = raw.trim();
if (!text) return { ok: false, error: '没有可导入的内容' };
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
if (!text) return { ok: false, error: N_('没有可导入的内容') };
try { raw = JSON.parse(text); } catch { return { ok: false, error: N_('无法识别的内容') }; }
}
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
if (!isObj(raw)) return { ok: false, error: N_('无法识别的格式') };
const v = Number.isInteger(raw.v) ? raw.v : null;
if (v != null && v > DIY_VERSION) return { ok: false, error: '这份数据来自更新的版本(v{v}),请先更新游戏', params: { v } };
if (v != null && v > DIY_VERSION) return { ok: false, error: N_('这份数据来自更新的版本(v{v}),请先更新游戏'), params: { v } };
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: '这不是自选编队的数据' };
if (!isObj(raw.picks)) return { ok: false, error: '里面没有自选编队的数据' };
if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: N_('这不是自选编队的数据') };
if (!isObj(raw.picks)) return { ok: false, error: N_('里面没有自选编队的数据') };
return { ok: true, picks: cleanPicks(raw.picks) };
}
+38 -4
View File
@@ -6,19 +6,53 @@ import { html, Tooltip, MicroLabel } from './components.js';
import { Img, RichText, GIcon } from './gameComponents.js';
import { effectIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { t, tName, N_ } from '../../../shared/i18n.js';
import { sentText } from './lang.js';
const KIND = { band: '策略', choice: '机变', team: '团队增益', item: '道具', garrison: '特质' };
const KIND = { band: N_('策略'), choice: N_('机变'), team: N_('团队增益'), item: N_('道具'), garrison: N_('特质') };
/**
* A 悬赏's counter line in the current language: the battles its enemies still come for (server/match/player/views.js
* sends the same Chinese text as `counterText` beside the count; a multi-round card has no count).
* @param {{ counter?: number|null }} e
*/
export const bountyCounterText = (e) => (e.counter == null ? t('之后的每场作战') : t('还剩 {left} 场作战', { left: e.counter }));
/** Data files whose records an effect entry copies its name / description from (by its id or icon id). */
const SOURCES = ['effects', 'bands', 'items', 'garrisons'];
/**
* An effect entry's description in the current language (the server copies a record's Chinese text: an effect's, a
* strategy's, an item's, a 特质's — sentText; a reworded one, such as a multi-round 悬赏's, stays as sent).
* @param {{ id?: string, iconId?: string, desc?: string }} e
*/
export function effectDesc(e) {
const desc = e.desc || '';
if (!desc) return desc;
for (const file of SOURCES) {
for (const id of [e.id, e.iconId]) {
const raw = typeof id === 'string' && id ? data.lookupRaw(file, id) : null;
if (!raw) continue;
const loc = data.lookup(file, id) || raw;
for (const k of ['descRaw', 'desc']) {
const v = sentText(desc, raw[k], loc[k]);
if (v !== desc) return v;
}
}
}
return desc;
}
/** @param {{ effects: any[] }} props */
export function EffectsList({ effects }) {
const list = (Array.isArray(effects) ? effects : []).filter((e) => e && (e.name || e.desc));
if (!list.length) return null;
const m = data.get('assets');
return html`<div class="effects" aria-label="生效中的效果">
return html`<div class="effects" aria-label=${t('生效中的效果')}>
<${MicroLabel}>EFFECTS</${MicroLabel}>
${list.slice(0, 10).map((e, i) => html`<${Tooltip} key=${e.id ?? i} placement="bottom" text=${html`<div class="efftip">
<b>${e.name || '效果'}</b><span class="efftip__kind">${KIND[e.iconKind] || ''}${e.counterText ? ` · ${e.counterText}` : e.counter != null ? ` · ${e.counter}` : ''}</span>
<${RichText} as="p" text=${e.desc || ''} />
<b>${tName(e.name) || t('效果')}</b><span class="efftip__kind">${t(KIND[e.iconKind] || '')}${e.counterText ? ` · ${bountyCounterText(e)}` : e.counter != null ? ` · ${e.counter}` : ''}</span>
<${RichText} as="p" text=${effectDesc(e)} />
</div>`}>
<span class=${`effect effect--${e.iconKind || 'x'}`}>
<${Img} src=${effectIconUrl(m, e)} fallback=${html`<${GIcon} name="bolt" />`} />
+11 -10
View File
@@ -25,6 +25,7 @@ import { html } from './components.js';
import { GIcon } from './gameComponents.js';
import { data, useData, localAsset, artUrls, nextArtUrl } from '../data.js';
import { loadPref, savePref } from '../store.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -196,7 +197,7 @@ export function EmoteBubble({ id, class: cls, ttl = EMOTE_BUBBLE_MS, at }) {
const e = emoteInfo(id);
const style = [`--ebubble-ttl:${life}ms`, age && `--ebubble-age:${Math.round(age)}ms`, bg && `--ebubble-bg:url("${bg}")`].filter(Boolean).join(';');
return html`<div class=${cx('ebubble', bg && 'has-sprite', cls)} style=${style} role="img"
aria-label=${e ? e.label : '表情'} data-emote=${e ? e.id : ''}>
aria-label=${e ? t(e.label) : t('表情')} data-emote=${e ? e.id : ''}>
<span class="ebubble__icon"><${EmoteArt} id=${id} /></span>
</div>`;
}
@@ -310,24 +311,24 @@ export function EmoteWheel({ onSend, open, onToggle, disabled = false, cooldownM
<button type="button" class=${cx('ewheel__btn', btnSprite && 'has-sprite', open && 'is-on', cooling && 'is-cooling')}
style=${btnSprite ? `--ewheel-btn:url("${btnSprite}")` : ''} onClick=${() => onToggle(!open)}
aria-expanded=${open ? 'true' : 'false'} aria-haspopup="dialog" disabled=${disabled || cooling}>
${btnSprite ? null : html`<${GIcon} name="emote" />`}<span class="ewheel__label">交流</span>
${btnSprite ? null : html`<${GIcon} name="emote" />`}<span class="ewheel__label">${t('交流')}</span>
</button>
${open ? html`<div class=${cx('ewheel__panel', panelBg && 'has-sprite', cellBg && 'has-cell')} style=${panelStyle} role="dialog" aria-label="交流">
${open ? html`<div class=${cx('ewheel__panel', panelBg && 'has-sprite', cellBg && 'has-cell')} style=${panelStyle} role="dialog" aria-label=${t('交流')}>
<div class="ewheel__viewport" onPointerDown=${onPointerDown} onPointerMove=${onPointerMove}
onPointerUp=${(e) => endDrag(e, false)} onPointerCancel=${(e) => endDrag(e, true)} onWheel=${onWheel}>
<div key=${theme.themeId} class=${cx('ewheel__page', dir > 0 && 'is-from-right', dir < 0 && 'is-from-left', dx !== 0 && 'is-dragging')}
style=${dx ? `transform:translateX(${dx}px)` : ''} role="group" aria-label=${theme.name} data-theme=${theme.themeId}>
${theme.emotes.map((e) => html`<button key=${e.id} type="button" class="ewheel__item" data-emote=${e.id} aria-label=${e.label}
style=${dx ? `transform:translateX(${dx}px)` : ''} role="group" aria-label=${t(theme.name)} data-theme=${theme.themeId}>
${theme.emotes.map((e) => html`<button key=${e.id} type="button" class="ewheel__item" data-emote=${e.id} aria-label=${t(e.label)}
disabled=${cooling || disabled} onClick=${() => send(e.id)}>
<${EmoteArt} id=${e.id} />
</button>`)}
</div>
</div>
<button type="button" class="ewheel__nav is-prev" aria-label="上一组表情" disabled=${page <= 0} onClick=${() => go(page - 1)}><${GIcon} name="chevronLeft" /></button>
<button type="button" class="ewheel__nav is-next" aria-label="下一组表情" disabled=${page >= EMOTE_THEMES.length - 1} onClick=${() => go(page + 1)}><${GIcon} name="chevronRight" /></button>
<div class="ewheel__dots" role="tablist" aria-label="表情主题">
${EMOTE_THEMES.map((t, i) => html`<button key=${t.themeId} type="button" role="tab" class=${cx('ewheel__dot', i === page && 'is-on')}
aria-selected=${i === page ? 'true' : 'false'} aria-label=${`${t.name} ${i + 1}/${EMOTE_THEMES.length}`} onClick=${() => go(i)}></button>`)}
<button type="button" class="ewheel__nav is-prev" aria-label=${t('上一组表情')} disabled=${page <= 0} onClick=${() => go(page - 1)}><${GIcon} name="chevronLeft" /></button>
<button type="button" class="ewheel__nav is-next" aria-label=${t('下一组表情')} disabled=${page >= EMOTE_THEMES.length - 1} onClick=${() => go(page + 1)}><${GIcon} name="chevronRight" /></button>
<div class="ewheel__dots" role="tablist" aria-label=${t('表情主题')}>
${EMOTE_THEMES.map((th, i) => html`<button key=${th.themeId} type="button" role="tab" class=${cx('ewheel__dot', i === page && 'is-on')}
aria-selected=${i === page ? 'true' : 'false'} aria-label=${`${t(th.name)} ${i + 1}/${EMOTE_THEMES.length}`} onClick=${() => go(i)}></button>`)}
</div>
</div>` : null}
</div>`;
+18 -17
View File
@@ -13,9 +13,10 @@ import { groupEnemies, factionTypes, briefingBondTip } from './gameLogic.js';
import { matchInfoModel, DiyBannedLine } from './matchInfo.js';
import { factionIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const TAG = { boss: '领袖', bounty: '悬赏', escort: '护卫' };
const TAG = { boss: N_('领袖'), bounty: N_('悬赏'), escort: N_('护卫') };
function FactionChips({ types }) {
const f = data.get('factions')?.types || {};
@@ -32,7 +33,7 @@ function EnemiesTab({ pub, priv, onEnemy }) {
const boss = pub?.bossId ? data.lookup('bosses', pub.bossId) : null;
return html`<div class="edrawer__body">
<div class="edrawer__sum">
<span>第 <b class="num">${pub?.round ?? '-'}</b> 回合 · 即将迎击 <b class="num t-orange">${total}</b> 名敌人</span>
<span>${tParts('第 {r} 回合 · 即将迎击 {n} 名敌人', { r: html`<b class="num">${pub?.round ?? '-'}</b>`, n: html`<b class="num t-orange">${total}</b>`, count: total })}</span>
${types.length ? html`<${FactionChips} types=${types} />` : null}
</div>
${rows.length ? html`<div class="elist">
@@ -42,19 +43,19 @@ function EnemiesTab({ pub, priv, onEnemy }) {
<span class="erow__text">
<b class="erow__name">${r.name}</b>
<span class="erow__tags">
${r.tag ? html`<span class=${`etag etag--${r.tag}`}>${TAG[r.tag] || r.tag}</span>` : null}
${r.rank === 'ELITE' ? html`<span class="etag etag--elite">精英</span>` : null}
${r.fly ? html`<span class="etag etag--fly">空中</span>` : null}
${r.acTypes.map((t) => html`<span key=${t} class="etag etag--faction">${(f[t]?.name || t).replace('特训敌人·', '')}</span>`)}
${r.tag ? html`<span class=${`etag etag--${r.tag}`}>${t(TAG[r.tag] || r.tag)}</span>` : null}
${r.rank === 'ELITE' ? html`<span class="etag etag--elite">${t('精英')}</span>` : null}
${r.fly ? html`<span class="etag etag--fly">${t('空中')}</span>` : null}
${r.acTypes.map((ty) => html`<span key=${ty} class="etag etag--faction">${(f[ty]?.name || ty).replace(t('特训敌人·'), '')}</span>`)}
</span>
</span>
<b class="erow__count num">×${r.count}</b>
</button>`)}
</div>` : html`<p class="edrawer__empty"><${GIcon} name="target" />暂无敌方情报</p>`}
</div>` : html`<p class="edrawer__empty"><${GIcon} name="target" />${t('暂无敌方情报')}</p>`}
${boss ? html`<div class="eboss">
<${UnitThumb} kind="enemy" id=${boss.enemyKey} size="md" />
<div><${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖</${MicroLabel}><b>${boss.name}</b>
<span class="t-lo">第 ${data.get('config')?.modes?.[pub.modeId]?.bossRound ?? pub.lastRound ?? 14} 回合 · 最终攻势</span></div>
<div><${MicroLabel} tone="gold">${t('ENEMY LEADER // 敌方领袖')}</${MicroLabel}><b>${boss.name}</b>
<span class="t-lo">${t('第 {r} 回合 · 最终攻势', { r: data.get('config')?.modes?.[pub.modeId]?.bossRound ?? pub.lastRound ?? 14 })}</span></div>
</div>` : null}
</div>`;
}
@@ -75,16 +76,16 @@ function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) {
return html`<div class="edrawer__body">
${band ? html`<div class="iband">
<${BandIcon} bandId=${band.bandId} size="md" />
<div><${MicroLabel} tone="mint">STRATEGY // ${bandOwner ? `${bandOwner} 的策略` : '我的策略'}</${MicroLabel}><b>${band.name} <small class="t-lo">${band.effectName}</small></b>
<div><${MicroLabel} tone="mint">STRATEGY // ${bandOwner ? t('{bandOwner} 的策略', { bandOwner }) : t('我的策略')}</${MicroLabel}><b>${band.name} <small class="t-lo">${band.effectName}</small></b>
<${RichText} text=${band.descRaw || band.desc} class="iband__desc" /></div>
</div>` : null}
${stage ? html`<p class="istage"><${Icon} name="rook" />战场:<b>${stage.name || stage.id}</b></p>` : null}
<h4 class="ihead">本局禁用干员情况 <small class="num">${banned.length}</small></h4>
${stage ? html`<p class="istage"><${Icon} name="rook" />${t('战场:')}<b>${stage.name || stage.id}</b></p>` : null}
<h4 class="ihead">${t('本局禁用干员情况')} <small class="num">${banned.length}</small></h4>
<div class="ibonds">
${withBans.length ? withBans.map((b) => html`<span key=${b.bondId} class=${cx('ibond', disabled.has(b.bondId) && 'is-off')} title=${briefingBondTip(b.name, stateOf(b.bondId), perBond.get(b.bondId) || 0)}>
<${BondGlyph} bondId=${b.bondId} /><span>${b.name}</span>
${perBond.get(b.bondId) ? html`<span class="ibond__ban num" title="该盟约中被禁用的干员数"><${Icon} name="user" />${perBond.get(b.bondId)}</span>` : null}
</span>`) : html`<span class="t-dim">本局没有禁用盟约</span>`}
${perBond.get(b.bondId) ? html`<span class="ibond__ban num" title=${t('该盟约中被禁用的干员数')}><${Icon} name="user" />${perBond.get(b.bondId)}</span>` : null}
</span>`) : html`<span class="t-dim">${t('本局没有禁用盟约')}</span>`}
</div>
${banned.length ? html`<div class="ibanned">${banned.map((id) => html`<button key=${id} type="button" class="ibanned__one" onClick=${() => onChess(id)}>
<${UnitThumb} kind="chess" id=${id} size="sm" dim=${true} /></button>`)}</div>` : null}
@@ -99,10 +100,10 @@ function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) {
* replaces one's own in the 本局信息 tab (game.js scoutBandId; user playtest #2 item 2)
*/
export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess, bandId = null, bandOwner = null }) {
return html`<div class="edrawer brackets" role="dialog" aria-label=${tab === 'info' ? '本局信息' : '敌方情报'}>
return html`<div class="edrawer brackets" role="dialog" aria-label=${tab === 'info' ? t('本局信息') : t('敌方情报')}>
<div class="edrawer__top">
<${Tabs} size="sm" value=${tab} onChange=${onTab} items=${[{ id: 'info', label: '本局信息' }, { id: 'enemies', label: '敌方情报' }]} />
<button type="button" class="edrawer__close tapx" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<${Tabs} size="sm" value=${tab} onChange=${onTab} items=${[{ id: 'info', label: t('本局信息') }, { id: 'enemies', label: t('敌方情报') }]} />
<button type="button" class="edrawer__close tapx" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
</div>
${tab === 'info' ? html`<${InfoTab} pub=${pub} priv=${priv} onChess=${onChess} bandId=${bandId} bandOwner=${bandOwner} />` : html`<${EnemiesTab} pub=${pub} priv=${priv} onEnemy=${onEnemy} />`}
</div>`;
+12 -11
View File
@@ -17,6 +17,7 @@ import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, TierChip, MicroLabel } from './components.js';
import { UnitThumb, RichText, GIcon } from './gameComponents.js';
import { localAsset } from '../data.js';
import { t, tParts } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const isObj = (v) => !!v && typeof v === 'object';
@@ -40,11 +41,11 @@ export function replaceRequest(ctx, intent, getChess = () => null, getItem = ()
return {
targetUid: target.uid,
targetId: target.id,
targetName: shown?.name || '该干员',
targetName: shown?.name || t('该干员'),
// the record the avatar draws (a 补位 stand-in's or a 自选 operator's composed record; the data's for any other chess)
targetRec: shown && (shown.standInFor || shown.diyFor) ? shown : null,
golden: !!target.golden,
item: { uid: item.uid, id: item.id, name: rec(item.id)?.name || '装备' },
item: { uid: item.uid, id: item.id, name: rec(item.id)?.name || t('装备') },
options: equipped.map((it) => ({ uid: it.uid, id: it.id, name: rec(it.id)?.name || it.id })),
};
}
@@ -76,7 +77,7 @@ function ItemCard({ id, item, rec, selected = false, option = false, onPick }) {
</span>
<span class="eqr__meta">
<span class="eqr__chips">${tier ? html`<${TierChip} tier=${tier} golden=${!!rec?.isGolden} size="sm" />` : null}
${rec?.isGolden ? html`<span class="eqr__elite">进阶</span>` : null}</span>
${rec?.isGolden ? html`<span class="eqr__elite">${t('进阶')}</span>` : null}</span>
<b class="eqr__name">${rec?.name || item?.name || id}</b>
</span>
<${RichText} as="p" class="eqr__desc" text=${rec?.descRaw || rec?.desc || ''} />`;
@@ -85,7 +86,7 @@ function ItemCard({ id, item, rec, selected = false, option = false, onPick }) {
class=${cx('eqr__card', 'eqr__opt', selected && 'is-sel')} onClick=${() => onPick(item.uid)}>
${body}
<span class="eqr__flag" aria-hidden=${selected ? 'false' : 'true'}>
<${GIcon} name="trash" class="eqr__flag-icon" /><span>将被销毁</span>
<${GIcon} name="trash" class="eqr__flag-icon" /><span>${t('将被销毁')}</span>
</span>
</button>`;
}
@@ -111,12 +112,12 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon');
const swapIcon = localAsset('ui/battle', 'equip_replace_replace_icon');
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title="替换装备" onClose=${onCancel}
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title=${t('替换装备')} onClose=${onCancel}
actions=${html`
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>取消<//>
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>${t('取消')}<//>
<${Button} variant="danger" class="eqr__ok" disabled=${!chosen} loading=${busy} data-autofocus onClick=${() => chosen && onConfirm(chosen.uid)}>
${confirmIcon ? html`<img class="eqr__ok-icon" src=${confirmIcon} alt="" draggable=${false} />` : null}确认替换<//>`}>
<p class="eqr__lead">「<b>${request.targetName}</b>」的装备栏已满(2/2)。选择一件装备进行替换,<b class="t-red">被替换的装备将被销毁</b>。</p>
${confirmIcon ? html`<img class="eqr__ok-icon" src=${confirmIcon} alt="" draggable=${false} />` : null}${t('确认替换')}<//>`}>
<p class="eqr__lead">${tParts('「{name}」的装备栏已满(2/2)。选择一件装备进行替换,{warn}。', { name: html`<b>${request.targetName}</b>`, warn: html`<b class="t-red">${t('被替换的装备将被销毁')}</b>` })}</p>
<div class="eqr__top">
<div class="eqr__op" title=${request.targetName}>
<span class="eqr__ava"><${UnitThumb} kind="chess" id=${request.targetId} golden=${request.golden} size="md" rec=${request.targetRec || null} /></span>
@@ -124,16 +125,16 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
</div>
<span class="eqr__swap" aria-hidden="true">${swapIcon ? html`<img src=${swapIcon} alt="" draggable=${false} />` : html`<${GIcon} name="refresh" />`}</span>
<div class="eqr__incoming">
<${MicroLabel} tone="mint">新装备</${MicroLabel}>
<${MicroLabel} tone="mint">${t('新装备')}</${MicroLabel}>
<${ItemCard} id=${request.item.id} item=${request.item} rec=${getItem(request.item.id)} />
</div>
</div>
<div class="eqr__arrow" aria-hidden="true"><i class="eqr__aline"></i><i class="eqr__ahead eqr__ahead--l"></i><i class="eqr__ahead eqr__ahead--r"></i></div>
<div class="eqr__opts" role="radiogroup" aria-label="选择被替换的装备">
<div class="eqr__opts" role="radiogroup" aria-label=${t('选择被替换的装备')}>
${request.options.map((o) => html`<${ItemCard} key=${o.uid} id=${o.id} item=${o} rec=${getItem(o.id)} option=${true}
selected=${o.uid === sel} onPick=${(uid) => setSel((s) => (s === uid ? null : uid))} />`)}
</div>
<p class="eqr__hint">${chosen ? html`将销毁「<b>${chosen.name}</b>」并配发「<b>${request.item.name}</b>」` : '点击选择要替换的装备'}</p>
<p class="eqr__hint">${chosen ? html`${tParts('将销毁「{old}」并配发「{item}」', { old: html`<b>${chosen.name}</b>`, item: html`<b>${request.item.name}</b>` })}` : t('点击选择要替换的装备')}</p>
<//>
</div>`;
}
+2 -1
View File
@@ -14,6 +14,7 @@
import { GEO } from '../../../shared/constants.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { N_ } from '../../../shared/i18n.js';
export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']);
export const DEFAULT_DIR = 'RIGHT';
@@ -29,7 +30,7 @@ export const DIR_VEC = Object.freeze({
});
/** Chinese label (for aria / tooltips). */
export const DIR_LABEL = Object.freeze({ UP: '上', RIGHT: '右', DOWN: '下', LEFT: '左' });
export const DIR_LABEL = Object.freeze({ UP: N_('上'), RIGHT: N_('右'), DOWN: N_('下'), LEFT: N_('左') });
/** Normalise any direction spelling ('up', 'Right', 1/−1 facing) to UP|RIGHT|DOWN|LEFT; unknown → `fallback`. */
export function normDir(d, fallback = DEFAULT_DIR) {
+6 -5
View File
@@ -25,6 +25,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks
import { html } from './components.js';
import { LocalSprite } from './gameComponents.js';
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const rawOf = (view) => (view && view.raw) || view || null;
@@ -249,7 +250,7 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
const outside = !!drag && !!dir;
const box = half * 2;
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${`选择${name ? `「${name}」的` : ''}朝向`}
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
@@ -264,12 +265,12 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
${DIRS.map((d) => html`<${Chevron} key=${d} dir=${d} on=${dir === d} />`)}
</svg>
<button type="button" class="fwheel__cancel" onPointerDown=${(e) => e.stopPropagation()}
onClick=${(e) => { e.stopPropagation(); onCancel(); }} aria-label="点击取消">
onClick=${(e) => { e.stopPropagation(); onCancel(); }} aria-label=${t('点击取消')}>
<${LocalSprite} name="cancel_icon" class="fwheel__x" fallback=${html`<span class="fwheel__x fwheel__x--txt">✕</span>`} />
<span>点击取消</span>
<span>${t('点击取消')}</span>
</button>
${outside ? html`<span class="fwheel__tip" role="status">拖回中心区域取消</span>` : null}
<span class="fwheel__sr" aria-live="polite">${dir ? `朝向:${DIR_LABEL[dir]}` : ''}</span>
${outside ? html`<span class="fwheel__tip" role="status">${t('拖回中心区域取消')}</span>` : null}
<span class="fwheel__sr" aria-live="polite">${dir ? t('朝向:{dir}', { dir: t(DIR_LABEL[dir]) }) : ''}</span>
</div>` : null}
</div>`;
}
+6 -5
View File
@@ -24,6 +24,7 @@ import { html, TierChip } from './components.js';
import { GEO } from '../../../shared/constants.js';
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, ownStandIn, ownDiyRecord } from './gameLogic.js';
import { t } from '../../../shared/i18n.js';
const DRAG_PX = 6;
const DMG_TTL = 900;
@@ -137,10 +138,10 @@ export function createFallbackView(host, opts = {}) {
return chess ? ownDiyRecord(chess, st.priv, { chess: dataStore?.get?.('chess') ?? null, backups: dataStore?.get?.('backups') ?? null }) : null;
}
function pieceName(p) {
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物';
if (p.kind === 'item') return lookup('items', p.id)?.name || t('道具');
if (p.kind === 'token') return lookup('tokens', p.id)?.name || t('召唤物');
const chess = lookup('chess', p.id);
return (ownSi(chess) || ownDiy(chess) || chess)?.name || '干员';
return (ownSi(chess) || ownDiy(chess) || chess)?.name || t('干员');
}
function Piece({ p, x, y, L, area }) {
@@ -303,7 +304,7 @@ export function createFallbackView(host, opts = {}) {
});
return html`<div class="ff-board ff-board--pen" style=${`left:${left}px;top:${top}px;width:${bw}px;height:${tile * rows}px;--tile:${tile}px`}>
${cells}${figs}
${models.length ? null : html`<p class="ff-pen__empty">暂无敌方情报</p>`}
${models.length ? null : html`<p class="ff-pen__empty">${t('暂无敌方情报')}</p>`}
</div>`;
}
@@ -372,7 +373,7 @@ export function createFallbackView(host, opts = {}) {
})() : null;
if (st.camera === 'pen') { render(html`${penView()}<div class="ff-badge">SIMPLIFIED VIEW</div>`, root); return; }
render(html`<div class=${cx('ff-board', `ff-board--${st.mode}`, `ff-cam--${st.camera}`)} style=${`left:${L.left}px;top:${L.top}px;width:${L.bw}px;height:${L.bh}px;--tile:${L.tile}px`}>
${st.mode === 'prep' ? html`<div class="ff-hand-label" style=${`top:${(L.rows + 0.18) * L.tile}px`}><span>整备区</span><i></i></div>` : null}
${st.mode === 'prep' ? html`<div class="ff-hand-label" style=${`top:${(L.rows + 0.18) * L.tile}px`}><span>${t('整备区')}</span><i></i></div>` : null}
${tiles}${hand}${units}${pieces}${floats}
</div>${ghost}
<div class="ff-badge">SIMPLIFIED VIEW</div>`, root);
+7 -6
View File
@@ -24,7 +24,8 @@ export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands
*/
export function useGameData() {
const ready = useData(...GAME_FILES);
return useMemo(() => makeLookups(ready), [ready]);
// (config / factions / choices are read once per memo: a language switch reads them again in the new locale)
return useMemo(() => makeLookups(ready), [ready, data.locale()]);
}
/** A 自选 operator's summon record (data/backups.json `tokens`, 0.2.0), or null. */
@@ -140,7 +141,7 @@ export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showT
<${Img} src=${src} fallback=${html`<span class="uthumb__glyph">${glyph}</span>`} />
</span>
${showTier && t && kind !== 'enemy' && kind !== 'token' ? html`<${TierChip} tier=${t} golden=${golden} size="sm" class="uthumb__tier" />` : null}
${kind === 'token' ? html`<span class="uthumb__tag">召唤</span>` : null}
${kind === 'token' ? html`<span class="uthumb__tag">${tr('召唤')}</span>` : null}
${si ? html`<span class="uthumb__si" data-standin=${si.charId} aria-hidden="true">${tr('替补')}</span>` : null}
${badge}
</span>`;
@@ -207,11 +208,11 @@ export function GIcon({ name, class: cls, title }) {
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
const ok = Number.isFinite(value);
const p = ok && Number(pending) > 0 ? Math.min(value, Math.trunc(Number(pending))) : 0;
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || '目标生命值'}
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || tr('目标生命值')}
data-pending=${p > 0 ? p : null}>
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${`结算时扣除 ${p}`}>−${p}</span>` : null}
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${tr('结算时扣除 {p}', { p })}>−${p}</span>` : null}
${note ? html`<span class="lp__note">${note}</span>` : null}
</span>`;
}
@@ -265,7 +266,7 @@ export function PlayerAvatar({ player, size = 'md', self = false, class: cls })
<span class="pavatar__img">
<${Img} src=${src} fallback=${player?.isBot ? html`<${Icon} name="robot" class="pavatar__bot" />` : html`<span class="pavatar__glyph">${glyph}</span>`} />
</span>
${dead ? html`<span class="pavatar__x" aria-label="已淘汰"><${Icon} name="close" /></span>` : null}
${left ? html`<span class="pavatar__door" aria-label="已离开"><${Icon} name="exit" /></span>` : null}
${dead ? html`<span class="pavatar__x" aria-label=${tr('已淘汰')}><${Icon} name="close" /></span>` : null}
${left ? html`<span class="pavatar__door" aria-label=${tr('已离开')}><${Icon} name="exit" /></span>` : null}
</span>`;
}
+4 -3
View File
@@ -1,6 +1,7 @@
// ui/gameLogic/bonds.js — bond counts, members, harmony, 本局禁用. Re-exported from ../gameLogic.js.
import { int, isObj } from './shared.js';
import { t } from '../../../../shared/i18n.js';
// ---- bonds -------------------------------------------------------------------------------------------
@@ -275,8 +276,8 @@ export function disabledBondSets(pub, staticInactive = []) {
* @param {number} bannedN banned operators of the bond
*/
export function briefingBondTip(name, state, bannedN = 0) {
if (state === 'off') return `${name}:本局禁用(该盟约不会激活)`;
if (state === 'drawn' || bannedN > 0) return `${name}:部分盟约所含干员阵容不完整${bannedN ? `(${bannedN} 名干员无法出现)` : ''}`;
if (state === 'off') return t('{name}:本局禁用(该盟约不会激活)', { name });
if (state === 'drawn' || bannedN > 0) return bannedN ? t('{name}:部分盟约所含干员阵容不完整({bannedN} 名干员无法出现)', { name, bannedN }) : t('{name}:部分盟约所含干员阵容不完整', { name });
return name;
}
@@ -314,5 +315,5 @@ export function bandOffBonds(band, off) {
*/
export function bandOffLine(names) {
const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === 'string' && n);
return list.length ? `本局禁用${list.map((n) => `【${n}】`).join('')}盟约,此策略效果可能无法发挥` : '';
return list.length ? t('本局禁用{names}盟约,此策略效果可能无法发挥', { names: list.map((n) => t('【{name}】', { name: n })).join('') }) : '';
}
+21 -11
View File
@@ -2,20 +2,21 @@
import { UF } from '../../../../shared/constants.js';
import { clamp, isObj, tileKey } from './shared.js';
import { N_, getLang } from '../../../../shared/i18n.js';
// ---- players, statuses, fields ------------------------------------------------------------------------
/** Status → glyph + text (research 06 §11.1). */
export const STATUS_META = Object.freeze({
acting: { glyph: 'dots', text: '行动中', tone: 'lo' },
ready: { glyph: 'check', text: '已就绪', tone: 'mint' },
deciding: { glyph: 'hourglass', text: '决策中', tone: 'gold' },
combat: { glyph: 'sword', text: '作战中', tone: 'orange' },
done: { glyph: 'check', text: '作战结束', tone: 'mint' },
helping: { glyph: 'shield', text: '联防中', tone: 'orange' },
left: { glyph: 'exit', text: '已离开', tone: 'red' },
dead: { glyph: 'close', text: '已淘汰', tone: 'red' },
acting: { glyph: 'dots', text: N_('行动中'), tone: 'lo' },
ready: { glyph: 'check', text: N_('已就绪'), tone: 'mint' },
deciding: { glyph: 'hourglass', text: N_('决策中'), tone: 'gold' },
combat: { glyph: 'sword', text: N_('作战中'), tone: 'orange' },
done: { glyph: 'check', text: N_('作战结束'), tone: 'mint' },
helping: { glyph: 'shield', text: N_('联防中'), tone: 'orange' },
left: { glyph: 'exit', text: N_('已离开'), tone: 'red' },
dead: { glyph: 'close', text: N_('已淘汰'), tone: 'red' },
});
// ---- snapshots / battle HUD ---------------------------------------------------------------------------------
@@ -69,12 +70,21 @@ export function attackInterval(bat, aspd = 100) {
return b * 100 / a;
}
/** Compact number: 12345 → '12,345'; 1.5e6 → '150万'. */
/**
* Compact number: 12345 → '12,345'; 1.5e6 → '150万' — in English the thousands-based units: 1.5e6 → '1.5M', 2.5e5 →
* '250K', 3e9 → '3B' (万 / 亿 count in 10⁴ / 10⁸ steps, no template can move the decimal point).
*/
export function fmtNum(v) {
const n = Number(v);
if (!Number.isFinite(n)) return '—';
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}亿`;
if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}万`;
if (getLang() === 'en') {
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(Math.abs(n) >= 1e10 ? 0 : 1)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(Math.abs(n) >= 1e7 ? 0 : 1)}M`;
if (Math.abs(n) >= 1e5) return `${Math.round(n / 1e3)}K`;
return Math.round(n).toLocaleString('en-US');
}
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}亿`; // i18n-ignore: the Chinese units (English above)
if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}万`; // i18n-ignore
return Math.round(n).toLocaleString('en-US');
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js';
import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js';
import { isObj } from './shared.js';
import { t } from '../../../../shared/i18n.js';
// ---- operator loadout (DESIGN §16) ----------------------------------------------------------------------------------
@@ -34,7 +35,7 @@ export function chessLoadout(chess, loadout, getChess = () => null) {
let defaultModule = true;
if (chess.isGolden) {
const id = r.moduleId ?? (chess.module?.active ? chess.module.id : MODULE_NONE);
if (id === MODULE_NONE) module = { id: MODULE_NONE, name: '未装备模组', typeName: '', none: true };
if (id === MODULE_NONE) module = { id: MODULE_NONE, name: t('未装备模组'), typeName: '', none: true };
else {
const rec = (Array.isArray(chess.modules) ? chess.modules : []).find((m) => isObj(m) && m.uniEquipId === id)
|| (isObj(chess.module) && chess.module.id === id ? { uniEquipId: id, name: chess.module.name, typeName: chess.module.type } : null);
+15 -14
View File
@@ -4,6 +4,7 @@ import { PHASE } from '../../../../shared/constants.js';
import { bossLevelSeconds } from '../matchStatus.js';
import { clamp, int, isObj, sortedPlayers } from './shared.js';
import { normalizeSp } from './draft.js';
import { t } from '../../../../shared/i18n.js';
// ---- phases ----------------------------------------------------------------------------------------
@@ -43,18 +44,18 @@ export const isBossPhase = (phase) => phase === PHASE.FINAL_ASSAULT || phase ===
export function phaseBanner(phase, pub) {
const r = int(pub?.round, 0);
switch (phase) {
case PHASE.BATTLE_CHECK: return { title: '协议启动', micro: 'PROTOCOL START', tone: 'mint', sub: '模拟即将开始', duration: 2600 };
case PHASE.ROUND_START: return { title: `第 ${r} 回合`, micro: `ROUND ${String(r).padStart(2, '0')}`, tone: 'mint', sub: '资金已到账' };
case PHASE.SP_DRAFT: return { title: '机变阶段', micro: 'CONTINGENCY', tone: 'gold', sub: '依次选择机变' };
case PHASE.PREP: return { title: '休整期', micro: `ROUND ${String(r).padStart(2, '0')} // REST`, tone: 'mint', sub: '部署干员,准备迎敌' };
case PHASE.COMBAT: return { title: '作战开始', micro: 'COMBAT', tone: 'orange', sub: '各自行动阶段' };
case PHASE.BATTLE_CHECK: return { title: t('协议启动'), micro: 'PROTOCOL START', tone: 'mint', sub: t('模拟即将开始'), duration: 2600 };
case PHASE.ROUND_START: return { title: t('第 {r} 回合', { r }), micro: `ROUND ${String(r).padStart(2, '0')}`, tone: 'mint', sub: t('资金已到账') };
case PHASE.SP_DRAFT: return { title: t('机变阶段'), micro: 'CONTINGENCY', tone: 'gold', sub: t('依次选择机变') };
case PHASE.PREP: return { title: t('休整期'), micro: `ROUND ${String(r).padStart(2, '0')} // REST`, tone: 'mint', sub: t('部署干员,准备迎敌') };
case PHASE.COMBAT: return { title: t('作战开始'), micro: 'COMBAT', tone: 'orange', sub: t('各自行动阶段') };
case PHASE.UNITE: {
const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || '博士']));
const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || t('博士')]));
const helpers = Array.isArray(pub?.unite?.helpers) ? pub.unite.helpers.map((id) => names.get(id)).filter(Boolean) : [];
return { title: '联防阶段', micro: 'JOINT DEFENSE', tone: 'orange', sub: helpers.length ? `联防:${helpers.join('、')}` : '完美作战的博士迎战突破防线的敌人' };
return { title: t('联防阶段'), micro: 'JOINT DEFENSE', tone: 'orange', sub: helpers.length ? t('联防:{names}', { names: helpers }) : t('完美作战的博士迎战突破防线的敌人') };
}
case PHASE.FINAL_ASSAULT: return { title: '最终攻势', micro: 'FINAL ASSAULT', tone: 'red', sub: '击败敌方领袖' };
case PHASE.HIDDEN_CORE: return { title: '隐秘核心', micro: 'HIDDEN CORE', tone: 'red', sub: '被源石侵蚀的假想敌' };
case PHASE.FINAL_ASSAULT: return { title: t('最终攻势'), micro: 'FINAL ASSAULT', tone: 'red', sub: t('击败敌方领袖') };
case PHASE.HIDDEN_CORE: return { title: t('隐秘核心'), micro: 'HIDDEN CORE', tone: 'red', sub: t('被源石侵蚀的假想敌') };
case PHASE.SETTLE: return null;
default: return null;
}
@@ -62,11 +63,11 @@ export function phaseBanner(phase, pub) {
/** Label of the prep capsule ("休息一下" in the original). */
export function prepCapsuleLabel(phase) {
if (phase === PHASE.SP_DRAFT) return '机变阶段';
if (phase === PHASE.ROUND_START) return '回合开始';
if (phase === PHASE.BATTLE_CHECK) return '协议启动';
if (phase === PHASE.SETTLE) return '回合结算';
return '休息一下';
if (phase === PHASE.SP_DRAFT) return t('机变阶段');
if (phase === PHASE.ROUND_START) return t('回合开始');
if (phase === PHASE.BATTLE_CHECK) return t('协议启动');
if (phase === PHASE.SETTLE) return t('回合结算');
return t('休息一下');
}
// ---- countdown -------------------------------------------------------------------------------------
+25 -24
View File
@@ -7,6 +7,7 @@ import { pieceDir, rangeTiles } from '../facing.js';
import { isObj, tileKey } from './shared.js';
import { boardTileOf, fieldTile } from './camera.js';
import { deployedRecord, fieldsStandIn, standInOf } from './standIn.js';
import { t } from '../../../../shared/i18n.js';
// ---- placement (canPlace mirror) ------------------------------------------------------------------------
@@ -310,45 +311,45 @@ function unitAllowed(ctx, piece, row, col, owner = null) {
*/
export function canPlace(ctx, uid, target) {
const no = (code, reason) => ({ ok: false, code, reason });
if (!ctx || !ctx.editable) return no('WRONG_PHASE', '当前阶段无法进行该操作');
if (!ctx || !ctx.editable) return no('WRONG_PHASE', t('当前阶段无法进行该操作'));
const src = ctx.pieces.get(uid);
if (!src) return no('BAD_TARGET', '找不到该单位');
if (!isObj(target)) return no('BAD_TILE', '无法部署在该位置');
if (!src) return no('BAD_TARGET', t('找不到该单位'));
if (!isObj(target)) return no('BAD_TILE', t('无法部署在该位置'));
const piece = src.piece;
const isMagic = piece.kind === 'item' && ctx.getItem(piece.id)?.itemType === 'MAGIC';
if (target.area === 'hand') {
const idx = target.idx;
if (!Number.isInteger(idx) || idx < 0 || idx >= GEO.HAND_SIZE) return no('BAD_TILE', '无法放置在该位置');
if (src.area === 'hand' && src.idx === idx) return no('ALREADY', '位置未变化');
if (!Number.isInteger(idx) || idx < 0 || idx >= GEO.HAND_SIZE) return no('BAD_TILE', t('无法放置在该位置'));
if (src.area === 'hand' && src.idx === idx) return no('ALREADY', t('位置未变化'));
const occ = ctx.handAt.get(idx);
if (!occ) return { ok: true, action: 'move' };
if (piece.kind === 'item') {
if (occ.piece.kind === 'chess') return isMagic ? no('BAD_TARGET', '该道具需要放置在战场上使用') : equipCheck(ctx, piece, occ.piece);
if (occ.piece.kind === 'chess') return isMagic ? no('BAD_TARGET', t('该道具需要放置在战场上使用')) : equipCheck(ctx, piece, occ.piece);
return { ok: true, action: 'swap' };
}
if (src.area === 'board') {
if (piece.kind === 'token') return { ok: true, action: 'move' }; // back onto its stack
if (occ.piece.kind === 'chess') {
if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置');
if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', t('交换后的单位无法部署在原位置'));
return { ok: true, action: 'swap' };
}
// the withdrawn operator goes to another free slot
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i));
return free ? { ok: true, action: 'move' } : no('HAND_FULL', '整备区已满');
return free ? { ok: true, action: 'move' } : no('HAND_FULL', t('整备区已满'));
}
return { ok: true, action: 'swap' };
}
if (target.area === 'board') {
const { row, col } = target;
if (!Number.isInteger(row) || !Number.isInteger(col)) return no('BAD_TILE', '无法部署在该位置');
if (!Number.isInteger(row) || !Number.isInteger(col)) return no('BAD_TILE', t('无法部署在该位置'));
const inField = row >= GEO.FIELD.r0 && row <= GEO.FIELD.r1 && col >= GEO.FIELD.c0 && col <= GEO.FIELD.c1;
if (!inField) return no('BAD_TILE', '无法部署在该位置');
if (!inField) return no('BAD_TILE', t('无法部署在该位置'));
const occ = ctx.boardAt.get(tileKey(row, col));
if (piece.kind === 'item') {
if (isMagic) return { ok: true, action: 'art' };
if (!occ) return no('BAD_TARGET', '请将装备拖拽至干员身上');
if (!occ) return no('BAD_TARGET', t('请将装备拖拽至干员身上'));
return equipCheck(ctx, piece, occ.piece);
}
// its own tile: re-orient in place through the direction wheel (research 09 §1.2)
@@ -356,38 +357,38 @@ export function canPlace(ctx, uid, target) {
if (!tileAllows(ctx, piece, row, col)) {
const deployable = ctx.deploy.ranged.has(tileKey(row, col));
const pos = piecePosition(ctx, piece);
return no('BAD_TILE', deployable && pos === 'MELEE' ? '近战单位只能部署在地面' : deployable && pos === 'HIGH' ? '只能部署在远程位' : '无法部署在该位置');
return no('BAD_TILE', deployable && pos === 'MELEE' ? t('近战单位只能部署在地面') : deployable && pos === 'HIGH' ? t('只能部署在远程位') : t('无法部署在该位置'));
}
// a range-bound summon (战术点): inside its owner's attack range — seen from the summon's old tile when it is
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
const range = summonRange(ctx, piece, ownerSwap);
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围内');
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围内'));
const out = summonExcluded(ctx, piece, ownerSwap);
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围外');
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外'));
if (src.area === 'board') {
// board → board: move or swap (the occupant must be legal on the source tile — the mover's own summon excepted:
// a moved operator's summons go back to the hand anyway)
const ownSummon = occ && occ.piece.kind === 'token' && occ.piece.ownerUid === piece.uid;
if (occ && !ownSummon && !unitAllowed(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置');
if (occ && !ownSummon && !unitAllowed(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', t('交换后的单位无法部署在原位置'));
return { ok: true, action: occ ? 'swap' : 'move' };
}
if (piece.kind === 'token') {
if (occ) return no('BAD_TILE', '该位置已有单位');
if (occ) return no('BAD_TILE', t('该位置已有单位'));
const ownerDeployed = [...ctx.boardAt.values()].some((e) => e.piece.uid === piece.ownerUid);
if (Number.isInteger(piece.ownerUid) && !ownerDeployed) return no('BAD_TARGET', '召唤者尚未部署');
if (Number.isInteger(piece.ownerUid) && !ownerDeployed) return no('BAD_TARGET', t('召唤者尚未部署'));
return { ok: true, action: 'move' };
}
if ((!occ || occ.piece.kind !== 'chess') && ctx.count >= ctx.cap) return no('BOARD_FULL', '已达到部署上限');
if ((!occ || occ.piece.kind !== 'chess') && ctx.count >= ctx.cap) return no('BOARD_FULL', t('已达到部署上限'));
return { ok: true, action: occ ? 'swap' : 'move' };
}
return no('BAD_TILE', '无法放置在该位置');
return no('BAD_TILE', t('无法放置在该位置'));
}
function equipCheck(ctx, itemPiece, targetPiece) {
const item = ctx.getItem(itemPiece.id);
if (item?.itemType === 'MAGIC') return { ok: false, code: 'BAD_TARGET', reason: '该道具需要放置在战场上使用' };
if (!isObj(targetPiece) || targetPiece.kind !== 'chess') return { ok: false, code: 'BAD_TARGET', reason: '装备只能配发给干员' };
if (item?.itemType === 'MAGIC') return { ok: false, code: 'BAD_TARGET', reason: t('该道具需要放置在战场上使用') };
if (!isObj(targetPiece) || targetPiece.kind !== 'chess') return { ok: false, code: 'BAD_TARGET', reason: t('装备只能配发给干员') };
return { ok: true, action: 'equip' };
}
@@ -477,10 +478,10 @@ export function dropFailureReason(ctx, uid, tile) {
let target = null;
if (tile.area === 'board') target = { area: 'board', row: tile.row, col: tile.col };
else if (tile.area === 'hand') target = { area: 'hand', idx: Number.isInteger(tile.idx) ? tile.idx : tile.col };
else if (tile.area === 'temp') return ctx.pieces.get(uid).area === 'temp' ? null : '临时整备区无法放入单位';
else if (Number.isInteger(tile.row) && tile.row >= GEO.FIELD.r0 && tile.row <= GEO.FIELD.r1) return '无法部署在该位置';
else if (tile.area === 'temp') return ctx.pieces.get(uid).area === 'temp' ? null : t('临时整备区无法放入单位');
else if (Number.isInteger(tile.row) && tile.row >= GEO.FIELD.r0 && tile.row <= GEO.FIELD.r1) return t('无法部署在该位置');
else return null;
const res = canPlace(ctx, uid, target);
if (res.ok || res.code === 'ALREADY') return null;
return res.reason || '无法放置在该位置';
return res.reason || t('无法放置在该位置');
}
+2 -1
View File
@@ -1,6 +1,7 @@
// ui/gameLogic/result.js — end-of-match result normalisation. Re-exported from ../gameLogic.js.
import { int, isObj, sortedPlayers } from './shared.js';
import { t } from '../../../../shared/i18n.js';
// ---- result -----------------------------------------------------------------------------------------------------------
@@ -24,7 +25,7 @@ export function normalizeResult(res, pub) {
return {
playerId: p.playerId,
seat: int(p.seat, int(pp.seat, 0)),
name: p.name || pp.name || '博士',
name: p.name || pp.name || t('博士'),
isBot: !!(p.isBot ?? pp.isBot),
alive,
lp: teamLp != null ? (alive === false ? 0 : Math.max(0, teamLp)) : ownLp,
+20 -18
View File
@@ -2,6 +2,7 @@
import { GEO } from '../../../../shared/constants.js';
import { isObj } from './shared.js';
import { t, tName } from '../../../../shared/i18n.js';
// ---- shop ---------------------------------------------------------------------------------------------
@@ -118,14 +119,15 @@ export function completesMerge(priv, slot, { getChess = () => null, getItem = ()
export function offerHeader(offer) {
const slots = isObj(offer) && Array.isArray(offer.slots) ? offer.slots : [];
const items = slots.some((s) => isObj(s) && s.kind === 'item');
const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? offer.label : null;
// (the server names the offer by its source in Chinese: a strategy's effect, an item, an operator — a data name)
const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? tName(offer.label) : null;
const queued = isObj(offer) && Number.isInteger(offer.queued) && offer.queued > 0 ? offer.queued : 0;
const tail = { queued, more: queued ? `之后还有 ${queued} 项` : null };
const tail = { queued, more: queued ? t('之后还有 {queued} 项', { queued }) : null };
if (!items && (!isObj(offer) || offer.source === 'merge' || offer.source == null) && !label) {
return { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择', ...tail };
return { title: t('晋升奖励'), micro: 'PROMOTION', sub: t('免费选择 1 名'), icon: 'crown', items: false, pill: t('晋升奖励待选择'), ...tail };
}
const title = label || (items ? '装备补给' : '特殊招募');
return { title, micro: 'SPECIAL', sub: items ? '免费选择 1 件' : '免费选择 1 名', icon: 'refresh', items, pill: `${title}待选择`, ...tail };
const title = label || (items ? t('装备补给') : t('特殊招募'));
return { title, micro: 'SPECIAL', sub: items ? t('免费选择 1 件') : t('免费选择 1 名'), icon: 'refresh', items, pill: t('{title}待选择', { title }), ...tail };
}
/**
@@ -135,26 +137,26 @@ export function offerHeader(offer) {
* @returns {string|null} Chinese reason
*/
export function shopBlockReason(kind, { priv, editable, slot } = {}) {
if (!priv) return '尚未就绪';
if (priv.alive === false) return '你已被淘汰';
if (kind === 'ready') return priv.canReady === false ? '临时整备区不为空,请先处理溢出的资源' : null;
if (!priv) return t('尚未就绪');
if (priv.alive === false) return t('你已被淘汰');
if (kind === 'ready') return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
if (!editable) {
if (kind === 'reward') return '当前无法选择';
return priv.ready ? '已准备就绪,取消准备后才能操作' : '当前阶段无法进行该操作';
if (kind === 'reward') return t('当前无法选择');
return priv.ready ? t('已准备就绪,取消准备后才能操作') : t('当前阶段无法进行该操作');
}
const funds = Number(priv.funds) || 0;
const shop = priv.shop || {};
if (kind === 'buy' || kind === 'reward') {
if (!isObj(slot) || slot.sold) return kind === 'reward' ? '已选择' : '已售出';
if ((Number(slot.price) || 0) > funds) return '资金不足';
if (!isObj(slot) || slot.sold) return kind === 'reward' ? t('已选择') : t('已售出');
if ((Number(slot.price) || 0) > funds) return t('资金不足');
// a full hand refuses every purchase / pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82)
if (handFull(priv)) return '整备区已满';
if (handFull(priv)) return t('整备区已满');
return null;
}
if (kind === 'refresh') return (Number(shop.refreshPrice) || 0) > funds ? '资金不足' : null;
if (kind === 'refresh') return (Number(shop.refreshPrice) || 0) > funds ? t('资金不足') : null;
if (kind === 'levelUp') {
if ((Number(shop.level) || 1) >= (Number(shop.maxLevel) || 6)) return '调度中心已达最高等级';
return (Number(shop.upgradePrice) || 0) > funds ? '资金不足' : null;
if ((Number(shop.level) || 1) >= (Number(shop.maxLevel) || 6)) return t('调度中心已达最高等级');
return (Number(shop.upgradePrice) || 0) > funds ? t('资金不足') : null;
}
return null;
}
@@ -177,7 +179,7 @@ export function readyFundsPrompt(priv, { ready = true, keptBands = null, autopla
const kept = Array.isArray(keptBands) ? keptBands : ['band_cannot'];
if (typeof priv.bandId === 'string' && kept.includes(priv.bandId)) return null;
return {
title: '剩余资金', micro: 'FUNDS LEFT', okText: '准备就绪', cancelText: '继续整备',
text: `还有 ${funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?`,
title: t('剩余资金'), micro: 'FUNDS LEFT', okText: t('准备就绪'), cancelText: t('继续整备'),
text: t('还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?', { funds }),
};
}
+12 -11
View File
@@ -2,6 +2,7 @@
import { isObj, sortedPlayers } from './shared.js';
import { isCombatPhase } from './phases.js';
import { t } from '../../../../shared/i18n.js';
/** Own normal field id (DESIGN §8.3). */
@@ -47,8 +48,8 @@ export function cycleField(fields, current, dir = 1) {
* @returns {{ fieldId: string } | { reason: string }}
*/
export function watchTarget(p, pub, myId) {
if (!isObj(p)) return { reason: '无效的目标' };
if (p.alive === false) return { reason: '该队友已被淘汰,无法查看其阵地' };
if (!isObj(p)) return { reason: t('无效的目标') };
if (p.alive === false) return { reason: t('该队友已被淘汰,无法查看其阵地') };
const combat = isCombatPhase(pub?.phase);
const fieldId = (combat && typeof p.fieldId === 'string' && p.fieldId) || ownFieldId(p.playerId);
if (combat) {
@@ -57,7 +58,7 @@ export function watchTarget(p, pub, myId) {
const me = sortedPlayers(pub).find((x) => x.playerId === myId);
const mine = fields.find((x) => Array.isArray(x.players) && x.players.includes(myId));
if (f && (f.kind === 'boss' || f.kind === 'hidden') && me?.alive !== false && mine && mine.fieldId !== f.fieldId) {
return { reason: '无法查看另一组队友的战场' };
return { reason: t('无法查看另一组队友的战场') };
}
}
return { fieldId };
@@ -76,9 +77,9 @@ export function switcherLabel(pub, watching, myId, spectating = false) {
const cur = fields.find((f) => f.fieldId === watching);
if (cur) return fieldLabel(cur, pub, myId);
if (typeof watching === 'string' && watching.startsWith('n:') && watching !== ownFieldId(myId)) {
return sortedPlayers(pub).find((p) => p.playerId === watching.slice(2))?.name || '队友';
return sortedPlayers(pub).find((p) => p.playerId === watching.slice(2))?.name || t('队友');
}
return spectating ? '观战' : '自己';
return spectating ? t('观战') : t('自己');
}
/**
@@ -89,16 +90,16 @@ export function switcherLabel(pub, watching, myId, spectating = false) {
*/
export function fieldLabel(field, pub, myId) {
if (!isObj(field)) return '—';
const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || '博士']));
const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || t('博士')]));
const ps = Array.isArray(field.players) ? field.players : [];
if (field.kind === 'unite') return ps.includes(myId) ? '联防(自己)' : '联防阵地';
if (field.kind === 'unite') return ps.includes(myId) ? t('联防(自己)') : t('联防阵地');
if (field.kind === 'boss' || field.kind === 'hidden') {
if (ps.includes(myId)) return ps.length > 1 ? '全景' : '自己';
return ps.map((id) => names.get(id) || '博士').join(' · ') || '领袖战场';
if (ps.includes(myId)) return ps.length > 1 ? t('全景') : t('自己');
return ps.map((id) => names.get(id) || t('博士')).join(' · ') || t('领袖战场');
}
if (ps.includes(myId) || field.fieldId === ownFieldId(myId)) return '自己';
if (ps.includes(myId) || field.fieldId === ownFieldId(myId)) return t('自己');
const id = ps[0] ?? String(field.fieldId || '').replace(/^n:/, '');
return names.get(id) || '队友';
return names.get(id) || t('队友');
}
/**
+27 -26
View File
@@ -16,23 +16,24 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
import { html, Icon, MicroLabel, Button, Spinner } from './components.js';
import { createStore, useStore } from '../store.js';
import { data, useData, artUrls, nextArtUrl } from '../data.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Page catalogue in reading order (titles transcribed from the pages). */
export const GUIDE_CHAPTERS = [
{ id: 'home', name: '基础规则', micro: 'BASICS', pages: [
['autochess_home_1', '卫戍协议已运行'], ['autochess_home_2', '攻防战'], ['autochess_home_3', '休整期 · 区域'],
['autochess_home_4', '休整期 · 资金与调度'], ['autochess_home_5', '机变阶段'], ['autochess_home_6', '限时战斗'],
['autochess_home_7', '作战期'], ['autochess_home_8', '协同战斗'], ['autochess_home_9', '盟约'],
{ id: 'home', name: N_('基础规则'), micro: 'BASICS', pages: [
['autochess_home_1', N_('卫戍协议已运行')], ['autochess_home_2', N_('攻防战')], ['autochess_home_3', N_('休整期 · 区域')],
['autochess_home_4', N_('休整期 · 资金与调度')], ['autochess_home_5', N_('机变阶段')], ['autochess_home_6', N_('限时战斗')],
['autochess_home_7', N_('作战期')], ['autochess_home_8', N_('协同战斗')], ['autochess_home_9', N_('盟约')],
] },
{ id: 'shop', name: '调度手册', micro: 'HANDBOOK', pages: [
['autochess_shop_1', '调度手册'], ['autochess_shop_2', '干员晋级'], ['autochess_shop_3', '加成情况'],
['autochess_shop_4', '卫戍能力'], ['autochess_shop_5', '盟约'], ['autochess_shop_6', '助战及自选编队'],
{ id: 'shop', name: N_('调度手册'), micro: 'HANDBOOK', pages: [
['autochess_shop_1', N_('调度手册')], ['autochess_shop_2', N_('干员晋级')], ['autochess_shop_3', N_('加成情况')],
['autochess_shop_4', N_('卫戍能力')], ['autochess_shop_5', N_('盟约')], ['autochess_shop_6', N_('助战及自选编队')],
] },
{ id: 'handbook', name: '进阶图鉴', micro: 'ADVANCED', pages: [
['autochess_handbook_1', '敌人类型'], ['autochess_handbook_2', '盟约激活与叠加'], ['autochess_handbook_3', '追加盟约'],
['autochess_handbook_4', '策略与轮选'],
{ id: 'handbook', name: N_('进阶图鉴'), micro: 'ADVANCED', pages: [
['autochess_handbook_1', N_('敌人类型')], ['autochess_handbook_2', N_('盟约激活与叠加')], ['autochess_handbook_3', N_('追加盟约')],
['autochess_handbook_4', N_('策略与轮选')],
] },
];
@@ -78,9 +79,9 @@ export function openGuide(page = 0) {
export const closeGuide = () => guideStore.set({ open: false });
/** Standard 玩法说明 trigger button. */
export function GuideButton({ class: cls, size = 'sm', variant = 'ghost', label = '玩法说明', square = false }) {
export function GuideButton({ class: cls, size = 'sm', variant = 'ghost', label = t('玩法说明'), square = false }) {
return html`<${Button} variant=${variant} size=${size} icon="book" square=${square} class=${cx('guide-btn', cls)}
onClick=${() => openGuide(0)} title="玩法说明" aria-label="玩法说明">${square ? null : label}<//>`;
onClick=${() => openGuide(0)} title=${t('玩法说明')} aria-label=${t('玩法说明')}>${square ? null : label}<//>`;
}
function preload(url) {
@@ -95,9 +96,9 @@ function preload(url) {
function TipsFallback() {
const tips = (Array.isArray(data.get('config')?.tips) ? data.get('config').tips : []).map((t) => t?.tip).filter(Boolean);
return html`<div class="guide__tips">
<${MicroLabel} tone="mint">TIPS // 模拟要点</${MicroLabel}>
<${MicroLabel} tone="mint">${t('TIPS // 模拟要点')}</${MicroLabel}>
<ol>${tips.map((t, i) => html`<li key=${i}>${t}</li>`)}</ol>
${!tips.length ? html`<p class="t-lo">暂无说明内容</p>` : null}
${!tips.length ? html`<p class="t-lo">${t('暂无说明内容')}</p>` : null}
</div>`;
}
@@ -146,42 +147,42 @@ export function GuideHost() {
const src = stage.kind === 'image' ? stage.src : null;
const isLoaded = !!src && loaded.has(src);
return html`<div class="guide" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeGuide(); }}>
<div class="guide__box brackets" role="dialog" aria-modal="true" aria-label="玩法说明" tabindex="-1" ref=${boxRef}>
<div class="guide__box brackets" role="dialog" aria-modal="true" aria-label=${t('玩法说明')} tabindex="-1" ref=${boxRef}>
<header class="guide__head">
<div class="guide__titles">
<${MicroLabel} tone="mint">HOW TO PLAY // STRONGHOLD PROTOCOL</${MicroLabel}>
<h2 class="guide__title">玩法说明</h2>
<h2 class="guide__title">${t('玩法说明')}</h2>
</div>
${n ? html`<nav class="guide__chapters" aria-label="章节">
${n ? html`<nav class="guide__chapters" aria-label=${t('章节')}>
${GUIDE_CHAPTERS.map((ch, ci) => {
const at = firstOf(ci);
if (at < 0) return null;
return html`<button key=${ch.id} type="button" class=${cx('guide__chapter', cur?.chapter === ci && 'is-on')} onClick=${() => go(at)}>
<span class="guide__chname">${ch.name}</span><span class="guide__chmicro">${ch.micro}</span>
<span class="guide__chname">${t(ch.name)}</span><span class="guide__chmicro">${ch.micro}</span>
</button>`;
})}
</nav>` : null}
<button type="button" class="guide__close" aria-label="关闭" title="关闭 (Esc)" onClick=${closeGuide}><${Icon} name="close" /></button>
<button type="button" class="guide__close" aria-label=${t('关闭')} title=${t('关闭 (Esc)')} onClick=${closeGuide}><${Icon} name="close" /></button>
</header>
${src ? html`<div class="guide__stage">
<button type="button" class="guide__nav guide__prev" aria-label="上一页" onClick=${() => go(i - 1)}><${Icon} name="chevronLeft" /></button>
<button type="button" class="guide__nav guide__prev" aria-label=${t('上一页')} onClick=${() => go(i - 1)}><${Icon} name="chevronLeft" /></button>
<div class=${cx('guide__page', isLoaded && 'is-loaded')}>
<img key=${src} src=${src} alt=${cur.title} draggable=${false}
<img key=${src} src=${src} alt=${t(cur.title)} draggable=${false}
onLoad=${() => setLoaded((s) => new Set(s).add(src))}
onError=${() => setFailed((s) => new Set(s).add(src))} />
${!isLoaded ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
</div>
<button type="button" class="guide__nav guide__next" aria-label="下一页" onClick=${() => go(i + 1)}><${Icon} name="chevronRight" /></button>
<button type="button" class="guide__nav guide__next" aria-label=${t('下一页')} onClick=${() => go(i + 1)}><${Icon} name="chevronRight" /></button>
</div>` : html`<div class="guide__stage guide__stage--text"><${TipsFallback} /></div>`}
${n ? html`<footer class="guide__foot">
<div class="guide__label">
<span class="guide__chtag">${chapter?.name || ''}</span>
<b class="guide__ptitle">${cur?.title || ''}</b>
<span class="guide__chtag">${t(chapter?.name || '')}</span>
<b class="guide__ptitle">${t(cur?.title || '')}</b>
</div>
<div class="guide__dots" role="tablist" aria-label="页码">
${pages.map((p, k) => html`<button key=${p.key} type="button" role="tab" aria-selected=${k === i ? 'true' : 'false'} title=${p.title}
<div class="guide__dots" role="tablist" aria-label=${t('页码')}>
${pages.map((p, k) => html`<button key=${p.key} type="button" role="tab" aria-selected=${k === i ? 'true' : 'false'} title=${t(p.title)}
class=${cx('guide__dot', k === i && 'is-on', k > 0 && pages[k - 1].chapter !== p.chapter && 'is-first')} onClick=${() => go(k)}></button>`)}
</div>
<span class="guide__count num">${i + 1} / ${n}</span>
+32 -30
View File
@@ -31,6 +31,7 @@ import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, bossPctText, fmtNum, shopBlockReason } from './gameLogic.js';
import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -48,9 +49,9 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
return html`<div class="capsule capsule--boss" role="status">
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} />
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.killed ?? 0}</b>/${hud.total}</span>` : null}
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : '敌方领袖'}>
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : t('敌方领袖')}>
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
<span class="bossbar__txt num">${frac == null ? '敌方领袖' : bossPctText(frac)}</span>
<span class="bossbar__txt num">${frac == null ? t('敌方领袖') : bossPctText(frac)}</span>
</div>
</div>`;
}
@@ -59,7 +60,7 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
<${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon"
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
<span class="capsule__kills num"><b>${hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
${phase === PHASE.UNITE ? html`<span class="capsule__tag">联防</span>` : null}
${phase === PHASE.UNITE ? html`<span class="capsule__tag">${t('联防')}</span>` : null}
${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
</div>`;
}
@@ -75,8 +76,8 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
* @param {number} n enemies still standing @param {string|null} [name] the leaker's name (null = the viewer)
*/
export function missTip(n, name = null) {
const who = name ? `${name} 漏过的敌人` : '你漏过的敌人';
return n > 0 ? `${who}还剩 ${n} 个(${name ? '联防中' : '队友正在迎战'})` : `${who}已全部被击倒`;
if (n > 0) return name ? t('{name} 漏过的敌人还剩 {n} 个(联防中)', { name, n }) : t('你漏过的敌人还剩 {n} 个(队友正在迎战)', { n });
return name ? t('{name} 漏过的敌人已全部被击倒', { name }) : t('你漏过的敌人已全部被击倒');
}
/**
@@ -90,7 +91,7 @@ export function MissTag({ n, name = null }) {
const v = Math.max(0, Math.trunc(Number(n) || 0));
const art = localAsset('ui/battle', 'bg_miss_enemy');
return html`<span class=${cx('misstag', art && 'has-art', v === 0 && 'is-clear')} data-testid="miss-tag"
title=${missTip(v, name)} aria-label=${`剩余敌人 ${v}`}
title=${missTip(v, name)} aria-label=${t('剩余敌人 {v}', { v })}
style=${art ? `background-image:url("${art}")` : null}>
${art ? null : html`<span class="misstag__icon" aria-hidden="true"><${GIcon} name="skull" />×</span>`}<b class="misstag__n num">${v}</b>
</span>`;
@@ -174,12 +175,13 @@ export function liveLp(base, { phase, round, lp, statsLeaks = null, leaks = 0, c
export function pendingTip(lp, pending, { unite = false, cap = 10, left = null } = {}) {
if (!(pending > 0)) return null;
if (unite && Number.isFinite(left)) {
const each = left > cap ? `剩余不足 ${cap} 个后,队友每击倒一个少扣 1 点` : '队友每击倒一个就少扣 1 点';
return `目标生命值 ${lp}:联防中,你漏过的敌人还剩 ${left} 个,${each};按现在结算扣除 ${pending} 点(每回合至多 ${cap} 点)`;
const each = left > cap ? t('剩余不足 {cap} 个后,队友每击倒一个少扣 1 点', { cap }) : t('队友每击倒一个就少扣 1 点');
return t('目标生命值 {lp}:联防中,你漏过的敌人还剩 {left} 个,{each};按现在结算扣除 {pending} 点(每回合至多 {cap} 点)', { lp, left, each, pending, cap });
}
return unite
? `目标生命值 ${lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 ${pending} 点)`
: `目标生命值 ${lp}:本回合已有 ${pending >= cap ? `${cap} 个以上` : `${pending} 个`}敌人进入蓝门,结算时扣除 ${pending} 点(每回合至多 ${cap} 点)`;
? t('目标生命值 {lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 {pending} 点)', { lp, pending })
: pending >= cap ? t('目标生命值 {lp}:本回合已有 {cap} 个以上敌人进入蓝门,结算时扣除 {pending} 点(每回合至多 {cap} 点)', { lp, pending, cap })
: t('目标生命值 {lp}:本回合已有 {pending} 个敌人进入蓝门,结算时扣除 {pending} 点(每回合至多 {cap} 点)', { lp, pending, cap });
}
// ---- temp overflow row (临时整备区, user playtest #3 item 3) ---------------------------------------------------
@@ -195,12 +197,12 @@ export function tempInfo(priv) {
}
/** What the temp row asks of the player (the ready button's reason, the row's label). */
export const TEMP_RULE = '放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁';
export const TEMP_RULE = N_('放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁');
/** Why 准备就绪 is refused while the temp row holds pieces (null when it is empty). */
export function tempReadyReason(priv) {
const t = tempInfo(priv);
return t.count ? `临时整备区还有 ${t.count} 个单位:${TEMP_RULE}` : null;
const info = tempInfo(priv);
return info.count ? t('临时整备区还有 {count} 个单位:{rule}', { count: info.count, rule: t(TEMP_RULE) }) : null;
}
/**
@@ -215,14 +217,14 @@ export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? '取消准备' : '准备就绪'}</span>
<span class="readybtn__label">${ready ? t('取消准备') : t('准备就绪')}</span>
<kbd class="readybtn__key">Space</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
${!ready && temp.count ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" /><span>临时整备区 <b class="num">${temp.count}</b> 个单位待处理</span></span>` : null}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">已就绪 <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
<${Icon} name="warn" /><span>${tParts('临时整备区 {n} 个单位待处理', { n: html`<b class="num">${temp.count}</b>`, count: temp.count })}</span></span>` : null}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">${t('已就绪')} <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`;
}
@@ -233,13 +235,13 @@ export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
*/
export function checkButtons({ pen, penAvail, infoOpen }) {
const left = pen
? { sprite: 'btn_check_player_back', back: true, label: '返回', tip: '返回战场' }
: { sprite: infoOpen ? 'btn_check_player_unfold' : 'btn_check_player_normal', back: false, label: '本局信息', tip: '本局信息(策略 / 禁用盟约 / 干员)' };
? { sprite: 'btn_check_player_back', back: true, label: t('返回'), tip: t('返回战场') }
: { sprite: infoOpen ? 'btn_check_player_unfold' : 'btn_check_player_normal', back: false, label: t('本局信息'), tip: t('本局信息(策略 / 禁用盟约 / 干员)') };
const right = pen
? { sprite: 'btn_check_enemy_unfold', grey: true, label: '返回', tip: '返回' }
? { sprite: 'btn_check_enemy_unfold', grey: true, label: t('返回'), tip: t('返回') }
: penAvail
? { sprite: 'btn_check_enemy', grey: false, label: '敌方情报', tip: '查看即将迎击的敌方单位' }
: { sprite: 'btn_check_enemy_unfold', grey: true, label: '敌方情报', tip: '休整期可以查看即将迎击的敌方单位' };
? { sprite: 'btn_check_enemy', grey: false, label: t('敌方情报'), tip: t('查看即将迎击的敌方单位') }
: { sprite: 'btn_check_enemy_unfold', grey: true, label: t('敌方情报'), tip: t('休整期可以查看即将迎击的敌方单位') };
return { left, right };
}
@@ -263,7 +265,7 @@ export function OvertimeWarning({ ot }) {
return html`<div class="otwarn otwarn--pending" role="alert" data-state="pending">
<${LocalSprite} name="icon_warn" class="otwarn__icon" fallback=${html`<${Icon} name="warn" class="otwarn__icon" />`} />
<span class="otwarn__tag">DOT</span>
<span class="otwarn__txt"><b class="num">${ot.secs}</b> 秒后全队生命值开始流失</span>
<span class="otwarn__txt">${tParts('{n} 秒后全队生命值开始流失', { n: html`<b class="num">${ot.secs}</b>`, count: ot.secs })}</span>
</div>`;
}
return html`<div class="otwarn otwarn--drain" role="alert" data-state="drain">
@@ -271,8 +273,8 @@ export function OvertimeWarning({ ot }) {
<${LocalSprite} name="blood_icon" class="otwarn__icon" fallback=${html`<${Icon} name="rook" class="otwarn__icon" />`} />
</span>
<span class="otwarn__tag">DOT</span>
<span class="otwarn__txt">超时 · 生命值 <b class="num">−${ot.perSec}</b>/秒</span>
${ot.lost > 0 ? html`<span class="otwarn__lost">已流失 <b class="num">${ot.lost}</b></span>` : null}
<span class="otwarn__txt">${tParts('超时 · 生命值 {n}/秒', { n: html`<b class="num">−${ot.perSec}</b>` })}</span>
${ot.lost > 0 ? html`<span class="otwarn__lost">${t('已流失')} <b class="num">${ot.lost}</b></span>` : null}
<span key=${ot.secs} class="otwarn__tick num" aria-hidden="true">−${ot.perSec}</span>
</div>`;
}
@@ -287,8 +289,8 @@ function PauseGlyph() {
* @param {{ paused: boolean, busy?: boolean, onToggle: () => void }} props
*/
export function PauseButton({ paused, busy = false, onToggle }) {
const label = paused ? '继续作战' : '暂停';
return html`<${Tooltip} text=${paused ? '继续作战(Space)' : '暂停作战(Space)'} placement="bottom">
const label = paused ? t('继续作战') : t('暂停');
return html`<${Tooltip} text=${paused ? t('继续作战(Space)') : t('暂停作战(Space)')} placement="bottom">
<button type="button" class=${cx('pausebtn', 'tapx', paused && 'is-on', busy && 'is-busy')} aria-pressed=${paused ? 'true' : 'false'}
aria-label=${label} disabled=${busy} data-testid="pause" onClick=${() => onToggle?.()}>
${paused ? html`<${Icon} name="play" class="pausebtn__glyph" />` : html`<${PauseGlyph} />`}
@@ -332,7 +334,7 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
const onRight = () => (pen ? onPen(false) : penAvail ? onPen(true) : null);
return html`<header class=${cx('gtop', pen && 'is-pen')}>
<div class="gtop__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label="离开" title="离开 / 暂离" class="gtop__exit tapx" />
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
<div class="gtop__meta">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
@@ -345,12 +347,12 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
chev=${btn.left.back ? '◀◀' : null} on=${!!drawer && !pen} onClick=${onLeft} testid="check-player" />
<//>
<div class="roundbox">
<span class="roundbox__label">回合</span>
<span class="roundbox__label">${t('回合')}</span>
<b class="roundbox__num num">${roundText}</b>
</div>
<${PhaseCapsule} pub=${pub} hud=${hud} miss=${!boss && Number.isFinite(live?.left) ? live.left : null} />
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} pending=${pending}
note=${pending > 0 && live?.unite ? '联防中' : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap, left: live?.left ?? null })} />
note=${pending > 0 && live?.unite ? t('联防中') : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap, left: live?.left ?? null })} />
<${Tooltip} text=${btn.right.tip} placement="bottom">
<${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label}
chev=${btn.right.grey ? null : '▶▶'} disabled=${btn.right.grey && !pen} onClick=${onRight} testid="check-enemy" />
@@ -373,7 +375,7 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
/** DP counter shown at the right edge during combat. */
export function DpCounter({ dp }) {
if (!Number.isFinite(dp)) return null;
return html`<div class="dpbox" title="部署费用(再部署消耗)">
return html`<div class="dpbox" title=${t('部署费用(再部署消耗)')}>
<${GIcon} name="dp" class="dpbox__icon" /><b class="num">${Math.floor(dp)}</b><${MicroLabel}>COST</${MicroLabel}>
</div>`;
}
+19 -3
View File
@@ -15,7 +15,9 @@ 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' });
export const LANG_LABELS = Object.freeze({ zh: '中文', en: 'English' }); // i18n-ignore: each language in its own name
/** The switch's own label, in both languages (whoever opens it may not read the current one). */
const SWITCH_LABEL = 'Language / 语言'; // i18n-ignore
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;
@@ -78,7 +80,7 @@ function applyDocument(lang) {
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';
doc.title = lang === 'en' ? 'Stronghold Protocol: Alliance · Web Simulation' : '卫戍协议:盟约 · STRONGHOLD PROTOCOL'; // i18n-ignore: one title per language
}
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
@@ -144,12 +146,26 @@ export function useLang() {
*/
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">
return html`<div class=${`set-seg lang-toggle${cls ? ` ${cls}` : ''}`} role="radiogroup" aria-label=${SWITCH_LABEL} 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>`;
}
/**
* A game text the server sent in Chinese — a 机变 card's or an effect's description, a draft's name, a result title —
* in the current language: the localized record's text when the sent text is that record's own Chinese text
* (data.lookupRaw / getRaw), else the sent text as it is (a line the server reworded stays Chinese). In Chinese this is
* always the sent text. Names have a shorter way: tName() (every record name of the data is in the overlay's names).
* @param {unknown} sent the server's text
* @param {unknown} raw the record's Chinese text
* @param {unknown} local the record's text in the current language
*/
export function sentText(sent, raw, local) {
if (typeof sent !== 'string' || !sent) return sent;
return typeof raw === 'string' && raw === sent && typeof local === 'string' && local ? local : sent;
}
/**
* 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
+19 -18
View File
@@ -10,6 +10,7 @@
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted.
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { t, N_ } from '../../../shared/i18n.js';
export { MODULE_NONE };
@@ -18,14 +19,14 @@ export const LOADOUT_PREF = 'loadout';
export const LOADOUT_VERSION = 1;
export const PROF_ORDER = ['PIONEER', 'WARRIOR', 'TANK', 'SNIPER', 'CASTER', 'MEDIC', 'SUPPORT', 'SPECIAL'];
export const PROF_NAME = Object.freeze({ PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种' });
export const SP_TYPE = Object.freeze({ INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动', 8: '被动' });
export const PROF_NAME = Object.freeze({ PIONEER: N_('先锋'), WARRIOR: N_('近卫'), TANK: N_('重装'), SNIPER: N_('狙击'), CASTER: N_('术师'), MEDIC: N_('医疗'), SUPPORT: N_('辅助'), SPECIAL: N_('特种') });
export const SP_TYPE = Object.freeze({ INCREASE_WITH_TIME: N_('自动回复'), INCREASE_WHEN_ATTACK: N_('攻击回复'), INCREASE_WHEN_TAKEN_DAMAGE: N_('受击回复'), ON_DEPLOY: N_('被动'), 8: N_('被动') });
/** Module attribute keys (ModuleRecord.attr / battle_equip attributeBlackboard) → label + unit. */
export const ATTR_LABEL = Object.freeze({
maxHp: ['生命上限', ''], max_hp: ['生命上限', ''], atk: ['攻击力', ''], def: ['防御力', ''], res: ['法术抗性', ''],
magic_resistance: ['法术抗性', ''], aspd: ['攻击速度', ''], attack_speed: ['攻击速度', ''], cost: ['部署费用', ''],
blockCnt: ['阻挡数', ''], block_cnt: ['阻挡数', ''], respawnTime: ['再部署时间', '秒'], respawn_time: ['再部署时间', '秒'],
baseAttackTime: ['攻击间隔', '秒'], base_attack_time: ['攻击间隔', '秒'], moveSpeed: ['移动速度', ''], hpRecoveryPerSec: ['每秒回复', ''],
maxHp: [N_('生命上限'), ''], max_hp: [N_('生命上限'), ''], atk: [N_('攻击力'), ''], def: [N_('防御力'), ''], res: [N_('法术抗性'), ''],
magic_resistance: [N_('法术抗性'), ''], aspd: [N_('攻击速度'), ''], attack_speed: [N_('攻击速度'), ''], cost: [N_('部署费用'), ''],
blockCnt: [N_('阻挡数'), ''], block_cnt: [N_('阻挡数'), ''], respawnTime: [N_('再部署时间'), N_('秒')], respawn_time: [N_('再部署时间'), N_('秒')],
baseAttackTime: [N_('攻击间隔'), N_('秒')], base_attack_time: [N_('攻击间隔'), N_('秒')], moveSpeed: [N_('移动速度'), ''], hpRecoveryPerSec: [N_('每秒回复'), ''],
});
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -102,19 +103,19 @@ export function serializeExport(entries, opts) {
export function parseImport(input) {
let raw = input;
if (typeof raw === 'string') {
if (raw.length > LOADOUT_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' };
if (raw.length > LOADOUT_IMPORT_MAX_BYTES) return { ok: false, error: t('内容过长,无法导入') };
const text = raw.trim();
if (!text) return { ok: false, error: '没有可导入的内容' };
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
if (!text) return { ok: false, error: t('没有可导入的内容') };
try { raw = JSON.parse(text); } catch { return { ok: false, error: t('无法识别的内容') }; }
}
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
if (!isObj(raw)) return { ok: false, error: t('无法识别的格式') };
const v = isInt(raw.v) ? raw.v : null;
// a newer envelope may reshuffle fields — refuse instead of silently reading it as something else
if (v != null && v > LOADOUT_VERSION) return { ok: false, error: `这份调配来自更新的版本(v${v}),请先更新游戏` };
if (v != null && v > LOADOUT_VERSION) return { ok: false, error: t('这份调配来自更新的版本(v{v}),请先更新游戏', { v }) };
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: '这不是干员调配的数据' };
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
const entries = parseStored(raw);
if (!Object.keys(entries).length) return { ok: false, error: '里面没有有效的调配条目' };
if (!Object.keys(entries).length) return { ok: false, error: t('里面没有有效的调配条目') };
return { ok: true, entries };
}
@@ -292,7 +293,7 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
if (!effectiveChoice(entries, c, golden).changed) return false;
}
if (q) {
const hay = [c.name, c.appellation, c.subProfessionName, PROF_NAME[c.profession], ...(c.bonds || []).map((b) => getBond(b)?.name)]
const hay = [c.name, c.appellation, c.subProfessionName, t(PROF_NAME[c.profession]), ...(c.bonds || []).map((b) => getBond(b)?.name)]
.filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
@@ -336,7 +337,7 @@ export function attrRows(attr) {
if (typeof v !== 'number' || !Number.isFinite(v) || v === 0) continue;
const [label, unit] = ATTR_LABEL[k] || [k, ''];
const n = Math.abs(v) < 10 && !Number.isInteger(v) ? Number(v.toFixed(2)) : Math.round(v);
out.push({ key: k, label, text: `${v > 0 ? '+' : ''}${n}${unit}`, positive: k === 'cost' || k === 'respawnTime' || k === 'respawn_time' || k === 'baseAttackTime' || k === 'base_attack_time' ? v < 0 : v > 0 });
out.push({ key: k, label: t(label), text: `${v > 0 ? '+' : ''}${n}${t(unit)}`, positive: k === 'cost' || k === 'respawnTime' || k === 'respawn_time' || k === 'baseAttackTime' || k === 'base_attack_time' ? v < 0 : v > 0 });
}
return out;
}
@@ -350,10 +351,10 @@ export function skillTags(rec) {
const passive = rec.skillType === 'PASSIVE' || rec.spType === 'ON_DEPLOY' || rec.spType === 8;
const spKind = passive ? 'passive' : rec.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : rec.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time';
let duration = null;
if (rec.durationType === 'AMMO') duration = '弹药';
else if (Number(rec.duration) > 0) duration = `${Number(rec.duration)}秒`;
if (rec.durationType === 'AMMO') duration = t('弹药');
else if (Number(rec.duration) > 0) duration = t('{n}秒', { n: Number(rec.duration) });
return {
sp: SP_TYPE[rec.spType] || (passive ? '被动' : '技力'),
sp: t(SP_TYPE[rec.spType]) || (passive ? t('被动') : t('技力')),
spKind,
init: passive ? null : Number.isFinite(rec.initSp) ? rec.initSp : 0,
cost: passive ? null : Number.isFinite(rec.spCost) ? rec.spCost : 0,
+4 -4
View File
@@ -22,7 +22,7 @@ import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutM
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
import { toast } from './toasts.js';
import { t } from '../../../shared/i18n.js';
import { t, N_ } from '../../../shared/i18n.js';
export const SYNC_DEBOUNCE_MS = 500;
export const RETRY_MS = 1500;
@@ -234,7 +234,7 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
const lookup = lookupChess || ((id) => data.lookup('chess', id));
return installPrefSync({
net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout',
lockedText: '本局的干员调配已锁定,修改将在下一局生效',
lockedText: N_('本局的干员调配已锁定,修改将在下一局生效'),
async prepare() {
const current = target.get().entries;
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
@@ -257,7 +257,7 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
export function installOwnershipSync({ net, timers, target = loadoutStore, notify } = {}) {
return installPrefSync({
net, timers, target, notify, key: 'notOwned', stateKey: 'ownSync', msgType: 'room.ownership', field: 'notOwned', tag: 'ownership',
lockedText: '干员持有是局外设置,修改将在下一局生效',
lockedText: N_('干员持有是局外设置,修改将在下一局生效'),
prepare: async () => cleanIds(target.get().notOwned),
});
}
@@ -277,7 +277,7 @@ export function installDiySync({ net, timers, target = loadoutStore, notify } =
});
const sync = installPrefSync({
net, timers, target, notify, key: 'diy', stateKey: 'diySync', msgType: 'room.diy', field: 'picks', tag: 'diy',
lockedText: '自选编队是局外设置,修改将在下一局生效',
lockedText: N_('自选编队是局外设置,修改将在下一局生效'),
prepare: async () => cleanPicks(target.get().diy),
});
return { flush: sync.flush, dispose() { offKit?.(); sync.dispose(); } };
+20 -19
View File
@@ -12,14 +12,15 @@ import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
import { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
/**
* Exit dialog lines. 放弃模拟 (g.leave → room.leave) ends the run on the server as 'abandoned' and returns to the
* lobby straight away — no settlement screen follows, so the solo text must not promise one.
*/
export const EXIT_TEXT = Object.freeze({
soloRest: '独立模拟的休整期没有时间限制,你可以随时继续。',
soloQuit: '放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。',
soloRest: N_('独立模拟的休整期没有时间限制,你可以随时继续。'),
soloQuit: N_('放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。'),
});
/** Local "暂离 / AI 托管" flag (the server keeps no per-client autoplay view). */
@@ -67,10 +68,10 @@ export function ExitModal({ open, onClose, solo, onAway }) {
onClose();
};
if (spectator) {
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开观战" micro="LEAVE SPECTATING" width="6.8rem"
actions=${html`<${Button} variant="secondary" onClick=${onClose}>取消<//>
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>离开观战<//>`}>
<div class="exitm"><p>离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。</p></div>
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title=${t('离开观战')} micro="LEAVE SPECTATING" width="6.8rem"
actions=${html`<${Button} variant="secondary" onClick=${onClose}>${t('取消')}<//>
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>${t('离开观战')}<//>`}>
<div class="exitm"><p>${t('离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。')}</p></div>
<//>`;
}
const away = async () => {
@@ -80,16 +81,16 @@ export function ExitModal({ open, onClose, solo, onAway }) {
onClose();
if (ok) { awayStore.set({ away: true }); onAway?.(); }
};
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开模拟" micro="LEAVE SIMULATION" width="6.8rem"
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title=${t('离开模拟')} micro="LEAVE SIMULATION" width="6.8rem"
actions=${html`
<${Button} variant="secondary" onClick=${onClose}>取消<//>
${!solo ? html`<${Button} variant="ice" icon="robot" loading=${busy === 'away'} onClick=${away}>暂离(AI 托管)<//>` : null}
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>放弃模拟<//>`}>
<${Button} variant="secondary" onClick=${onClose}>${t('取消')}<//>
${!solo ? html`<${Button} variant="ice" icon="robot" loading=${busy === 'away'} onClick=${away}>${t('暂离(AI 托管)')}<//>` : null}
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>${t('放弃模拟')}<//>`}>
<div class="exitm">
${solo
? html`<p>${EXIT_TEXT.soloRest}</p><p class="t-lo">${EXIT_TEXT.soloQuit}</p>`
: html`<p><b class="t-ice">暂离</b>:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。</p>
<p><b class="t-red">放弃模拟</b>:离开后无法返回本局,你的干员将回到共享卡池。</p>`}
? html`<p>${t(EXIT_TEXT.soloRest)}</p><p class="t-lo">${t(EXIT_TEXT.soloQuit)}</p>`
: html`<p>${tParts('{away}:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。', { away: html`<b class="t-ice">${t('暂离')}</b>` })}</p>
<p>${tParts('{quit}:离开后无法返回本局,你的干员将回到共享卡池。', { quit: html`<b class="t-red">${t('放弃模拟')}</b>` })}</p>`}
</div>
<//>`;
}
@@ -97,13 +98,13 @@ export function ExitModal({ open, onClose, solo, onAway }) {
/** Full-screen "AI 托管中" overlay with 返回模拟. */
export function AwayOverlay({ onBack = () => {} }) {
const [busy, setBusy] = useState(false);
return html`<div class="awayov" role="dialog" aria-label="AI 托管中">
return html`<div class="awayov" role="dialog" aria-label=${t('AI 托管中')}>
<div class="awayov__box brackets">
<${GIcon} name="robot" class="awayov__icon" />
<${MicroLabel} tone="mint">AUTOPILOT // AI 托管</${MicroLabel}>
<h2>AI 托管中</h2>
<p class="t-lo">AI 正在代为操作你的席位</p>
<${Button} variant="primary" size="lg" icon="play" loading=${busy} onClick=${async () => { setBusy(true); const ok = await actions.autoplay(false); setBusy(false); if (ok) { awayStore.set({ away: false }); onBack(); } }}>返回模拟<//>
<${MicroLabel} tone="mint">${t('AUTOPILOT // AI 托管')}</${MicroLabel}>
<h2>${t('AI 托管中')}</h2>
<p class="t-lo">${t('AI 正在代为操作你的席位')}</p>
<${Button} variant="primary" size="lg" icon="play" loading=${busy} onClick=${async () => { setBusy(true); const ok = await actions.autoplay(false); setBusy(false); if (ok) { awayStore.set({ away: false }); onBack(); } }}>${t('返回模拟')}<//>
</div>
</div>`;
}
@@ -116,7 +117,7 @@ export function StepHeader({ step, of, title, micro, pub, total, onExit }) {
const conn = useStore((s) => s.connection, shallowEqual);
return html`<header class="stephead">
<div class="stephead__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label="离开" title="离开" />
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开')} />
<div class="stephead__meta">
<${PingPill} ms=${conn.ping} online=${conn.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} />` : null}
+7 -7
View File
@@ -101,7 +101,7 @@ export function MatchBondRow({ title, micro, bonds, model }) {
* @param {{ model: MatchInfoModel }} props
*/
export function MatchLegend({ model }) {
return html`<p class="brief-legend"><span class="brief-legend__off"></span>灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活)${model.sets.off.size ? ',或本模式禁用' : ''} · <span class="brief-legend__ban"><${Icon} name="user" /></span>该盟约中无法出现的干员数</p>`;
return html`<p class="brief-legend"><span class="brief-legend__off"></span>${t('灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活)')}${model.sets.off.size ? t(',或本模式禁用') : ''} · <span class="brief-legend__ban"><${Icon} name="user" /></span>${t('该盟约中无法出现的干员数')}</p>`;
}
/**
@@ -111,10 +111,10 @@ export function MatchLegend({ model }) {
export function BannedOperators({ model }) {
const { banned } = model;
return html`<div class="brief-banned">
<h3 class="brief-h"><span>本局禁用干员</span><${MicroLabel}>BANNED OPERATORS</${MicroLabel}><b class="num brief-banned__n">${banned.length}</b></h3>
<h3 class="brief-h"><span>${t('本局禁用干员')}</span><${MicroLabel}>BANNED OPERATORS</${MicroLabel}><b class="num brief-banned__n">${banned.length}</b></h3>
${banned.length ? html`<div class="brief-banned__grid">
${banned.map((id) => html`<${UnitThumb} key=${id} kind="chess" id=${id} size="sm" dim=${true} />`)}
</div>` : html`<p class="t-dim">本局没有禁用干员</p>`}
</div>` : html`<p class="t-dim">${t('本局没有禁用干员')}</p>`}
<${DiyBannedLine} model=${model} />
</div>`;
}
@@ -124,8 +124,8 @@ export function BannedOperators({ model }) {
* @param {{ model: MatchInfoModel }} props
*/
export function MatchInfo({ model }) {
return html`<${MatchBondRow} title="核心盟约" micro="CORE BONDS" bonds=${model.core} model=${model} />
<${MatchBondRow} title="附加盟约" micro="ADD-ON BONDS" bonds=${model.addon} model=${model} />
return html`<${MatchBondRow} title=${t('核心盟约')} micro="CORE BONDS" bonds=${model.core} model=${model} />
<${MatchBondRow} title=${t('附加盟约')} micro="ADD-ON BONDS" bonds=${model.addon} model=${model} />
<${MatchLegend} model=${model} />
<${BannedOperators} model=${model} />`;
}
@@ -136,9 +136,9 @@ export function MatchInfo({ model }) {
* @param {{ open: boolean, onClose: Function, model: MatchInfoModel|null, status?: any }} props
*/
export function MatchInfoDialog({ open, onClose, model, status = null }) {
return html`<${Modal} open=${open && !!model} onClose=${onClose} title="本局信息" micro="MATCH INFO // BONDS & BANNED OPERATORS" class="minfo-dlg"
return html`<${Modal} open=${open && !!model} onClose=${onClose} title=${t('本局信息')} micro="MATCH INFO // BONDS & BANNED OPERATORS" class="minfo-dlg"
actions=${html`${status ? html`<div class="minfo-dlg__status" data-testid="match-info-status">${status}</div>` : null}
<${Button} variant="secondary" icon="close" data-autofocus data-testid="match-info-close" onClick=${onClose}>关闭<//>`}>
<${Button} variant="secondary" icon="close" data-autofocus data-testid="match-info-close" onClick=${onClose}>${t('关闭')}<//>`}>
${open && model ? html`<${MatchInfo} model=${model} />` : null}
<//>`;
}
+8 -7
View File
@@ -13,6 +13,7 @@
import { checkNotOwned, OWNERSHIP_LIMITS } from '../../../shared/protocol.js';
import { isDroppableChess, standInRecord } from '../../../shared/standIn.js';
import { t } from '../../../shared/i18n.js';
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const OWNERSHIP_PREF = 'ownership';
@@ -153,17 +154,17 @@ export const serializeOwnership = (notOwned, opts) => JSON.stringify(exportOwner
export function parseOwnershipImport(input) {
let raw = input;
if (typeof raw === 'string') {
if (raw.length > OWNERSHIP_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' };
if (raw.length > OWNERSHIP_IMPORT_MAX_BYTES) return { ok: false, error: t('内容过长,无法导入') };
const text = raw.trim();
if (!text) return { ok: false, error: '没有可导入的内容' };
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
if (!text) return { ok: false, error: t('没有可导入的内容') };
try { raw = JSON.parse(text); } catch { return { ok: false, error: t('无法识别的内容') }; }
}
if (Array.isArray(raw)) return { ok: true, notOwned: cleanIds(raw) };
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
if (!isObj(raw)) return { ok: false, error: t('无法识别的格式') };
const v = Number.isInteger(raw.v) ? raw.v : null;
if (v != null && v > OWNERSHIP_VERSION) return { ok: false, error: `这份数据来自更新的版本(v${v}),请先更新游戏` };
if (v != null && v > OWNERSHIP_VERSION) return { ok: false, error: t('这份数据来自更新的版本(v{v}),请先更新游戏', { v }) };
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: '这不是干员持有的数据' };
if (!Array.isArray(raw.notOwned)) return { ok: false, error: '里面没有干员持有的数据' };
if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: t('这不是干员持有的数据') };
if (!Array.isArray(raw.notOwned)) return { ok: false, error: t('里面没有干员持有的数据') };
return { ok: true, notOwned: cleanIds(raw.notOwned) };
}
+31 -31
View File
@@ -40,7 +40,7 @@ function PriceHex({ slot, free, poor = false }) {
if (free || price === 0) return html`<span class="scard__free">FREE</span>`;
// like the original (img_bg_price_not_enough) the price turns grey while the funds don't cover it
return html`<${HexBadge} value=${price} tone=${poor ? 'dark' : tone} size="md" class=${cx('scard__price', poor && 'is-poor')}
title=${poor ? '资金不足' : tone === 'discount' ? `折扣价(原价 ${slot.basePrice})` : tone === 'premium' ? `加价(原价 ${slot.basePrice})` : '价格'} />`;
title=${poor ? t('资金不足') : tone === 'discount' ? t('折扣价(原价 {basePrice})', { basePrice: slot.basePrice }) : tone === 'premium' ? t('加价(原价 {basePrice})', { basePrice: slot.basePrice }) : t('价格')} />`;
}
/** Data lookups (merge progress / target, the loadout; shopBlockReason ignores them — a full hand refuses every purchase). */
@@ -53,13 +53,13 @@ const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => d
export function mergeHint(priv, chessId) {
const prog = mergeProgress(priv, chessId, LOOKUPS.getChess);
if (!(prog.copies > 0 && prog.copies + 1 >= prog.need)) return null;
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? '精锐干员将出现在作战区原位置' : '精锐干员将进入整备区';
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区');
}
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
function ArmedTag({ reason, free }) {
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</small></span>`;
return html`<span class="scard__confirm" role="status"><b>${free ? '确认选择' : '确认购买'}</b><small>再次点击</small></span>`;
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>${t('无法购买')}</b><small>${reason}</small></span>`;
return html`<span class="scard__confirm" role="status"><b>${free ? t('确认选择') : t('确认购买')}</b><small>${t('再次点击')}</small></span>`;
}
/**
@@ -85,17 +85,17 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const disabled = !!reason;
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
const name = `${c?.name || '干员'}${si ? `(${standInForText(c0.name)})` : ''}`;
const name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`;
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed', si && 'is-standin')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
aria-label=${`${name},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-pressed=${onTap ? String(!!armed) : undefined}>
aria-label=${`${t('{name},价格 {price}', { name, price: slot.price })}${armed ? (disabled ? t(',无法购买') : t(',再次点击确认')) : ''}`} aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span>
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
<${Img} src=${chessPortraitUrl(m, c)} class="scard__art" />
<span class="scard__top">
<${TierChip} tier=${tier} size="md" />
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} />
${prog.copies > 0 ? html`<span class="scard__pips" title=${`已拥有 ${prog.copies}/${prog.need}`}>
<${PriceHex} slot=${slot} free=${free} poor=${reason === t('资金不足')} />
${prog.copies > 0 ? html`<span class="scard__pips" title=${t('已拥有 {copies}/{need}', { copies: prog.copies, need: prog.need })}>
${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
</span>` : null}
</span>
@@ -116,11 +116,11 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
<span>${c?.subProfessionName || ''}</span>
</span>
</span>
${willMerge ? html`<span class="scard__mergetag" title=${hint}>可晋升</span>` : null}
${willMerge ? html`<span class="scard__mergetag" title=${hint}>${t('可晋升')}</span>` : null}
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
return reason && reason !== t('已售出') && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
/** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */
@@ -131,7 +131,7 @@ function SkillBadge({ chess, lo, standIn = false }) {
const src = custom ? skillRecordIconUrl(m, lo.skill, { empty: false }) : skillIconUrl(m, chess);
const slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null;
const mod = lo.module && !lo.module.none ? lo.module : null;
const tip = `技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
const tip = `${slot ? t('技能 {slot}:{name}', { slot, name: lo.skill.name || '' }) : t('技能:{name}', { name: lo.skill.name || '' })}${custom ? t('(已调配)') : ''}${mod ? t(' · 模组:{name}', { name: mod.name }) : lo.module?.none ? t(' · 未装备模组') : ''}`;
return html`<span class=${cx('scard__skill', custom && 'is-custom')} title=${tip} aria-label=${tip} data-skill=${lo.skill.skillId || ''}>
<${Img} src=${src} fallback=${slot ? html`<span class="scard__sglyph num">${slot}</span>` : html`<${GIcon} name="bolt" />`} />
${mod && mod.typeName ? html`<span class="scard__mod" data-type=${mod.typeName}><${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}</span>` : null}
@@ -150,12 +150,12 @@ export function ItemCard({ slot, idx, frozen = false, reason = null, free = fals
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'item'); };
const card = html`<button type="button" class=${cx('scard', 'scard--item', frozen && 'is-frozen', disabled && 'is-disabled', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'item'); }}
aria-label=${`${it?.name || '装备'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`}
aria-label=${`${t('{name},价格 {price}', { name: it?.name || t('装备'), price: slot.price })}${armed ? (disabled ? t(',无法购买') : t(',再次点击确认')) : ''}`}
aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span>
<span class="scard__top">
<${TierChip} tier=${it?.tier ?? 1} size="md" />
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} />
<${PriceHex} slot=${slot} free=${free} poor=${reason === t('资金不足')} />
</span>
<span class="scard__itemart"><${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} /></span>
<span class="scard__body">
@@ -165,12 +165,12 @@ export function ItemCard({ slot, idx, frozen = false, reason = null, free = fals
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
return reason && reason !== t('已售出') && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
function SoldCard({ item = false }) {
return html`<div class=${cx('scard', 'scard--sold', item && 'scard--item')} aria-label="已售出">
<span class="scard__soldtxt"><${MicroLabel}>SOLD OUT</${MicroLabel}><span>${item ? '已购买' : '已招募'}</span></span>
return html`<div class=${cx('scard', 'scard--sold', item && 'scard--item')} aria-label=${t('已售出')}>
<span class="scard__soldtxt"><${MicroLabel}>SOLD OUT</${MicroLabel}><span>${item ? t('已购买') : t('已招募')}</span></span>
</div>`;
}
@@ -179,14 +179,14 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
const max = lv >= (shop?.maxLevel ?? 6);
const price = shop?.upgradePrice ?? 0;
return html`<button type="button" class=${cx('lvcard', max && 'is-max', reason && 'is-disabled', armed && 'is-armed')} onClick=${() => !reason && onTap()}
title=${reason || (armed ? `再次点击确认升级(${price} 资金)` : `升级调度中心(${price} 资金) · D`)} aria-disabled=${reason ? 'true' : 'false'}
title=${reason || (armed ? t('再次点击确认升级({price} 资金)', { price }) : t('升级调度中心({price} 资金) · D', { price }))} aria-disabled=${reason ? 'true' : 'false'}
aria-pressed=${String(!!armed)}>
${!max ? html`<${HexBadge} value=${price} tone=${reason && reason !== '调度中心已达最高等级' ? 'dark' : 'gold'} size="md" class="lvcard__price" />` : null}
${!max ? html`<${HexBadge} value=${price} tone=${reason && reason !== t('调度中心已达最高等级') ? 'dark' : 'gold'} size="md" class="lvcard__price" />` : null}
<span class="lvcard__frame">
<span class="lvcard__micro">LEVEL</span>
<b class="lvcard__num num">${lv}</b>
</span>
<span class="lvcard__label">${max ? '已满级' : armed ? '确认升级' : '升级'}</span>
<span class="lvcard__label">${max ? t('已满级') : armed ? t('确认升级') : t('升级')}</span>
<kbd class="lvcard__key">D</kbd>
</button>`;
}
@@ -249,11 +249,11 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
<span class="rwtag__micro">${head.micro}</span>
<span class="rwtag__sub">${head.sub}</span>
${head.more ? html`<span class="rwtag__sub rwtag__more">${head.more}</span>` : null}
<button type="button" class="rwtag__later" onClick=${onLater} title="稍后选择(回合结束后消失)"><${Icon} name="minus" />稍后</button>
<button type="button" class="rwtag__later" onClick=${onLater} title=${t('稍后选择(回合结束后消失)')}><${Icon} name="minus" />${t('稍后')}</button>
</div>
<div class="shopbar__rwcards">
${offer.slots.map((s, i) => {
if (!s || s.sold) return html`<div key=${`rw${i}`} class=${cx('scard', 'scard--sold', s && s.kind === 'item' && 'scard--item')}><span class="scard__soldtxt"><span>已选择</span></span></div>`;
if (!s || s.sold) return html`<div key=${`rw${i}`} class=${cx('scard', 'scard--sold', s && s.kind === 'item' && 'scard--item')}><span class="scard__soldtxt"><span>${t('已选择')}</span></span></div>`;
const kind = s.kind === 'item' ? 'item' : 'chess';
const reason = shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS });
const props = { slot: { ...s, price: 0 }, idx: i, free: true, reason, armed: armed === armKey('r', i, s), onBuy: onPick, onDetail,
@@ -319,22 +319,22 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
if (collapsed) {
return html`<div class="shopbar-tab" ref=${barRef}>
<div class="shopbar-tab__funds"><${CoinGlyph} /><b class="num">${funds}</b></div>
<button type="button" class="shopbar-tab__btn" onClick=${() => onCollapse(false)}><${Icon} name="chevronLeft" />展开商店</button>
<button type="button" class="shopbar-tab__btn" onClick=${() => onCollapse(false)}><${Icon} name="chevronLeft" />${t('展开商店')}</button>
</div>`;
}
return html`<section class=${cx('shopbar', frozen && 'is-frozen', !editable && 'is-locked', showReward && 'has-reward', armed && 'has-armed')} ref=${barRef} aria-label="调度中心">
return html`<section class=${cx('shopbar', frozen && 'is-frozen', !editable && 'is-locked', showReward && 'has-reward', armed && 'has-armed')} ref=${barRef} aria-label=${t('调度中心')}>
<div class="shopbar__tools">
<span class="shopbar__remain">剩余可放置角色:<b class=${cx('num', remaining === 0 && 't-orange')}>${remaining}</b></span>
<span class="shopbar__remain">${t('剩余可放置角色:')}<b class=${cx('num', remaining === 0 && 't-orange')}>${remaining}</b></span>
<button type="button" class=${cx('toolbtn', 'toolbtn--ice', frozen && 'is-on')} disabled=${!!frzReason} onClick=${onFreeze}
title=${frzReason || (frozen ? '解冻商店 · F' : '冻结商店(下回合保留) · F')}>
title=${frzReason || (frozen ? t('解冻商店 · F') : t('冻结商店(下回合保留) · F'))}>
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
<span>${frozen ? '解冻' : '冻结'}</span><kbd>F</kbd>
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>F</kbd>
</button>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || '刷新商店 · R'}>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · R')}>
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
<span>刷新</span>
${free > 0 ? html`<span class="toolbtn__free">免费 ×${free}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
<span>${t('刷新')}</span>
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
<kbd>R</kbd>
</button>
</div>
@@ -365,8 +365,8 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
<${CoinGlyph} class="funds__coin" />
<b class="funds__num num">${funds}</b>
</div>
<span class="funds__label">目前资金</span>
<button type="button" class="funds__collapse" onClick=${() => onCollapse(true)}><${Icon} name="close" />收起</button>
<span class="funds__label">${t('目前资金')}</span>
<button type="button" class="funds__collapse" onClick=${() => onCollapse(true)}><${Icon} name="close" />${t('收起')}</button>
</div>
</div>
</section>`;
+13 -11
View File
@@ -22,6 +22,7 @@ import { EmoteBubble } from './emotes.js';
import { STATUS_META, sortedPlayers } from './gameLogic.js';
import { MissTag, uniteRemaining } from './hud.js';
import { localAsset } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -60,8 +61,9 @@ export function rowLp(p, pub, self = null, { uniteLocal = null, cap = 10 } = {})
*/
export function rowLpTip(lp, cap = 10) {
if (!lp || !(lp.pending > 0)) return null;
if (lp.unite && lp.left != null) return `目标生命值 ${lp.lp},联防中:漏过的敌人还剩 ${lp.left} 个,按现在结算扣除 ${lp.pending} 点(每回合至多 ${cap} 点)`;
return `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点`;
if (lp.unite && lp.left != null) return t('目标生命值 {lp},联防中:漏过的敌人还剩 {left} 个,按现在结算扣除 {pending} 点(每回合至多 {cap} 点)', { lp: lp.lp, left: lp.left, pending: lp.pending, cap });
return lp.unite ? t('目标生命值 {lp},联防中,结算时扣除至多 {pending} 点', { lp: lp.lp, pending: lp.pending })
: t('目标生命值 {lp},结算时扣除 {pending} 点', { lp: lp.lp, pending: lp.pending });
}
/**
@@ -84,7 +86,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
if (!t.fieldId) { setOpenPid(null); onWatch(p); return; } // the game screen toasts the reason
setOpenPid((cur) => (cur === p.playerId ? null : p.playerId));
};
return html`<aside class=${cx('team', compact && 'team--compact')} aria-label="同盟成员">
return html`<aside class=${cx('team', compact && 'team--compact')} aria-label=${t('同盟成员')}>
${players.map((p) => {
const self = p.playerId === myId;
const status = p.alive === false ? 'dead' : p.status;
@@ -94,7 +96,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
const offline = p.connected === false && !p.isBot;
const open = !!observe && openPid === p.playerId && !self;
const back = !!observe && self && observe.observing;
const title = observe ? (self ? (observe.observing ? '返回战场' : '你自己') : `查看 ${p.name} 的战场`) : (self ? '查看自己的阵地' : `查看 ${p.name} 的阵地`);
const title = observe ? (self ? (observe.observing ? t('返回战场') : t('你自己')) : t('查看 {name} 的战场', { name: p.name })) : (self ? t('查看自己的阵地') : t('查看 {name} 的阵地', { name: p.name }));
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
@@ -104,24 +106,24 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
${self ? html`<span class="team__you"><${Icon} name="user" /></span>` : null}
</button>
<div class="team__info">
<span class="team__name">${p.name || '博士'}</span>
<span class="team__name">${p.name || t('博士')}</span>
<div class="team__line">
<${LpTower} value=${lp.lp} size="sm" tone=${Number.isFinite(lp.lp) && lp.lp - lp.pending <= 5 ? 'danger' : null} pending=${lp.pending}
tip=${rowLpTip(lp, cap)} />
${lp.left != null ? html`<${MissTag} n=${lp.left} name=${self ? null : p.name || '博士'} />` : null}
<${Tooltip} text=${offline ? '连接已断开' : meta.text} placement="right">
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${meta.text}>
${lp.left != null ? html`<${MissTag} n=${lp.left} name=${self ? null : p.name || t('博士')} />` : null}
<${Tooltip} text=${offline ? t('连接已断开') : t(meta.text)} placement="right">
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${t(meta.text)}>
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />`
: STATUS_SPRITE[status] ? html`<${LocalSprite} name=${STATUS_SPRITE[status]} fallback=${html`<${GIcon} name=${meta.glyph} />`} />`
: html`<${GIcon} name=${meta.glyph} />`}
</span>
<//>
${watched && !self ? html`<span class="team__eye" title="正在查看"><${GIcon} name="eye" /></span>` : null}
${watched && !self ? html`<span class="team__eye" title=${t('正在查看')}><${GIcon} name="eye" /></span>` : null}
</div>
${open ? html`<button type="button" class="btn btn--primary btn--sm team__ob"
onClick=${() => { setOpenPid(null); onWatch(p); }}><span class="btn__label">前往查看</span></button>` : null}
onClick=${() => { setOpenPid(null); onWatch(p); }}><span class="btn__label">${t('前往查看')}</span></button>` : null}
${back ? html`<button type="button" class="btn btn--secondary btn--sm team__back"
onClick=${() => observe.onBack()}><span class="btn__label">返回战场</span></button>` : null}
onClick=${() => observe.onBack()}><span class="btn__label">${t('返回战场')}</span></button>` : null}
</div>
${bubble ? html`<${EmoteBubble} key=${bubble.seq} id=${bubble.id} class="team__bubble" />` : null}
</div>`;
+13 -11
View File
@@ -29,6 +29,7 @@ import { GIcon } from './gameComponents.js';
import { useTileScreen } from './facingWheel.js';
import { localAsset } from '../data.js';
import { GEO } from '../../../shared/constants.js';
import { t, tParts } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -85,25 +86,25 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus
const half = s * 1.05;
const stop = (e) => e.stopPropagation();
return html`<div class="uframe" data-uid=${uid} style=${`left:${g.x}px;top:${g.y}px;width:${half * 2}px;height:${half * 2}px`} role="group"
aria-label=${`${name || '单位'} 操作`}>
aria-label=${t('{name} 操作', { name: name || t('单位') })}>
<svg class="uframe__dia" viewBox="-110 -110 220 220" aria-hidden="true">
<path class="uframe__outer" d="M0 -100 L100 0 L0 100 L-100 0 Z" />
<path class="uframe__corner" d="M-100 0 L-86 -14 M-100 0 L-86 14 M100 0 L86 -14 M100 0 L86 14 M0 -100 L-14 -86 M0 -100 L14 -86 M0 100 L-14 86 M0 100 L14 86" />
</svg>
${actions.retreat ? html`<button type="button" class="uframe__btn uframe__btn--retreat" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onRetreat?.(); }} title=${actions.sell != null ? '撤退至整备区(Q)' : '撤退至整备区'} aria-label="撤退" aria-keyshortcuts=${actions.sell != null ? 'Q' : undefined}>
<${RetreatGlyph} /><span class="uframe__label">${actions.sell != null ? '撤退[Q]' : '撤退'}</span>
onClick=${(e) => { stop(e); onRetreat?.(); }} title=${actions.sell != null ? t('撤退至整备区(Q)') : t('撤退至整备区')} aria-label=${t('撤退')} aria-keyshortcuts=${actions.sell != null ? 'Q' : undefined}>
<${RetreatGlyph} /><span class="uframe__label">${actions.sell != null ? t('撤退[Q]') : t('撤退')}</span>
</button>` : null}
${actions.sell != null ? html`<button type="button" class="uframe__btn uframe__btn--sell" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onSell?.(); }} title=${`出售(+${actions.sell} 资金,X)`} aria-label=${`出售,获得 ${actions.sell} 资金`} aria-keyshortcuts="X">
onClick=${(e) => { stop(e); onSell?.(); }} title=${t('出售(+{sell} 资金,X)', { sell: actions.sell })} aria-label=${t('出售,获得 {sell} 资金', { sell: actions.sell })} aria-keyshortcuts="X">
<${PlateIcon} sprite="icon_sell" glyph="sell" tone="sell" />
<span class="uframe__label">出售[X]</span>
<span class="uframe__label">${t('出售[X]')}</span>
<${HexBadge} value=${`+${actions.sell}`} tone="gold" size="sm" class="uframe__price" />
</button>` : null}
${actions.destroy ? html`<button type="button" class=${cx('uframe__btn', 'uframe__btn--destroy')} disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onDestroy?.(); }} title="销毁道具" aria-label="销毁">
onClick=${(e) => { stop(e); onDestroy?.(); }} title=${t('销毁道具')} aria-label=${t('销毁')}>
<${PlateIcon} sprite="icon_destory" glyph="trash" tone="destroy" />
<span class="uframe__label">销毁</span>
<span class="uframe__label">${t('销毁')}</span>
</button>` : null}
</div>`;
}
@@ -143,8 +144,8 @@ export function tempRowFrame(a, b, { labelW = 0, gap = 8, vw = Infinity } = {})
*/
export function tempRowRule(ready) {
return ready
? '已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)'
: '放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁';
? t('已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)')
: t('放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁');
}
/**
@@ -163,11 +164,12 @@ export function TempRowNotice({ view, count, items = 0, label = true, ready = fa
const pts = f.quad.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const pad = rem * 0.1;
const style = f.side === 'left' ? `left:${(f.left - pad).toFixed(1)}px;top:${f.y.toFixed(1)}px` : `left:${(f.right + pad).toFixed(1)}px;top:${f.y.toFixed(1)}px`;
const what = items > 0 && items === count ? '件道具' : '个单位';
return html`<div class="tempnote" aria-hidden="false" data-testid="temp-notice">
<svg class="tempnote__frame" aria-hidden="true"><polygon points=${pts} /></svg>
${label ? html`<div class=${`tempnote__label is-${f.side}`} style=${style} role="status">
<b class="tempnote__title"><${Icon} name="warn" />临时整备区 <span class="num">${count}</span> ${what}待处理</b>
<b class="tempnote__title"><${Icon} name="warn" />${items > 0 && items === count
? tParts('临时整备区 {n} 件道具待处理', { n: html`<span class="num">${count}</span>`, count })
: tParts('临时整备区 {n} 个单位待处理', { n: html`<span class="num">${count}</span>`, count })}</b>
<span class="tempnote__rule">${tempRowRule(ready)}</span>
</div>` : null}
</div>`;
+1
View File
@@ -1,4 +1,5 @@
// server/http/common.js — what every HTTP answer shares:
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
//
// * the security headers, set on every response before any route runs (routes.js);
// * splitUrl (raw path + query, also from absolute-form URLs), the bilingual error page (sendError) and JSON replies
+1
View File
@@ -1,4 +1,5 @@
// server/http/media.js — the extension-less audio route /media/… → public/assets/audio (see serveMedia).
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
import fsp from 'node:fs/promises';
import path from 'node:path';
+1
View File
@@ -1,4 +1,5 @@
// server/http/routes.js — the node:http request listener. Every response gets the security headers (common.js), then:
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
//
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
+1
View File
@@ -1,4 +1,5 @@
// server/http/static.js — the static file server (routes.js hands it every GET / HEAD that is not an API route):
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
//
// / → public/ (index.html for directories)
// /data/ → data/ (generated game data)
+1 -1
View File
@@ -119,7 +119,7 @@ export const LOBBY_DEFAULTS = Object.freeze({
export const SOLO_RECONNECT_FALLBACK_SEC = 86_400;
/** Display names for AI teammates (the tutorial NPCs first, then a few familiar faces). */
export const BOT_NAMES = Object.freeze(['AI·华法琳', 'AI·阿米娅', 'AI·惊蛰', 'AI·杜宾', 'AI·凯尔希', 'AI·可露希尔']);
export const BOT_NAMES = Object.freeze(['AI·华法琳', 'AI·阿米娅', 'AI·惊蛰', 'AI·杜宾', 'AI·凯尔希', 'AI·可露希尔']); // i18n-ignore: player names (docs/I18N.md)
const OK = Object.freeze({ ok: true });
const fail = (code, detail) => (detail ? { error: code, detail } : { error: code });
+1 -1
View File
@@ -225,7 +225,7 @@ export class StubMatch {
})),
fields: [],
stub: true,
message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。',
message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。', // i18n-ignore: development placeholder
};
}
+1
View File
@@ -1,4 +1,5 @@
// server/match/audit.js — rule auditor for sweeps and tests (tools/matchrun.mjs --check, test/match/fullmatch.test.js).
// (i18n-ignore-file: developer reports in English with the game's terms, never shown to players — docs/I18N.md)
//
// attachAudit(m) wraps a live Match's phase transitions and a few prep handlers (instance-level wrappers; the engine
// is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js:
+1 -1
View File
@@ -91,7 +91,7 @@ const ITEM_HANDLERS = {
const n = int(paramsOf(ctx, ev.item).count, 1);
const rec = ctx.gd.item(ev.item.id);
ctx.addEffect({
id: `doll:${ev.item.uid}`, key: 'effect:builtin_round_coin', name: rec ? rec.name : '精打细算玩偶', desc: rec ? rec.desc : '',
id: `doll:${ev.item.uid}`, key: 'effect:builtin_round_coin', name: rec ? rec.name : '精打细算玩偶', desc: rec ? rec.desc : '', // i18n-ignore: the item's data name
iconKind: 'item', iconId: rec ? rec.iconId || rec.id : ev.item.id, battle: false, params: { count: n },
});
},
+3 -3
View File
@@ -63,7 +63,7 @@
import { weightedPick } from './waves.js';
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' };
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' }; // i18n-ignore: = choices.json families (the client shows the localized record)
/**
* Battles a multi-round bounty card lasts (data `rounds` 99, official text "之后 / 后续的<@ba.vdown>每场</>作战":
@@ -82,7 +82,7 @@ export function bountyBattles(c) {
const r = Number(c && c.rounds);
return Math.max(1, Math.min(99, Number.isInteger(r) ? r : 1));
}
const N_ZH = ['', '一', '两', '三', '四', '五'];
const N_ZH = ['', '一', '两', '三', '四', '五']; // i18n-ignore: rewrites the official Chinese bounty text
/**
* A bounty text as the card lasts: a multi-round card's "之后的 / 后续每场作战" (rich `<@ba.vdown>每场</>` or plain) reads
* "接下来<@ba.vup>两场作战</>" like the official two-battle cards while MULTI_ROUND_BOUNTY_BATTLES is set; any other
@@ -91,7 +91,7 @@ const N_ZH = ['', '一', '两', '三', '四', '五'];
export function bountyText(text, c) {
if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text;
const n = MULTI_ROUND_BOUNTY_BATTLES;
const battles = `${N_ZH[n] || n}场作战`;
const battles = `${N_ZH[n] || n}场作战`; // i18n-ignore
return text
.replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}</>`)
.replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`);
+1
View File
@@ -1,4 +1,5 @@
// server/match/invariants.js — engine invariants (DESIGN §11) as a non-throwing check. Used by the test harness
// (i18n-ignore-file: developer reports in English with the game's terms, never shown to players — docs/I18N.md)
// (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps.
//
// collectViolations(m) → string[] (empty when every invariant holds):
+1 -1
View File
@@ -166,7 +166,7 @@ export class MatchPhases {
if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY);
if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
if (typeof bandId !== 'string' || !this.gd.bandAllowed(bandId)) return fail(ERR.BAD_TARGET);
if (this.bandTaken(bandId, ps.playerId)) return fail(ERR.BAD_TARGET, '队友已选');
if (this.bandTaken(bandId, ps.playerId)) return fail(ERR.BAD_TARGET, '队友已选'); // i18n-ignore: developer detail (players see ERR_TEXT)
this._applyBand(ps, bandId);
return OK;
}
+1 -1
View File
@@ -112,7 +112,7 @@ export class MatchSpDraft {
const rounds = bountyBattles(card);
const b = {
id: `bounty:${this.nextUid()}`,
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) }, // i18n-ignore: a data-less card's fallback name
roundsLeft: rounds,
};
ps.bounties.push(b);
+2 -2
View File
@@ -42,8 +42,8 @@ export class PlayerViews {
const left = b.roundsLeft >= 90 ? null : b.roundsLeft;
const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null;
out.push({
id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty',
counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`,
id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty', // i18n-ignore: a data-less card's fallback name
counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`, // i18n-ignore: older clients (ui/effectsList.js builds it from counter)
});
}
return out;
+53 -51
View File
@@ -1,5 +1,7 @@
// Shared enums & constants (server + browser). Pure ESM, no Node APIs.
import { N_ } from './i18n.js';
export const PROTOCOL_VERSION = 1;
/** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version"
* (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */
@@ -17,7 +19,7 @@ export const ROOM_CODE_LEN = 4;
export const NAME_MAX_LEN = 12;
export const DIFFICULTIES = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'];
export const DIFFICULTY_NAMES = { FUNNY: '标准模拟', NORMAL: '险境模拟', HARD: '绝境模拟', ABYSS: '终极模拟' };
export const DIFFICULTY_NAMES = { FUNNY: N_('标准模拟'), NORMAL: N_('险境模拟'), HARD: N_('绝境模拟'), ABYSS: N_('终极模拟') };
export const DIFFICULTY_COLORS = { FUNNY: '#f6a329', NORMAL: '#e85a1a', HARD: '#e73118', ABYSS: '#ff0024' };
// modeId in data/config.json = `mode_${type}_${difficulty.toLowerCase()}` with type single|multi
@@ -41,9 +43,9 @@ export const PHASE = Object.freeze({
});
export const PHASE_NAMES = {
LOBBY: '等待中', INFO_CHECK: '确认本局信息', BAND_DRAFT: '选择策略', BATTLE_CHECK: '协议启动',
ROUND_START: '回合开始', SP_DRAFT: '机变阶段', PREP: '休整期', COMBAT: '作战中', UNITE: '联防阶段',
SETTLE: '结算', FINAL_ASSAULT: '最终攻势', HIDDEN_CORE: '隐秘核心', RESULT: '模拟结束',
LOBBY: N_('等待中'), INFO_CHECK: N_('确认本局信息'), BAND_DRAFT: N_('选择策略'), BATTLE_CHECK: N_('协议启动'),
ROUND_START: N_('回合开始'), SP_DRAFT: N_('机变阶段'), PREP: N_('休整期'), COMBAT: N_('作战中'), UNITE: N_('联防阶段'),
SETTLE: N_('结算'), FINAL_ASSAULT: N_('最终攻势'), HIDDEN_CORE: N_('隐秘核心'), RESULT: N_('模拟结束'),
};
// Board geometry on the 19x21 stage grid (row 0 = bottom). See DESIGN §3.
@@ -145,12 +147,12 @@ export const ERR = Object.freeze({
});
export const ERR_TEXT = {
BAD_MSG: '无效的请求', RATE: '操作过于频繁', NOT_IN_ROOM: '你不在房间中', ROOM_NOT_FOUND: '未找到该同盟密钥对应的房间',
ROOM_FULL: '房间已满', ROOM_STARTED: '模拟已开始', NOT_HOST: '只有房主可以操作', NOT_READY: '仍有玩家未就绪',
WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限',
BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级',
NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰',
SPECTATOR: '观战中无法进行该操作', INTERNAL: '服务器内部错误',
BAD_MSG: N_('无效的请求'), RATE: N_('操作过于频繁'), NOT_IN_ROOM: N_('你不在房间中'), ROOM_NOT_FOUND: N_('未找到该同盟密钥对应的房间'),
ROOM_FULL: N_('房间已满'), ROOM_STARTED: N_('模拟已开始'), NOT_HOST: N_('只有房主可以操作'), NOT_READY: N_('仍有玩家未就绪'),
WRONG_PHASE: N_('当前阶段无法进行该操作'), NO_FUNDS: N_('资金不足'), HAND_FULL: N_('整备区已满'), BOARD_FULL: N_('已达到部署上限'),
BAD_TILE: N_('无法部署在该位置'), BAD_TARGET: N_('无效的目标'), SOLD_OUT: N_('已售出'), MAX_LEVEL: N_('调度中心已达最高等级'),
NOT_YOUR_TURN: N_('尚未轮到你'), ALREADY: N_('已完成该操作'), TEMP_NOT_EMPTY: N_('临时整备区不为空'), ELIMINATED: N_('你已被淘汰'),
SPECTATOR: N_('观战中无法进行该操作'), INTERNAL: N_('服务器内部错误'),
};
// ---- Emotes (交流, research 09 §4) -----------------------------------------------------------------------------
@@ -166,53 +168,53 @@ export const ERR_TEXT = {
// derived from the id (autochess_battle_fooldoctor_03…06 → pic_fooldoctor_04/05/06/08_battle).
const emo = (id, sortId, picId, label) => Object.freeze({ id, sortId, picId, label });
export const EMOTE_THEMES = Object.freeze([
{ themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: '表情套组:卫戍协议', emotes: [
emo('autochess_battle_happy', 1001, 'pic_happy_battle', '开心'),
emo('autochess_battle_scared', 1002, 'pic_scared_battle', '害怕'),
emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', '对不起'),
emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', '谢谢'),
emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', '思考'),
emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', '合作愉快'),
{ themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: N_('表情套组:卫戍协议'), emotes: [
emo('autochess_battle_happy', 1001, 'pic_happy_battle', N_('开心')),
emo('autochess_battle_scared', 1002, 'pic_scared_battle', N_('害怕')),
emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', N_('对不起')),
emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', N_('谢谢')),
emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', N_('思考')),
emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', N_('合作愉快')),
] },
{ themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: '表情套组:虫动', emotes: [
emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', '合作愉快!'),
emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', '谢谢!'),
emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', '对不起!'),
emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', '再见!'),
{ themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: N_('表情套组:虫动'), emotes: [
emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', N_('合作愉快!')),
emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', N_('谢谢!')),
emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', N_('对不起!')),
emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', N_('再见!')),
emo('slug_autochess_battle_distrust', 2005, 'pic_distrust_battle', '???'),
emo('slug_autochess_battle_very_soon', 2006, 'pic_very_soon_battle', '很快就好!'),
emo('slug_autochess_battle_very_soon', 2006, 'pic_very_soon_battle', N_('很快就好!')),
] },
{ themeId: 'emoticon_autochess_basic_2', dir: 'basic_2', sortId: 100001, isBasic: true, name: '表情套组:卫戍协议', emotes: [
emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', '没问题!'),
emo('autochess_battle_respect', 1008, 'pic_respect_battle', '敬礼!'),
emo('autochess_battle_call', 1009, 'pic_call_battle', '欢呼!'),
emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', '酷!'),
emo('autochess_battle_sad', 1011, 'pic_sad_battle', '伤心'),
emo('autochess_battle_dying', 1012, 'pic_dying_battle', '快死了'),
{ themeId: 'emoticon_autochess_basic_2', dir: 'basic_2', sortId: 100001, isBasic: true, name: N_('表情套组:卫戍协议'), emotes: [
emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', N_('没问题!')),
emo('autochess_battle_respect', 1008, 'pic_respect_battle', N_('敬礼!')),
emo('autochess_battle_call', 1009, 'pic_call_battle', N_('欢呼!')),
emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', N_('酷!')),
emo('autochess_battle_sad', 1011, 'pic_sad_battle', N_('伤心')),
emo('autochess_battle_dying', 1012, 'pic_dying_battle', N_('快死了')),
] },
{ themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: '表情套组:博士士', emotes: [
emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', '博士士 1'),
emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', '博士士 2'),
emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', '博士士 3'),
emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', '博士士 4'),
emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', '博士士 5'),
emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', '博士士 6'),
{ themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: N_('表情套组:博士士'), emotes: [
emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', N_('博士士 1')),
emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', N_('博士士 2')),
emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', N_('博士士 3')),
emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', N_('博士士 4')),
emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', N_('博士士 5')),
emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', N_('博士士 6')),
] },
{ themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: '表情套组:米米子', emotes: [
emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', '米米子 1'),
emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', '米米子 2'),
emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', '米米子 3'),
emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', '米米子 4'),
emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', '米米子 5'),
emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', '米米子 6'),
{ themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: N_('表情套组:米米子'), emotes: [
emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', N_('米米子 1')),
emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', N_('米米子 2')),
emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', N_('米米子 3')),
emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', N_('米米子 4')),
emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', N_('米米子 5')),
emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', N_('米米子 6')),
] },
{ themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: '表情套组:维维美', emotes: [
emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', '维维美 1'),
emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', '维维美 2'),
emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', '维维美 3'),
emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', '维维美 4'),
emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', '维维美 5'),
emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', '维维美 6'),
{ themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: N_('表情套组:维维美'), emotes: [
emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', N_('维维美 1')),
emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', N_('维维美 2')),
emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', N_('维维美 3')),
emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', N_('维维美 4')),
emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', N_('维维美 5')),
emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', N_('维维美 6')),
] },
].map((t) => Object.freeze({ ...t, emotes: Object.freeze(t.emotes) })));
/** Every emote with its theme: `{ id, sortId, picId, label, themeId, dir }`, in wheel order. */
+3 -3
View File
@@ -155,12 +155,12 @@ export function diyPool(tier, { data, kitted = null } = { data: null }) {
*/
export function checkDiyPick(slotId, pick, data) {
const slot = diySlot(slotId, data);
if (!slot) return { error: `not a 自选 slot: ${slotId}` };
if (!slot) return { error: `not a 自选 slot: ${slotId}` }; // i18n-ignore: developer detail
if (!isObj(pick) || typeof pick.charId !== 'string' || !ID.test(pick.charId)) return { error: `${slot.baseId}: bad pick` };
const { charId } = pick;
const diy = diyOf(data);
const proto = isPrototypePick(data, slot.tier, charId);
if (!proto && !(diy.ownedPool ?? []).includes(charId)) return { error: `${charId} is not a tier-${slot.tier} 自选 pick` };
if (!proto && !(diy.ownedPool ?? []).includes(charId)) return { error: `${charId} is not a tier-${slot.tier} 自选 pick` }; // i18n-ignore: developer detail
const want = pick.skillIndex ?? null;
const wantMod = pick.uniEquipId === undefined || pick.uniEquipId === 'none' ? null : pick.uniEquipId;
if (want !== null && !(Number.isInteger(want) && want >= 0 && want <= 9)) return { error: `${charId}: bad skill index ${want}` };
@@ -172,7 +172,7 @@ export function checkDiyPick(slotId, pick, data) {
const lk = lockedSelection(data, slot.tier, charId);
if (!lk) return { error: `${charId}: no locked selection at tier ${slot.tier}` };
// omitted / null = its locked selection; another skill, another module or an explicit 'none' is refused
if (want !== null && want !== lk.skillIndex) return { error: `${charId}: a prototype carries skill ${lk.skillIndex} (与系统补位时一致)` };
if (want !== null && want !== lk.skillIndex) return { error: `${charId}: a prototype carries skill ${lk.skillIndex} (与系统补位时一致)` }; // i18n-ignore: developer detail
if (pick.uniEquipId != null && wantMod !== lk.uniEquipId) return { error: `${charId}: a prototype carries module ${lk.uniEquipId ?? 'none'}` };
({ skillIndex, uniEquipId } = lk);
} else {
+1 -1
View File
@@ -10,7 +10,7 @@
// §战斗部署), and is not read. The battle keeps position MELEE: on a 高台 the unit attacks and blocks nothing.
/** The trait line that lets a MELEE chess stand on a ranged (高台) tile. */
export const PLACE_ON_RANGED = '可以放置于远程位';
export const PLACE_ON_RANGED = '可以放置于远程位'; // i18n-ignore: matched against the data's trait text
/**
* The record's trait text without a module ('' when it has none).
+44
View File
@@ -225,6 +225,50 @@ export function tc(context, msgid, params) {
return t(id, params);
}
/** A tParts() param kept as it is: an object that is not a `{ dn }` name (a vnode), or an array holding one. */
const isMarkup = (v) => !!v && typeof v === 'object' && !own(v, 'dn') && (!Array.isArray(v) || v.some((x) => isMarkup(x)));
/**
* A message as pieces for a renderer, for a sentence with markup inside (「第 <b>14</b> 回合」): the translation split at
* its placeholders; a param that is markup (a vnode, or an array holding vnodes) is kept as it is, the others render as
* in t() ({ dn } names, lists, {n|one|other}). Adjacent text joins, empty text is dropped, so the Chinese pieces are the
* text around the markup exactly as written before:
* html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${n}</b>` })}</span>`
* @param {unknown} msgid
* @param {Record<string, unknown> | unknown[] | null} [params]
* @returns {unknown[]} strings and the markup params, in the order of the translation
*/
export function tParts(msgid, params) {
const id = msgid == null ? '' : String(msgid);
let text = id;
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(id);
if (tr) text = tr;
}
/** @type {unknown[]} */
const out = [];
const push = (s) => {
if (s === '') return;
if (typeof out[out.length - 1] === 'string') out[out.length - 1] += s;
else out.push(s);
};
let last = 0;
if (params && typeof params === 'object') {
for (const m of text.matchAll(PLACEHOLDER)) {
const [whole, key, one, other] = m;
if (!own(params, key)) continue;
push(text.slice(last, m.index));
const v = /** @type {any} */ (params)[key];
if (one !== undefined) push(Number(v) === 1 ? one : other);
else if (isMarkup(v)) out.push(v);
else push(renderValue(v, current, nameResolver));
last = m.index + whole.length;
}
}
push(text.slice(last));
return out;
}
/**
* Mark a string as a msgid without translating it (module-level tables: translate with t() where it is shown).
* @template {string} S
+36
View File
@@ -127,3 +127,39 @@ test('a real match: 歌蕾蒂娅 grants a chess → 「歌蕾蒂娅:获得X」
m.dispose();
}
});
test('server-sent game texts in English: 机变 cards, the draft name, effect entries and offer labels come from the localized data; a reworded text stays as sent; Chinese unchanged', async () => {
await data.loadAll('choices', 'effects', 'items', 'bands', 'garrisons');
const { resolveSpCard } = await import('../public/js/ui/choiceOverlay.js');
const { effectDesc } = await import('../public/js/ui/effectsList.js');
const { offerHeader } = await import('../public/js/ui/gameLogic/shop.js');
const HAN = /[一-鿿]/;
const raw = data.getRaw('choices');
// a 战术决策 card as server/match/choices.js tacticCard sends it (the card's Chinese name and plain text)
const tac = raw.cards.tactic.find((c) => c.effectId && c.name && c.desc && data.lookupRaw('effects', c.effectId)?.descRaw);
const card = { kind: 'tactic', id: tac.effectId, name: tac.name, desc: tac.desc };
const reworded = { ...card, desc: `${tac.desc}(服务器改写)` };
const zh = resolveSpCard(card, 'tactic');
assert.equal(zh.name, tac.name, 'Chinese: the name as sent');
const eff = data.lookupRaw('effects', tac.effectId);
const entry = { id: tac.effectId, iconId: tac.effectId, iconKind: 'choice', name: eff.name, desc: eff.descRaw };
assert.equal(effectDesc(entry), eff.descRaw, 'Chinese: the description as sent');
const band = data.list('bands').find((b) => b && b.effectName);
const offer = { source: 'special', label: band.effectName, slots: [{ kind: 'chess', id: 'x' }] };
assert.equal(offerHeader(offer).title, band.effectName);
setLang('en');
await data.setLocale('en');
try {
const en = resolveSpCard(card, 'tactic');
assert.ok(en.name && !HAN.test(en.name), `card name: ${en.name}`);
if (zh.desc === data.lookupRaw('effects', tac.effectId).descRaw) assert.equal(en.desc, data.lookup('effects', tac.effectId).descRaw, 'card text: the localized record');
assert.equal(resolveSpCard(reworded, 'tactic').desc, reworded.desc, 'a reworded text stays as the server sent it');
assert.equal(effectDesc(entry), data.lookup('effects', tac.effectId).descRaw);
assert.equal(effectDesc({ ...entry, desc: `${eff.descRaw}!` }), `${eff.descRaw}!`);
assert.ok(!HAN.test(offerHeader(offer).title), offerHeader(offer).title);
assert.equal(lang.sentText(raw.families.bounty.name, raw.families.bounty.name, data.get('choices').families.bounty.name), 'Bounty Decision');
} finally {
setLang('zh');
await data.setLocale('zh');
}
});
+59 -12
View File
@@ -4,11 +4,11 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
t, tc, N_, setLang, getLang, onLangChange, addMessages, setMessages, hasMessage, normalizeLang, format, setNameResolver,
t, tc, tParts, N_, setLang, getLang, onLangChange, addMessages, setMessages, hasMessage, normalizeLang, format, setNameResolver,
tName, dn, msg, renderMessage, wireMessage, translateWire, DEFAULT_LANG,
} from '../shared/i18n.js';
import { templateMsgid, paramName, quote, codemodSource, scanSource } from '../tools/i18n.mjs';
@@ -56,6 +56,22 @@ test('tc(): a context key wins, else the plain msgid; Chinese shows the msgid',
assert.equal(N_('标记'), '标记', 'N_ only marks a msgid');
});
test('tParts(): a sentence with markup inside — the translation split at its placeholders, object params kept as they are', () => {
const b = { type: 'b', props: { children: 14 } }; // a vnode stand-in
addMessages('en', { '第 {r} 回合 · 最终攻势': 'Final Assault in round {r}', '{n} 名{who}已调整': '{n} {n|operator|operators} of {who} adjusted' });
assert.deepEqual(tParts('第 {r} 回合 · 最终攻势', { r: b }), ['第 ', b, ' 回合 · 最终攻势'], 'zh: the text around the markup as written');
assert.deepEqual(tParts('只有文字'), ['只有文字']);
assert.deepEqual(tParts('{a}{b}', { a: '甲', b: 2 }), ['甲2'], 'plain values join the text around them');
assert.deepEqual(tParts('{r}', { r: b }), [b], 'empty text is dropped');
assert.deepEqual(tParts('留着 {x}', {}), ['留着 {x}'], 'a missing value keeps the placeholder');
assert.deepEqual(tParts('禁用{names}盟约', { names: [b, b] }), ['禁用', [b, b], '盟约'], 'an array of vnodes is markup too');
assert.deepEqual(tParts('{names}', { names: ['甲', dn('乙')] }), ['甲、乙'], 'an array of texts is a list');
setLang('en');
assert.deepEqual(tParts('第 {r} 回合 · 最终攻势', { r: b }), ['Final Assault in round ', b], 'the English word order');
setNameResolver((name) => ({ 阿米娅: 'Amiya' })[name] || name);
assert.deepEqual(tParts('{n} 名{who}已调整', { n: 1, who: dn('阿米娅') }), ['1 operator of Amiya adjusted'], 'plurals and data names as in t()');
});
test('data names: { dn } params and tName go through the resolver, only outside Chinese; player names never do', () => {
setNameResolver((name) => ({ 琳琅诗怀雅: 'Swire the Elegant Wit', 阿米娅: 'Amiya' })[name] || name);
addMessages('en', { '{names}只能部署在召唤者攻击范围内,已退回整备区': '{names} were returned to the Bench', '{name}博士中途退出了模拟': 'Dr. {name} left the simulation' });
@@ -100,7 +116,7 @@ test('server wire format: text keeps the Chinese rendering for older clients; pa
test('public/i18n/en.json: valid, English values, placeholders kept, the pilot screens and server messages translated', () => {
const entries = Object.entries(EN).filter(([k]) => !k.startsWith('_'));
assert.ok(entries.length > 800, `seeded from PR #70: ${entries.length}`);
assert.ok(entries.length >= 1000, `PR #70's seed + phase 2: ${entries.length}`);
for (const [k, v] of entries) {
assert.equal(typeof v, 'string', k);
assert.ok(v.trim(), `empty translation for ${k}`);
@@ -110,25 +126,40 @@ test('public/i18n/en.json: valid, English values, placeholders kept, the pilot s
for (const p of want) assert.ok(have.has(p), `${k}: {${p}} missing in "${v}"`);
}
for (const k of ['开始', '博士代号', '设置', '语言', '同盟模拟', '创建同盟', '加入同盟', '开始模拟', 'toggle::关闭', '整备区已满',
'第 {r} 回合 · 最终攻势', '本局禁用{names}盟约,此策略效果可能无法发挥', 'bond-threshold::名', 'list::、', '干员持有', '先锋', '开心',
'{names}只能部署在召唤者攻击范围内,已退回整备区', '{name}博士的目标生命值已耗尽', '联防阶段:{names} 迎战突破防线的敌人', '【维多利亚】获得{n}件维式重锤']) {
assert.ok(EN[k], `en.json lacks ${k}`);
}
});
test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => {
const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js',
'public/js/screens/diy.js',
'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js',
'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js',
'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js',
'server/sim/content/bonds/addon/meta.js', 'server/sim/content/bonds/core.js'];
test('the whole client, shared and the server messages: no Chinese literal left unwrapped, every msgid in public/i18n/en.json (extract 0, check --strict)', async () => {
// what tools/i18n.mjs scans: public/js (not dev / vendor / assets / fonts), shared, server — server/sim only for the
// texts it sends (msg() / ctx.toast), its own t() / N_() are other helpers
const SKIP = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']);
const files = [];
const visit = (rel) => {
for (const name of readdirSync(path.join(ROOT, rel)).sort()) {
if (SKIP.has(name) || name.startsWith('.')) continue;
const r = `${rel}/${name}`;
if (statSync(path.join(ROOT, r)).isDirectory()) visit(r);
else if (/\.m?js$/.test(name)) files.push(r);
}
};
for (const root of ['public/js', 'shared', 'server']) visit(root);
assert.ok(files.length > 300, `${files.length} files`);
const missing = [];
let used = 0;
for (const f of files) {
const { msgids, literals } = await scanSource(readFileSync(path.join(ROOT, f), 'utf8'), f);
const server = f.startsWith('server/');
for (const m of msgids) if ((!server || m.via === 'msg' || m.via === 'server') && !EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`);
if (!server) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`);
for (const m of msgids) {
if (server && m.via !== 'msg' && m.via !== 'server') continue;
used++;
if (!EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`);
}
if (!f.startsWith('server/sim/')) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`);
}
assert.ok(used >= 1350, `msgid uses: ${used} (1 404 at phase 2)`);
assert.deepEqual(missing, []);
});
@@ -160,4 +191,20 @@ test('tools/i18n.mjs: msgids of template literals name their params; the codemod
assert.match(out.src, />\$\{t\('你好'\)\} \$\{name \|\| t\('博士'\)\} \$\{t\('世界'\)\}<b>/);
assert.match(out.src, /<\/b> \$\{t\('回合'\)\}<\/p>/);
assert.equal(out.manual.length, 1);
// tParts() is a msgid call: its msgid is extracted, the text inside is not a literal left to wrap
const scanned = await scanSource("const v = html`<span>${tParts('第 {r} 回合', { r: html`<b>${n}</b>` })}</span>`;", 'x.js');
assert.deepEqual(scanned.msgids.map((m) => [m.msgid, m.via]), [['第 {r} 回合', 'tParts']]);
assert.deepEqual(scanned.literals.filter((l) => !l.reason), []);
// an aliased import (`t as tr`, where t is a local variable) is the same msgid call
const aliased = await scanSource("import { t as tr } from '../../../shared/i18n.js';\nconst t = 3;\nexport const f = () => tr('替补') + t;", 'x.js');
assert.deepEqual(aliased.msgids.map((m) => [m.msgid, m.via]), [['替补', 't']]);
assert.deepEqual(aliased.literals.filter((l) => !l.reason), []);
// developer text is not UI text: an error result's detail, a logger call, a file marked i18n-ignore-file
const dev = await scanSource([
"export const a = (x) => ({ error: 'BAD_TARGET', detail: `不是自选格子 ${x ? '甲' : '乙'}` });",
"export function b(ev, log) { ev.detail = '已经是精锐'; log.warn(`[match] 自选 被忽略`); this.m.log?.info?.('信息'); return '界面文字'; }",
].join('\n'), 'x.js');
assert.deepEqual(dev.literals.filter((l) => !l.reason).map((l) => l.msgid), ['界面文字']);
const whole = await scanSource("// (i18n-ignore-file: developer reports)\nexport const r = ['开发者报告', `第${1}条`];", 'x.js');
assert.deepEqual(whole.literals.filter((l) => !l.reason), []);
});
+2 -2
View File
@@ -250,8 +250,8 @@ test('the 普通 / 精锐 toggle: 精锐 is the shown variant, the tabs say so,
test('place in the detail: after the skills, before the modules; the first .lo-seg stays the skill level toggle (e2e selector)', () => {
const src = read('public/js/screens/loadout.js');
const detail = src.slice(src.indexOf('function Detail('), src.indexOf('// ---- filters'));
const toggle = detail.indexOf('aria-label="技能等级"');
const skills = detail.indexOf('aria-label="选择技能"');
const toggle = detail.indexOf("aria-label=${t('技能等级')}");
const skills = detail.indexOf("aria-label=${t('选择技能')}");
const stats = detail.indexOf('<${LoadoutStats} base=${chess}');
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
+1 -1
View File
@@ -199,7 +199,7 @@ test('the briefing, the strategy draft and the in-game 本局信息 tab all read
const draft = read('public/js/screens/bandDraft.js');
assert.match(draft, /import \{ MatchInfoDialog, matchInfoModel \} from '\.\.\/ui\/matchInfo\.js';/);
assert.match(draft, /const info = infoOpen \? matchInfoModel\(pub, \{ bonds: gd\.list\('bonds'\), chess: gd\.chess, mode, priv, diyData: \{ chess: data\.get\('chess'\), backups: data\.get\('backups'\) \} \}\) : null;/);
assert.match(draft, /data-testid="match-info-open"[\s\S]*?onClick=\$\{\(\) => setInfoOpen\(true\)\}>查看禁用盟约与干员</);
assert.match(draft, /data-testid="match-info-open"[\s\S]*?onClick=\$\{\(\) => setInfoOpen\(true\)\}>\$\{t\('查看禁用盟约与干员'\)\}</);
assert.match(draft, /<\$\{MatchInfoDialog\} open=\$\{infoOpen\} onClose=\$\{\(\) => setInfoOpen\(false\)\} model=\$\{info\}/);
// a turn change or my pick closes it; the draft's end unmounts the screen
assert.match(draft, /const turnKey = `\$\{draft\.turnPid \|\| ''\}\|\$\{myPick \|\| ''\}`;\n\s*useEffect\(\(\) => \{ setInfoOpen\(false\); \}, \[turnKey\]\);/);
+1 -1
View File
@@ -114,7 +114,7 @@ describe('#7 the 联防 leak counter (HUD)', () => {
assert.equal(missTip(0, 'Doctor·B'), 'Doctor·B 漏过的敌人已全部被击倒');
assert.equal(MissTag({ n: 5, name: 'Doctor·B' }).props.title, 'Doctor·B 漏过的敌人还剩 5 个(联防中)');
assert.equal(MissTag({ n: 5 }).props.title, '你漏过的敌人还剩 5 个(队友正在迎战)');
assert.match(read('public/js/ui/teamPanel.js'), /<\$\{MissTag\} n=\$\{lp\.left\} name=\$\{self \? null : p\.name \|\| '博士'\} \/>/);
assert.match(read('public/js/ui/teamPanel.js'), /<\$\{MissTag\} n=\$\{lp\.left\} name=\$\{self \? null : p\.name \|\| t\('博士'\)\} \/>/);
});
test('the phase capsule carries the official runner tag ×N in 联防 only; the tag reads the uncapped number', () => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe('the drawer shows the watched player\'s 策略 while scouting', () => {
test('InfoTab overrides the band and labels it with the owner (source); the detail card has no 策略 section', () => {
const drawer = read('public/js/ui/enemyDrawer.js');
assert.match(drawer, /const band = \(bandId \|\| priv\?\.bandId\) \? data\.lookup\('bands', bandId \|\| priv\.bandId\) : null/);
assert.match(drawer, /bandOwner \? `\$\{bandOwner\} 的策略` : '我的策略'/);
assert.match(drawer, /bandOwner \? t\('\{bandOwner\} 的策略', \{ bandOwner \}\) : t\('我的策略'\)/);
const panel = read('public/js/ui/detailPanel.js');
assert.doesNotMatch(panel, /key="band"/, 'no 策略 section on the detail card');
assert.doesNotMatch(panel, /'band'/, "CHESS_SECTIONS has no 'band' block");
+39 -15
View File
@@ -2,7 +2,7 @@
// tools/i18n.mjs — UI string tooling for the gettext-style i18n (shared/i18n.js, public/i18n/<lang>.json; docs/I18N.md).
//
// node tools/i18n.mjs extract [paths…] [--list] [--json]
// per file: the msgids passed to t() / tc() / N_() / msg(), and the Chinese literals still shown untranslated
// per file: the msgids passed to t() / tc() / tParts() / N_() / msg(), and the Chinese literals still shown untranslated
// (string literals, template literals, html`` text and attribute values outside those calls; comments,
// console.* and Error messages are ignored). --list prints each literal with its line.
// node tools/i18n.mjs check [paths…] [--lang en] [--strict] [--stale]
@@ -34,14 +34,14 @@ const HAN = /[\u3400-\u9fff\uf900-\ufaff]/;
const DEFAULT_ROOTS = ['public/js', 'shared', 'server'];
const SKIP_DIRS = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']);
/** Calls whose first argument is a msgid (tc: the second, keyed `context::msgid`). */
const MSGID_CALLS = new Set(['t', 'N_', 'msg']);
const MSGID_CALLS = new Set(['t', 'tParts', 'N_', 'msg']);
/**
* Server messaging methods (m.toast, ctx.toast, this.tickerText …, called on an object): a Chinese string literal they
* get is sent as text and translated by the client as a msgid (main.js translateWire).
*/
const SERVER_TEXT_CALLS = new Set(['toast', 'tickerText', 'ticker']);
/** Calls whose arguments are never UI text to wrap. */
const SKIP_CALLS = new Set(['t', 'tc', 'N_', 'msg', 'tName', 'dn', 'format', 'renderMessage', 'require', 'import']);
const SKIP_CALLS = new Set(['t', 'tc', 'tParts', 'N_', 'msg', 'tName', 'dn', 'format', 'renderMessage', 'require', 'import']);
/** String methods: a literal argument is data, not display text. */
const STRING_METHODS = new Set(['includes', 'startsWith', 'endsWith', 'indexOf', 'lastIndexOf', 'replace', 'replaceAll', 'split',
'match', 'matchAll', 'test', 'search', 'has', 'get', 'set', 'delete', 'localeCompare', 'padStart', 'padEnd', 'join', 'add']);
@@ -143,22 +143,35 @@ const calleeName = (call) => {
return null;
};
const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console';
/** A logger call (`log.warn(…)`, `this.m.log?.warn?.(…)`): developer text, like console.*. */
const isLogCall = (call) => {
const c = call?.callee;
if (!c || c.type !== 'MemberExpression') return false;
const o = c.object;
const LOG = /^(log|logger)$/;
return (o.type === 'Identifier' && LOG.test(o.name)) || (o.type === 'MemberExpression' && !o.computed && o.property.type === 'Identifier' && LOG.test(o.property.name));
};
/** The developer detail of an error result (`{ error: 'BAD_TARGET', detail: '…' }`, `ev.detail = '…'`): never shown to players. */
const isDetail = (node, parent) => (parent.type === 'Property' && parent.value === node && !parent.computed && (parent.key.name ?? parent.key.value) === 'detail')
|| (parent.type === 'AssignmentExpression' && parent.right === node && parent.left.type === 'MemberExpression' && !parent.left.computed && parent.left.property.name === 'detail');
/** Why a literal must not be wrapped (null = wrap it). */
function skipReason(node, ancestors) {
/** Why a literal must not be wrapped (null = wrap it). `aliases`: local name → shared/i18n.js export (`t as tr`). */
function skipReason(node, ancestors, aliases = new Map()) {
const parent = ancestors[ancestors.length - 1];
for (let i = ancestors.length - 1; i >= 0; i--) {
const a = ancestors[i];
if (a.type === 'CallExpression') {
if (isConsoleCall(a)) return 'console';
if (isConsoleCall(a) || isLogCall(a)) return 'console';
const name = calleeName(a);
if (SKIP_CALLS.has(name) && a.callee.type === 'Identifier') return 'in-call';
if (a.callee.type === 'Identifier' && SKIP_CALLS.has(aliases.get(name) ?? name)) return 'in-call';
}
if (a.type === 'NewExpression' && a.callee.type === 'Identifier' && /Error$/.test(a.callee.name)) return 'error';
if (a.type === 'ThrowStatement') return 'error';
if (a.type === 'ImportDeclaration' || a.type === 'ExportAllDeclaration' || (a.type === 'ExportNamedDeclaration' && a.source)) return 'import';
}
if (!parent) return null;
// (inside a detail too: `detail: \`… ${x ? '甲' : '乙'}\``)
if (isDetail(node, parent) || ancestors.some((a, i) => i > 0 && isDetail(a, ancestors[i - 1]))) return 'detail';
if (parent.type === 'Property' && parent.key === node && !parent.computed) return 'key';
if (parent.type === 'MemberExpression' && parent.property === node) return 'key';
if (parent.type === 'BinaryExpression' && ['===', '!==', '==', '!=', 'in', 'instanceof'].includes(parent.operator)) return 'compare';
@@ -324,22 +337,33 @@ export async function scanSource(src, file = '<src>') {
const msgids = [];
const literals = [];
const lineOf = (pos) => src.slice(0, pos).split('\n').length;
// shared/i18n.js functions imported under another name (`import { t as tr }` where `t` is a local variable)
const aliases = new Map();
for (const n of ast.body) {
if (n.type !== 'ImportDeclaration' || !/shared\/i18n\.js$/.test(String(n.source.value))) continue;
for (const sp of n.specifiers) if (sp.type === 'ImportSpecifier' && sp.imported.name !== sp.local.name) aliases.set(sp.local.name, sp.imported.name);
}
const fnName = (callee) => (callee.type === 'Identifier' ? aliases.get(callee.name) ?? callee.name : null);
// a line marked `i18n-ignore` (in a comment) holds no UI text (font samples, data keys …)
const ignored = new Set(src.split('\n').map((l, i) => (/i18n-ignore/.test(l) ? i + 1 : 0)).filter(Boolean));
// a file marked `i18n-ignore-file` (developer reports, bilingual error pages) holds no UI text to translate
const fileIgnored = /\/\/[^\n]*i18n-ignore-file/.test(src);
walk(ast, (node, ancestors) => {
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'tc') {
if (node.type === 'CallExpression' && fnName(node.callee) === 'tc') {
const [c, a] = node.arguments;
if (c?.type === 'Literal' && a?.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: `${c.value}::${a.value}`, line: lineOf(a.start), via: 'tc' });
}
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && MSGID_CALLS.has(node.callee.name)) {
if (node.type === 'CallExpression' && MSGID_CALLS.has(fnName(node.callee))) {
const a = node.arguments[0];
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via: node.callee.name });
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via: node.callee.name });
const via = fnName(node.callee);
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via });
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via });
}
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
for (const seg of htmlSegments(node.quasi)) {
const { msgid, params } = templateMsgid(seg.quasis, seg.exprs.map((e) => src.slice(e.start, e.end)));
literals.push({ kind: seg.kind, attr: seg.attr, start: seg.start, end: seg.end, line: lineOf(seg.start), msgid, params, exprs: seg.exprs.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason: null });
const line = lineOf(seg.start);
literals.push({ kind: seg.kind, attr: seg.attr, start: seg.start, end: seg.end, line, msgid, params, exprs: seg.exprs.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason: fileIgnored || ignored.has(line) ? 'ignored' : null });
}
// expressions inside are walked normally (nested html``, strings in ${…})
return true;
@@ -357,7 +381,7 @@ export async function scanSource(src, file = '<src>') {
return true;
}
const line = lineOf(node.start);
const reason = ignored.has(line) ? 'ignored' : skipReason(node, ancestors);
const reason = fileIgnored || ignored.has(line) ? 'ignored' : skipReason(node, ancestors, aliases);
literals.push({ kind: 'str', start: node.start, end: node.end, line, msgid: node.value, params: [], module: !inFunction(ancestors), reason });
return true;
}
@@ -366,7 +390,7 @@ export async function scanSource(src, file = '<src>') {
if (parent && parent.type === 'TaggedTemplateExpression') return true;
const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw);
if (!cooked.some((s) => HAN.test(s))) return true;
const reason = ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors);
const reason = fileIgnored || ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors, aliases);
const { msgid, params } = templateMsgid(cooked, node.expressions.map((e) => src.slice(e.start, e.end)));
literals.push({ kind: 'tpl', start: node.start, end: node.end, line: lineOf(node.start), msgid, params, exprs: node.expressions.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason });
return true;
@@ -560,7 +584,7 @@ async function cmdExtract(flags) {
}
const cat = readCatalog(flags.lang || 'en');
const ready = rows.reduce((n, r) => n + r.literals.filter((l) => typeof cat[l.msgid] === 'string' && cat[l.msgid]).length, 0);
console.log(`total: ${tCount} msgids in t() / tc() / N_() / msg(); ${litCount} Chinese literals not wrapped, in ${rows.filter((r) => r.untranslated).length} files`
console.log(`total: ${tCount} msgids in t() / tc() / tParts() / N_() / msg(); ${litCount} Chinese literals not wrapped, in ${rows.filter((r) => r.untranslated).length} files`
+ ` (${ready} of them already translated in public/i18n/${flags.lang || 'en'}.json, ready for the codemod)`);
}