Merge branch 'master' into fix/spine-start-anim
This commit is contained in:
577 files changed
+43650
-10558
No files matched your search
@@ -11,6 +11,7 @@ import { useGameData, Img, RichText } from '../ui/gameComponents.js';
|
||||
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
|
||||
import { MatchInfo, matchInfoModel } from '../ui/matchInfo.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { SetupReroll } from '../ui/setupReroll.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
|
||||
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
|
||||
@@ -50,7 +51,7 @@ export function BriefingScreen() {
|
||||
const ready = async () => {
|
||||
if (busy || me?.ready) return;
|
||||
setBusy(true);
|
||||
await actions.infoReady();
|
||||
await actions.infoReady(pub.setupRevision ?? 0);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
@@ -91,6 +92,7 @@ export function BriefingScreen() {
|
||||
<${MatchInfo} model=${info} />
|
||||
</section>
|
||||
</main>
|
||||
<${SetupReroll} pub=${pub} />
|
||||
<footer class="brief__foot">
|
||||
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
|
||||
<div class="brief-ready">
|
||||
@@ -98,7 +100,7 @@ export function BriefingScreen() {
|
||||
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
|
||||
</div>
|
||||
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy}
|
||||
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
|
||||
disabled=${!!me?.ready || !me || !!pub.rerollVote} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
|
||||
</footer>
|
||||
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
|
||||
</div>`;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 潜能 / 练度 controls of the 干员调配 overlay (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜
|
||||
// 满加成」): per operator (by charId — the normal, elite and 自选 forms share them), 潜能 1–6 and the 自持有 练度 tier
|
||||
// (effects.json CHAR_MAP aceffect_char_1…4: 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — their official names and
|
||||
// texts, localized by the data overlay), default 潜能 6 / 精英阶段2-60级. Official: 调度手册 「卫戍协议中干员潜能由自身已
|
||||
// 持有干员潜能决定」 and 「提升已持有的干员,将为卫戍协议中同名的干员提供加成」 (an operator the player does not own fights
|
||||
// at 潜能 1 with no bonus: set by hand for a 特许 one; a NORMAL one not owned is its stand-in, which has neither).
|
||||
// CultivationSelects: two compact selects (a roster row, a 自选 slot card); CultivationSection: the detail's segmented
|
||||
// controls with the tier's effect. Hook-free views (the tests draw them). The settings live in ui/loadoutSync.js
|
||||
// (`ops`, synced with room.loadout); the model is ui/loadoutModel.js opsOf / setOps.
|
||||
|
||||
import { html, MicroLabel } from '../ui/components.js';
|
||||
import { data } from '../data.js';
|
||||
import { opsOf } from '../ui/loadoutModel.js';
|
||||
import { CULTIVATE_EFFECTS, POTENTIAL_MIN, POTENTIAL_MAX } from '../../../shared/potential.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const POTENTIALS = Array.from({ length: POTENTIAL_MAX - POTENTIAL_MIN + 1 }, (_, i) => POTENTIAL_MIN + i);
|
||||
const TIERS = CULTIVATE_EFFECTS.map((_, i) => i);
|
||||
/** Names while effects.json is not loaded (msgids; the data's own names win). */
|
||||
const FALLBACK_NAMES = Object.freeze([N_('未精英化'), N_('精英阶段1'), N_('精英阶段2'), N_('精英阶段2-60级')]);
|
||||
/** The short names a row's select shows (the full official name is its title and the detail's). */
|
||||
const SHORT_NAMES = Object.freeze([N_('未精英化'), N_('精英1'), N_('精英2'), N_('精英2 Lv.60')]);
|
||||
|
||||
/** The official name of 练度 tier `n` (effects.json `name`, localized), else its fallback. */
|
||||
export function cultivateName(n) {
|
||||
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.name || t(FALLBACK_NAMES[n] || '');
|
||||
}
|
||||
|
||||
/** The official effect text of 练度 tier `n` (effects.json `desc`: 「攻击力、防御力和最大生命值+10%」 …), or ''. */
|
||||
export function cultivateDesc(n) {
|
||||
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.desc || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Two compact selects — 潜能 and 练度 — of operator `charId` under the stored settings `ops` (a roster row, a 自选 slot).
|
||||
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: { potential?: number, cultivate?: number }) => void,
|
||||
* note?: string|null }} props `note`: why the settings do not apply now (a 补位 stand-in), shown as the tooltip
|
||||
*/
|
||||
export function CultivationSelects({ charId, ops, onSet, note = null }) {
|
||||
if (!charId) return null;
|
||||
const cur = opsOf(ops, charId);
|
||||
const set = (patch) => onSet(charId, patch);
|
||||
// a setting off the default stands out (mint); the default stays quiet — it is the same on every row
|
||||
return html`<span class=${cx('lo-cult', cur.changed && 'is-changed', note && 'is-moot')} data-char=${charId} title=${note || undefined}>
|
||||
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__pot', cur.potential !== 6 && 'is-off')}>
|
||||
<select value=${String(cur.potential)} aria-label=${t('潜能')} data-cult="potential"
|
||||
onChange=${(e) => set({ potential: Number(e.currentTarget.value) })}>
|
||||
${POTENTIALS.map((n) => html`<option key=${n} value=${String(n)}>${t('潜能 {n}', { n })}</option>`)}
|
||||
</select>
|
||||
</span>
|
||||
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__tier', cur.cultivate !== TIERS.length - 1 && 'is-off')} title=${note ? undefined : cultivateName(cur.cultivate)}>
|
||||
<select value=${String(cur.cultivate)} aria-label=${t('练度')} data-cult="cultivate"
|
||||
onChange=${(e) => set({ cultivate: Number(e.currentTarget.value) })}>
|
||||
${TIERS.map((n) => html`<option key=${n} value=${String(n)}>${t(SHORT_NAMES[n])}</option>`)}
|
||||
</select>
|
||||
</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail's 潜能 / 练度 section: 潜能 1–6 and the four 练度 tiers as segmented buttons, the tier's official effect,
|
||||
* the rule in one line, and — for a chess the player marked not owned — that its stand-in has neither.
|
||||
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: object) => void, standIn?: boolean }} props
|
||||
*/
|
||||
export function CultivationSection({ charId, ops, onSet, standIn = false }) {
|
||||
if (!charId) return null;
|
||||
const cur = opsOf(ops, charId);
|
||||
const set = (patch) => onSet(charId, patch);
|
||||
return html`<section class=${cx('lo-sec', 'lo-sec--cult', standIn && 'is-moot')} aria-label=${t('潜能与练度')} data-char=${charId}>
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('潜能与练度')}<${MicroLabel}>POTENTIAL<//></h3>
|
||||
${cur.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('满潜能 · 精英2 Lv.60')}</span>`}
|
||||
</header>
|
||||
<div class="lo-cult__row">
|
||||
<span class="lo-cult__k">${t('潜能')}</span>
|
||||
<div class="lo-seg lo-seg--pot" role="radiogroup" aria-label=${t('潜能')}>
|
||||
${POTENTIALS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.potential === n ? 'true' : 'false'}
|
||||
class=${cx(cur.potential === n && 'is-on')} data-potential=${n} onClick=${() => set({ potential: n })}><span class="num">${n}</span></button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="lo-cult__row">
|
||||
<span class="lo-cult__k">${t('练度')}</span>
|
||||
<div class="lo-seg lo-seg--tier" role="radiogroup" aria-label=${t('练度')}>
|
||||
${TIERS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.cultivate === n ? 'true' : 'false'}
|
||||
class=${cx(cur.cultivate === n && 'is-on')} data-cultivate=${n} onClick=${() => set({ cultivate: n })}>${cultivateName(n)}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<p class="lo-cult__eff" data-cultivate=${cur.cultivate}><b>${cultivateName(cur.cultivate)}</b> <span>${cultivateDesc(cur.cultivate)}</span></p>
|
||||
<p class="lo-cult__note">${standIn
|
||||
? t('干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效')
|
||||
: t('默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。')}</p>
|
||||
</section>`;
|
||||
}
|
||||
+43
-18
@@ -6,15 +6,19 @@
|
||||
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
|
||||
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
|
||||
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
|
||||
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*).
|
||||
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 /
|
||||
// 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither.
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, Button, TierChip } from '../ui/components.js';
|
||||
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText, skillTags } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { OperatorVoice } from '../ui/operatorVoice.js';
|
||||
import { LoadoutStats } from '../ui/loadoutStats.js';
|
||||
import { CultivationSelects } from './cultivation.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
@@ -38,8 +42,8 @@ function Bonds({ bonds }) {
|
||||
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
|
||||
}
|
||||
|
||||
/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */
|
||||
function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
|
||||
/** One slot: the pick (operator, class, bonds, skill, module, an owned pick's 潜能 / 练度) or an empty slot to fill. */
|
||||
function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = null }) {
|
||||
const D = diyData();
|
||||
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
|
||||
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
|
||||
@@ -73,6 +77,9 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
|
||||
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
|
||||
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
|
||||
</span>
|
||||
${proto ? html`<small class="diy-slot__cult t-dim">${t('原型干员没有潜能与练度')}</small>`
|
||||
: onOps ? html`<span class="diy-slot__cult"><${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} /></span>` : null}
|
||||
<${OperatorVoice} charId=${rec.charId} />
|
||||
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
||||
</div>
|
||||
<div class="diy-slot__acts">
|
||||
@@ -101,14 +108,16 @@ function OptionCard({ m, opt, selected, onSel }) {
|
||||
}
|
||||
|
||||
/** A skill choice of an owned pick (or the locked skill of a prototype). */
|
||||
function SkillRow({ m, s, on, locked, onPick }) {
|
||||
const rec = s.elite || s.normal;
|
||||
function SkillRow({ m, s, on, locked, onPick, level }) {
|
||||
const rec = level === 'elite' ? s.elite || s.normal : s.normal || s.elite;
|
||||
const tags = skillTags(rec);
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-skill=${s.index} disabled=${locked}
|
||||
class=${cx('diy-choice', on && 'is-on')} onClick=${() => onPick(s.index)}>
|
||||
<span class="diy-choice__icon"><${Img} src=${skillRecordIconUrl(m, rec, { empty: false })} fallback=${html`<b class="num">${skillLabel(s.index)}</b>`} /></span>
|
||||
<span class="diy-choice__body">
|
||||
<span class="diy-choice__head"><b class="num">${skillLabel(s.index)}</b><b>${rec?.name || ''}</b>${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
|
||||
<${RichText} as="span" class="diy-choice__desc" text=${s.normal?.descRaw || s.normal?.desc || ''} />
|
||||
<span class="diy-choice__tags"><span>${tags.sp}</span>${!tags.passive ? html`<span>${t('初始')} ${tags.init} · ${t('消耗')} ${tags.cost}</span>` : null}${tags.duration ? html`<span>${tags.duration}</span>` : null}</span>
|
||||
<${RichText} as="span" class="diy-choice__desc" text=${rec?.descRaw || rec?.desc || ''} />
|
||||
</span>
|
||||
</button>`;
|
||||
}
|
||||
@@ -137,13 +146,16 @@ function ModuleRow({ id, rec, on, locked, onPick }) {
|
||||
* @param {{ m: any, slot: any, picks: Record<string, any>, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function DiyPicker(props) {
|
||||
const [previewLevel, setPreviewLevel] = useState('elite');
|
||||
const cur = props.picks[props.slot.slotId] || null;
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const [draft, setDraft] = useState(cur ? { ...cur } : null);
|
||||
// Esc cancels only the picker, like its 取消 (GitHub #284, idea from PR #286); the 干员调配 overlay's own Esc skips
|
||||
// while a picker is open, and a dialog over the picker (导入) still takes Esc first
|
||||
useEffect(() => {
|
||||
// while a picker is open, and a dialog over the picker (导入) still takes Esc first. A layout effect: the listener is
|
||||
// attached in the same commit as the picker (a plain effect waits for the next frame, and an Esc pressed in between
|
||||
// was swallowed — the overlay skipped it, the picker did not hear it yet; the 0.2.2 full browser pass)
|
||||
useLayoutEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || e.ctrlKey || e.metaKey || e.altKey || document.querySelector('.modal')) return;
|
||||
e.preventDefault();
|
||||
@@ -153,14 +165,14 @@ export function DiyPicker(props) {
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [props.onClose]);
|
||||
return DiyPickerView({ ...props, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
|
||||
return DiyPickerView({ ...props, previewLevel, onPreviewLevel: setPreviewLevel, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being
|
||||
* made (null = none chosen yet) and their setters.
|
||||
*/
|
||||
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }) {
|
||||
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft, ops = {}, onOps = null, previewLevel = 'elite', onPreviewLevel = () => {} }) {
|
||||
const D = diyData();
|
||||
const cur = picks[slot.slotId] || null;
|
||||
const options = pickOptions(slot.slotId, picks, D, kitted);
|
||||
@@ -168,12 +180,20 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto)
|
||||
&& (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q))));
|
||||
const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null;
|
||||
const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
|
||||
const sel = (charId) => {
|
||||
if (draft?.charId === charId) return;
|
||||
setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
|
||||
};
|
||||
const lk = ch?.locked || null;
|
||||
const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null;
|
||||
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
|
||||
const unit = draft ? D.backups?.units?.[draft.charId] : null;
|
||||
const label = t('{tier}阶 自选', { tier: slot.tier });
|
||||
const potential = ops[draft?.charId]?.potential ?? 6;
|
||||
const base = draft ? slotRecord(slot.slotId, draft, D, { potential }) : null;
|
||||
const golden = draft ? slotRecord(slot.slotId, draft, D, { elite: true, potential }) : null;
|
||||
const previewEntries = base ? { [base.baseId || base.chessId]: { skill: skillOn, module: modOn || 'none' } } : {};
|
||||
const previewChess = (id) => id === golden?.chessId ? golden : base;
|
||||
return html`<section class="diy-pick" role="dialog" aria-label=${t('{slot}:选择干员', { slot: label })} data-testid="diy-picker" data-slot=${slot.slotId}>
|
||||
<header class="diy-pick__head">
|
||||
<${TierChip} tier=${slot.tier} size="sm" /><b>${label}</b>
|
||||
@@ -201,9 +221,12 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
<span class="diy-pick__wtxt"><b>${unit.name}</b><small>${classLine(unit)}</small></span>
|
||||
<${KindTag} proto=${ch.proto} />
|
||||
</div>
|
||||
${!ch.proto && onOps ? html`<section class="lo-sec"><header class="lo-sec__head"><h3>${t('潜能与练度')}</h3></header><${CultivationSelects} charId=${draft.charId} ops=${ops} onSet=${onOps} /></section>` : null}
|
||||
<${LoadoutStats} base=${base} golden=${golden} entries=${previewEntries} level=${previewLevel}
|
||||
onLevel=${onPreviewLevel} getChess=${previewChess} ops=${ch.proto ? null : ops} />
|
||||
<h4 class="diy-pick__sec">${t('技能')}${ch.proto ? html`<small>${t('原型干员的技能与补位时一致,不可更改')}</small>` : null}</h4>
|
||||
<div role="radiogroup" aria-label=${t('选择技能')} class="diy-pick__choices">
|
||||
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} on=${s.index === skillOn} locked=${ch.proto}
|
||||
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} level=${previewLevel} on=${s.index === skillOn} locked=${ch.proto}
|
||||
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
|
||||
</div>
|
||||
<h4 class="diy-pick__sec">${t('模组')}<small>${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}</small></h4>
|
||||
@@ -226,7 +249,9 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
/**
|
||||
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
|
||||
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
|
||||
* onSet: (slotId: string, pick: any) => void }} props
|
||||
* onSet: (slotId: string, pick: any) => void, ops?: Record<string, any>,
|
||||
* onOps?: ((charId: string, patch: { potential?: number, cultivate?: number }) => void) | null }} props
|
||||
* `ops` / `onOps`: the stored 潜能 / 练度 and their setter (0.2.2 — an owned pick's card shows its selects)
|
||||
*/
|
||||
export function DiyPanel(props) {
|
||||
const [picking, setPicking] = useState(null);
|
||||
@@ -234,7 +259,7 @@ export function DiyPanel(props) {
|
||||
}
|
||||
|
||||
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
|
||||
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) {
|
||||
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking, ops = {}, onOps = null }) {
|
||||
const slots = diySlotList(diyData());
|
||||
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
|
||||
const tiers = [...new Set(slots.map((s) => s.tier))];
|
||||
@@ -247,11 +272,11 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin
|
||||
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
|
||||
<div class="diy-grid">
|
||||
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
|
||||
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)}
|
||||
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} ops=${ops} onOps=${onOps} />`)}
|
||||
</div>
|
||||
</section>`)}
|
||||
</div>
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)}
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} ops=${ops} onOps=${onOps} onClose=${() => setPicking(null)}
|
||||
onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
|
||||
</main>`;
|
||||
}
|
||||
+155
-31
@@ -95,11 +95,11 @@ import { pieceTile } from '../render/drag.js';
|
||||
import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
battleOverSfx, uniteResultBox, battleResultBox,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
terrainInfo,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
|
||||
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -115,8 +115,9 @@ import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
|
||||
import { useDocClass, FullscreenButton } from '../ui/device.js';
|
||||
// MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js.
|
||||
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
|
||||
import { keepEarly } from './game/early.js';
|
||||
import { keepEarly, audioEarly } from './game/early.js';
|
||||
import { MatchEnded, PausedOverlay } from './game/overlays.js';
|
||||
import { inspectRange } from './game/range.js';
|
||||
import { t, tParts } from '../../../shared/i18n.js';
|
||||
|
||||
/** Router for the in-match screens. */
|
||||
@@ -146,6 +147,9 @@ export function GameScreen() {
|
||||
|
||||
// ---- match screen ----------------------------------------------------------------------------------------
|
||||
|
||||
/** A card of a unit of the battle on screen: a unit's, or a crate's / turret's (a device card with its `unitId`, #228). */
|
||||
const inBattleCard = (d) => d?.kind === 'unit' || (d?.kind === 'device' && d.device?.unitId != null);
|
||||
|
||||
function MatchScreen() {
|
||||
useDocClass('sp-in-match');
|
||||
const pub = useStore((s) => s.match.public);
|
||||
@@ -156,6 +160,12 @@ function MatchScreen() {
|
||||
const emotes = useStore((s) => s.emotes);
|
||||
const roomSolo = useStore((s) => s.room?.mode === 'solo');
|
||||
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
|
||||
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
|
||||
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
|
||||
const specFacts = useStore((s) => ({
|
||||
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
|
||||
isHost: !!s.room && s.room.hostId === s.me.playerId,
|
||||
}), shallowEqual);
|
||||
const gd = useGameData();
|
||||
|
||||
const hostRef = useRef(null);
|
||||
@@ -169,6 +179,9 @@ function MatchScreen() {
|
||||
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
|
||||
const [bondsCollapsed, setBondsCollapsed] = useState(false);
|
||||
const [detail, setDetail] = useState(null); // detail target
|
||||
// every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool
|
||||
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
|
||||
const cardTap = useRef(0);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||
@@ -184,6 +197,7 @@ function MatchScreen() {
|
||||
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
|
||||
const [readyBusy, setReadyBusy] = useState(false);
|
||||
const [spBusy, setSpBusy] = useState(null);
|
||||
const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick
|
||||
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
|
||||
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2)
|
||||
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
|
||||
@@ -207,7 +221,9 @@ function MatchScreen() {
|
||||
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
|
||||
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
|
||||
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
|
||||
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
|
||||
const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null;
|
||||
const hasPersonalChoice = !!personalChoice;
|
||||
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice;
|
||||
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
|
||||
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
|
||||
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
|
||||
@@ -271,7 +287,7 @@ function MatchScreen() {
|
||||
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
|
||||
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
|
||||
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
|
||||
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
|
||||
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
|
||||
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
|
||||
@@ -286,6 +302,18 @@ function MatchScreen() {
|
||||
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
|
||||
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
|
||||
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
|
||||
// 准备就绪 folds the shop bar, cancelling it unfolds the bar again (GitHub #138; gameLogic readyShopFold): the board is set,
|
||||
// the fight is what to look at — the board camera follows the fold like a hand-made one (the effect below). Inside the pen
|
||||
// the bar is folded for itself: the state it returns to is the one that changes.
|
||||
const readySeen = useRef(null);
|
||||
useEffect(() => {
|
||||
const cur = phase === PHASE.PREP && priv && alive && !watchingOther ? { round: pub?.round, ready: !!priv.ready } : null;
|
||||
const change = readyShopFold(readySeen.current, cur);
|
||||
readySeen.current = cur;
|
||||
if (!change) return;
|
||||
if (penRef.current.on) penRef.current.collapsed = change === 'fold';
|
||||
else setCollapsed(change === 'fold');
|
||||
}, [phase, pub?.round, priv?.ready, !!priv, alive, watchingOther]);
|
||||
const cancelFacingRef = useRef(() => {});
|
||||
const setCam = useCallback((kind, opts) => {
|
||||
camRef.current = { kind, opts: opts || {} };
|
||||
@@ -335,6 +363,9 @@ function MatchScreen() {
|
||||
const lastFieldRef = useRef(null);
|
||||
const viewModeRef = useRef(null);
|
||||
const snapUnitsRef = useRef(new Map());
|
||||
// the shown battle's device units (crates / “双眼皮” turrets: UnitInfo by id) — the meta's `units` plus the 'spawn' events,
|
||||
// for the tap on a device (gameLogic.deviceTipAt); the renderer's pick skips devices, a tap on one lands in tileClick
|
||||
const deviceUnitsRef = useRef(new Map());
|
||||
const hudRef = useRef(null);
|
||||
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
|
||||
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
|
||||
@@ -372,10 +403,27 @@ function MatchScreen() {
|
||||
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
|
||||
? (row, col) => fieldTile(deployField, row, col)
|
||||
: (row, col) => [row, col];
|
||||
// a battle on screen (not the prep board, not a scouted prep board): its crates and turrets are device UNITS, found by
|
||||
// unit; everything else answers by the stage's own device entries (gameLogic.deviceTipAt / deviceInfo, #228)
|
||||
live.current.deviceBattle = !!shownField && !shownField.prep;
|
||||
const staleFieldRef = useRef(null);
|
||||
const enteredFieldRef = useRef(null);
|
||||
const enteredViewRef = useRef(null);
|
||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||
useEffect(() => {
|
||||
// Read before a view change clears the refs. A later refresh of this same field (g.watch / reshow replaces the
|
||||
// field object) must still know which battle the device units already noted belong to.
|
||||
const previousFieldId = lastFieldRef.current;
|
||||
const previousDevices = deviceUnitsRef.current;
|
||||
if (enteredViewRef.current !== view) {
|
||||
// A replacement engine has no field, even when the match's m.field object did not change. While it loads,
|
||||
// buffer frames instead of sending them to a null/destroyed view; its subscription below asks for fresh meta.
|
||||
enteredViewRef.current = view;
|
||||
enteredFieldRef.current = null;
|
||||
lastFieldRef.current = null;
|
||||
viewModeRef.current = null;
|
||||
reentryRef.current = null;
|
||||
}
|
||||
if (!view) return;
|
||||
if (showPrep) {
|
||||
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
|
||||
@@ -402,15 +450,24 @@ function MatchScreen() {
|
||||
const wanted = combat || mode === 'settle' || watchingOther;
|
||||
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
|
||||
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
|
||||
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
|
||||
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (inBattleCard(d) ? null : d));
|
||||
enteredFieldRef.current = field;
|
||||
lastFieldRef.current = field.fieldId;
|
||||
reentryRef.current = null;
|
||||
viewModeRef.current = 'battle';
|
||||
snapUnitsRef.current = new Map();
|
||||
view.enterBattle(field);
|
||||
const early = evBufRef.current.get(field.fieldId);
|
||||
evBufRef.current.delete(field.fieldId);
|
||||
// Crates and turrets are absent from a start-of-battle meta and arrive once, as spawn events. Re-entering the
|
||||
// same field from a refresh that repeats that meta (the subscription below asks for one) must not drop them.
|
||||
const noted = noteDeviceUnits(new Map(), { units: field.units, events: early });
|
||||
deviceUnitsRef.current = deviceUnitsForEntry(previousDevices, noted, previousFieldId != null && previousFieldId === field.fieldId);
|
||||
// The renderer clears its unit metadata on entry too. Restore known devices there as well as in the card lookup;
|
||||
// otherwise their next HP tuple becomes an unknown enemy whose pieceClick consumes the device's tileClick.
|
||||
const units = Array.isArray(field.units) ? [...field.units] : [];
|
||||
const ids = new Set(units.map((u) => u?.id));
|
||||
for (const [id, info] of deviceUnitsRef.current) if (!ids.has(id)) units.push(info);
|
||||
view.enterBattle({ ...field, units });
|
||||
const earlySnap = snapBufRef.current.get(field.fieldId);
|
||||
snapBufRef.current.delete(field.fieldId);
|
||||
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
|
||||
@@ -434,7 +491,7 @@ function MatchScreen() {
|
||||
if (early && early.length) {
|
||||
// replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong)
|
||||
view.pushEvents(early);
|
||||
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
|
||||
audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast)
|
||||
}
|
||||
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
|
||||
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
|
||||
@@ -446,6 +503,9 @@ function MatchScreen() {
|
||||
}
|
||||
if (earlySnap) {
|
||||
view.pushSnapshot(earlySnap);
|
||||
if (Array.isArray(earlySnap.units)) {
|
||||
snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t]));
|
||||
}
|
||||
hudRef.current = snapHud(earlySnap);
|
||||
setHud(hudRef.current);
|
||||
}
|
||||
@@ -498,6 +558,7 @@ function MatchScreen() {
|
||||
return;
|
||||
}
|
||||
view?.pushEvents(msg);
|
||||
noteDeviceUnits(deviceUnitsRef.current, { events: msg.ev });
|
||||
audio.handleBattleEvents(msg.ev);
|
||||
};
|
||||
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
|
||||
@@ -526,6 +587,15 @@ function MatchScreen() {
|
||||
};
|
||||
const offs = [net.on('m.field', onFieldMeta), net.on('b.snap', onSnap), net.on('b.ev', onEv)];
|
||||
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv), battleRunner.on('result', onResult));
|
||||
// Metadata must include enemies/tokens spawned before this renderer existed, not only events buffered during
|
||||
// loading. Subscribe first so the reshow's metadata and snapshot take the ordinary buffered re-entry path.
|
||||
if (view && !showPrep && field?.fieldId) {
|
||||
if (field.local) battleRunner?.reshow();
|
||||
else if (combat || watchingOther) {
|
||||
const who = watchWho?.fieldId === field.fieldId ? watchWho.playerId : null;
|
||||
net.request('g.watch', { fieldId: field.fieldId, ...(who ? { playerId: who } : {}) }).catch(() => {});
|
||||
}
|
||||
}
|
||||
return () => { for (const off of offs) { try { off(); } catch { /* ignore */ } } clearTimeout(pending); };
|
||||
}, [view]);
|
||||
|
||||
@@ -585,7 +655,7 @@ function MatchScreen() {
|
||||
let asked = null;
|
||||
try { asked = new URLSearchParams(globalThis.location?.search || '').get('render'); } catch { asked = null; }
|
||||
if (asked === 'fallback' || globalThis.__SP_RENDER__ === 'fallback') return;
|
||||
toast(t('当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)'), 'info', { ttl: 5000 });
|
||||
toast(t('渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)'), 'info', { ttl: 5000 });
|
||||
}, [viewKind]);
|
||||
|
||||
// phase changes: banners, sounds, resets
|
||||
@@ -630,7 +700,7 @@ function MatchScreen() {
|
||||
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
|
||||
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
|
||||
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
|
||||
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (d?.kind === 'unit' ? null : d));
|
||||
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (inBattleCard(d) ? null : d));
|
||||
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
|
||||
setSel(null);
|
||||
if (phase !== PHASE.PREP) setRewardMin(false);
|
||||
@@ -650,6 +720,17 @@ function MatchScreen() {
|
||||
}, [phase, pub?.round, priv?.board, view]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null);
|
||||
if (!personalChoice) return;
|
||||
setSel(null);
|
||||
setDetail(null);
|
||||
setArmedCard(null);
|
||||
setDrag(null);
|
||||
view?.highlightTiles(null, null);
|
||||
cancelFacingRef.current();
|
||||
}, [personalChoice?.id, view]);
|
||||
|
||||
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
|
||||
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
|
||||
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
|
||||
@@ -707,6 +788,7 @@ function MatchScreen() {
|
||||
|
||||
// ---- actions ----------------------------------------------------------------------------------------------
|
||||
const buy = useCallback((i) => actions.buy(i), []);
|
||||
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
|
||||
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
|
||||
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
|
||||
const askingReady = useRef(false);
|
||||
@@ -941,13 +1023,24 @@ function MatchScreen() {
|
||||
setSel(wasSel ? null : { uid: e.uid });
|
||||
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
|
||||
}),
|
||||
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
|
||||
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
|
||||
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
|
||||
// a tap on the ground itself: a stage device on the tile (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置: GitHub #228, PR
|
||||
// #229) explains itself first — the renderer's pick skips devices, so the tap lands here —, then a special terrain
|
||||
// tile (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 /
|
||||
// 传送, with the numbers of the stage behind the board. An ordinary tile (road / floor / wall) says nothing, so the
|
||||
// press keeps its other meanings (deselect, close).
|
||||
view.on('tileClick', (t) => {
|
||||
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
|
||||
const L = live.current;
|
||||
const [row, col] = L.terrainTile(t.row, t.col);
|
||||
const device = L.deviceBattle
|
||||
? deviceTipAt(L.terrainStage, row, col, { units: [...deviceUnitsRef.current.values()], snap: snapUnitsRef.current })
|
||||
: deviceInfo(L.terrainStage, row, col);
|
||||
if (device) {
|
||||
audio.sfx('click', { volume: 0.4 });
|
||||
setSel(null);
|
||||
setDetail({ kind: 'device', device });
|
||||
return;
|
||||
}
|
||||
const info = terrainInfo(L.terrainStage, row, col);
|
||||
if (!info) return;
|
||||
audio.sfx('click', { volume: 0.4 });
|
||||
@@ -1014,10 +1107,6 @@ function MatchScreen() {
|
||||
}, []);
|
||||
|
||||
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
|
||||
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
|
||||
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
|
||||
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
|
||||
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]);
|
||||
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
|
||||
const releaseHold = useCallback((uid) => {
|
||||
heldRef.current.delete(uid);
|
||||
@@ -1070,16 +1159,10 @@ function MatchScreen() {
|
||||
useEffect(() => {
|
||||
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
|
||||
}, [editable, placeCtx, facing]);
|
||||
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing)
|
||||
// the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below)
|
||||
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
|
||||
live.current.showPrep = showPrep;
|
||||
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
|
||||
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
|
||||
useEffect(() => {
|
||||
if (!view || !selRangeKey) return undefined;
|
||||
showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE);
|
||||
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
|
||||
}, [view, selRangeKey]);
|
||||
// the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2
|
||||
// item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every
|
||||
// panel anyway (css z-index), this keeps it visible too
|
||||
@@ -1121,6 +1204,13 @@ function MatchScreen() {
|
||||
const t = id != null ? snapUnitsRef.current.get(id) : null;
|
||||
return t ? { hp: t[3], max: t[4] } : null;
|
||||
})();
|
||||
// a crate's / turret's card closes with it: destroyed (HP gone, or its tuple left the snapshots once the die animation
|
||||
// is over — before the first snapshot of a battle nothing is judged)
|
||||
useEffect(() => {
|
||||
const id = detail?.kind === 'device' ? detail.device?.unitId : null;
|
||||
const snap = snapUnitsRef.current;
|
||||
if (id != null && snap.size > 0 && !(snap.get(id)?.[3] > 0)) setDetail(null);
|
||||
}, [hud, detail]);
|
||||
|
||||
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
|
||||
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
|
||||
@@ -1168,6 +1258,14 @@ function MatchScreen() {
|
||||
const L = live.current;
|
||||
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
|
||||
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
|
||||
if (L.hasPersonalChoice) {
|
||||
if (act !== 'escape') e.preventDefault();
|
||||
if (act === 'ready') {
|
||||
audio.sfx('error', { volume: 0.5 });
|
||||
toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn');
|
||||
}
|
||||
return; // ChoiceOverlay handles Escape without cancelling the server's pending choice.
|
||||
}
|
||||
if (act === 'escape') {
|
||||
if (L.emoteOpen) setEmoteOpen(false);
|
||||
else if (L.pen && !L.detail) togglePenRef.current(false);
|
||||
@@ -1259,7 +1357,7 @@ function MatchScreen() {
|
||||
}, [strip.ownerId]);
|
||||
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
|
||||
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
|
||||
// (the runner's field meta is taken before they deploy); their hand is never sent
|
||||
// (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard)
|
||||
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
|
||||
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
|
||||
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
|
||||
@@ -1270,7 +1368,7 @@ function MatchScreen() {
|
||||
const temp = tempInfo(priv);
|
||||
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
|
||||
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
|
||||
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
|
||||
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0);
|
||||
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
|
||||
// and the root font size (1rem)
|
||||
const panelFrame = () => {
|
||||
@@ -1313,6 +1411,23 @@ function MatchScreen() {
|
||||
}
|
||||
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
|
||||
})();
|
||||
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
|
||||
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
|
||||
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
|
||||
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
|
||||
// draw their own
|
||||
const cardRange = !drag && !facing && !pen ? inspectRange({
|
||||
target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field,
|
||||
snapshot: snapUnitsRef.current.get(resolved?.unitId),
|
||||
live: typeof liveStats === 'function' ? liveStats() : liveStats,
|
||||
loadout: detailLoadout, getChess: gd.chess, backups: gd.backups,
|
||||
}) : null;
|
||||
const cardRangeKey = cardRange ? JSON.stringify(cardRange) : '';
|
||||
useEffect(() => {
|
||||
if (!view) return undefined;
|
||||
showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE);
|
||||
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
|
||||
}, [view, cardRangeKey, showPrep, field]);
|
||||
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
|
||||
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
|
||||
// cards read the strip's (ui/watchBonds.js detailBondOwner)
|
||||
@@ -1321,7 +1436,7 @@ function MatchScreen() {
|
||||
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
|
||||
const offBonds = modeOffBonds(getMode(pub?.modeId));
|
||||
|
||||
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
|
||||
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', (sp || hasPersonalChoice) && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
|
||||
data-camera=${pen ? 'pen' : camKind}>
|
||||
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
|
||||
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
|
||||
@@ -1336,7 +1451,8 @@ function MatchScreen() {
|
||||
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
|
||||
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
|
||||
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
|
||||
live=${liveLpNow} spectator=${spectator} />
|
||||
live=${liveLpNow} spectator=${spectator}
|
||||
spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />
|
||||
|
||||
<div class="gm__bonds">
|
||||
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
||||
@@ -1369,7 +1485,7 @@ function MatchScreen() {
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
barRef=${barRef} offBonds=${offBonds}
|
||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
|
||||
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
|
||||
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
|
||||
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
|
||||
@@ -1405,7 +1521,7 @@ function MatchScreen() {
|
||||
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
|
||||
|
||||
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
|
||||
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
|
||||
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} ops=${priv?.ops ?? null} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true}
|
||||
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
|
||||
|
||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||
@@ -1416,6 +1532,14 @@ function MatchScreen() {
|
||||
|
||||
${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total}
|
||||
onPick=${async (i) => { setSpBusy(i); await actions.choice(i); setSpBusy(null); }} />` : null}
|
||||
${personalChoice ? html`<${ChoiceOverlay} key=${personalChoice.id} pub=${pub} sp=${personalChoice} myId=${myId} solo=${solo} personal=${true}
|
||||
busyIdx=${personalBusy?.choiceId === personalChoice.id ? personalBusy.idx : null} total=${total}
|
||||
onPick=${async (i) => {
|
||||
const choiceId = personalChoice.id;
|
||||
setPersonalBusy({ choiceId, idx: i });
|
||||
await actions.choice(i, choiceId);
|
||||
setPersonalBusy((b) => b?.choiceId === choiceId ? null : b);
|
||||
}} />` : null}
|
||||
|
||||
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
|
||||
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}
|
||||
|
||||
@@ -7,3 +7,12 @@ export const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
|
||||
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
|
||||
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
|
||||
export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
|
||||
|
||||
/**
|
||||
* What the SOUND gets of that replay: the 'spawn' tuples alone, which teach the audio who is on the field. The buffered
|
||||
* list is every spawn, die, deploy, status and skill since the field's m.field — up to 1500 of them, while the view was not
|
||||
* showing it — so handing it to the audio would play the old deaths, deploy lines and skill cues all at once on entering
|
||||
* (GitHub PR #292 by @LimitlessHPPK handed over the whole list; its hold of a cast that arrives before its unit,
|
||||
* audio.js `pendingSkill`, is what the live path needs, and `onEv` already forwards the full list).
|
||||
*/
|
||||
export const audioEarly = (early) => (Array.isArray(early) ? early.filter((e) => Array.isArray(e) && e[0] === 'spawn') : []);
|
||||
@@ -0,0 +1,32 @@
|
||||
// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by
|
||||
// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange.
|
||||
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
|
||||
import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js';
|
||||
import { tokenVariantFor } from '../../ui/gameLogic/loadout.js';
|
||||
import { normDir } from '../../ui/facing.js';
|
||||
|
||||
/** Resolve the inspected field unit without granting placement or sale actions. */
|
||||
export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) {
|
||||
if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null;
|
||||
let row, col, dir;
|
||||
if (target?.kind === 'piece' && showPrep) {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e || e.area !== 'board') return null;
|
||||
row = e.row; col = e.col; dir = normDir(e.piece.dir);
|
||||
} else if (target?.kind === 'unit' && !showPrep) {
|
||||
const unit = target.unit;
|
||||
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
|
||||
if (unit.area != null && unit.area !== 'board') return null;
|
||||
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
|
||||
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
||||
row = Math.round(snapshot ? snapshot[2] : unit.y);
|
||||
col = Math.round(snapshot ? snapshot[1] : unit.x);
|
||||
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
|
||||
} else return null;
|
||||
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
|
||||
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
|
||||
: detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn
|
||||
: chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess;
|
||||
const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record);
|
||||
return grid?.length ? { grid, row, col, dir } : null;
|
||||
}
|
||||
+178
-113
@@ -5,12 +5,16 @@
|
||||
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
|
||||
// groups.module — lettered tiles without them).
|
||||
//
|
||||
// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped
|
||||
// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认,
|
||||
// Left: the 112 visible chess as one list (tier / class / bond filters, search, 仅看已调整) — a row per operator, its columns
|
||||
// aligned (0.2.2; the quick choices of PR #301 by @farridge, laid out as one list instead of three columns of cards — the
|
||||
// owner's decision of 2026-10-08): portrait, name and bonds (a click opens the detail), the skills and the elite's modules
|
||||
// to tap (不装备 first), the 潜能 and 练度 selects (screens/cultivation.js). Right: the selected chess — its 潜能 / 练度,
|
||||
// its 特质 (the in-match card's block, PR #301), skills (icon, name, 默认,
|
||||
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and
|
||||
// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail
|
||||
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
|
||||
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
|
||||
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. The detail's 普通 / 精锐 skill toggle is one
|
||||
// preview for the skills, the 特质 and the rows' skill details (display only: never stored).
|
||||
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
|
||||
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
|
||||
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
|
||||
@@ -22,19 +26,25 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
|
||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
|
||||
import { chessLoadout } from '../ui/gameLogic.js';
|
||||
import { GarrisonBlock } from '../ui/detailPanel.js';
|
||||
import { data, useData, localAsset, DATA_FILES } from '../data.js';
|
||||
import { useStore } from '../store.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import {
|
||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, quickSkillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
opsOf, setOps, resetOps, moduleRecord,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
||||
import { OperatorVoice } from '../ui/operatorVoice.js';
|
||||
import { CultivationSelects, CultivationSection } from './cultivation.js';
|
||||
import { cultivationCharIds } from '../../../shared/protocol.js';
|
||||
import { atPotential } from '../../../shared/potential.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
||||
import { DiyPanel, diyData } from './diy.js';
|
||||
import { LoadoutStats } from '../ui/loadoutStats.js';
|
||||
export { LoadoutStats, statsPreview } from '../ui/loadoutStats.js';
|
||||
import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
|
||||
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
@@ -140,38 +150,96 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) {
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// ---- roster card ---------------------------------------------------------------------------------------------------
|
||||
// ---- roster rows -----------------------------------------------------------------------------------------------------
|
||||
//
|
||||
// One list, one row per operator, its columns aligned (the head row names them): the quick choices of PR #301 (by
|
||||
// @farridge) without its three columns of cards. Every row is the same few columns — tap a skill, a module, a 潜能 / 练度 —
|
||||
// so the eye runs down a column; the portrait and name open the detail (on a phone it slides over; a quick choice keeps
|
||||
// the list). Hook-free (the tests draw it).
|
||||
|
||||
function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) {
|
||||
const choice = effectiveChoice(entries, chess, golden);
|
||||
const opt = chessOptions(chess, golden);
|
||||
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
|
||||
const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null;
|
||||
const modChanged = golden && choice.module !== opt.defaultModule;
|
||||
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-chess=${chess.chessId}
|
||||
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', choice.changed && 'is-changed')} onClick=${() => onPick(chess.chessId)}
|
||||
title=${`${chess.name} · ${skillRec?.name || ''}`}>
|
||||
<span class="lo-card__art">
|
||||
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
|
||||
</span>
|
||||
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
|
||||
${choice.changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
|
||||
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')} aria-label=${t('未持有')}>${t('替补')}</span>` : null}
|
||||
<span class="lo-card__name">${chess.name}</span>
|
||||
<span class="lo-card__kit">
|
||||
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
|
||||
<${SkillIcon} m=${m} rec=${skillRec} index=${choice.skill} size="xs" />
|
||||
<b class="num">${skillLabel(choice.skill)}</b>
|
||||
</span>
|
||||
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : t('不装备模组')}>
|
||||
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
|
||||
</span>` : null}
|
||||
</span>
|
||||
/** One skill to tap: its icon, the equipped one ringed; the label / tooltip says slot, name, SP and duration (quickSkillTags). */
|
||||
function QuickSkill({ m, opt, on, elite, onPick }) {
|
||||
const rec = elite ? opt.elite || opt.normal : opt.normal || opt.elite;
|
||||
const tags = quickSkillTags(rec);
|
||||
const label = `${skillLabel(opt.index)} · ${rec?.name || t('未知技能')} · ${tags.sp} · ${t('初始')} ${tags.init ?? '—'} / ${t('消耗')} ${tags.cost ?? '—'} · ${t('持续')} ${tags.duration}`;
|
||||
return html`<button type="button" class=${cx('lo-q', 'lo-q--skill', on && 'is-on')} data-skill=${opt.index} aria-pressed=${on ? 'true' : 'false'}
|
||||
aria-label=${label} title=${label} onClick=${() => onPick(opt.index)}>
|
||||
<${SkillIcon} m=${m} rec=${rec} index=${opt.index} on=${on} size="q" />
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** One module to tap (不装备 first): the type icon with its letter in the corner; the elite's equipped one ringed. */
|
||||
function QuickModule({ m, opt, on, onPick }) {
|
||||
const label = opt.id === MODULE_NONE ? t('不装备模组') : `${opt.rec?.typeName || ''} · ${opt.rec?.name || opt.id}`;
|
||||
return html`<button type="button" class=${cx('lo-q', 'lo-q--mod', on && 'is-on')} data-module=${opt.id} aria-pressed=${on ? 'true' : 'false'}
|
||||
aria-label=${label} title=${label} onClick=${() => onPick(opt.id)}>
|
||||
<${ModuleGlyph} m=${m} rec=${opt.rec} id=${opt.id} size="q" />
|
||||
${opt.id !== MODULE_NONE ? html`<b class="lo-q__type num" aria-hidden="true">${moduleBadge(opt.rec, opt.id)}</b>` : null}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** The list's column heads (the rows share its grid). */
|
||||
export function RosterHead() {
|
||||
return html`<div class="lo-list__head" aria-hidden="true">
|
||||
<span class="lo-list__h lo-list__h--op">${t('干员')}</span>
|
||||
<span class="lo-list__h lo-list__h--skills">${t('技能')}</span>
|
||||
<span class="lo-list__h lo-list__h--mods" title=${t('模组仅在精锐形态生效')}>${t('模组')}<small>${t('精锐')}</small></span>
|
||||
<span class="lo-list__h lo-list__h--cult">${t('潜能')} · ${t('练度')}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One operator's row (`.lo-card`, like the cards before it): portrait + name + bonds (`.lo-card__pick`: selects it, opens
|
||||
* the detail), three skill slots (an empty one when the chess has two skills), the elite's modules, the 潜能 / 练度 selects.
|
||||
* `level` 'elite': the skills' details at 精锐 Lv.7 (the shared preview). A chess marked not owned (干员持有) shows the
|
||||
* 「替补」 tag: its stand-in fights with a fixed skill and no 潜能 / 练度, the choices here apply once it is owned again.
|
||||
*/
|
||||
export function RosterRow({ m, chess, golden, entries, ops = {}, selected, onPick, onChange, onOps, level = 'normal', notOwned = false }) {
|
||||
const choice = effectiveChoice(entries, chess, golden);
|
||||
const opt = chessOptions(chess, golden);
|
||||
const cv = opsOf(ops, chess.charId);
|
||||
const changed = choice.changed || cv.changed;
|
||||
const modules = [...opt.moduleOptions].sort((a, b) => Number(b.id === MODULE_NONE) - Number(a.id === MODULE_NONE));
|
||||
const elite = level === 'elite' && !!golden;
|
||||
const slots = [0, 1, 2].map((i) => opt.skillOptions[i] || null);
|
||||
const standInNote = notOwned ? t('未持有(干员持有):由替补干员上场,替补干员没有潜能与练度') : null;
|
||||
return html`<div role="listitem" data-chess=${chess.chessId} data-variant=${elite ? 'elite' : 'normal'}
|
||||
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', changed && 'is-changed', notOwned && 'is-standin')}>
|
||||
<button type="button" class="lo-card__pick" aria-pressed=${selected ? 'true' : 'false'} title=${chess.name} onClick=${() => onPick(chess.chessId)}>
|
||||
<span class="lo-card__art">
|
||||
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
|
||||
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
|
||||
</span>
|
||||
<span class="lo-card__id">
|
||||
<span class="lo-card__name">${chess.name}</span>
|
||||
<span class="lo-card__bonds">${(chess.bonds || []).map((b) => html`<${Img} key=${b} src=${bondIconUrl(m, b)} class="lo-card__bond"
|
||||
alt=${data.lookup('bonds', b)?.name || b} fallback=${html`<i class="lo-bond__dot" title=${data.lookup('bonds', b)?.name || b}></i>`} />`)}</span>
|
||||
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')}>${t('替补')}</span>` : null}
|
||||
</span>
|
||||
</button>
|
||||
${changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
|
||||
<div class="lo-card__skills lo-quick" role="group" aria-label=${t('选择技能')}>
|
||||
${slots.map((sk, i) => (sk ? html`<${QuickSkill} key=${sk.index} m=${m} opt=${sk} elite=${elite} on=${sk.index === choice.skill}
|
||||
onPick=${(skill) => onChange(chess.chessId, { skill })} />` : html`<span key=${`e${i}`} class="lo-q lo-q--empty" aria-hidden="true"></span>`))}
|
||||
</div>
|
||||
<div class="lo-card__mods lo-quick" role="group" aria-label=${t('选择模组')} title=${t('模组仅在精锐形态生效')}>
|
||||
${golden ? modules.map((mo) => html`<${QuickModule} key=${mo.id} m=${m} opt=${mo} on=${mo.id === choice.module}
|
||||
onPick=${(module) => onChange(chess.chessId, { module })} />`) : null}
|
||||
</div>
|
||||
<${CultivationSelects} charId=${chess.charId} ops=${ops} onSet=${onOps} note=${standInNote} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- detail --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** The same attribute records and rich-text renderer as the in-match card, including literal trigger markers. */
|
||||
export function LoadoutGarrisons({ chess, m }) {
|
||||
const records = (chess?.garrisonIds || []).map((id) => data.lookup('garrisons', id)).filter(Boolean);
|
||||
return records.length ? html`<section class="lo-garrisons" data-variant=${chess.isGolden ? 'elite' : 'normal'}>
|
||||
${records.map((garrison) => html`<${GarrisonBlock} key=${garrison.garrisonId} garrison=${garrison} m=${m} />`)}
|
||||
</section>` : null;
|
||||
}
|
||||
|
||||
function SkillOption({ m, opt, on, level, onPick }) {
|
||||
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
|
||||
const tags = skillTags(rec);
|
||||
@@ -198,9 +266,16 @@ function SkillOption({ m, opt, on, level, onPick }) {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
export function ModuleInfo({ m, golden, opt }) {
|
||||
/**
|
||||
* The chosen module's card: its stat bonus, the 特性 it fights with, its talent changes — read at the operator's 潜能
|
||||
* (`potential`, 0.2.2: the elite composed at it first, shared/potential.js atPotential, as 局内数值 and the battle do; the
|
||||
* data record is the full-potential one — 余's 闲云隐市 「生命上限2%(+0.5%)」 is 「1.5%」 at 潜能1). null = full potential.
|
||||
* @param {{ m: any, golden: any, opt: { id: string, rec: any, isDefault?: boolean }, potential?: number|null }} props
|
||||
*/
|
||||
export function ModuleInfo({ m, golden, opt, potential = null }) {
|
||||
if (!golden) return null;
|
||||
const rec = opt.id === MODULE_NONE ? null : opt.rec;
|
||||
const at = atPotential(golden, potential);
|
||||
const rec = opt.id === MODULE_NONE ? null : at === golden ? opt.rec : moduleRecord(at, opt.id) || opt.rec;
|
||||
if (!rec) {
|
||||
const traitBase = golden.traitBase || null;
|
||||
return html`<div class="lo-minfo lo-minfo--none">
|
||||
@@ -227,67 +302,19 @@ export function ModuleInfo({ m, golden, opt }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const getChessRec = (id) => data.lookup('chess', id);
|
||||
/** The name of a chess's 补位 stand-in (data/backups.json), or null. */
|
||||
const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charId]?.name ?? null;
|
||||
|
||||
/**
|
||||
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
|
||||
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
|
||||
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
|
||||
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
|
||||
* stats. Nothing is recomputed here.
|
||||
* @param {any} base normal chess record @param {any} golden its elite record or null
|
||||
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
|
||||
* @param {(id: string) => any} getChess
|
||||
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
|
||||
*/
|
||||
export function statsPreview(base, golden, entries, level, getChess) {
|
||||
const elite = level === 'elite' && !!golden;
|
||||
const chess = elite ? golden : base;
|
||||
if (!chess) return null;
|
||||
const lo = chessLoadout(chess, entries, getChess);
|
||||
const record = lo?.record || chess;
|
||||
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
|
||||
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
|
||||
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
|
||||
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
|
||||
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
|
||||
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
|
||||
*/
|
||||
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) {
|
||||
const pv = statsPreview(base, golden, entries, level, getChess);
|
||||
if (!pv) return null;
|
||||
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
|
||||
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
|
||||
</div>
|
||||
</header>
|
||||
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
|
||||
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
|
||||
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
|
||||
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||
</div>` : null}
|
||||
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) {
|
||||
const [level, setLevel] = useState('normal');
|
||||
function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset, locked, level = 'normal', onLevel, notOwned = false }) {
|
||||
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
|
||||
const setLevel = onLevel;
|
||||
const bodyRef = useRef(null);
|
||||
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
|
||||
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">${t('没有符合条件的干员')}</p></aside>`;
|
||||
const opt = chessOptions(chess, golden);
|
||||
const choice = effectiveChoice(entries, chess, golden);
|
||||
const changed = choice.changed || opsOf(ops, chess.charId).changed;
|
||||
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
|
||||
const lv = (c) => c?.status?.skillLevel ?? '—';
|
||||
return html`<aside class="lo-detail" aria-label=${t('{name} 调配', { name: chess.name })}>
|
||||
@@ -297,7 +324,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
||||
</div>
|
||||
<div class="lo-dhead__info">
|
||||
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
|
||||
${choice.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
|
||||
${changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
|
||||
<h2 class="lo-dhead__name">${chess.name}</h2>
|
||||
<span class="lo-dhead__en">${chess.appellation || ''}</span>
|
||||
<span class="lo-dhead__class">
|
||||
@@ -307,9 +334,12 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
||||
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
|
||||
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
|
||||
</div>
|
||||
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
|
||||
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!changed} onClick=${onReset}>${t('恢复默认')}<//>
|
||||
</div>
|
||||
<div class="lo-detail__body" ref=${bodyRef}>
|
||||
<${OperatorVoice} charId=${chess.charId} />
|
||||
<${CultivationSection} charId=${chess.charId} ops=${ops} onSet=${onOps} standIn=${notOwned} />
|
||||
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
|
||||
<section class="lo-sec">
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
|
||||
@@ -323,7 +353,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
||||
onPick=${(i) => onChange({ skill: i })} />`)}
|
||||
</div>
|
||||
</section>
|
||||
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} />
|
||||
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} ops=${ops} level=${statLevel} onLevel=${setStatLevel} />
|
||||
${golden ? html`<section class="lo-sec lo-sec--mod">
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('模组')}<${MicroLabel}>MODULE<//></h3>
|
||||
@@ -341,7 +371,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
||||
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">${t('默认')}</span>` : null}
|
||||
</button>`)}
|
||||
</div>
|
||||
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
|
||||
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} potential=${opsOf(ops, chess.charId).potential} />` : null}
|
||||
</section>` : null}
|
||||
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />${standInName(chess) ? t('干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效', { name: standInName(chess) })
|
||||
: t('干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效')}</p>` : null}
|
||||
@@ -433,7 +463,7 @@ export function DataMissing({ files }) {
|
||||
|
||||
/** The overlay screen. */
|
||||
function LoadoutScreen({ st }) {
|
||||
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
|
||||
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups', 'garrisons', 'effects');
|
||||
const phase = useStore((s) => s.match?.public?.phase || null);
|
||||
const inMatch = useStore((s) => !!s.room?.inMatch);
|
||||
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
|
||||
@@ -448,14 +478,16 @@ function LoadoutScreen({ st }) {
|
||||
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
|
||||
.sort((a, b) => (b.isCore ? 1 : 0) - (a.isCore ? 1 : 0) || (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || String(a.name).localeCompare(String(b.name), 'zh'));
|
||||
}, [ready, roster]);
|
||||
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond);
|
||||
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond, st.ops);
|
||||
const selId = st.sel && roster.some((c) => c.chessId === st.sel) ? st.sel : list[0]?.chessId || roster[0]?.chessId || null;
|
||||
const { base, golden } = selId ? recordsOf(selId, getChess) : { base: null, golden: null };
|
||||
const nChanged = changedCount(st.entries, getChess);
|
||||
const nChanged = changedCount(st.entries, getChess, st.ops, roster);
|
||||
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
|
||||
const gridRef = useRef(null);
|
||||
const fileRef = useRef(null); // hidden <input type=file> of the 导入 dialog
|
||||
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
|
||||
// the detail's 普通 / 精锐 skill toggle: one preview for its skills, its 特质 and the rows' skill details (display only)
|
||||
const [previewLevel, setPreviewLevel] = useState('normal');
|
||||
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
|
||||
|
||||
const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
|
||||
@@ -479,12 +511,30 @@ function LoadoutScreen({ st }) {
|
||||
if (ok) setNotOwned([]);
|
||||
};
|
||||
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
|
||||
// a row's quick choice (PR #301): the same setChoice / setEntries path; the row becomes the selection, a phone stays on the list
|
||||
const quickChange = (id, patch) => {
|
||||
const recs = recordsOf(id, getChess);
|
||||
setEntries(setChoice(loadoutStore.get().entries, recs.base, recs.golden, patch));
|
||||
loadoutStore.set({ sel: id });
|
||||
};
|
||||
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
|
||||
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
|
||||
// 0.2.2: an operator's 潜能 / 练度 (by charId: its normal, elite and 自选 forms share them)
|
||||
const setOpsOf = (charId, patch) => setOpsMap(setOps(loadoutStore.get().ops, charId, patch));
|
||||
const resetOne = () => {
|
||||
if (!base) return;
|
||||
setOpsMap(resetOps(loadoutStore.get().ops, base.charId));
|
||||
setEntries(resetChoice(loadoutStore.get().entries, base.chessId));
|
||||
};
|
||||
const resetAll = async () => {
|
||||
if (!nChanged) return;
|
||||
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能与模组恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
|
||||
if (ok) setEntries({});
|
||||
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
|
||||
if (!ok) return;
|
||||
// the roster's operators only: a 自选 pick's settings belong to the 自选编队 tab
|
||||
const rosterChars = new Set(roster.map((c) => c.charId));
|
||||
const keep = {};
|
||||
for (const [id, e] of Object.entries(loadoutStore.get().ops || {})) if (!rosterChars.has(id)) keep[id] = e;
|
||||
setOpsMap(keep);
|
||||
setEntries({});
|
||||
};
|
||||
|
||||
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
|
||||
@@ -495,7 +545,7 @@ function LoadoutScreen({ st }) {
|
||||
const openExport = () => setIo(tab === 'ownership'
|
||||
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
|
||||
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
|
||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
|
||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries, { ops: loadoutStore.get().ops }) });
|
||||
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
|
||||
const ioCopy = async () => {
|
||||
const ok = await copyText(ioText);
|
||||
@@ -536,13 +586,17 @@ function LoadoutScreen({ st }) {
|
||||
}
|
||||
const res = parseImport(ioText);
|
||||
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
|
||||
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
|
||||
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
|
||||
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
|
||||
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
|
||||
const { applied, dropped } = r;
|
||||
const nOps = r.ops ?? 0;
|
||||
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
|
||||
if (!applied) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
|
||||
if (!applied && !nOps) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
|
||||
setIo(null);
|
||||
toast(dropped
|
||||
? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
|
||||
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
|
||||
toast(!applied ? t('已导入 {n} 名干员的潜能与练度', { n: nOps })
|
||||
: dropped ? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
|
||||
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
|
||||
};
|
||||
|
||||
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
|
||||
@@ -554,8 +608,10 @@ function LoadoutScreen({ st }) {
|
||||
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
|
||||
if (typing) return;
|
||||
// (a row's quick choices: ←/→ stay with the focused group, PR #301)
|
||||
if (e.target?.closest?.('.lo-quick') && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) return;
|
||||
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
|
||||
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
|
||||
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond, loadoutStore.get().ops).map((c) => c.chessId);
|
||||
if (!ids.length) return;
|
||||
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
|
||||
const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length];
|
||||
@@ -577,7 +633,7 @@ function LoadoutScreen({ st }) {
|
||||
|
||||
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
|
||||
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,干员等级不可调整');
|
||||
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整');
|
||||
// 干员持有 is out of match: a running match keeps the list its seat had at its start
|
||||
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
|
||||
const ownText = ownLocked ? t('干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效')
|
||||
@@ -631,20 +687,25 @@ function LoadoutScreen({ st }) {
|
||||
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}</p>`}
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}</div>`
|
||||
: lost.length ? html`<${DataMissing} files=${lost} />` : tab === 'diy'
|
||||
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
|
||||
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} ops=${st.ops} onOps=${setOpsOf} />`
|
||||
: tab === 'ownership'
|
||||
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
|
||||
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
||||
<section class="lo-roster">
|
||||
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
|
||||
<div class="lo-grid" role="listbox" aria-label=${t('干员列表')} ref=${gridRef}>
|
||||
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
|
||||
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
|
||||
<div class="lo-list" ref=${gridRef}>
|
||||
<${RosterHead} />
|
||||
<div class="lo-list__rows" role="list" aria-label=${t('干员列表')}>
|
||||
${list.length ? list.map((c) => html`<${RosterRow} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
|
||||
entries=${st.entries} ops=${st.ops} selected=${c.chessId === selId} onPick=${pick} onChange=${quickChange} onOps=${setOpsOf}
|
||||
level=${previewLevel} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="lo-detail-wrap">
|
||||
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />${t('干员列表')}</button>
|
||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
|
||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} ops=${st.ops} onChange=${change} onOps=${setOpsOf} onReset=${resetOne} locked=${locked}
|
||||
level=${golden ? previewLevel : 'normal'} onLevel=${setPreviewLevel}
|
||||
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
|
||||
</div>
|
||||
</main>`}
|
||||
@@ -711,11 +772,13 @@ export function LoadoutHost() {
|
||||
/**
|
||||
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
|
||||
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
|
||||
* download). Review fix: it counted stale entries the screen does not.
|
||||
* download). Review fix: it counted stale entries the screen does not. With `ops` (0.2.2) the operators whose 潜能 / 练度
|
||||
* differ count too (before chess.json: the stored entries plus the stored settings, an operator possibly twice).
|
||||
* @param {Record<string, any>} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
|
||||
* @param {Record<string, any>|null} [ops] @param {any[]|null} [roster] rosterOf(…) of the loaded data
|
||||
*/
|
||||
export function badgeCount(entries, getChess) {
|
||||
return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length;
|
||||
export function badgeCount(entries, getChess, ops = null, roster = null) {
|
||||
return getChess ? changedCount(entries, getChess, ops, roster) : Object.keys(entries || {}).length + Object.keys(ops || {}).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -725,9 +788,11 @@ export function badgeCount(entries, getChess) {
|
||||
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = t('干员调配') }) {
|
||||
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
|
||||
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
|
||||
const ops = useStore((s) => s.ops, Object.is, loadoutStore);
|
||||
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
|
||||
const diy = useStore((s) => s.diy, Object.is, loadoutStore);
|
||||
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
|
||||
const chessReady = data.status('chess') === 'ready';
|
||||
const n = badgeCount(entries, chessReady ? (id) => data.lookup('chess', id) : null, ops, chessReady ? rosterOf(data.list('chess')) : null);
|
||||
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
|
||||
const off = Array.isArray(notOwned) ? notOwned.length : 0;
|
||||
// 0.2.0 自选编队: how many DIY slots the player filled
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
|
||||
// config.json mode texts come localized from data.js.
|
||||
|
||||
import { ResumeMatchButton } from '../ui/resumeMatch.js';
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { SettingsButton } from '../ui/settings.js';
|
||||
import { PwaInstallButton } from '../ui/device.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
|
||||
@@ -305,21 +309,28 @@ export function LobbyScreen() {
|
||||
<div class="topbar__left">
|
||||
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}<//>
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
<span class="topbar__sep" aria-hidden="true"></span>
|
||||
<div class="topbar__tools">
|
||||
<${SettingsButton} class="lobby-settings" variant="secondary" square=${true} />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" square=${true} />
|
||||
<${PwaInstallButton} class="lobby-pwa" square=${true} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
|
||||
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${ResumeMatchButton} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
<button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>
|
||||
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
|
||||
<div class="me-chip__text">
|
||||
<span class="me-chip__text">
|
||||
<span class="me-chip__name">${me.name || t('博士')}</span>
|
||||
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<${Icon} name="chart" class="me-chip__stats" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -103,12 +103,22 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/** RESULT screen. */
|
||||
/** RESULT screen of the match on screen: reads the store; 返回同盟 / 返回大厅 drops the match. */
|
||||
export function ResultScreen() {
|
||||
const res = useStore((s) => s.match.result);
|
||||
const pub = useStore((s) => s.match.public);
|
||||
const myId = useStore((s) => s.me.playerId);
|
||||
const hasRoom = useStore((s) => !!s.room);
|
||||
const back = () => store.set({ match: emptyMatch() });
|
||||
return html`<${ResultView} res=${res} pub=${pub} myId=${myId} backLabel=${hasRoom ? t('返回同盟') : t('返回大厅')} onBack=${back} />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settlement view of one m.result payload — the live screen above, and the stats page's re-view of a stored match
|
||||
* (screens/stats.js, which hands it the payload recordToResult rebuilt; `quiet`: no settlement jingle).
|
||||
* @param {{ res: any, pub?: any, myId?: string|null, backLabel: string, onBack: () => void, quiet?: boolean }} props
|
||||
*/
|
||||
export function ResultView({ res, pub = null, myId = null, backLabel, onBack, quiet = false }) {
|
||||
const gd = useGameData();
|
||||
const r = normalizeResult(res, pub);
|
||||
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
|
||||
@@ -118,8 +128,7 @@ export function ResultScreen() {
|
||||
for (const p of r.players) if (Number.isFinite(p.stats[k]) && p.stats[k] > 0 && (top == null || p.stats[k] > top.v)) top = { v: p.stats[k], id: p.playerId };
|
||||
if (top && r.players.length > 1) best[k] = top.id;
|
||||
}
|
||||
useEffect(() => { audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
|
||||
const back = () => store.set({ match: emptyMatch() });
|
||||
useEffect(() => { if (!quiet) audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
|
||||
const boss = r.bossId ? gd.boss(r.bossId) : null;
|
||||
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
|
||||
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
|
||||
@@ -131,6 +140,7 @@ export function ResultScreen() {
|
||||
<div class="result__grid" aria-hidden="true"></div>
|
||||
<main class="result__main">
|
||||
<section class="result__hero">
|
||||
<div class="result__summary">
|
||||
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
|
||||
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
|
||||
<h1 class="result__headline">${r.victory ? t('模拟完成') : t('模拟失败')}</h1>
|
||||
@@ -149,8 +159,9 @@ export function ResultScreen() {
|
||||
<span class="medal__label">${t('隐秘核心')}</span></div>` : null}
|
||||
</div>
|
||||
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
|
||||
</div>
|
||||
<footer class="result__foot">
|
||||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')}<//>
|
||||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
|
||||
</footer>
|
||||
</section>
|
||||
<section class="result__players">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
|
||||
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
|
||||
// copy link, ready toggle and leave.
|
||||
// host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with
|
||||
// copy code / copy link, ready toggle and leave.
|
||||
//
|
||||
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
|
||||
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { SettingsButton } from '../ui/settings.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
|
||||
@@ -69,6 +71,19 @@ export function roomFacts(room, myId) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and
|
||||
* 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off
|
||||
* when the server sends none) and whether this player may switch it — the host only, the others see it read-only.
|
||||
* @param {any} room room.state payload
|
||||
* @param {any} myId
|
||||
* @returns {{ on: boolean, editable: boolean } | null}
|
||||
*/
|
||||
export function aiLastOption(room, myId) {
|
||||
if (!room || room.mode === 'solo') return null;
|
||||
return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId };
|
||||
}
|
||||
|
||||
/** Invite link for a room code (current page URL with ?room=CODE). */
|
||||
export function inviteLink(code) {
|
||||
const loc = globalThis.location;
|
||||
@@ -189,6 +204,21 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */
|
||||
function AiLastToggle({ option, busy, onToggle }) {
|
||||
if (!option) return null;
|
||||
return html`<div class="ailast">
|
||||
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}>
|
||||
<button type="button" role="switch" aria-checked=${option.on ? 'true' : 'false'}
|
||||
class=${`dpick__opt ailast__opt${option.on ? ' is-active' : ''}`} disabled=${!option.editable || !!busy}
|
||||
onClick=${() => option.editable && onToggle(!option.on)}>
|
||||
<${Icon} name="check" class=${option.on ? 'is-on' : ''} />${t('AI 队友最后选择')}
|
||||
</button>
|
||||
<//>
|
||||
${option.editable ? null : html`<span class="t-dim">${t('由创建者设置')}</span>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Room screen component. */
|
||||
export function RoomScreen() {
|
||||
const room = useStore((s) => s.room);
|
||||
@@ -229,6 +259,7 @@ export function RoomScreen() {
|
||||
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
|
||||
};
|
||||
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
|
||||
const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on }));
|
||||
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
|
||||
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
|
||||
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
|
||||
@@ -277,6 +308,8 @@ export function RoomScreen() {
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
<${MicroLabel}>${t('当前延迟')}<//>
|
||||
</div>
|
||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||
<${SettingsButton} class="room-settings" variant="secondary" label=${t('设置')} />
|
||||
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
@@ -307,7 +340,10 @@ export function RoomScreen() {
|
||||
<footer class="room-bar">
|
||||
<div class="room-bar__left">
|
||||
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
|
||||
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
|
||||
<div class="room-bar__opts">
|
||||
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
|
||||
<${AiLastToggle} option=${aiLastOption(room, me.playerId)} busy=${busy} onToggle=${setAiLast} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="room-bar__center">
|
||||
<div class="ready-count" hidden=${!coop}>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// The local stats viewer (统计数据): this browser's record of the matches the player took part in — see ui/stats.js for
|
||||
// what is recorded, what counts and how the storage is bounded. A full-screen overlay opened imperatively from the
|
||||
// title screen / lobby / room (`openStats()`); <StatsHost/> is mounted once by main.js. Ported from PR #323 by
|
||||
// @2321Robin; reworked for 0.2.2 in the grammar of 干员调配 (top bar with tabs, a note line, a scrolling body).
|
||||
//
|
||||
// 总览 – games, wins, win rate, hidden-core reaches / clears, best round, time played; the difficulties; the six
|
||||
// 评语 (titles) with their counts; what counts and what was left out
|
||||
// 策略 – per strategy (band): games used / passed (self alive on a winning team) / pass rate
|
||||
// 战斗累计 – the player's summed combat bookkeeping of the settled matches
|
||||
// 对局记录 – every stored match, newest first. A settled row re-views that match's settlement screen (the record is
|
||||
// inverted into an m.result payload for <ResultView/>); a match given up early is marked 中途退出 and,
|
||||
// when it did not count, 不计入统计
|
||||
// Everything is 本机数据 (localStorage, `sp.pref.stats`) and the page says so, teammates' names included. 导出 / 导入 (a
|
||||
// JSON file, merged by record id on the way in) moves it between devices; 清空 resets it after a confirm dialog.
|
||||
// The host renders nothing until opened and the game data files the replay needs load only then (the lobby alone never
|
||||
// downloads them): the overview reads config / bands / assets only.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
|
||||
import { html, Icon, MicroLabel, Spinner, Button, confirmDialog } from '../ui/components.js';
|
||||
import { useGameData, makeLookups, BandIcon, Img } from '../ui/gameComponents.js';
|
||||
import { data, useData } from '../data.js';
|
||||
import { fmtNum } from '../ui/gameLogic.js';
|
||||
import { titleIconUrl } from '../ui/assetUrls.js';
|
||||
import { useDocClass } from '../ui/device.js';
|
||||
import { createStore, useStore, selectRoute } from '../store.js';
|
||||
import { ResultView } from './result.js';
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import {
|
||||
loadStats, saveStats, emptyStats, importStats, exportStats, aggregateStats, countsTowardStats, selfRowOf, roundsOf,
|
||||
recordToResult, MAX_RECORDS, FULL_KEEP, IMPORT_MAX_CHARS,
|
||||
} from '../ui/stats.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
/** Rows of 对局记录 shown at first and added by each 显示更多. */
|
||||
export const HISTORY_PAGE = 50;
|
||||
|
||||
const statsStore = createStore({ open: false, tab: 'overview' });
|
||||
|
||||
/** Open the stats viewer (title screen / lobby / room entry points). */
|
||||
export function openStats() {
|
||||
data.load('config');
|
||||
data.load('bands');
|
||||
data.load('assets');
|
||||
statsStore.set({ open: true, tab: 'overview' });
|
||||
}
|
||||
export const closeStats = () => statsStore.set({ open: false });
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', name: N_('总览'), micro: 'OVERVIEW' },
|
||||
{ id: 'bands', name: N_('策略'), micro: 'STRATEGIES' },
|
||||
{ id: 'totals', name: N_('战斗累计'), micro: 'COMBAT TOTALS' },
|
||||
{ id: 'history', name: N_('对局记录'), micro: 'HISTORY' },
|
||||
];
|
||||
|
||||
const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—');
|
||||
const dateTime = (ms) => {
|
||||
if (!ms) return '—';
|
||||
const d = new Date(ms);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
};
|
||||
/** Time played: minutes up to 99, then hours with one decimal. */
|
||||
function playedText(ms) {
|
||||
const mins = Math.round((ms || 0) / 60000);
|
||||
return mins < 100 ? t('{n} 分', { n: fmtNum(mins) }) : t('{n} 小时', { n: (mins / 60).toFixed(1) });
|
||||
}
|
||||
const difficultyName = (d) => (DIFFICULTY_NAMES[d] ? t(DIFFICULTY_NAMES[d]) : t('未知'));
|
||||
|
||||
/** Save `text` as a download. Silent no-op when the browser refuses downloads. */
|
||||
function downloadText(filename, text) {
|
||||
try {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
|
||||
function readFileText(file) {
|
||||
if (typeof file?.text === 'function') return file.text();
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result ?? ''));
|
||||
fr.onerror = () => reject(fr.error || new Error('read failed'));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** `stronghold-stats-20261008-2130.json` */
|
||||
function exportFilename(now = new Date()) {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `stronghold-stats-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
}
|
||||
|
||||
/** One labelled number card. */
|
||||
function StatCard({ label, micro, value, tone }) {
|
||||
return html`<div class=${cx('st-card', tone && `st-card--${tone}`)}>
|
||||
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
|
||||
<b class="st-card__num num">${value}</b>
|
||||
<span class="st-card__label">${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function Section({ title, micro, children, class: cls }) {
|
||||
return html`<section class=${cx('st-sec', cls)}>
|
||||
<h3 class="st-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
|
||||
${children}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/** 总览: cards, the per-difficulty table, the six titles, and the line about what counts. */
|
||||
function Overview({ agg, gd }) {
|
||||
const rows = DIFFICULTIES.filter((d) => agg.byDifficulty[d]).concat(Object.keys(agg.byDifficulty).filter((d) => !DIFFICULTIES.includes(d)));
|
||||
const titles = (Array.isArray(gd.config?.titles) ? gd.config.titles : []).map((x) => ({ ...x, count: agg.titles[x.id]?.count || 0 }));
|
||||
return html`<${Section} title=${t('总览')} micro="OVERVIEW">
|
||||
<div class="st-cards">
|
||||
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} />
|
||||
<${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" />
|
||||
<${StatCard} micro="WIN RATE" value=${pct(agg.wins, agg.count)} label=${t('胜率')} tone="gold" />
|
||||
<${StatCard} micro="HIDDEN CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} />
|
||||
<${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} />
|
||||
<${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} />
|
||||
<${StatCard} micro="TIME PLAYED" value=${playedText(agg.duration.totalMs)} label=${t('累计时长')} />
|
||||
</div>
|
||||
<p class="st-split"><span>${t('单人')} <b class="num">${fmtNum(agg.byMode.solo)}</b></span><span>${t('同盟')} <b class="num">${fmtNum(agg.byMode.coop)}</b></span></p>
|
||||
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
|
||||
<thead><tr><th>${t('难度')}</th><th class="num">${t('局数')}</th><th class="num">${t('胜场')}</th><th class="num">${t('胜率')}</th><th class="num">${t('隐秘核心通关')}</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map((d) => {
|
||||
const v = agg.byDifficulty[d];
|
||||
return html`<tr key=${d}>
|
||||
<td class="st-diff" style=${`--d-color:${DIFFICULTY_COLORS[d] || 'var(--text-lo)'}`}>${difficultyName(d)}</td>
|
||||
<td class="num">${fmtNum(v.games)}</td>
|
||||
<td class="num">${fmtNum(v.wins)}</td>
|
||||
<td class="num">${pct(v.wins, v.games)}</td>
|
||||
<td class="num">${fmtNum(v.hiddenCleared)}</td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>` : null}
|
||||
${titles.length ? html`<h4 class="st-sub">${t('称号')}</h4>
|
||||
<div class="st-titles">
|
||||
${titles.map((x) => html`<div key=${x.id} class=${cx('st-title', !x.count && 'is-none')} title=${x.text || ''}>
|
||||
<${Img} src=${titleIconUrl(gd.m, x.picId)} class="st-title__icon" fallback=${html`<${Icon} name="crown" />`} />
|
||||
<div class="st-title__text"><span>${x.name}</span><b class="num">${fmtNum(x.count)}</b></div>
|
||||
</div>`)}
|
||||
</div>` : null}
|
||||
<p class="st-rule"><${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded
|
||||
? html` <b>${t('另有 {n} 局未计入统计', { n: agg.excluded })}</b>` : null}</p>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */
|
||||
function Bands({ agg, gd }) {
|
||||
const rows = Object.entries(agg.bands).sort((a, b) => (b[1].games - a[1].games) || (b[1].wins - a[1].wins));
|
||||
return html`<${Section} title=${t('策略')} micro="STRATEGIES">
|
||||
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
|
||||
<thead><tr><th>${t('策略')}</th><th class="num">${t('使用局数')}</th><th class="num">${t('通过局数')}</th><th class="num">${t('通过率')}</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map(([bandId, v]) => {
|
||||
const band = gd.band(bandId);
|
||||
const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0;
|
||||
return html`<tr key=${bandId}>
|
||||
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</span></td>
|
||||
<td class="num">${fmtNum(v.games)}</td>
|
||||
<td class="num">${fmtNum(v.wins)}</td>
|
||||
<td class="num st-rate"><span class="st-bar" aria-hidden="true"><i style=${`width:${rate}%`}></i></span><b>${pct(v.wins, v.games)}</b></td>
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
const SUM_ROWS = [
|
||||
['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['dmgDealt', N_('造成伤害')], ['activatedLayers', N_('盟约层数')],
|
||||
['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')],
|
||||
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
|
||||
];
|
||||
|
||||
/** 战斗累计: the self player's sums over the settled matches. */
|
||||
function CombatSums({ agg }) {
|
||||
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
|
||||
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
|
||||
${rows.length ? html`<div class="st-sums">
|
||||
${rows.map(([k, label]) => html`<div key=${k} class="st-sum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
|
||||
</div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** One 对局记录 row: what it was, who won, whether it counted and whether the settlement can be re-viewed. */
|
||||
function HistoryRow({ rec, gd, onOpen }) {
|
||||
const self = selfRowOf(rec);
|
||||
const quit = rec.end === 'quit';
|
||||
const counted = countsTowardStats(rec);
|
||||
const won = self ? !!self.victory : !!rec.victory;
|
||||
const band = self?.bandId ? gd.band(self.bandId) : null;
|
||||
const rounds = roundsOf(rec);
|
||||
const known = Number.isFinite(self?.roundsPassed) || Number.isFinite(rec.roundsPassed);
|
||||
const view = !quit && !rec.compact; // a quit has nothing settled; a compacted record no rows left
|
||||
const open = () => onOpen(rec);
|
||||
return html`<tr class=${cx(quit ? 'is-quit' : won ? 'is-win' : 'is-lose', !counted && 'is-excluded', view && 'is-clickable')}
|
||||
title=${view ? t('点击查看该局结算') : !counted ? t('没有清完第一个回合就退出,不计入统计') : quit ? t('中途退出的对局没有结算可看') : t('较早的对局只保留汇总,不能回看结算')}
|
||||
tabIndex=${view ? 0 : undefined}
|
||||
onClick=${view ? open : undefined} onKeyDown=${view ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}>
|
||||
<td class="st-t">${dateTime(rec.t)}</td>
|
||||
<td>${difficultyName(rec.difficulty)}</td>
|
||||
<td>${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'}</td>
|
||||
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'}</span></td>
|
||||
<td class="st-res">${quit ? html`<span class="t-quit">${t('中途退出')}</span>` : html`<span class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</span>`}
|
||||
${!counted ? html`<em class="st-chip">${t('不计入统计')}</em>` : null}</td>
|
||||
<td class="num">${known ? fmtNum(rounds) : '—'}</td>
|
||||
<td class="st-go" aria-hidden="true">${view ? html`<${Icon} name="chevronRight" />` : null}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
/** 对局记录: every stored match, newest first, HISTORY_PAGE rows at a time. */
|
||||
function History({ records, gd, onOpen }) {
|
||||
const [shown, setShown] = useState(HISTORY_PAGE);
|
||||
const rows = records.slice(0, shown);
|
||||
return html`<${Section} title=${t('对局记录')} micro=${`HISTORY · ${records.length}`}>
|
||||
${rows.length ? html`<div class="st-tablewrap"><table class="st-table st-table--history">
|
||||
<thead><tr><th>${t('时间')}</th><th>${t('难度')}</th><th>${t('模式')}</th><th>${t('策略')}</th><th>${t('结果')}</th><th class="num">${t('回合')}</th><th aria-hidden="true"></th></tr></thead>
|
||||
<tbody>${rows.map((r) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)}</tbody>
|
||||
</table></div>
|
||||
${records.length > shown ? html`<div class="st-more"><${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}<//></div>` : null}
|
||||
<p class="st-hint">${t('点击一行,回看该局结算;中途退出的对局只留记录。')} ${t('最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。', { max: MAX_RECORDS, full: FULL_KEEP })}</p>`
|
||||
: html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/** A stored match re-viewed as its settlement screen: the full game data is loaded now (the lobby alone never loads it). */
|
||||
function Replay({ rec, onBack }) {
|
||||
const gd = useGameData();
|
||||
const res = useMemo(() => recordToResult(rec), [rec]);
|
||||
if (!gd.ready) return html`<div class="st-loading"><${Spinner} size="md" /></div>`;
|
||||
return html`<${ResultView} res=${res} pub=${null} myId=${rec.selfId} backLabel=${t('返回统计')} onBack=${onBack} quiet=${true} />`;
|
||||
}
|
||||
|
||||
/** The overlay, rendered while open (its data hooks run only then). */
|
||||
function StatsScreen({ tab }) {
|
||||
const ready = useData('config', 'bands', 'assets');
|
||||
const gd = useMemo(() => makeLookups(ready), [ready, data.locale()]);
|
||||
const [stats, setStats] = useState(() => loadStats());
|
||||
const [replay, setReplay] = useState(null);
|
||||
const boxRef = useRef(null);
|
||||
const fileRef = useRef(null);
|
||||
const replayRef = useRef(null);
|
||||
replayRef.current = replay;
|
||||
const inMatch = useStore((s) => selectRoute(s) === 'game');
|
||||
useDocClass('sp-stats-open');
|
||||
|
||||
// a match starting (the host pressed 开始) takes the player to its briefing: the overlay must not sit on top of it
|
||||
useEffect(() => { if (inMatch) closeStats(); }, [inMatch]);
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || document.querySelector('.modal')) return; // a confirm dialog on top takes the Esc
|
||||
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||
if (replayRef.current) setReplay(null); else closeStats();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
const timer = setTimeout(() => boxRef.current?.focus?.(), 30);
|
||||
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
const agg = useMemo(() => aggregateStats(stats.records), [stats]);
|
||||
const reload = () => setStats(loadStats());
|
||||
|
||||
const doExport = () => {
|
||||
try {
|
||||
const payload = exportStats(stats);
|
||||
downloadText(exportFilename(), JSON.stringify(payload));
|
||||
toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
const doFile = async (e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
e.currentTarget.value = ''; // allow re-picking the same file
|
||||
if (!file) return;
|
||||
if (file.size > IMPORT_MAX_CHARS) { toast(t('文件过大,无法导入'), 'error'); return; }
|
||||
try {
|
||||
const raw = JSON.parse(await readFileText(file));
|
||||
const { stats: merged, added, skipped, dropped } = importStats(raw, loadStats());
|
||||
if (added && !saveStats(merged)) { toast(t('无法写入本机存储,可能已满'), 'error'); return; }
|
||||
reload();
|
||||
const tail = (skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '') + (dropped ? t('({n} 条无法读取)', { n: dropped }) : '');
|
||||
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail }) : t('没有新记录(全部与现有数据重复)'), 'success');
|
||||
} catch (err) {
|
||||
toast(err?.code === 'stats-newer-version' ? t('导入失败:这份文件由更新版本的客户端导出,请先升级客户端') : t('导入失败:不是有效的统计导出文件'), 'error');
|
||||
}
|
||||
};
|
||||
const doClear = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t('清空统计数据'), micro: 'CLEAR STATISTICS', danger: true, okText: t('清空'),
|
||||
text: t('将删除本机保存的 {n} 条对局记录,无法恢复。', { n: stats.records.length }),
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!saveStats(emptyStats())) { try { globalThis.localStorage?.removeItem('sp.pref.stats'); } catch { /* ignore */ } }
|
||||
reload();
|
||||
toast(t('已清空本机统计数据'), 'success');
|
||||
};
|
||||
|
||||
if (replay) return html`<div class="st st--replay" ref=${boxRef} tabindex="-1" role="dialog" aria-modal="true" aria-label=${t('统计数据')}>
|
||||
<${Replay} rec=${replay} onBack=${() => setReplay(null)} />
|
||||
</div>`;
|
||||
|
||||
const tabInfo = TABS.find((x) => x.id === tab) || TABS[0];
|
||||
const empty = !stats.records.length;
|
||||
return html`<div class="st" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
|
||||
<div class="st__bg" aria-hidden="true"></div>
|
||||
<header class="st-top">
|
||||
<div class="st-top__left">
|
||||
<${Button} variant="ghost" size="md" icon="chevronLeft" class="st-back" onClick=${closeStats} aria-label=${t('返回')} title=${t('返回 (Esc)')}>${t('返回')}<//>
|
||||
</div>
|
||||
<div class="st-top__center">
|
||||
<${MicroLabel} tone="mint">STATISTICS // ${tabInfo.micro}<//>
|
||||
<div class="st-tabs" role="tablist" aria-label=${t('统计数据')}>
|
||||
${TABS.map((x) => html`<button key=${x.id} type="button" role="tab" aria-selected=${x.id === tab ? 'true' : 'false'} data-tab=${x.id}
|
||||
class=${cx('st-tab', x.id === tab && 'is-on')} onClick=${() => statsStore.set({ tab: x.id })}>${t(x.name)}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-top__right">
|
||||
<${Button} variant="ghost" size="sm" data-testid="stats-export" disabled=${empty || stats.newer} onClick=${doExport} title=${t('导出为文件(可在别的设备上导入)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="stats-import" disabled=${stats.newer} onClick=${() => fileRef.current?.click()} title=${t('从导出的文件导入(与现有记录合并)')}>${t('导入')}<//>
|
||||
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
|
||||
<${Button} variant="secondary" size="sm" data-testid="stats-clear" disabled=${empty || stats.newer} onClick=${doClear} title=${t('清空本机的统计数据')}>${t('清空')}<//>
|
||||
</div>
|
||||
</header>
|
||||
<p class="st-note"><${Icon} name="info" />${t('本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。')}</p>
|
||||
${stats.damaged ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。')}</p>` : null}
|
||||
${stats.dropped ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('有 {n} 条记录已损坏,已忽略。', { n: stats.dropped })}</p>` : null}
|
||||
<main class="st-body" data-tab=${tab}>
|
||||
${!ready ? html`<div class="st-loading"><${Spinner} size="md" /></div>`
|
||||
: stats.newer ? html`<p class="st-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
|
||||
: empty ? html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
|
||||
: tab === 'bands' ? html`<${Bands} agg=${agg} gd=${gd} />`
|
||||
: tab === 'totals' ? html`<${CombatSums} agg=${agg} />`
|
||||
: tab === 'history' ? html`<${History} records=${stats.records} gd=${gd} onOpen=${setReplay} />`
|
||||
: html`<${Overview} agg=${agg} gd=${gd} />`}
|
||||
</main>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Global stats viewer host — mounted once by main.js; renders nothing (and loads nothing) until opened. */
|
||||
export function StatsHost() {
|
||||
const { open, tab } = useStore((s) => s, Object.is, statsStore);
|
||||
if (!open) return null;
|
||||
return html`<${StatsScreen} tab=${tab} />`;
|
||||
}
|
||||
|
||||
@@ -10,15 +10,17 @@
|
||||
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
|
||||
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
|
||||
|
||||
import { ResumeMatchButton } from '../ui/resumeMatch.js';
|
||||
import { useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual } from '../store.js';
|
||||
import { data, useData } from '../data.js';
|
||||
import { FullscreenButton, detectFeatures } from '../ui/device.js';
|
||||
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js';
|
||||
import { LangToggle, useLang } from '../ui/lang.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
import { scriptOf } from '../../../shared/i18nPacks.js';
|
||||
@@ -245,7 +247,10 @@ export function TitleScreen() {
|
||||
</div>
|
||||
<div class="title-corner title-corner--tr">
|
||||
<div>
|
||||
<${LangToggle} class="title-lang" />
|
||||
<div class="title-corner__tools">
|
||||
<${Button} variant="ghost" size="sm" icon="chart" class="title-stats" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
|
||||
<${LangToggle} class="title-lang" />
|
||||
</div>
|
||||
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,6 +288,8 @@ export function TitleScreen() {
|
||||
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
|
||||
|
||||
<footer class="title-foot">
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} />
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
|
||||
Reference in new issue
Block a user