feedback5 W5S: i18n phase 2, ui — HUD, shop bar, detail card, bond strip and popup, 机变 overlay, enemy drawer, team panel, under-frame, guide, emotes, match chrome, connection banner, components, the loadout / roster models, the store, net and observe texts through t() / tParts() (whole sentences where a number or a name sat inside), msgid tables marked N_() and translated where shown, the shop's refusal reasons compared through t(); tools/i18n.mjs reads aliased imports (t as tr); English for the 59 new msgids; Chinese output unchanged (zh render proof; five rewrites checked by hand, listed in the notes)

This commit is contained in:
sganggs committed 2026-10-06 18:09:50 +08:00
1 parent 166c4fbbfb
commit 647bd23ef2
33 files changed
+530 -427

No files matched your search

+60 -1
View File
@@ -1010,5 +1010,64 @@
"全部持有:没有由替补干员上场的棋子": "Everything is owned: no chess takes the field as a stand-in", "全部持有:没有由替补干员上场的棋子": "Everything is owned: no chess takes the field as a stand-in",
"没有可下掉的干员": "No Operators can be marked", "没有可下掉的干员": "No Operators can be marked",
"{name}({note})": "{name} ({note})", "{name}({note})": "{name} ({note})",
"本局耗时 {n} 分钟": "Duration: {n} {mins|minute|minutes}" "本局耗时 {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"
} }
+11 -10
View File
@@ -10,6 +10,7 @@
import { PHASE } from '../../../shared/constants.js'; import { PHASE } from '../../../shared/constants.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const isObj = (v) => !!v && typeof v === 'object'; const isObj = (v) => !!v && typeof v === 'object';
const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]); 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). */ /** Display name of a player id ('队友' when unknown). */
export function nameOf(pub, playerId) { 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) * (its result is on the way to the server)
*/ */
export function observeTarget(p, pub, myId, { observing = false, ownDone = false } = {}) { 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.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 phase = pub?.phase;
const me = players(pub).find((x) => x.playerId === myId) || null; const me = players(pub).find((x) => x.playerId === myId) || null;
const meAlive = me ? me.alive !== false : true; const meAlive = me ? me.alive !== false : true;
if (!COMBAT.has(phase)) return { fieldId: `n:${p.playerId}` }; if (!COMBAT.has(phase)) return { fieldId: `n:${p.playerId}` };
const target = fieldOf(pub, p.playerId); const target = fieldOf(pub, p.playerId);
if (!target) return { reason: '该队友当前没有战场' }; if (!target) return { reason: t('该队友当前没有战场') };
const own = fieldOf(pub, myId); const own = fieldOf(pub, myId);
if (!meAlive || !own) return { fieldId: target.fieldId }; if (!meAlive || !own) return { fieldId: target.fieldId };
if (own.fieldId === target.fieldId) return { reason: '队友与你在同一战场,使用 ‹ › 切换视角' }; if (own.fieldId === target.fieldId) return { reason: t('队友与你在同一战场,使用 ‹ › 切换视角') };
if (target.kind === 'boss' || target.kind === 'hidden') return { reason: '无法查看另一组队友的战场' }; if (target.kind === 'boss' || target.kind === 'hidden') return { reason: t('无法查看另一组队友的战场') };
if (own.kind === 'normal' && own.live !== false && !ownDone) return { reason: '作战中无法查看队友,作战结束后可前往查看' }; if (own.kind === 'normal' && own.live !== false && !ownDone) return { reason: t('作战中无法查看队友,作战结束后可前往查看') };
return { fieldId: target.fieldId }; return { fieldId: target.fieldId };
} }
@@ -91,7 +92,7 @@ export function teammateProgress(pub, myId) {
const p = players(pub).find((x) => x.playerId === pid); const p = players(pub).find((x) => x.playerId === pid);
const pr = isObj(f.progress) ? f.progress : null; const pr = isObj(f.progress) ? f.progress : null;
out.push({ 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, killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null,
done: f.live === false || !!pr?.done, 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 []; if (!isObj(field) || (field.kind !== 'unite' && field.kind !== 'boss' && field.kind !== 'hidden')) return [];
const sides = sidesOf(field); const sides = sidesOf(field);
const at = (side) => Object.keys(sides).find((pid) => sides[pid] === side) || null; 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 left = at('L');
const right = at('R'); const right = at('R');
if ((field.kind === 'boss' || field.kind === 'hidden') && (!left || !right)) return []; if ((field.kind === 'boss' || field.kind === 'hidden') && (!left || !right)) return [];
return [ return [
{ key: 'L', label: label(left), self: left === myId, watch: !!left && left !== myId }, { 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 }, { key: 'R', label: label(right), self: right === myId, watch: !!right && right !== myId },
]; ];
} }
+8 -7
View File
@@ -31,6 +31,7 @@
import { PROTOCOL_VERSION, ERR_TEXT } from '../../shared/constants.js'; import { PROTOCOL_VERSION, ERR_TEXT } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js'; import { validateC2S } from '../../shared/protocol.js';
import { N_ } from '../../shared/i18n.js';
export const REQUEST_TIMEOUT_MS = 8000; export const REQUEST_TIMEOUT_MS = 8000;
export const HELLO_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). */ /** Client-side error codes (in addition to shared ERR codes). */
export const CLIENT_ERR_TEXT = Object.freeze({ export const CLIENT_ERR_TEXT = Object.freeze({
TIMEOUT: '请求超时,请重试', TIMEOUT: N_('请求超时,请重试'),
OFFLINE: '未连接到服务器', OFFLINE: N_('未连接到服务器'),
DISCONNECTED: '连接已断开,请重试', DISCONNECTED: N_('连接已断开,请重试'),
CLOSED: '连接已关闭', CLOSED: N_('连接已关闭'),
REPLACED: '该身份已在其他页面登录', REPLACED: N_('该身份已在其他页面登录'),
VERSION: '客户端版本与服务器不一致,请刷新页面', VERSION: N_('客户端版本与服务器不一致,请刷新页面'),
}); });
/** Server close code: the session was taken over by another socket (server/net.js CLOSE.REPLACED). */ /** 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} * @returns {string}
*/ */
export function errorText(code, msg) { 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. */ /** Error thrown/rejected by requests. `code` is an ERR code or a CLIENT_ERR_TEXT key. */
+3 -3
View File
@@ -299,7 +299,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
<h2 class="lo-dhead__name">${chess.name}</h2> <h2 class="lo-dhead__name">${chess.name}</h2>
<span class="lo-dhead__en">${chess.appellation || ''}</span> <span class="lo-dhead__en">${chess.appellation || ''}</span>
<span class="lo-dhead__class"> <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 || ''} <i class="lo-sep"></i><${Img} src=${subProfIconUrl(m, chess)} class="lo-dhead__prof" />${chess.subProfessionName || ''}
</span> </span>
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond"> <span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
@@ -365,8 +365,8 @@ function Filters({ m, filters, onFilters, bonds }) {
<div class="lo-frow"> <div class="lo-frow">
<div class="lo-chips lo-chips--prof" role="group" aria-label=${t('职业')}> <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')} ${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 })}> 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>${PROF_NAME[p][0]}</span>`} /><span class="lo-chip__lbl">${PROF_NAME[p]}</span></button>`)} <${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> </div>
<label class="lo-select"> <label class="lo-select">
<span class="lo-select__k">${t('盟约')}</span> <span class="lo-select__k">${t('盟约')}</span>
+3 -2
View File
@@ -21,6 +21,7 @@
import { useLayoutEffect, useReducer, useRef } from '../vendor/hooks.module.js'; import { useLayoutEffect, useReducer, useRef } from '../vendor/hooks.module.js';
import { PHASE } from '../../shared/constants.js'; import { PHASE } from '../../shared/constants.js';
import { t } from '../../shared/i18n.js';
/** /**
* Create an observable store. * Create an observable store.
@@ -125,8 +126,8 @@ export function isSpectating(room, playerId) {
export function sessionResetNotice(prev, playerId) { export function sessionResetNotice(prev, playerId) {
const prevId = prev?.me?.playerId; const prevId = prev?.me?.playerId;
if (prevId == null || prevId === playerId) return null; if (prevId == null || prevId === playerId) return null;
if (prev?.match?.public || prev?.room?.inMatch) return '服务器会话已重置,上一局模拟已结束'; if (prev?.match?.public || prev?.room?.inMatch) return t('服务器会话已重置,上一局模拟已结束');
if (prev?.room) return '服务器会话已重置,已返回大厅'; if (prev?.room) return t('服务器会话已重置,已返回大厅');
return null; return null;
} }
+25 -24
View File
@@ -28,13 +28,13 @@ import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMON
import { formatBondEffect } from './richText.js'; import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js'; import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.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(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** The "👁 name" tag of a teammate's strip (DESIGN §20.15). */ /** The "👁 name" tag of a teammate's strip (DESIGN §20.15). */
function OwnerTag({ owner }) { 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}> <${GIcon} name="eye" class="bstrip__eye" /><${MicroLabel}>${owner}</${MicroLabel}>
</span>`; </span>`;
} }
@@ -47,12 +47,12 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
const sorted = sortBonds(bonds, (id) => data.lookup('bonds', id)); const sorted = sortBonds(bonds, (id) => data.lookup('bonds', id));
if (!sorted.length) { if (!sorted.length) {
return html`<div class=${cx('bstrip', 'bstrip--empty', owner && 'is-other')} data-owner=${owner || null}> 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 m = data.get('assets');
const shown = sorted.slice(0, max); const shown = sorted.slice(0, max);
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list" 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} ${owner ? html`<${OwnerTag} owner=${owner} />` : null}
${shown.map((b) => { ${shown.map((b) => {
const rec = data.lookup('bonds', b.bondId); 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')}> 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} <${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)} disabled=${true} size="sm" showName=${true} layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
title=${`${briefingBondTip(name, 'off')} · ${b.count ?? 0} 名成员`} /> title=${t('{tip} · {n} 名成员', { tip: briefingBondTip(name, 'off'), n: b.count ?? 0 })} />
<span class="bslot__count bslot__off">本局禁用</span> <span class="bslot__count bslot__off">${t('本局禁用')}</span>
</div>`; </div>`;
} }
const next = nextThreshold(b.count ?? 0, th); 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} <${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} tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)} 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> <span class=${cx('bslot__count', 'num', next == null && 'is-max')}>${b.count ?? 0}<small>/${next ?? th[th.length - 1] ?? '-'}</small></span>
</div>`; </div>`;
})} })}
${sorted.length > shown.length ? html`<span class="bstrip__more num">+${sorted.length - shown.length}</span>` : null} ${sorted.length > shown.length ? html`<span class="bstrip__more num">+${sorted.length - shown.length}</span>` : null}
</div>`; </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); const si = memberStandIn(priv, rawChess(x.id), data.get('backups'), rawChess);
return si ? { ...x, name: si.name } : x; return si ? { ...x, name: si.name } : x;
}) : []; }) : [];
const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || '调和'; const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || tName('调和');
const harmonyText = `${harmonyBy.length ? `${harmonyBy.map((x) => x.name).join('、')} 在场` : `${harmonyName}已激活`}:核心盟约激活人数 +${harmony}`; 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} 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}`}> 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="关闭" onClick=${onClose}><${Icon} name="close" /></button> <button type="button" class="bpop__close" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
<header class="bpop__head"> <header class="bpop__head">
<div class=${cx('bpop__disc', active && 'is-active')}><${BondGlyph} bondId=${bondId} /></div> <div class=${cx('bpop__disc', active && 'is-active')}><${BondGlyph} bondId=${bondId} /></div>
<div class="bpop__titles"> <div class="bpop__titles">
<${MicroLabel} tone="mint">${b.isCore ? 'CORE BOND // 核心盟约' : 'ADD-ON BOND // 附加盟约'}</${MicroLabel}> <${MicroLabel} tone="mint">${b.isCore ? t('CORE BOND // 核心盟约') : t('ADD-ON BOND // 附加盟约')}</${MicroLabel}>
${owner ? html`<span class="bpop__owner"><${GIcon} name="eye" /><b>${owner}</b> 的盟约</span>` : null} ${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> <h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts"> <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>`} ${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>层数不显示</span>` : html`<span>层数 <b class="num t-mint">${layers}</b></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 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span> <span class=${active ? 't-mint' : 't-lo'}>${active ? (th.length > 1 ? t('已激活 · {tier} 阶', { tier }) : t('已激活')) : off ? t('本局禁用') : t('未激活')}</span>
</div> </div>
</div> </div>
</header> </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"> <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> </div>
${hasNow ? html`<section class="bpop__sec bpop__sec--now"> ${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" /> <${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
</section>` : null} </section>` : null}
<section class="bpop__sec"> <section class="bpop__sec">
<h4>盟约效果</h4> <h4>${t('盟约效果')}</h4>
<${RichText} as="p" text=${b.descRaw || b.desc} class="bpop__desc" /> <${RichText} as="p" text=${b.descRaw || b.desc} class="bpop__desc" />
</section> </section>
<section class="bpop__sec"> <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 ${harmony ? (harmonyBy.length
? html`<button type="button" class="bpop__harmony" data-harmony=${harmony} title=${harmonyText} onClick=${() => onMember?.(harmonyBy[0].id, null)}> ? 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>` <${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')} 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))} onClick=${() => (si ? onMember?.(mb.id, items, si.standInFor) : onMember?.(mb.id, items))}
data-granted=${mb.granted ? '1' : null} data-standin=${si ? si.charId : null} 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} /> <${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> <span class="bpop__mname">${name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null} ${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} ${mb.diy ? html`<span class="bpop__iso bpop__diy" data-diy="1" aria-hidden="true">${t('自选')}</span>` : null}
</button>`; </button>`;
})} })}
+20 -19
View File
@@ -23,6 +23,7 @@ import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
import { richTextPlain } from './richText.js'; import { richTextPlain } from './richText.js';
import { sortedPlayers } from './gameLogic.js'; import { sortedPlayers } from './gameLogic.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -66,7 +67,7 @@ export function resolveSpCard(card, family) {
else icon = uiUrl(m, `buffIcon/${team ? 'icon_team_buff' : 'icon_player_buff'}`); else icon = uiUrl(m, `buffIcon/${team ? 'icon_team_buff' : 'icon_player_buff'}`);
return { return {
kind, kind,
name: card.name || item?.name || eff?.name || bounty?.name || tactic?.name || '机变', name: card.name || item?.name || eff?.name || bounty?.name || tactic?.name || t('机变'),
desc: cardText(card, item?.descRaw || eff?.descRaw || '') || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '', desc: cardText(card, item?.descRaw || eff?.descRaw || '') || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '',
tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null, tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null,
icon, icon,
@@ -158,7 +159,7 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p])); const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
const myTurn = solo || sp.turnPid === myId; const myTurn = solo || sp.turnPid === myId;
const mine = sp.pickOf.get(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 special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order; const order = solo ? [] : sp.order;
const timed = !solo && !sp.untimed; const timed = !solo && !sp.untimed;
@@ -171,25 +172,25 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return; if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm(); 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__veil" aria-hidden="true"></div>
<div class="spov__inner"> <div class="spov__inner">
<header class="spov__head"> <header class="spov__head">
<div class="spov__titles"> <div class="spov__titles">
<${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}> <${MicroLabel} tone="mint">${t('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> <h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || t('选择一项')} /></span></h2>
<p class="spov__sub">${timed ? '倒计时结束后仍未选定将自动分配' : '选择一项(无时间限制)'}${mine == null && myTurn ? ' · 点击卡牌选中,再次点击确认' : ''}</p> <p class="spov__sub">${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
</div> </div>
<div class="spov__turn"> <div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>` ${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>` : myTurn ? html`<span class="spov__turntxt is-mine">${t('当前轮到你决策')}</span>`
: html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></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" ${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} ${timed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
</div> </div>
</header> </header>
${order.length ? html`<div class="spov__order" aria-label="决策顺序"> ${order.length ? html`<div class="spov__order" aria-label=${t('决策顺序')}>
${order.map((pid, i) => { ${order.map((pid, i) => {
const p = players.get(pid); const p = players.get(pid);
const picked = sp.pickOf.has(pid); const picked = sp.pickOf.has(pid);
@@ -198,7 +199,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')}> 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> <span class="spov__idx num">${i + 1}</span>
<${PlayerAvatar} player=${p || { name: '?' }} size="sm" self=${pid === myId} /> <${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> <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>`; </div>`;
})} })}
@@ -210,26 +211,26 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const can = cardPickable(sp, card, { myId, solo, busyIdx }); const can = cardPickable(sp, card, { myId, solo, busyIdx });
const busy = pickBusy(busyIdx, card, mine); const busy = pickBusy(busyIdx, card, mine);
const isArmed = can && armed === card.idx; 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')} 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-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__glow" aria-hidden="true"></span>
<span class="spcard__head"> <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__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
<span class="spcard__title"> <span class="spcard__title">
<b class="spcard__name">${r.name}</b> <b class="spcard__name">${r.name}</b>
${r.team || (r.kind === 'bounty' && r.coin) ? html`<span class="spcard__tags"> ${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.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">赏金 ${r.coin}</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>` : null}
</span> </span>
</span> </span>
<${RichText} text=${r.desc} class="spcard__desc" /> <${RichText} text=${r.desc} class="spcard__desc" />
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null} ${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} ${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>确认选择</b><small>再次点击</small></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">选择中</span>` : null} ${busy ? html`<span class="spcard__busy" role="status">${t('选择中')}</span>` : null}
</button>`; </button>`;
})} })}
</div> </div>
+16 -15
View File
@@ -13,17 +13,18 @@ import { html, Icon } from './components.js';
import { DpCounter } from './hud.js'; import { DpCounter } from './hud.js';
import { GIcon } from './gameComponents.js'; import { GIcon } from './gameComponents.js';
import { switcherLabel, cycleField } from './gameLogic.js'; import { switcherLabel, cycleField } from './gameLogic.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Teammates' progress under the waiting pill (css/screens/game.css .chud__progress / .chud__prog). */ /** Teammates' progress under the waiting pill (css/screens/game.css .chud__progress / .chud__prog). */
function ProgressList({ list }) { function ProgressList({ list }) {
if (!Array.isArray(list) || !list.length) return null; 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')}> ${list.map((p) => html`<span key=${p.playerId} role="listitem" class=${cx('chud__prog', p.done && 'is-done')}>
<b>${p.name}</b> <b>${p.name}</b>
${p.done ${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>`} : html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
</span>`)} </span>`)}
</div>`; </div>`;
@@ -44,14 +45,14 @@ export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectatin
return html`<div class="chud"> return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} /> <${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom"> <div class="chud__bottom">
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><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>观战中</span></div>` ${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>你已被淘汰,正在观战</span></div>`) : null} : 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')}> ${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> onClick=${() => { const n = cycleField(fields, watching, -1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">${label}</span> <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> onClick=${() => { const n = cycleField(fields, watching, 1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronRight" /></button>
</div>` : null} </div>` : null}
</div> </div>
@@ -72,22 +73,22 @@ function ClientHud({ hud, myDone, spectating, spectator = false, client }) {
return html`<div class="chud"> return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} /> <${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom"> <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} <${ProgressList} list=${client.progress} />` : null}
${spectating && !observing ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>观战中,可点击左侧成员头像前往查看</span></div>` ${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>你已被淘汰,可点击队友头像前往查看</span></div>`) : null} : 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"> ${observing && !halves ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span> <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()}> ${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>` : null}
${halves ? html`<div class=${cx('vswitch', 'chud__layers', observing && 'is-observing')}> ${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"> <span class="vswitch__label">
${cur && cur.watch ? html`<${GIcon} name="eye" />` : null}<span>${cur ? cur.label : '全景'}</span></span> ${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="右侧战场" onClick=${() => step(1)}><${Icon} name="chevronRight" /></button> <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()}> ${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>` : null}
</div> </div>
</div>`; </div>`;
+11 -11
View File
@@ -194,12 +194,12 @@ export function tierChipUrl(t) {
* @param {{ tier: number, golden?: boolean, size?: 'sm'|'md'|'lg', class?: string }} props * @param {{ tier: number, golden?: boolean, size?: 'sm'|'md'|'lg', class?: string }} props
*/ */
export function TierChip({ tier, golden = false, size = 'md', class: cls }) { export function TierChip({ tier, golden = false, size = 'md', class: cls }) {
const t = Math.max(1, Math.min(6, Number(tier) | 0 || 1)); const n = Math.max(1, Math.min(6, Number(tier) | 0 || 1));
const src = tierChipUrl(t); const src = tierChipUrl(n);
const [bad, setBad] = useState(null); const [bad, setBad] = useState(null);
const img = !!src && bad !== src; 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}阶`}> 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(t)} ${img ? html`<img src=${src} alt="" draggable=${false} onError=${() => setBad(src)} />` : roman(n)}
</span>`; </span>`;
} }
@@ -240,7 +240,7 @@ export function BondDisc({
: html`<span class="bond__glyph">${glyph}</span>`} : html`<span class="bond__glyph">${glyph}</span>`}
</span> </span>
${stack != null ? html`<span class="bond__count">${stack}</span>` : null} ${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> </span>
${showName && name ? html`<span class="bond__name">${name}</span>` : null} ${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) // untimed phase: no timer on screen (hooks above run unconditionally, so the component may toggle freely)
if (remain == null) return null; 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"> <div class="countdown__main">
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} /> <${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} />
${label ? html`<span class="countdown__label">${label}</span>` : null} ${label ? html`<span class="countdown__label">${label}</span>` : null}
@@ -489,7 +489,7 @@ export function DialogHost() {
}, []); }, []);
const d = dialogs[0]; const d = dialogs[0];
if (!d) return null; 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'; const isConfirm = d.kind === 'confirm';
// Enter confirms unless a specific button has focus (then the native click decides). // Enter confirms unless a specific button has focus (then the native click decides).
const onKey = (e) => { 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__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" /> <polygon class="spinner__arc" points="25,3 44,14 44,36 25,47 6,36 6,14" />
</svg> </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>`; </span>`;
} }
@@ -695,8 +695,8 @@ export function AvatarFrame({ name = '', src, size = 'md', seat = 0, host, bot,
: bot ? html`<${Icon} name="robot" class="avatar__bot" />` : bot ? html`<${Icon} name="robot" class="avatar__bot" />`
: html`<span class="avatar__glyph">${glyph}</span>`} : html`<span class="avatar__glyph">${glyph}</span>`}
</div> </div>
${host ? html`<span class="avatar__badge avatar__badge--host" title="创建者"><${Icon} name="crown" /></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="你"><${Icon} name="user" /></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} ${bot && !empty ? html`<span class="avatar__tag">AI</span>` : null}
</div>`; </div>`;
} }
@@ -755,7 +755,7 @@ export function doctorNo(id) {
export function PingPill({ ms, online = true, class: cls }) { export function PingPill({ ms, online = true, class: cls }) {
const ok = online && Number.isFinite(ms); const ok = online && Number.isFinite(ms);
const tier = !ok ? 'off' : ms < 60 ? 'low' : ms < 200 ? 'medium' : 'high'; 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" /> <${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 class="ping__value">${ok ? Math.min(9999, Math.round(ms)) : '--'}</span><span class="ping__unit">ms</span>
</span>`; </span>`;
+13 -12
View File
@@ -9,6 +9,7 @@ import { html, Button, Icon, useTicker } from './components.js';
import { net, CLIENT_ERR_TEXT } from '../net.js'; import { net, CLIENT_ERR_TEXT } from '../net.js';
import { useStore, shallowEqual } from '../store.js'; import { useStore, shallowEqual } from '../store.js';
import { useDocClass } from './device.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). */ /** Whether the banner shows for this connection state (mirrors the early returns below). */
export function bannerVisible(conn, entered, restoring, buildStale = false) { export function bannerVisible(conn, entered, restoring, buildStale = false) {
@@ -28,15 +29,15 @@ export function ConnectionBanner() {
if (!entered) return null; if (!entered) return null;
if (conn.status === 'online' && !restoring && !buildStale) return null; if (conn.status === 'online' && !restoring && !buildStale) return null;
if (conn.status === 'online' && restoring) { 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) { 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 // 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 // for a player who would rather do it now
return html`<div class="conn-banner" role="alert"> return html`<div class="conn-banner" role="alert">
<${Icon} name="refresh" /> <${Icon} name="refresh" />
<span>服务器已更新,本局结束后刷新</span> <span>${t('服务器已更新,本局结束后刷新')}</span>
<${Button} size="sm" variant="secondary" icon="refresh" onClick=${() => location.reload()}>刷新页面<//> <${Button} size="sm" variant="secondary" icon="refresh" onClick=${() => location.reload()}>${t('刷新页面')}<//>
</div>`; </div>`;
} }
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return null; 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. // 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 transient = conn.status === 'connecting' || conn.status === 'handshaking' || (conn.status === 'connected' && !rejected);
const text = conn.status === 'reconnecting' const text = conn.status === 'reconnecting'
? '与服务器的连接已中断,正在重连' ? t('与服务器的连接已中断,正在重连')
: replaced ? '该身份已在其他页面登录' : replaced ? t('该身份已在其他页面登录')
: conn.status === 'closed' ? '连接已关闭' : conn.status === 'closed' ? t('连接已关闭')
: rejected ? conn.lastError.text : '正在连接服务器'; : rejected ? t(conn.lastError.text) : t('正在连接服务器');
const action = conn.status === 'reconnecting' ? { label: '立即重连', run: () => net.retryNow() } const action = conn.status === 'reconnecting' ? { label: t('立即重连'), run: () => net.retryNow() }
: conn.status === 'closed' ? { label: replaced ? '在此页面继续' : '重新连接', run: () => net.connect() } : conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: () => net.connect() }
: versionMismatch ? { label: '刷新页面', run: () => location.reload() } : versionMismatch ? { label: t('刷新页面'), run: () => location.reload() }
: rejected ? { label: '重试', run: () => net.reconnectNow() } : null; : rejected ? { label: t('重试'), run: () => net.reconnectNow() } : null;
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert"> return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
<${Icon} name="wifiOff" /> <${Icon} name="wifiOff" />
<span>${text}</span> <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} ${action ? html`<${Button} size="sm" variant="secondary" icon="refresh" onClick=${action.run}>${action.label}<//>` : null}
</div>`; </div>`;
} }
+92 -92
View File
@@ -41,18 +41,18 @@ import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js'; import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js'; import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.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'; import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
const PROF_NAME = { PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种', TOKEN: '召唤物' }; 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: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动' }; const SP_TYPE = { INCREASE_WITH_TIME: N_('自动回复'), INCREASE_WHEN_ATTACK: N_('攻击回复'), INCREASE_WHEN_TAKEN_DAMAGE: N_('受击回复'), ON_DEPLOY: N_('被动') };
const SKILL_TYPE = { MANUAL: '自动触发', AUTO: '自动触发', PASSIVE: '被动' }; const SKILL_TYPE = { MANUAL: N_('自动触发'), AUTO: N_('自动触发'), PASSIVE: N_('被动') };
/** Fallback type icon by trigger, for a garrison record without its official `eventTypeIcon` (garrisonTypeIconKey). */ /** 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 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 RANK = { NORMAL: N_('普通'), ELITE: N_('精英'), BOSS: N_('领袖') };
const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' }; 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. */ /** A range grid of at least this many tiles covers the field (纯烬艾雅法拉 S3 "攻击范围扩大至整个战场"): named, not drawn. */
export const FIELD_WIDE_CELLS = 400; export const FIELD_WIDE_CELLS = 400;
@@ -87,7 +87,7 @@ export function cardRangeGrid(live, rec, chess) {
/** Mini range map. */ /** Mini range map. */
export function RangeGrid({ grid, class: cls }) { 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); const box = rangeGridBox(grid);
if (!box.cells.size) return html`<span class="t-dim">—</span>`; if (!box.cells.size) return html`<span class="t-dim">—</span>`;
const cells = []; 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>`); 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 }) { function Stat({ k, v, sub, tone = null, title }) {
@@ -140,15 +140,15 @@ export function liveStat(live, key, fallback, fmt = fmtNum) {
const d = cur - base; 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))}`; 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). */ /** The tag of a live stats block: 实时 (battle) / 开战时 (the prep preview). */
function LiveTag({ live }) { function LiveTag({ live }) {
if (!live) return null; if (!live) return null;
const battle = live.src === 'battle'; const battle = live.src === 'battle';
return html`<span class=${cx('dstats__tag', battle && 'is-battle')} title=${battle ? '当前作战中的实时数值(绿色为增益,红色为减益)' return html`<span class=${cx('dstats__tag', battle && 'is-battle')} title=${battle ? t('当前作战中的实时数值(绿色为增益,红色为减益)')
: '下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)'}>${battle ? '实时' : '开战时'}</span>`; : t('下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)')}>${battle ? t('实时') : t('开战时')}</span>`;
} }
const fmtInterval = (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(2)}s` : '—'); 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; if (!rows.length) return null;
const wearer = Array.isArray(carried); const wearer = Array.isArray(carried);
return html`<div class=${cx('dmorph', wearer && 'is-wearer')}> return html`<div class=${cx('dmorph', wearer && 'is-wearer')}>
<p class="dmorph__lead">搭配以下装备时,携带者视为对应盟约的成员:</p> <p class="dmorph__lead">${t('搭配以下装备时,携带者视为对应盟约的成员:')}</p>
<ul class="dmorph__list" aria-label="变形同构体对应关系"> <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} ${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 ? '(本局禁用)' : ''}`}> title=${`${t('{items} → 【{name}】', { items: r.items.map((it) => it.name), name: r.name })}${r.off ? t('(本局禁用)') : ''}`}>
<span class="dmorph__bond">【${r.name}】</span> <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 ? '、' : ''}${it.name}</span>`)}${r.worn <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 ? '已搭配' : '生效中'}</span>` : null}${r.off ? html`<span class="dmorph__tag is-off">本局禁用</span>` : null}</span> ? 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>`)} </li>`)}
</ul> </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>`; </div>`;
} }
@@ -207,8 +207,8 @@ export function MorphGrantLine({ item, off = null, carried = null }) {
const isOff = isOffIn(off, item.giveBondId); const isOff = isOffIn(off, item.giveBondId);
const worn = grantedBonds(carried, (id) => data.lookup('items', id)).includes(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}> 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 <${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 ? '已搭配' : '生效中'}</span>` : null}</span> ? html`<span class="dmorph__tag is-on">${isOff ? t('已搭配') : t('生效中')}</span>` : null}</span>
</p>`; </p>`;
} }
@@ -233,7 +233,7 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
if (!ids.length) return null; if (!ids.length) return null;
const iso = new Set(Array.isArray(granted) ? granted : []); 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])); 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) => { ${ids.map((id) => {
const rec = data.lookup('bonds', id); const rec = data.lookup('bonds', id);
// a bond this mode never activates (gameLogic modeOffBonds): 本局禁用, no count or tier pips // 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 active = !isOff && (e ? !!e.active : tier > 0);
const next = nextThreshold(count, th); const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null; 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) // 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') 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 const body = isOff
? html` ? html`
<${BondGlyph} bondId=${id} class="dbond__icon" /> <${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span> <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="dbond__off">本局禁用</span>` <span class="dbond__off">${t('本局禁用')}</span>`
: html` : html`
<${BondGlyph} bondId=${id} class="dbond__icon" /> <${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span> <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> <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}`; ${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'); 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. */ /** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
function GarrisonBlock({ garrison, m }) { 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"> <div class="dgarrison__head">
<span class="dgarrison__k">特质</span> <span class="dgarrison__k">${t('特质')}</span>
<span class="dgarrison__type"> <span class="dgarrison__type">
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${garrisonTypeIconKey(garrison)}`)} class="dgarrison__icon" /> <${Img} src=${uiUrl(m, `garrisonTypeIcon/${garrisonTypeIconKey(garrison)}`)} class="dgarrison__icon" />
${garrison.eventTypeDesc || ''} ${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 key="stats" class=${cx('dstats-wrap', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<div class="dstats"> <div class="dstats">
<${LiveTag} live=${live} /> <${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} /> <${Stat} k=${t('生命上限')} ...${st.maxHp} />
<${Stat} k="攻击" ...${st.atk} /> <${Stat} k=${t('攻击')} ...${st.atk} />
<${Stat} k="防御" ...${st.def} /> <${Stat} k=${t('防御')} ...${st.def} />
<${Stat} k="法术抗性" ...${st.res} /> <${Stat} k=${t('法术抗性')} ...${st.res} />
<${Stat} k="攻击间隔" ...${st.interval} /> <${Stat} k=${t('攻击间隔')} ...${st.interval} />
<${Stat} k="阻挡数" ...${st.blockCnt} /> <${Stat} k=${t('阻挡数')} ...${st.blockCnt} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} /> <${Stat} k=${t('部署费用')} v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} /> <${Stat} k=${t('再部署')} v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div> </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>`; </div>`;
} }
@@ -396,8 +396,8 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<div class="dhead__info"> <div class="dhead__info">
<div class="dhead__chips"> <div class="dhead__chips">
<${TierChip} tier=${c.tier} golden=${golden} size="lg" /> <${TierChip} tier=${c.tier} golden=${golden} size="lg" />
${golden ? html`<span class="dtag-elite">精锐</span>` : null} ${golden ? html`<span class="dtag-elite">${t('精锐')}</span>` : null}
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</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} ${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} ${diy ? html`<span class="dtag-diy" data-diy=${c.charId || ''} title=${t('自选编队:所选技能与模组,没有特质,盟约按所属阵营分配')}>${t('自选')}</span>` : null}
</div> </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>`} ${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"> <div class="dhead__class">
<${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" /> <${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> <i class="sep"></i>
<${Img} src=${subProfIconUrl(m, body)} class="dhead__sub" /> <${Img} src=${subProfIconUrl(m, body)} class="dhead__sub" />
<span>${body.subProfessionName || ''}</span> <span>${body.subProfessionName || ''}</span>
<span class="dhead__pos">${body.position === 'MELEE' ? '近战位' : '远程位'}</span> <span class="dhead__pos">${body.position === 'MELEE' ? t('近战位') : t('远程位')}</span>
</div> </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} ${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} /> <${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; 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) // (`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.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 || ''}> <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>`} /> <${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"> <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"> <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--${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">${SKILL_TYPE[sk.skillType] || '自动触发'}</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" />初始 <b class="num">${sk.initSp ?? 0}</b> · 消耗 <b class="num">${sk.spCost ?? 0}</b></span>` : null} ${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">持续 <b class="num">${sk.duration}</b>s</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">充能 <b class="num">${sk.maxChargeTime}</b></span>` : null} ${sk.maxChargeTime > 1 ? html`<span class="dsp__num">${t('充能')} <b class="num">${sk.maxChargeTime}</b></span>` : null}
</div> </div>
</div> </div>
</div> </div>
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" /> <${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
<//>` : null; <//>` : 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}> <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}> ${!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} <${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> <b class="dmodule__name">${lo.module.name}</b>
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null} ${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> </div>
<//>` : null; <//>` : null;
// (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`) // (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"> 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">拖拽装备至该干员以配发(最多 2 件)</p>`} ${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 // 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} />`)} ${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} carried=${carried} off=${offBonds} />`)}
<//>` : null; <//>` : null;
const talents = chessTalents(fr); 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>`)} ${talents.map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
<//>` : null; <//>` : null;
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions"> 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; </div>` : null;
const out = CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean); 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 // 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; return out;
} }
@@ -478,21 +478,21 @@ export function ItemDetail({ item, piece, editable, onDestroy, offBonds = null }
<div class="dhead dhead--item"> <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=${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__info">
<div class="dhead__chips"><${TierChip} tier=${item.tier} golden=${item.isGolden} size="lg" />${item.isGolden ? html`<span class="dtag-elite">进阶</span>` : null} <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' ? '奇术' : '装备'}</span></div> <span class="dtag-kind">${item.itemType === 'MAGIC' ? t('奇术') : t('装备')}</span></div>
<h3 class="dhead__name">${item.name}</h3> <h3 class="dhead__name">${item.name}</h3>
${item.flavor ? html`<span class="dhead__flavor">${item.flavor}</span>` : null} ${item.flavor ? html`<span class="dhead__flavor">${item.flavor}</span>` : null}
</div> </div>
</div> </div>
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" /><//> <${Section} title=${t('效果')} 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} ${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.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.note ? html`<p class="dhint dhint--rule"><${Icon} name="info" />${item.note}</p>` : null}
${item.itemType === 'MAGIC' ${item.itemType === 'MAGIC'
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>` ? html`<p class="dhint"><${Icon} name="info" />${t('将其拖拽至战场上的格子使用')}</p>`
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</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" />调度中心不出售 · 获取途径:${item.shopExcludedBy || '效果获得'}</p>` : null} ${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)}>销毁道具<//></div>` : null}`; ${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>${t('销毁道具')}<//></div>` : null}`;
} }
function EnemyDetail({ enemy, snapHp, count, live = null }) { function EnemyDetail({ enemy, snapHp, count, live = null }) {
@@ -500,7 +500,7 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
const s = enemy.stats || {}; const s = enemy.stats || {};
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : []; const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
const factions = data.get('factions')?.types || {}; 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 interval = attackInterval(s.bat, s.aspd);
const hp = hpOf(live, snapHp); const hp = hpOf(live, snapHp);
// a battle enemy: its live stats against its spawned ones (the round's multipliers included — unitStatsEntry base) // 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>
<div class="dhead__info"> <div class="dhead__info">
<div class="dhead__chips"> <div class="dhead__chips">
<span class=${cx('drank', `drank--${(enemy.rank || 'NORMAL').toLowerCase()}`)}>${RANK[enemy.rank] || '普通'}</span> <span class=${cx('drank', `drank--${(enemy.rank || 'NORMAL').toLowerCase()}`)}>${t(RANK[enemy.rank]) || t('普通')}</span>
<span class="dtag-kind">${s.motion === 'FLY' ? '空中' : '地面'}</span> <span class="dtag-kind">${s.motion === 'FLY' ? t('空中') : t('地面')}</span>
${count ? html`<span class="dtag-kind num">×${count}</span>` : null} ${count ? html`<span class="dtag-kind num">×${count}</span>` : null}
</div> </div>
<h3 class="dhead__name">${enemy.name}</h3> <h3 class="dhead__name">${enemy.name}</h3>
@@ -527,19 +527,19 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
</div> </div>
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'battle' : undefined}> <div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'battle' : undefined}>
<${LiveTag} live=${live} /> <${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} /> <${Stat} k=${t('生命上限')} ...${st.maxHp} />
<${Stat} k="攻击" ...${st.atk} sub=${st.atk.sub || DMG[s.dmgType] || ''} /> <${Stat} k=${t('攻击')} ...${st.atk} sub=${st.atk.sub || t(DMG[s.dmgType]) || ''} />
<${Stat} k="防御" ...${st.def} /> <${Stat} k=${t('防御')} ...${st.def} />
<${Stat} k="法术抗性" ...${st.res} /> <${Stat} k=${t('法术抗性')} ...${st.res} />
<${Stat} k="移动速度" ...${st.moveSpeed} /> <${Stat} k=${t('移动速度')} ...${st.moveSpeed} />
<${Stat} k="攻击间隔" ...${st.interval} /> <${Stat} k=${t('攻击间隔')} ...${st.interval} />
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} /> <${Stat} k=${t('攻击范围')} v=${s.rangeRadius > 0 ? s.rangeRadius : t('近战')} />
<${Stat} k="目标价值" v=${s.lpr ?? 1} /> <${Stat} k=${t('目标价值')} v=${s.lpr ?? 1} />
</div> </div>
${imm.length ? html`<p class="dhint"><${Icon} name="shield" />免疫:${imm.join('、')}</p>` : null} ${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="能力" micro="ABILITIES"> ${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> <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) { export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY) {
if (!token || token.kind !== 'summon' || token.placeable !== true) return null; if (!token || token.kind !== 'summon' || token.placeable !== true) return null;
const talent = Object.values(token.variants || {}).some((v) => (v?.sources || []).includes('talent')); const talent = Object.values(token.variants || {}).some((v) => (v?.sources || []).includes('talent'));
if (talent) return '作战开始时在摆放的位置部署'; if (talent) return t('作战开始时在摆放的位置部署');
return startDeploy 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 dhead--item">
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div> <div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
<div class="dhead__info"> <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> <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} ${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> </div>
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}> <div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
<${LiveTag} live=${live} /> <${LiveTag} live=${live} />
<${Stat} k="生命上限" ...${st.maxHp} /><${Stat} k="攻击" ...${st.atk} /> <${Stat} k=${t('生命上限')} ...${st.maxHp} /><${Stat} k=${t('攻击')} ...${st.atk} />
<${Stat} k="防御" ...${st.def} /><${Stat} k="阻挡数" ...${st.blockCnt} /> <${Stat} k=${t('防御')} ...${st.def} /><${Stat} k=${t('阻挡数')} ...${st.blockCnt} />
</div> </div>
${hint ? html`<p class="dhint"><${Icon} name="info" />${hint}</p>` : null} ${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} ${token.descRaw || token.desc ? html`<${Section} title=${t('说明')}><${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} ${skill ? html`<${Section} title=${t('技能')}><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}`; ${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 sellIt = async (piece, chess) => {
const golden = piece.golden || chess?.isGolden; const golden = piece.golden || chess?.isGolden;
if (golden) { 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; if (!ok) return;
} }
onSell(piece); onSell(piece);
}; };
const destroyIt = async (piece, item) => { 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); 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'}> 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"> <div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} ${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} bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
+2 -1
View File
@@ -21,6 +21,7 @@
import { useEffect, useState } from '../../vendor/hooks.module.js'; import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon } from './components.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. */ /** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
export const LONG_PRESS_MS = 520; export const LONG_PRESS_MS = 520;
@@ -154,7 +155,7 @@ export function FullscreenButton({ class: cls = '' }) {
return () => { d.removeEventListener('fullscreenchange', upd); d.removeEventListener('webkitfullscreenchange', upd); }; return () => { d.removeEventListener('fullscreenchange', upd); d.removeEventListener('webkitfullscreenchange', upd); };
}, []); }, []);
if (!ok) return null; 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'} return html`<button type="button" class=${`fsbtn tapx ${cls}`} aria-label=${label} title=${label} aria-pressed=${on ? 'true' : 'false'}
onClick=${() => fullscreen.toggle()}> onClick=${() => fullscreen.toggle()}>
<${Icon} name=${on ? 'collapse' : 'expand'} /> <${Icon} name=${on ? 'collapse' : 'expand'} />
+8 -7
View File
@@ -16,6 +16,7 @@
import { DIY_LIMITS, checkDiyPicks } from '../../../shared/protocol.js'; import { DIY_LIMITS, checkDiyPicks } from '../../../shared/protocol.js';
import { diySlotIds, diySlot, diyPool, isPrototypePick, lockedSelection, isDiyModule, diyRecord } from '../../../shared/diy.js'; import { diySlotIds, diySlot, diyPool, isPrototypePick, lockedSelection, isDiyModule, diyRecord } from '../../../shared/diy.js';
import { unitForm } from '../../../shared/standIn.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. */ /** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const DIY_PREF = 'diy'; export const DIY_PREF = 'diy';
@@ -186,16 +187,16 @@ export const serializeDiy = (picks, opts) => JSON.stringify(exportDiy(picks, opt
export function parseDiyImport(input) { export function parseDiyImport(input) {
let raw = input; let raw = input;
if (typeof raw === 'string') { 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(); const text = raw.trim();
if (!text) return { ok: false, error: '没有可导入的内容' }; if (!text) return { ok: false, error: N_('没有可导入的内容') };
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; } 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; 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; const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: '这不是自选编队的数据' }; if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: N_('这不是自选编队的数据') };
if (!isObj(raw.picks)) return { ok: false, error: '里面没有自选编队的数据' }; if (!isObj(raw.picks)) return { ok: false, error: N_('里面没有自选编队的数据') };
return { ok: true, picks: cleanPicks(raw.picks) }; return { ok: true, picks: cleanPicks(raw.picks) };
} }
+11 -3
View File
@@ -6,18 +6,26 @@ import { html, Tooltip, MicroLabel } from './components.js';
import { Img, RichText, GIcon } from './gameComponents.js'; import { Img, RichText, GIcon } from './gameComponents.js';
import { effectIconUrl } from './assetUrls.js'; import { effectIconUrl } from './assetUrls.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { t, N_ } from '../../../shared/i18n.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 }));
/** @param {{ effects: any[] }} props */ /** @param {{ effects: any[] }} props */
export function EffectsList({ effects }) { export function EffectsList({ effects }) {
const list = (Array.isArray(effects) ? effects : []).filter((e) => e && (e.name || e.desc)); const list = (Array.isArray(effects) ? effects : []).filter((e) => e && (e.name || e.desc));
if (!list.length) return null; if (!list.length) return null;
const m = data.get('assets'); const m = data.get('assets');
return html`<div class="effects" aria-label="生效中的效果"> return html`<div class="effects" aria-label=${t('生效中的效果')}>
<${MicroLabel}>EFFECTS</${MicroLabel}> <${MicroLabel}>EFFECTS</${MicroLabel}>
${list.slice(0, 10).map((e, i) => html`<${Tooltip} key=${e.id ?? i} placement="bottom" text=${html`<div class="efftip"> ${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> <b>${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=${e.desc || ''} /> <${RichText} as="p" text=${e.desc || ''} />
</div>`}> </div>`}>
<span class=${`effect effect--${e.iconKind || 'x'}`}> <span class=${`effect effect--${e.iconKind || 'x'}`}>
+11 -10
View File
@@ -25,6 +25,7 @@ import { html } from './components.js';
import { GIcon } from './gameComponents.js'; import { GIcon } from './gameComponents.js';
import { data, useData, localAsset, artUrls, nextArtUrl } from '../data.js'; import { data, useData, localAsset, artUrls, nextArtUrl } from '../data.js';
import { loadPref, savePref } from '../store.js'; import { loadPref, savePref } from '../store.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); 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 e = emoteInfo(id);
const style = [`--ebubble-ttl:${life}ms`, age && `--ebubble-age:${Math.round(age)}ms`, bg && `--ebubble-bg:url("${bg}")`].filter(Boolean).join(';'); 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" 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> <span class="ebubble__icon"><${EmoteArt} id=${id} /></span>
</div>`; </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')} <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)} style=${btnSprite ? `--ewheel-btn:url("${btnSprite}")` : ''} onClick=${() => onToggle(!open)}
aria-expanded=${open ? 'true' : 'false'} aria-haspopup="dialog" disabled=${disabled || cooling}> 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> </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} <div class="ewheel__viewport" onPointerDown=${onPointerDown} onPointerMove=${onPointerMove}
onPointerUp=${(e) => endDrag(e, false)} onPointerCancel=${(e) => endDrag(e, true)} onWheel=${onWheel}> 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')} <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}> 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=${e.label} ${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)}> disabled=${cooling || disabled} onClick=${() => send(e.id)}>
<${EmoteArt} id=${e.id} /> <${EmoteArt} id=${e.id} />
</button>`)} </button>`)}
</div> </div>
</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-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="下一组表情" disabled=${page >= EMOTE_THEMES.length - 1} onClick=${() => go(page + 1)}><${GIcon} name="chevronRight" /></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="表情主题"> <div class="ewheel__dots" role="tablist" aria-label=${t('表情主题')}>
${EMOTE_THEMES.map((t, i) => html`<button key=${t.themeId} type="button" role="tab" class=${cx('ewheel__dot', i === page && 'is-on')} ${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.name} ${i + 1}/${EMOTE_THEMES.length}`} onClick=${() => go(i)}></button>`)} aria-selected=${i === page ? 'true' : 'false'} aria-label=${`${t(th.name)} ${i + 1}/${EMOTE_THEMES.length}`} onClick=${() => go(i)}></button>`)}
</div> </div>
</div>` : null} </div>` : null}
</div>`; </div>`;
+18 -17
View File
@@ -13,9 +13,10 @@ import { groupEnemies, factionTypes, briefingBondTip } from './gameLogic.js';
import { matchInfoModel, DiyBannedLine } from './matchInfo.js'; import { matchInfoModel, DiyBannedLine } from './matchInfo.js';
import { factionIconUrl } from './assetUrls.js'; import { factionIconUrl } from './assetUrls.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
const TAG = { boss: '领袖', bounty: '悬赏', escort: '护卫' }; const TAG = { boss: N_('领袖'), bounty: N_('悬赏'), escort: N_('护卫') };
function FactionChips({ types }) { function FactionChips({ types }) {
const f = data.get('factions')?.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; const boss = pub?.bossId ? data.lookup('bosses', pub.bossId) : null;
return html`<div class="edrawer__body"> return html`<div class="edrawer__body">
<div class="edrawer__sum"> <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} ${types.length ? html`<${FactionChips} types=${types} />` : null}
</div> </div>
${rows.length ? html`<div class="elist"> ${rows.length ? html`<div class="elist">
@@ -42,19 +43,19 @@ function EnemiesTab({ pub, priv, onEnemy }) {
<span class="erow__text"> <span class="erow__text">
<b class="erow__name">${r.name}</b> <b class="erow__name">${r.name}</b>
<span class="erow__tags"> <span class="erow__tags">
${r.tag ? html`<span class=${`etag etag--${r.tag}`}>${TAG[r.tag] || r.tag}</span>` : null} ${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">精英</span>` : null} ${r.rank === 'ELITE' ? html`<span class="etag etag--elite">${t('精英')}</span>` : null}
${r.fly ? html`<span class="etag etag--fly">空中</span>` : null} ${r.fly ? html`<span class="etag etag--fly">${t('空中')}</span>` : null}
${r.acTypes.map((t) => html`<span key=${t} class="etag etag--faction">${(f[t]?.name || t).replace('特训敌人·', '')}</span>`)} ${r.acTypes.map((ty) => html`<span key=${ty} class="etag etag--faction">${(f[ty]?.name || ty).replace(t('特训敌人·'), '')}</span>`)}
</span> </span>
</span> </span>
<b class="erow__count num">×${r.count}</b> <b class="erow__count num">×${r.count}</b>
</button>`)} </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"> ${boss ? html`<div class="eboss">
<${UnitThumb} kind="enemy" id=${boss.enemyKey} size="md" /> <${UnitThumb} kind="enemy" id=${boss.enemyKey} size="md" />
<div><${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖</${MicroLabel}><b>${boss.name}</b> <div><${MicroLabel} tone="gold">${t('ENEMY LEADER // 敌方领袖')}</${MicroLabel}><b>${boss.name}</b>
<span class="t-lo">第 ${data.get('config')?.modes?.[pub.modeId]?.bossRound ?? pub.lastRound ?? 14} 回合 · 最终攻势</span></div> <span class="t-lo">${t('第 {r} 回合 · 最终攻势', { r: data.get('config')?.modes?.[pub.modeId]?.bossRound ?? pub.lastRound ?? 14 })}</span></div>
</div>` : null} </div>` : null}
</div>`; </div>`;
} }
@@ -75,16 +76,16 @@ function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) {
return html`<div class="edrawer__body"> return html`<div class="edrawer__body">
${band ? html`<div class="iband"> ${band ? html`<div class="iband">
<${BandIcon} bandId=${band.bandId} size="md" /> <${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> <${RichText} text=${band.descRaw || band.desc} class="iband__desc" /></div>
</div>` : null} </div>` : null}
${stage ? html`<p class="istage"><${Icon} name="rook" />战场:<b>${stage.name || stage.id}</b></p>` : null} ${stage ? html`<p class="istage"><${Icon} name="rook" />${t('战场:')}<b>${stage.name || stage.id}</b></p>` : null}
<h4 class="ihead">本局禁用干员情况 <small class="num">${banned.length}</small></h4> <h4 class="ihead">${t('本局禁用干员情况')} <small class="num">${banned.length}</small></h4>
<div class="ibonds"> <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)}> ${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> <${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} ${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">本局没有禁用盟约</span>`} </span>`) : html`<span class="t-dim">${t('本局没有禁用盟约')}</span>`}
</div> </div>
${banned.length ? html`<div class="ibanned">${banned.map((id) => html`<button key=${id} type="button" class="ibanned__one" onClick=${() => onChess(id)}> ${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} <${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) * 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 }) { 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"> <div class="edrawer__top">
<${Tabs} size="sm" value=${tab} onChange=${onTab} items=${[{ id: 'info', label: '本局信息' }, { id: 'enemies', label: '敌方情报' }]} /> <${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="关闭" onClick=${onClose}><${Icon} name="close" /></button> <button type="button" class="edrawer__close tapx" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
</div> </div>
${tab === 'info' ? html`<${InfoTab} pub=${pub} priv=${priv} onChess=${onChess} bandId=${bandId} bandOwner=${bandOwner} />` : html`<${EnemiesTab} pub=${pub} priv=${priv} onEnemy=${onEnemy} />`} ${tab === 'info' ? html`<${InfoTab} pub=${pub} priv=${priv} onChess=${onChess} bandId=${bandId} bandOwner=${bandOwner} />` : html`<${EnemiesTab} pub=${pub} priv=${priv} onEnemy=${onEnemy} />`}
</div>`; </div>`;
+12 -11
View File
@@ -17,6 +17,7 @@ import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, TierChip, MicroLabel } from './components.js'; import { html, Modal, Button, TierChip, MicroLabel } from './components.js';
import { UnitThumb, RichText, GIcon } from './gameComponents.js'; import { UnitThumb, RichText, GIcon } from './gameComponents.js';
import { localAsset } from '../data.js'; import { localAsset } from '../data.js';
import { t, tParts } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
const isObj = (v) => !!v && typeof v === 'object'; const isObj = (v) => !!v && typeof v === 'object';
@@ -40,11 +41,11 @@ export function replaceRequest(ctx, intent, getChess = () => null, getItem = ()
return { return {
targetUid: target.uid, targetUid: target.uid,
targetId: target.id, 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) // 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, targetRec: shown && (shown.standInFor || shown.diyFor) ? shown : null,
golden: !!target.golden, 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 })), 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>
<span class="eqr__meta"> <span class="eqr__meta">
<span class="eqr__chips">${tier ? html`<${TierChip} tier=${tier} golden=${!!rec?.isGolden} size="sm" />` : null} <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> <b class="eqr__name">${rec?.name || item?.name || id}</b>
</span> </span>
<${RichText} as="p" class="eqr__desc" text=${rec?.descRaw || rec?.desc || ''} />`; <${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)}> class=${cx('eqr__card', 'eqr__opt', selected && 'is-sel')} onClick=${() => onPick(item.uid)}>
${body} ${body}
<span class="eqr__flag" aria-hidden=${selected ? 'false' : 'true'}> <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> </span>
</button>`; </button>`;
} }
@@ -111,12 +112,12 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon'); const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon');
const swapIcon = localAsset('ui/battle', 'equip_replace_replace_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); } }}> 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` 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)}> <${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}确认替换<//>`}> ${confirmIcon ? html`<img class="eqr__ok-icon" src=${confirmIcon} alt="" draggable=${false} />` : null}${t('确认替换')}<//>`}>
<p class="eqr__lead">「<b>${request.targetName}</b>」的装备栏已满(2/2)。选择一件装备进行替换,<b class="t-red">被替换的装备将被销毁</b>。</p> <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__top">
<div class="eqr__op" title=${request.targetName}> <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> <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> </div>
<span class="eqr__swap" aria-hidden="true">${swapIcon ? html`<img src=${swapIcon} alt="" draggable=${false} />` : html`<${GIcon} name="refresh" />`}</span> <span class="eqr__swap" aria-hidden="true">${swapIcon ? html`<img src=${swapIcon} alt="" draggable=${false} />` : html`<${GIcon} name="refresh" />`}</span>
<div class="eqr__incoming"> <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)} /> <${ItemCard} id=${request.item.id} item=${request.item} rec=${getItem(request.item.id)} />
</div> </div>
</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__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} ${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))} />`)} selected=${o.uid === sel} onPick=${(uid) => setSel((s) => (s === uid ? null : uid))} />`)}
</div> </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>`; </div>`;
} }
+5 -5
View File
@@ -140,7 +140,7 @@ export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showT
<${Img} src=${src} fallback=${html`<span class="uthumb__glyph">${glyph}</span>`} /> <${Img} src=${src} fallback=${html`<span class="uthumb__glyph">${glyph}</span>`} />
</span> </span>
${showTier && t && kind !== 'enemy' && kind !== 'token' ? html`<${TierChip} tier=${t} golden=${golden} size="sm" class="uthumb__tier" />` : null} ${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} ${si ? html`<span class="uthumb__si" data-standin=${si.charId} aria-hidden="true">${tr('替补')}</span>` : null}
${badge} ${badge}
</span>`; </span>`;
@@ -207,11 +207,11 @@ export function GIcon({ name, class: cls, title }) {
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) { export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
const ok = Number.isFinite(value); const ok = Number.isFinite(value);
const p = ok && Number(pending) > 0 ? Math.min(value, Math.trunc(Number(pending))) : 0; 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}> data-pending=${p > 0 ? p : null}>
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} /> <${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> <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} ${note ? html`<span class="lp__note">${note}</span>` : null}
</span>`; </span>`;
} }
@@ -265,7 +265,7 @@ export function PlayerAvatar({ player, size = 'md', self = false, class: cls })
<span class="pavatar__img"> <span class="pavatar__img">
<${Img} src=${src} fallback=${player?.isBot ? html`<${Icon} name="robot" class="pavatar__bot" />` : html`<span class="pavatar__glyph">${glyph}</span>`} /> <${Img} src=${src} fallback=${player?.isBot ? html`<${Icon} name="robot" class="pavatar__bot" />` : html`<span class="pavatar__glyph">${glyph}</span>`} />
</span> </span>
${dead ? html`<span class="pavatar__x" aria-label="已淘汰"><${Icon} name="close" /></span>` : null} ${dead ? html`<span class="pavatar__x" aria-label=${tr('已淘汰')}><${Icon} name="close" /></span>` : null}
${left ? html`<span class="pavatar__door" aria-label="已离开"><${Icon} name="exit" /></span>` : null} ${left ? html`<span class="pavatar__door" aria-label=${tr('已离开')}><${Icon} name="exit" /></span>` : null}
</span>`; </span>`;
} }
+27 -26
View File
@@ -16,23 +16,24 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
import { html, Icon, MicroLabel, Button, Spinner } from './components.js'; import { html, Icon, MicroLabel, Button, Spinner } from './components.js';
import { createStore, useStore } from '../store.js'; import { createStore, useStore } from '../store.js';
import { data, useData, artUrls, nextArtUrl } from '../data.js'; import { data, useData, artUrls, nextArtUrl } from '../data.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Page catalogue in reading order (titles transcribed from the pages). */ /** Page catalogue in reading order (titles transcribed from the pages). */
export const GUIDE_CHAPTERS = [ export const GUIDE_CHAPTERS = [
{ id: 'home', name: '基础规则', micro: 'BASICS', pages: [ { id: 'home', name: N_('基础规则'), micro: 'BASICS', pages: [
['autochess_home_1', '卫戍协议已运行'], ['autochess_home_2', '攻防战'], ['autochess_home_3', '休整期 · 区域'], ['autochess_home_1', N_('卫戍协议已运行')], ['autochess_home_2', N_('攻防战')], ['autochess_home_3', N_('休整期 · 区域')],
['autochess_home_4', '休整期 · 资金与调度'], ['autochess_home_5', '机变阶段'], ['autochess_home_6', '限时战斗'], ['autochess_home_4', N_('休整期 · 资金与调度')], ['autochess_home_5', N_('机变阶段')], ['autochess_home_6', N_('限时战斗')],
['autochess_home_7', '作战期'], ['autochess_home_8', '协同战斗'], ['autochess_home_9', '盟约'], ['autochess_home_7', N_('作战期')], ['autochess_home_8', N_('协同战斗')], ['autochess_home_9', N_('盟约')],
] }, ] },
{ id: 'shop', name: '调度手册', micro: 'HANDBOOK', pages: [ { id: 'shop', name: N_('调度手册'), micro: 'HANDBOOK', pages: [
['autochess_shop_1', '调度手册'], ['autochess_shop_2', '干员晋级'], ['autochess_shop_3', '加成情况'], ['autochess_shop_1', N_('调度手册')], ['autochess_shop_2', N_('干员晋级')], ['autochess_shop_3', N_('加成情况')],
['autochess_shop_4', '卫戍能力'], ['autochess_shop_5', '盟约'], ['autochess_shop_6', '助战及自选编队'], ['autochess_shop_4', N_('卫戍能力')], ['autochess_shop_5', N_('盟约')], ['autochess_shop_6', N_('助战及自选编队')],
] }, ] },
{ id: 'handbook', name: '进阶图鉴', micro: 'ADVANCED', pages: [ { id: 'handbook', name: N_('进阶图鉴'), micro: 'ADVANCED', pages: [
['autochess_handbook_1', '敌人类型'], ['autochess_handbook_2', '盟约激活与叠加'], ['autochess_handbook_3', '追加盟约'], ['autochess_handbook_1', N_('敌人类型')], ['autochess_handbook_2', N_('盟约激活与叠加')], ['autochess_handbook_3', N_('追加盟约')],
['autochess_handbook_4', '策略与轮选'], ['autochess_handbook_4', N_('策略与轮选')],
] }, ] },
]; ];
@@ -78,9 +79,9 @@ export function openGuide(page = 0) {
export const closeGuide = () => guideStore.set({ open: false }); export const closeGuide = () => guideStore.set({ open: false });
/** Standard 玩法说明 trigger button. */ /** 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)} 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) { function preload(url) {
@@ -95,9 +96,9 @@ function preload(url) {
function TipsFallback() { function TipsFallback() {
const tips = (Array.isArray(data.get('config')?.tips) ? data.get('config').tips : []).map((t) => t?.tip).filter(Boolean); const tips = (Array.isArray(data.get('config')?.tips) ? data.get('config').tips : []).map((t) => t?.tip).filter(Boolean);
return html`<div class="guide__tips"> 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> <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>`; </div>`;
} }
@@ -146,42 +147,42 @@ export function GuideHost() {
const src = stage.kind === 'image' ? stage.src : null; const src = stage.kind === 'image' ? stage.src : null;
const isLoaded = !!src && loaded.has(src); const isLoaded = !!src && loaded.has(src);
return html`<div class="guide" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeGuide(); }}> 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"> <header class="guide__head">
<div class="guide__titles"> <div class="guide__titles">
<${MicroLabel} tone="mint">HOW TO PLAY // STRONGHOLD PROTOCOL</${MicroLabel}> <${MicroLabel} tone="mint">HOW TO PLAY // STRONGHOLD PROTOCOL</${MicroLabel}>
<h2 class="guide__title">玩法说明</h2> <h2 class="guide__title">${t('玩法说明')}</h2>
</div> </div>
${n ? html`<nav class="guide__chapters" aria-label="章节"> ${n ? html`<nav class="guide__chapters" aria-label=${t('章节')}>
${GUIDE_CHAPTERS.map((ch, ci) => { ${GUIDE_CHAPTERS.map((ch, ci) => {
const at = firstOf(ci); const at = firstOf(ci);
if (at < 0) return null; 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)}> 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>`; </button>`;
})} })}
</nav>` : null} </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> </header>
${src ? html`<div class="guide__stage"> ${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')}> <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))} onLoad=${() => setLoaded((s) => new Set(s).add(src))}
onError=${() => setFailed((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} ${!isLoaded ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
</div> </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>`} </div>` : html`<div class="guide__stage guide__stage--text"><${TipsFallback} /></div>`}
${n ? html`<footer class="guide__foot"> ${n ? html`<footer class="guide__foot">
<div class="guide__label"> <div class="guide__label">
<span class="guide__chtag">${chapter?.name || ''}</span> <span class="guide__chtag">${t(chapter?.name || '')}</span>
<b class="guide__ptitle">${cur?.title || ''}</b> <b class="guide__ptitle">${t(cur?.title || '')}</b>
</div> </div>
<div class="guide__dots" role="tablist" aria-label="页码"> <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=${p.title} ${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>`)} class=${cx('guide__dot', k === i && 'is-on', k > 0 && pages[k - 1].chapter !== p.chapter && 'is-first')} onClick=${() => go(k)}></button>`)}
</div> </div>
<span class="guide__count num">${i + 1} / ${n}</span> <span class="guide__count num">${i + 1} / ${n}</span>
+32 -30
View File
@@ -31,6 +31,7 @@ import { localAsset } from '../data.js';
import { serverNow } from '../store.js'; import { serverNow } from '../store.js';
import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, bossPctText, fmtNum, shopBlockReason } from './gameLogic.js'; import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, bossPctText, fmtNum, shopBlockReason } from './gameLogic.js';
import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js'; import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); 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"> 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" />`} /> <${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} ${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> <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>
</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" <${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon"
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} /> fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
<span class="capsule__kills num"><b>${hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span> <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} ${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
</div>`; </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) * @param {number} n enemies still standing @param {string|null} [name] the leaker's name (null = the viewer)
*/ */
export function missTip(n, name = null) { export function missTip(n, name = null) {
const who = name ? `${name} 漏过的敌人` : '你漏过的敌人'; if (n > 0) return name ? t('{name} 漏过的敌人还剩 {n} 个(联防中)', { name, n }) : t('你漏过的敌人还剩 {n} 个(队友正在迎战)', { n });
return n > 0 ? `${who}还剩 ${n} 个(${name ? '联防中' : '队友正在迎战'})` : `${who}已全部被击倒`; 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 v = Math.max(0, Math.trunc(Number(n) || 0));
const art = localAsset('ui/battle', 'bg_miss_enemy'); 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" 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}> 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> ${art ? null : html`<span class="misstag__icon" aria-hidden="true"><${GIcon} name="skull" />×</span>`}<b class="misstag__n num">${v}</b>
</span>`; </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 } = {}) { export function pendingTip(lp, pending, { unite = false, cap = 10, left = null } = {}) {
if (!(pending > 0)) return null; if (!(pending > 0)) return null;
if (unite && Number.isFinite(left)) { if (unite && Number.isFinite(left)) {
const each = left > cap ? `剩余不足 ${cap} 个后,队友每击倒一个少扣 1 点` : '队友每击倒一个就少扣 1 点'; const each = left > cap ? t('剩余不足 {cap} 个后,队友每击倒一个少扣 1 点', { cap }) : t('队友每击倒一个就少扣 1 点');
return `目标生命值 ${lp}:联防中,你漏过的敌人还剩 ${left} 个,${each};按现在结算扣除 ${pending} 点(每回合至多 ${cap} 点)`; return t('目标生命值 {lp}:联防中,你漏过的敌人还剩 {left} 个,{each};按现在结算扣除 {pending} 点(每回合至多 {cap} 点)', { lp, left, each, pending, cap });
} }
return unite return unite
? `目标生命值 ${lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 ${pending} 点)` ? t('目标生命值 {lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 {pending} 点)', { lp, pending })
: `目标生命值 ${lp}:本回合已有 ${pending >= cap ? `${cap} 个以上` : `${pending} 个`}敌人进入蓝门,结算时扣除 ${pending} 点(每回合至多 ${cap} 点)`; : 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) --------------------------------------------------- // ---- 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). */ /** 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). */ /** Why 准备就绪 is refused while the temp row holds pieces (null when it is empty). */
export function tempReadyReason(priv) { export function tempReadyReason(priv) {
const t = tempInfo(priv); const info = tempInfo(priv);
return t.count ? `临时整备区还有 ${t.count} 个单位:${TEMP_RULE}` : null; 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} 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)}> 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__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> <kbd class="readybtn__key">Space</kbd>
</button>`; </button>`;
return html`<div class="readywrap"> return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn} ${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"> ${!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} <${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">已就绪 <b class="num">${readyCount}</b>/<span class="num">${total}</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>`; </div>`;
} }
@@ -233,13 +235,13 @@ export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
*/ */
export function checkButtons({ pen, penAvail, infoOpen }) { export function checkButtons({ pen, penAvail, infoOpen }) {
const left = pen const left = pen
? { sprite: 'btn_check_player_back', back: true, 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: '本局信息', tip: '本局信息(策略 / 禁用盟约 / 干员)' }; : { sprite: infoOpen ? 'btn_check_player_unfold' : 'btn_check_player_normal', back: false, label: t('本局信息'), tip: t('本局信息(策略 / 禁用盟约 / 干员)') };
const right = pen const right = pen
? { sprite: 'btn_check_enemy_unfold', grey: true, label: '返回', tip: '返回' } ? { sprite: 'btn_check_enemy_unfold', grey: true, label: t('返回'), tip: t('返回') }
: penAvail : penAvail
? { sprite: 'btn_check_enemy', grey: false, label: '敌方情报', tip: '查看即将迎击的敌方单位' } ? { sprite: 'btn_check_enemy', grey: false, label: t('敌方情报'), tip: t('查看即将迎击的敌方单位') }
: { sprite: 'btn_check_enemy_unfold', grey: true, label: '敌方情报', tip: '休整期可以查看即将迎击的敌方单位' }; : { sprite: 'btn_check_enemy_unfold', grey: true, label: t('敌方情报'), tip: t('休整期可以查看即将迎击的敌方单位') };
return { left, right }; return { left, right };
} }
@@ -263,7 +265,7 @@ export function OvertimeWarning({ ot }) {
return html`<div class="otwarn otwarn--pending" role="alert" data-state="pending"> 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" />`} /> <${LocalSprite} name="icon_warn" class="otwarn__icon" fallback=${html`<${Icon} name="warn" class="otwarn__icon" />`} />
<span class="otwarn__tag">DOT</span> <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>`; </div>`;
} }
return html`<div class="otwarn otwarn--drain" role="alert" data-state="drain"> 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" />`} /> <${LocalSprite} name="blood_icon" class="otwarn__icon" fallback=${html`<${Icon} name="rook" class="otwarn__icon" />`} />
</span> </span>
<span class="otwarn__tag">DOT</span> <span class="otwarn__tag">DOT</span>
<span class="otwarn__txt">超时 · 生命值 <b class="num">−${ot.perSec}</b>/秒</span> <span class="otwarn__txt">${tParts('超时 · 生命值 {n}/秒', { n: html`<b class="num">−${ot.perSec}</b>` })}</span>
${ot.lost > 0 ? html`<span class="otwarn__lost">已流失 <b class="num">${ot.lost}</b></span>` : null} ${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> <span key=${ot.secs} class="otwarn__tick num" aria-hidden="true">−${ot.perSec}</span>
</div>`; </div>`;
} }
@@ -287,8 +289,8 @@ function PauseGlyph() {
* @param {{ paused: boolean, busy?: boolean, onToggle: () => void }} props * @param {{ paused: boolean, busy?: boolean, onToggle: () => void }} props
*/ */
export function PauseButton({ paused, busy = false, onToggle }) { export function PauseButton({ paused, busy = false, onToggle }) {
const label = paused ? '继续作战' : '暂停'; const label = paused ? t('继续作战') : t('暂停');
return html`<${Tooltip} text=${paused ? '继续作战(Space)' : '暂停作战(Space)'} placement="bottom"> 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'} <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?.()}> aria-label=${label} disabled=${busy} data-testid="pause" onClick=${() => onToggle?.()}>
${paused ? html`<${Icon} name="play" class="pausebtn__glyph" />` : html`<${PauseGlyph} />`} ${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); const onRight = () => (pen ? onPen(false) : penAvail ? onPen(true) : null);
return html`<header class=${cx('gtop', pen && 'is-pen')}> return html`<header class=${cx('gtop', pen && 'is-pen')}>
<div class="gtop__left"> <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"> <div class="gtop__meta">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} /> <${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null} ${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" /> chev=${btn.left.back ? '◀◀' : null} on=${!!drawer && !pen} onClick=${onLeft} testid="check-player" />
<//> <//>
<div class="roundbox"> <div class="roundbox">
<span class="roundbox__label">回合</span> <span class="roundbox__label">${t('回合')}</span>
<b class="roundbox__num num">${roundText}</b> <b class="roundbox__num num">${roundText}</b>
</div> </div>
<${PhaseCapsule} pub=${pub} hud=${hud} miss=${!boss && Number.isFinite(live?.left) ? live.left : null} /> <${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} <${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"> <${Tooltip} text=${btn.right.tip} placement="bottom">
<${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label} <${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" /> 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. */ /** DP counter shown at the right edge during combat. */
export function DpCounter({ dp }) { export function DpCounter({ dp }) {
if (!Number.isFinite(dp)) return null; 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}> <${GIcon} name="dp" class="dpbox__icon" /><b class="num">${Math.floor(dp)}</b><${MicroLabel}>COST</${MicroLabel}>
</div>`; </div>`;
} }
+5 -3
View File
@@ -15,7 +15,9 @@ import { data } from '../data.js';
import { html } from './components.js'; import { html } from './components.js';
/** Labels of the language switch (each in its own language). */ /** 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'; const PREF_KEY = 'lang';
/** How long boot waits for the UI translations before rendering in Chinese anyway (they apply when they arrive). */ /** How long boot waits for the UI translations before rendering in Chinese anyway (they apply when they arrive). */
const BOOT_WAIT_MS = 2500; const BOOT_WAIT_MS = 2500;
@@ -78,7 +80,7 @@ function applyDocument(lang) {
if (!doc) return; if (!doc) return;
doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN'; doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
doc.documentElement.dataset.lang = lang; 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) // `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
@@ -144,7 +146,7 @@ export function useLang() {
*/ */
export function LangToggle({ class: cls }) { export function LangToggle({ class: cls }) {
const lang = useLang(); 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'} ${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>`)} class=${lang === l ? 'is-on' : ''} data-lang=${l} onClick=${() => { if (lang !== l) switchLang(l); }}>${LANG_LABELS[l]}</button>`)}
</div>`; </div>`;
+19 -18
View File
@@ -10,6 +10,7 @@
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted. // (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 { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { t, N_ } from '../../../shared/i18n.js';
export { MODULE_NONE }; export { MODULE_NONE };
@@ -18,14 +19,14 @@ export const LOADOUT_PREF = 'loadout';
export const LOADOUT_VERSION = 1; export const LOADOUT_VERSION = 1;
export const PROF_ORDER = ['PIONEER', 'WARRIOR', 'TANK', 'SNIPER', 'CASTER', 'MEDIC', 'SUPPORT', 'SPECIAL']; 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 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: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动', 8: '被动' }); 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. */ /** Module attribute keys (ModuleRecord.attr / battle_equip attributeBlackboard) → label + unit. */
export const ATTR_LABEL = Object.freeze({ export const ATTR_LABEL = Object.freeze({
maxHp: ['生命上限', ''], max_hp: ['生命上限', ''], atk: ['攻击力', ''], def: ['防御力', ''], res: ['法术抗性', ''], maxHp: [N_('生命上限'), ''], max_hp: [N_('生命上限'), ''], atk: [N_('攻击力'), ''], def: [N_('防御力'), ''], res: [N_('法术抗性'), ''],
magic_resistance: ['法术抗性', ''], aspd: ['攻击速度', ''], attack_speed: ['攻击速度', ''], cost: ['部署费用', ''], magic_resistance: [N_('法术抗性'), ''], aspd: [N_('攻击速度'), ''], attack_speed: [N_('攻击速度'), ''], cost: [N_('部署费用'), ''],
blockCnt: ['阻挡数', ''], block_cnt: ['阻挡数', ''], respawnTime: ['再部署时间', '秒'], respawn_time: ['再部署时间', '秒'], blockCnt: [N_('阻挡数'), ''], block_cnt: [N_('阻挡数'), ''], respawnTime: [N_('再部署时间'), N_('秒')], respawn_time: [N_('再部署时间'), N_('秒')],
baseAttackTime: ['攻击间隔', '秒'], base_attack_time: ['攻击间隔', '秒'], moveSpeed: ['移动速度', ''], hpRecoveryPerSec: ['每秒回复', ''], baseAttackTime: [N_('攻击间隔'), N_('秒')], base_attack_time: [N_('攻击间隔'), N_('秒')], moveSpeed: [N_('移动速度'), ''], hpRecoveryPerSec: [N_('每秒回复'), ''],
}); });
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v); const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -102,19 +103,19 @@ export function serializeExport(entries, opts) {
export function parseImport(input) { export function parseImport(input) {
let raw = input; let raw = input;
if (typeof raw === 'string') { 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(); const text = raw.trim();
if (!text) return { ok: false, error: '没有可导入的内容' }; if (!text) return { ok: false, error: t('没有可导入的内容') };
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; } 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; const v = isInt(raw.v) ? raw.v : null;
// a newer envelope may reshuffle fields — refuse instead of silently reading it as something else // 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; 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); 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 }; 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 (!effectiveChoice(entries, c, golden).changed) return false;
} }
if (q) { 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(); .filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) return false; if (!hay.includes(q)) return false;
} }
@@ -336,7 +337,7 @@ export function attrRows(attr) {
if (typeof v !== 'number' || !Number.isFinite(v) || v === 0) continue; if (typeof v !== 'number' || !Number.isFinite(v) || v === 0) continue;
const [label, unit] = ATTR_LABEL[k] || [k, '']; const [label, unit] = ATTR_LABEL[k] || [k, ''];
const n = Math.abs(v) < 10 && !Number.isInteger(v) ? Number(v.toFixed(2)) : Math.round(v); 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; return out;
} }
@@ -350,10 +351,10 @@ export function skillTags(rec) {
const passive = rec.skillType === 'PASSIVE' || rec.spType === 'ON_DEPLOY' || rec.spType === 8; 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'; const spKind = passive ? 'passive' : rec.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : rec.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time';
let duration = null; let duration = null;
if (rec.durationType === 'AMMO') duration = '弹药'; if (rec.durationType === 'AMMO') duration = t('弹药');
else if (Number(rec.duration) > 0) duration = `${Number(rec.duration)}秒`; else if (Number(rec.duration) > 0) duration = t('{n}秒', { n: Number(rec.duration) });
return { return {
sp: SP_TYPE[rec.spType] || (passive ? '被动' : '技力'), sp: t(SP_TYPE[rec.spType]) || (passive ? t('被动') : t('技力')),
spKind, spKind,
init: passive ? null : Number.isFinite(rec.initSp) ? rec.initSp : 0, init: passive ? null : Number.isFinite(rec.initSp) ? rec.initSp : 0,
cost: passive ? null : Number.isFinite(rec.spCost) ? rec.spCost : 0, cost: passive ? null : Number.isFinite(rec.spCost) ? rec.spCost : 0,
+4 -4
View File
@@ -22,7 +22,7 @@ import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutM
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js'; import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js'; import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
import { toast } from './toasts.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 SYNC_DEBOUNCE_MS = 500;
export const RETRY_MS = 1500; 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)); const lookup = lookupChess || ((id) => data.lookup('chess', id));
return installPrefSync({ return installPrefSync({
net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout', net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout',
lockedText: '本局的干员调配已锁定,修改将在下一局生效', lockedText: N_('本局的干员调配已锁定,修改将在下一局生效'),
async prepare() { async prepare() {
const current = target.get().entries; const current = target.get().entries;
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download // 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 } = {}) { export function installOwnershipSync({ net, timers, target = loadoutStore, notify } = {}) {
return installPrefSync({ return installPrefSync({
net, timers, target, notify, key: 'notOwned', stateKey: 'ownSync', msgType: 'room.ownership', field: 'notOwned', tag: 'ownership', net, timers, target, notify, key: 'notOwned', stateKey: 'ownSync', msgType: 'room.ownership', field: 'notOwned', tag: 'ownership',
lockedText: '干员持有是局外设置,修改将在下一局生效', lockedText: N_('干员持有是局外设置,修改将在下一局生效'),
prepare: async () => cleanIds(target.get().notOwned), prepare: async () => cleanIds(target.get().notOwned),
}); });
} }
@@ -277,7 +277,7 @@ export function installDiySync({ net, timers, target = loadoutStore, notify } =
}); });
const sync = installPrefSync({ const sync = installPrefSync({
net, timers, target, notify, key: 'diy', stateKey: 'diySync', msgType: 'room.diy', field: 'picks', tag: 'diy', net, timers, target, notify, key: 'diy', stateKey: 'diySync', msgType: 'room.diy', field: 'picks', tag: 'diy',
lockedText: '自选编队是局外设置,修改将在下一局生效', lockedText: N_('自选编队是局外设置,修改将在下一局生效'),
prepare: async () => cleanPicks(target.get().diy), prepare: async () => cleanPicks(target.get().diy),
}); });
return { flush: sync.flush, dispose() { offKit?.(); sync.dispose(); } }; return { flush: sync.flush, dispose() { offKit?.(); sync.dispose(); } };
+20 -19
View File
@@ -12,14 +12,15 @@ import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js'; import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
import { GIcon } from './gameComponents.js'; import { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.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 * 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. * lobby straight away — no settlement screen follows, so the solo text must not promise one.
*/ */
export const EXIT_TEXT = Object.freeze({ export const EXIT_TEXT = Object.freeze({
soloRest: '独立模拟的休整期没有时间限制,你可以随时继续。', soloRest: N_('独立模拟的休整期没有时间限制,你可以随时继续。'),
soloQuit: '放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。', soloQuit: N_('放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。'),
}); });
/** Local "暂离 / AI 托管" flag (the server keeps no per-client autoplay view). */ /** Local "暂离 / AI 托管" flag (the server keeps no per-client autoplay view). */
@@ -67,10 +68,10 @@ export function ExitModal({ open, onClose, solo, onAway }) {
onClose(); onClose();
}; };
if (spectator) { if (spectator) {
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开观战" micro="LEAVE SPECTATING" width="6.8rem" return html`<${Modal} open=${open} onClose=${onClose} tone="red" title=${t('离开观战')} micro="LEAVE SPECTATING" width="6.8rem"
actions=${html`<${Button} variant="secondary" onClick=${onClose}>取消<//> actions=${html`<${Button} variant="secondary" onClick=${onClose}>${t('取消')}<//>
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>离开观战<//>`}> <${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>${t('离开观战')}<//>`}>
<div class="exitm"><p>离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。</p></div> <div class="exitm"><p>${t('离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。')}</p></div>
<//>`; <//>`;
} }
const away = async () => { const away = async () => {
@@ -80,16 +81,16 @@ export function ExitModal({ open, onClose, solo, onAway }) {
onClose(); onClose();
if (ok) { awayStore.set({ away: true }); onAway?.(); } 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` actions=${html`
<${Button} variant="secondary" onClick=${onClose}>取消<//> <${Button} variant="secondary" onClick=${onClose}>${t('取消')}<//>
${!solo ? html`<${Button} variant="ice" icon="robot" loading=${busy === 'away'} onClick=${away}>暂离(AI 托管)<//>` : null} ${!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}>放弃模拟<//>`}> <${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>${t('放弃模拟')}<//>`}>
<div class="exitm"> <div class="exitm">
${solo ${solo
? html`<p>${EXIT_TEXT.soloRest}</p><p class="t-lo">${EXIT_TEXT.soloQuit}</p>` ? html`<p>${t(EXIT_TEXT.soloRest)}</p><p class="t-lo">${t(EXIT_TEXT.soloQuit)}</p>`
: html`<p><b class="t-ice">暂离</b>:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。</p> : html`<p>${tParts('{away}:由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。', { away: html`<b class="t-ice">${t('暂离')}</b>` })}</p>
<p><b class="t-red">放弃模拟</b>:离开后无法返回本局,你的干员将回到共享卡池。</p>`} <p>${tParts('{quit}:离开后无法返回本局,你的干员将回到共享卡池。', { quit: html`<b class="t-red">${t('放弃模拟')}</b>` })}</p>`}
</div> </div>
<//>`; <//>`;
} }
@@ -97,13 +98,13 @@ export function ExitModal({ open, onClose, solo, onAway }) {
/** Full-screen "AI 托管中" overlay with 返回模拟. */ /** Full-screen "AI 托管中" overlay with 返回模拟. */
export function AwayOverlay({ onBack = () => {} }) { export function AwayOverlay({ onBack = () => {} }) {
const [busy, setBusy] = useState(false); 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"> <div class="awayov__box brackets">
<${GIcon} name="robot" class="awayov__icon" /> <${GIcon} name="robot" class="awayov__icon" />
<${MicroLabel} tone="mint">AUTOPILOT // AI 托管</${MicroLabel}> <${MicroLabel} tone="mint">${t('AUTOPILOT // AI 托管')}</${MicroLabel}>
<h2>AI 托管中</h2> <h2>${t('AI 托管中')}</h2>
<p class="t-lo">AI 正在代为操作你的席位</p> <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(); } }}>返回模拟<//> <${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>
</div>`; </div>`;
} }
@@ -116,7 +117,7 @@ export function StepHeader({ step, of, title, micro, pub, total, onExit }) {
const conn = useStore((s) => s.connection, shallowEqual); const conn = useStore((s) => s.connection, shallowEqual);
return html`<header class="stephead"> return html`<header class="stephead">
<div class="stephead__left"> <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"> <div class="stephead__meta">
<${PingPill} ms=${conn.ping} online=${conn.status === 'online'} /> <${PingPill} ms=${conn.ping} online=${conn.status === 'online'} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} />` : null} ${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} />` : null}
+7 -7
View File
@@ -101,7 +101,7 @@ export function MatchBondRow({ title, micro, bonds, model }) {
* @param {{ model: MatchInfoModel }} props * @param {{ model: MatchInfoModel }} props
*/ */
export function MatchLegend({ model }) { 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 }) { export function BannedOperators({ model }) {
const { banned } = model; const { banned } = model;
return html`<div class="brief-banned"> 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.length ? html`<div class="brief-banned__grid">
${banned.map((id) => html`<${UnitThumb} key=${id} kind="chess" id=${id} size="sm" dim=${true} />`)} ${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} /> <${DiyBannedLine} model=${model} />
</div>`; </div>`;
} }
@@ -124,8 +124,8 @@ export function BannedOperators({ model }) {
* @param {{ model: MatchInfoModel }} props * @param {{ model: MatchInfoModel }} props
*/ */
export function MatchInfo({ model }) { export function MatchInfo({ model }) {
return html`<${MatchBondRow} title="核心盟约" micro="CORE BONDS" bonds=${model.core} model=${model} /> return html`<${MatchBondRow} title=${t('核心盟约')} micro="CORE BONDS" bonds=${model.core} model=${model} />
<${MatchBondRow} title="附加盟约" micro="ADD-ON BONDS" bonds=${model.addon} model=${model} /> <${MatchBondRow} title=${t('附加盟约')} micro="ADD-ON BONDS" bonds=${model.addon} model=${model} />
<${MatchLegend} model=${model} /> <${MatchLegend} model=${model} />
<${BannedOperators} model=${model} />`; <${BannedOperators} model=${model} />`;
} }
@@ -136,9 +136,9 @@ export function MatchInfo({ model }) {
* @param {{ open: boolean, onClose: Function, model: MatchInfoModel|null, status?: any }} props * @param {{ open: boolean, onClose: Function, model: MatchInfoModel|null, status?: any }} props
*/ */
export function MatchInfoDialog({ open, onClose, model, status = null }) { 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} 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} ${open && model ? html`<${MatchInfo} model=${model} />` : null}
<//>`; <//>`;
} }
+8 -7
View File
@@ -13,6 +13,7 @@
import { checkNotOwned, OWNERSHIP_LIMITS } from '../../../shared/protocol.js'; import { checkNotOwned, OWNERSHIP_LIMITS } from '../../../shared/protocol.js';
import { isDroppableChess, standInRecord } from '../../../shared/standIn.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. */ /** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const OWNERSHIP_PREF = 'ownership'; export const OWNERSHIP_PREF = 'ownership';
@@ -153,17 +154,17 @@ export const serializeOwnership = (notOwned, opts) => JSON.stringify(exportOwner
export function parseOwnershipImport(input) { export function parseOwnershipImport(input) {
let raw = input; let raw = input;
if (typeof raw === 'string') { 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(); const text = raw.trim();
if (!text) return { ok: false, error: '没有可导入的内容' }; if (!text) return { ok: false, error: t('没有可导入的内容') };
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; } try { raw = JSON.parse(text); } catch { return { ok: false, error: t('无法识别的内容') }; }
} }
if (Array.isArray(raw)) return { ok: true, notOwned: cleanIds(raw) }; 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; 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; const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: '这不是干员持有的数据' }; if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: t('这不是干员持有的数据') };
if (!Array.isArray(raw.notOwned)) return { ok: false, error: '里面没有干员持有的数据' }; if (!Array.isArray(raw.notOwned)) return { ok: false, error: t('里面没有干员持有的数据') };
return { ok: true, notOwned: cleanIds(raw.notOwned) }; return { ok: true, notOwned: cleanIds(raw.notOwned) };
} }
+31 -31
View File
@@ -40,7 +40,7 @@ function PriceHex({ slot, free, poor = false }) {
if (free || price === 0) return html`<span class="scard__free">FREE</span>`; 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 // 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')} 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). */ /** 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) { export function mergeHint(priv, chessId) {
const prog = mergeProgress(priv, chessId, LOOKUPS.getChess); const prog = mergeProgress(priv, chessId, LOOKUPS.getChess);
if (!(prog.copies > 0 && prog.copies + 1 >= prog.need)) return null; 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. */ /** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
function ArmedTag({ reason, free }) { function ArmedTag({ reason, free }) {
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</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 ? '确认选择' : '确认购买'}</b><small>再次点击</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 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 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 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')} 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); }} 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__bg" aria-hidden="true"></span>
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</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" /> <${Img} src=${chessPortraitUrl(m, c)} class="scard__art" />
<span class="scard__top"> <span class="scard__top">
<${TierChip} tier=${tier} size="md" /> <${TierChip} tier=${tier} size="md" />
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} /> <${PriceHex} slot=${slot} free=${free} poor=${reason === t('资金不足')} />
${prog.copies > 0 ? html`<span class="scard__pips" title=${`已拥有 ${prog.copies}/${prog.need}`}> ${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>`)} ${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
</span>` : null} </span>` : null}
</span> </span>
@@ -116,11 +116,11 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
<span>${c?.subProfessionName || ''}</span> <span>${c?.subProfessionName || ''}</span>
</span> </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} ${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null} ${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
</button>`; </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. */ /** 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 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 slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null;
const mod = lo.module && !lo.module.none ? lo.module : 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 || ''}> 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" />`} /> <${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} ${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 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')} 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'); }} 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}> aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span> <span class="scard__bg" aria-hidden="true"></span>
<span class="scard__top"> <span class="scard__top">
<${TierChip} tier=${it?.tier ?? 1} size="md" /> <${TierChip} tier=${it?.tier ?? 1} size="md" />
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} /> <${PriceHex} slot=${slot} free=${free} poor=${reason === t('资金不足')} />
</span> </span>
<span class="scard__itemart"><${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} /></span> <span class="scard__itemart"><${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} /></span>
<span class="scard__body"> <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} ${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null} ${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
</button>`; </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 }) { function SoldCard({ item = false }) {
return html`<div class=${cx('scard', 'scard--sold', item && 'scard--item')} aria-label="已售出"> return html`<div class=${cx('scard', 'scard--sold', item && 'scard--item')} aria-label=${t('已售出')}>
<span class="scard__soldtxt"><${MicroLabel}>SOLD OUT</${MicroLabel}><span>${item ? '已购买' : '已招募'}</span></span> <span class="scard__soldtxt"><${MicroLabel}>SOLD OUT</${MicroLabel}><span>${item ? t('已购买') : t('已招募')}</span></span>
</div>`; </div>`;
} }
@@ -179,14 +179,14 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
const max = lv >= (shop?.maxLevel ?? 6); const max = lv >= (shop?.maxLevel ?? 6);
const price = shop?.upgradePrice ?? 0; 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()} 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)}> 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__frame">
<span class="lvcard__micro">LEVEL</span> <span class="lvcard__micro">LEVEL</span>
<b class="lvcard__num num">${lv}</b> <b class="lvcard__num num">${lv}</b>
</span> </span>
<span class="lvcard__label">${max ? '已满级' : armed ? '确认升级' : '升级'}</span> <span class="lvcard__label">${max ? t('已满级') : armed ? t('确认升级') : t('升级')}</span>
<kbd class="lvcard__key">D</kbd> <kbd class="lvcard__key">D</kbd>
</button>`; </button>`;
} }
@@ -249,11 +249,11 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
<span class="rwtag__micro">${head.micro}</span> <span class="rwtag__micro">${head.micro}</span>
<span class="rwtag__sub">${head.sub}</span> <span class="rwtag__sub">${head.sub}</span>
${head.more ? html`<span class="rwtag__sub rwtag__more">${head.more}</span>` : null} ${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>
<div class="shopbar__rwcards"> <div class="shopbar__rwcards">
${offer.slots.map((s, i) => { ${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 kind = s.kind === 'item' ? 'item' : 'chess';
const reason = shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS }); 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, 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) { if (collapsed) {
return html`<div class="shopbar-tab" ref=${barRef}> return html`<div class="shopbar-tab" ref=${barRef}>
<div class="shopbar-tab__funds"><${CoinGlyph} /><b class="num">${funds}</b></div> <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>`; </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"> <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} <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" />`} /> <${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>
<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" />`} /> <${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
<span>刷新</span> <span>${t('刷新')}</span>
${free > 0 ? html`<span class="toolbtn__free">免费 ×${free}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`} ${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> <kbd>R</kbd>
</button> </button>
</div> </div>
@@ -365,8 +365,8 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
<${CoinGlyph} class="funds__coin" /> <${CoinGlyph} class="funds__coin" />
<b class="funds__num num">${funds}</b> <b class="funds__num num">${funds}</b>
</div> </div>
<span class="funds__label">目前资金</span> <span class="funds__label">${t('目前资金')}</span>
<button type="button" class="funds__collapse" onClick=${() => onCollapse(true)}><${Icon} name="close" />收起</button> <button type="button" class="funds__collapse" onClick=${() => onCollapse(true)}><${Icon} name="close" />${t('收起')}</button>
</div> </div>
</div> </div>
</section>`; </section>`;
+12 -10
View File
@@ -22,6 +22,7 @@ import { EmoteBubble } from './emotes.js';
import { STATUS_META, sortedPlayers } from './gameLogic.js'; import { STATUS_META, sortedPlayers } from './gameLogic.js';
import { MissTag, uniteRemaining } from './hud.js'; import { MissTag, uniteRemaining } from './hud.js';
import { localAsset } from '../data.js'; import { localAsset } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); 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) { export function rowLpTip(lp, cap = 10) {
if (!lp || !(lp.pending > 0)) return null; if (!lp || !(lp.pending > 0)) return null;
if (lp.unite && lp.left != null) return `目标生命值 ${lp.lp},联防中:漏过的敌人还剩 ${lp.left} 个,按现在结算扣除 ${lp.pending} 点(每回合至多 ${cap} 点)`; if (lp.unite && lp.left != null) return t('目标生命值 {lp},联防中:漏过的敌人还剩 {left} 个,按现在结算扣除 {pending} 点(每回合至多 {cap} 点)', { lp: lp.lp, left: lp.left, pending: lp.pending, cap });
return `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点`; 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 if (!t.fieldId) { setOpenPid(null); onWatch(p); return; } // the game screen toasts the reason
setOpenPid((cur) => (cur === p.playerId ? null : p.playerId)); 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) => { ${players.map((p) => {
const self = p.playerId === myId; const self = p.playerId === myId;
const status = p.alive === false ? 'dead' : p.status; 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 offline = p.connected === false && !p.isBot;
const open = !!observe && openPid === p.playerId && !self; const open = !!observe && openPid === p.playerId && !self;
const back = !!observe && self && observe.observing; 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 }); 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')}> 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}> <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} ${self ? html`<span class="team__you"><${Icon} name="user" /></span>` : null}
</button> </button>
<div class="team__info"> <div class="team__info">
<span class="team__name">${p.name || '博士'}</span> <span class="team__name">${p.name || t('博士')}</span>
<div class="team__line"> <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} <${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)} /> tip=${rowLpTip(lp, cap)} />
${lp.left != null ? html`<${MissTag} n=${lp.left} name=${self ? null : p.name || '博士'} />` : null} ${lp.left != null ? html`<${MissTag} n=${lp.left} name=${self ? null : p.name || t('博士')} />` : null}
<${Tooltip} text=${offline ? '连接已断开' : meta.text} placement="right"> <${Tooltip} text=${offline ? 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=${meta.text}> <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}>
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />` ${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} />`} />` : STATUS_SPRITE[status] ? html`<${LocalSprite} name=${STATUS_SPRITE[status]} fallback=${html`<${GIcon} name=${meta.glyph} />`} />`
: html`<${GIcon} name=${meta.glyph} />`} : html`<${GIcon} name=${meta.glyph} />`}
</span> </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> </div>
${open ? html`<button type="button" class="btn btn--primary btn--sm team__ob" ${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" ${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> </div>
${bubble ? html`<${EmoteBubble} key=${bubble.seq} id=${bubble.id} class="team__bubble" />` : null} ${bubble ? html`<${EmoteBubble} key=${bubble.seq} id=${bubble.id} class="team__bubble" />` : null}
</div>`; </div>`;
+13 -11
View File
@@ -29,6 +29,7 @@ import { GIcon } from './gameComponents.js';
import { useTileScreen } from './facingWheel.js'; import { useTileScreen } from './facingWheel.js';
import { localAsset } from '../data.js'; import { localAsset } from '../data.js';
import { GEO } from '../../../shared/constants.js'; import { GEO } from '../../../shared/constants.js';
import { t, tParts } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); 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 half = s * 1.05;
const stop = (e) => e.stopPropagation(); 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" 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"> <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__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" /> <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> </svg>
${actions.retreat ? html`<button type="button" class="uframe__btn uframe__btn--retreat" disabled=${busy} onPointerDown=${stop} ${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}> 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 ? '撤退[Q]' : '撤退'}</span> <${RetreatGlyph} /><span class="uframe__label">${actions.sell != null ? t('撤退[Q]') : t('撤退')}</span>
</button>` : null} </button>` : null}
${actions.sell != null ? html`<button type="button" class="uframe__btn uframe__btn--sell" disabled=${busy} onPointerDown=${stop} ${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" /> <${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" /> <${HexBadge} value=${`+${actions.sell}`} tone="gold" size="sm" class="uframe__price" />
</button>` : null} </button>` : null}
${actions.destroy ? html`<button type="button" class=${cx('uframe__btn', 'uframe__btn--destroy')} disabled=${busy} onPointerDown=${stop} ${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" /> <${PlateIcon} sprite="icon_destory" glyph="trash" tone="destroy" />
<span class="uframe__label">销毁</span> <span class="uframe__label">${t('销毁')}</span>
</button>` : null} </button>` : null}
</div>`; </div>`;
} }
@@ -143,8 +144,8 @@ export function tempRowFrame(a, b, { labelW = 0, gap = 8, vw = Infinity } = {})
*/ */
export function tempRowRule(ready) { export function tempRowRule(ready) {
return 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 pts = f.quad.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const pad = rem * 0.1; 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 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"> return html`<div class="tempnote" aria-hidden="false" data-testid="temp-notice">
<svg class="tempnote__frame" aria-hidden="true"><polygon points=${pts} /></svg> <svg class="tempnote__frame" aria-hidden="true"><polygon points=${pts} /></svg>
${label ? html`<div class=${`tempnote__label is-${f.side}`} style=${style} role="status"> ${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> <span class="tempnote__rule">${tempRowRule(ready)}</span>
</div>` : null} </div>` : null}
</div>`; </div>`;
+4
View File
@@ -180,4 +180,8 @@ test('tools/i18n.mjs: msgids of template literals name their params; the codemod
const scanned = await scanSource("const v = html`<span>${tParts('第 {r} 回合', { r: html`<b>${n}</b>` })}</span>`;", 'x.js'); 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.msgids.map((m) => [m.msgid, m.via]), [['第 {r} 回合', 'tParts']]);
assert.deepEqual(scanned.literals.filter((l) => !l.reason), []); 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), []);
}); });
+1 -1
View File
@@ -114,7 +114,7 @@ describe('#7 the 联防 leak counter (HUD)', () => {
assert.equal(missTip(0, 'Doctor·B'), 'Doctor·B 漏过的敌人已全部被击倒'); 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, name: 'Doctor·B' }).props.title, 'Doctor·B 漏过的敌人还剩 5 个(联防中)');
assert.equal(MissTag({ n: 5 }).props.title, '你漏过的敌人还剩 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', () => { test('the phase capsule carries the official runner tag ×N in 联防 only; the tag reads the uncapped number', () => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe('the drawer shows the watched player\'s 策略 while scouting', () => {
test('InfoTab overrides the band and labels it with the owner (source); the detail card has no 策略 section', () => { 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'); 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, /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'); const panel = read('public/js/ui/detailPanel.js');
assert.doesNotMatch(panel, /key="band"/, 'no 策略 section on the detail card'); assert.doesNotMatch(panel, /key="band"/, 'no 策略 section on the detail card');
assert.doesNotMatch(panel, /'band'/, "CHESS_SECTIONS has no 'band' block"); assert.doesNotMatch(panel, /'band'/, "CHESS_SECTIONS has no 'band' block");
+17 -9
View File
@@ -144,15 +144,15 @@ const calleeName = (call) => {
}; };
const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console'; const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console';
/** Why a literal must not be wrapped (null = wrap it). */ /** Why a literal must not be wrapped (null = wrap it). `aliases`: local name → shared/i18n.js export (`t as tr`). */
function skipReason(node, ancestors) { function skipReason(node, ancestors, aliases = new Map()) {
const parent = ancestors[ancestors.length - 1]; const parent = ancestors[ancestors.length - 1];
for (let i = ancestors.length - 1; i >= 0; i--) { for (let i = ancestors.length - 1; i >= 0; i--) {
const a = ancestors[i]; const a = ancestors[i];
if (a.type === 'CallExpression') { if (a.type === 'CallExpression') {
if (isConsoleCall(a)) return 'console'; if (isConsoleCall(a)) return 'console';
const name = calleeName(a); 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 === 'NewExpression' && a.callee.type === 'Identifier' && /Error$/.test(a.callee.name)) return 'error';
if (a.type === 'ThrowStatement') return 'error'; if (a.type === 'ThrowStatement') return 'error';
@@ -324,17 +324,25 @@ export async function scanSource(src, file = '<src>') {
const msgids = []; const msgids = [];
const literals = []; const literals = [];
const lineOf = (pos) => src.slice(0, pos).split('\n').length; 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 …) // 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)); const ignored = new Set(src.split('\n').map((l, i) => (/i18n-ignore/.test(l) ? i + 1 : 0)).filter(Boolean));
walk(ast, (node, ancestors) => { 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; 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 (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]; 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 }); const via = fnName(node.callee);
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 }); 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') { if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
for (const seg of htmlSegments(node.quasi)) { for (const seg of htmlSegments(node.quasi)) {
@@ -357,7 +365,7 @@ export async function scanSource(src, file = '<src>') {
return true; return true;
} }
const line = lineOf(node.start); const line = lineOf(node.start);
const reason = ignored.has(line) ? 'ignored' : skipReason(node, ancestors); const reason = 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 }); literals.push({ kind: 'str', start: node.start, end: node.end, line, msgid: node.value, params: [], module: !inFunction(ancestors), reason });
return true; return true;
} }
@@ -366,7 +374,7 @@ export async function scanSource(src, file = '<src>') {
if (parent && parent.type === 'TaggedTemplateExpression') return true; if (parent && parent.type === 'TaggedTemplateExpression') return true;
const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw); const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw);
if (!cooked.some((s) => HAN.test(s))) return true; if (!cooked.some((s) => HAN.test(s))) return true;
const reason = ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors); const reason = 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))); 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 }); 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; return true;