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:
commit
3ac88dea43
73 files changed
+1280
-903
No files matched your search
+51
-18
@@ -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
@@ -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
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>`;
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>`;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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('') }) : '';
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 -------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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('无法放置在该位置');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
<//>`;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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,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,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,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
@@ -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 });
|
||||
|
||||
@@ -225,7 +225,7 @@ export class StubMatch {
|
||||
})),
|
||||
fields: [],
|
||||
stub: true,
|
||||
message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。',
|
||||
message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。', // i18n-ignore: development placeholder
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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), []);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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\]\);/);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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)`);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user