feedback5 WK: 自选编队 in the browser — the 干员调配 overlay's third tab 自选编队 (5阶 ×2, 6阶 ×2 slots; the slot picker lists the kitted picks of the tier with avatar, name, class, derived bonds and a 原型 / 已持有 tag; an owned pick chooses its skill and module — 集成战略 modules left out —, a prototype shows its locked skill; sp.pref.diy, export / import, 全部清空, synced as room.diy through the shared engine, welcome.diyKitted kept), the operator on shop / reward cards, own pieces (「自选」 tag, its model on bench and board), the detail card (pick's skill and module), placement and range previews, bond popups (own and a teammate's 自选 member), the result screen; new UI strings through t() with English; the e2e fast server's SP_START_LEVEL hook
Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged. Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass).
This commit is contained in:
1 parent
e480b1cb26
commit
0def7babfd
31 files changed
+1637
-78
No files matched your search
@@ -0,0 +1,244 @@
|
||||
// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05): the third tab of the 干员调配 overlay
|
||||
// (screens/loadout.js). Four slots — 5阶 ×2, 6阶 ×2 — each filled with an operator: a 6★ the player owns outside the
|
||||
// chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (原型干员, its skill
|
||||
// and module locked as when it stands in). Only operators with a kit are listed (the server's welcome.diyKitted). The
|
||||
// slot picker lists the legal picks of the slot's tier — avatar, name, class, the bonds derived from its factions, a
|
||||
// 原型 / 已持有 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-*).
|
||||
|
||||
import { 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 } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
|
||||
/** The data the 自选 rules read (shared/diy.js): chess.json + backups.json (localized copies). */
|
||||
export const diyData = () => ({ chess: data.get('chess'), backups: data.get('backups') });
|
||||
|
||||
/** A unit summary's avatar / portrait URL (a backups.json unit or a composed record). */
|
||||
const avatarOf = (m, unit, elite = false) => chessAvatarUrl(m, unit && { charId: unit.charId, assets: { avatar: elite ? unit.assets?.avatarGolden : unit.assets?.avatar } });
|
||||
const classLine = (u) => `${t(PROF_NAME[u?.profession] || '') || ''}${u?.subProfessionName ? ` · ${u.subProfessionName}` : ''}`;
|
||||
const bondName = (id) => data.lookup('bonds', id)?.name || id;
|
||||
|
||||
/** The 原型 / 已持有 tag of a pick. */
|
||||
function KindTag({ proto }) {
|
||||
return html`<span class=${cx('diy-tag', proto ? 'diy-tag--proto' : 'diy-tag--own')}>${proto ? t('原型') : t('已持有')}</span>`;
|
||||
}
|
||||
|
||||
/** Bond chips (the bonds derived from the operator's factions; 协防 when none matches). */
|
||||
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 }) {
|
||||
const D = diyData();
|
||||
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
|
||||
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
|
||||
const label = t('{tier}阶 自选', { tier: slot.tier });
|
||||
if (!rec) {
|
||||
return html`<div class=${cx('diy-slot', `diy-slot--t${slot.tier}`, 'is-empty', pick && 'is-bad')} data-slot=${slot.slotId}>
|
||||
<button type="button" class="diy-slot__fill" onClick=${() => onOpen(slot.slotId)} aria-label=${t('{slot}:选择干员', { slot: label })}>
|
||||
<${TierChip} tier=${slot.tier} size="sm" />
|
||||
<span class="diy-slot__plus"><${Icon} name="plus" /></span>
|
||||
<b>${t('选择干员')}</b>
|
||||
<small>${t('调度中心等级 {level} 起出现在商店', { level: slot.shopLevel })}</small>
|
||||
</button>
|
||||
${pick ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
||||
</div>`;
|
||||
}
|
||||
const proto = !!pick && D.backups?.diy?.prototypes?.[slot.tier]?.includes(pick.charId);
|
||||
const sk = rec.skill;
|
||||
const mod = elite?.module?.active ? elite.module : null;
|
||||
return html`<div class=${cx('diy-slot', `diy-slot--t${slot.tier}`, illegal && 'is-bad')} data-slot=${slot.slotId} data-char=${rec.charId}>
|
||||
<div class="diy-slot__art"><${Img} src=${chessPortraitUrl(m, elite || rec)} fallback=${html`<b>${[...(rec.name || '?')][0]}</b>`} /></div>
|
||||
<div class="diy-slot__body">
|
||||
<div class="diy-slot__head"><${TierChip} tier=${slot.tier} size="sm" /><span class="diy-slot__label">${label}</span><${KindTag} proto=${proto} /></div>
|
||||
<b class="diy-slot__name">${rec.name}</b>
|
||||
<small class="diy-slot__class">${classLine(rec)}</small>
|
||||
<${Bonds} bonds=${rec.bonds} />
|
||||
<span class="diy-slot__kit" data-skill=${sk?.index ?? ''}>
|
||||
<${Img} src=${skillRecordIconUrl(m, sk, { empty: false })} class="diy-slot__sicon" fallback=${html`<b class="num">${skillLabel(sk?.index)}</b>`} />
|
||||
<span><b class="num">${skillLabel(sk?.index)}</b> ${sk?.name || ''}${proto ? html` <small class="t-dim">${t('(锁定)')}</small>` : null}</span>
|
||||
</span>
|
||||
<span class="diy-slot__kit" data-module=${mod ? mod.id : 'none'}>
|
||||
${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>
|
||||
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
||||
</div>
|
||||
<div class="diy-slot__acts">
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-change" onClick=${() => onOpen(slot.slotId)}>${t('更换')}<//>
|
||||
<${Button} variant="ghost" size="sm" icon="close" data-testid="diy-clear" onClick=${() => onClear(slot.slotId)}>${t('清空')}<//>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** One operator of the picker's list. */
|
||||
function OptionCard({ m, opt, selected, onSel }) {
|
||||
const u = opt.unit;
|
||||
const name = u?.name || opt.charId;
|
||||
const takenBy = opt.taken ? diySlotList(diyData()).find((s) => s.slotId === opt.taken) : null;
|
||||
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-char=${opt.charId}
|
||||
class=${cx('diy-opt', selected && 'is-sel', opt.taken && 'is-taken')} disabled=${!!opt.taken} onClick=${() => onSel(opt.charId)}
|
||||
title=${takenBy ? t('已在{tier}阶自选中', { tier: takenBy.tier }) : name}>
|
||||
<span class="diy-opt__ava"><${Img} src=${avatarOf(m, u)} fallback=${html`<b>${[...name][0]}</b>`} /></span>
|
||||
<span class="diy-opt__txt">
|
||||
<span class="diy-opt__top"><b class="diy-opt__name">${name}</b><${KindTag} proto=${opt.proto} /></span>
|
||||
<small class="diy-opt__class"><${Img} src=${profIconUrl(m, u?.profession)} class="diy-opt__prof" />${classLine(u)}</small>
|
||||
<${Bonds} bonds=${opt.bonds} />
|
||||
</span>
|
||||
${takenBy ? html`<span class="diy-opt__taken">${t('已在{tier}阶自选中', { tier: takenBy.tier })}</span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** A module choice of an owned pick (不装备 or a module of the elite form). */
|
||||
function ModuleRow({ id, rec, on, locked, onPick }) {
|
||||
const none = id == null;
|
||||
const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : [];
|
||||
const trait = rec?.traitOverride ? (rec.traitOverride.moduleDescRaw || rec.traitOverride.moduleDesc || rec.traitOverride.descRaw || rec.traitOverride.desc) : null;
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-module=${none ? 'none' : id} disabled=${locked}
|
||||
class=${cx('diy-choice', 'diy-choice--mod', on && 'is-on')} onClick=${() => onPick(id)}>
|
||||
<span class="diy-choice__icon">${none ? html`<b>—</b>` : html`<${Img} src=${moduleTypeIconUrl(data.get('local'), rec.typeName)} fallback=${html`<b class="num">${moduleBadge(rec)}</b>`} />`}</span>
|
||||
<span class="diy-choice__body">
|
||||
<span class="diy-choice__head">${none ? html`<b>${t('不装备')}</b>` : html`<b class="num">${rec.typeName || ''}</b><b>${rec.name || id}</b>`}${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
|
||||
${trait ? html`<${RichText} as="span" class="diy-choice__desc" text=${trait} />` : null}
|
||||
${talents.map((x, i) => html`<${RichText} key=${i} as="span" class="diy-choice__desc" text=${x.descRaw || x.desc} />`)}
|
||||
</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot picker: the legal operators of the slot's tier (prototypes first, then the owned 6★; a 原型 / 已持有 filter and
|
||||
* a search), the chosen one's skill / module (an owned pick) or its locked selection (a prototype), 确认 / 取消. The
|
||||
* state (filter, search, the draft pick) lives here; DiyPickerView draws it.
|
||||
* @param {{ m: any, slot: any, picks: Record<string, any>, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function DiyPicker(props) {
|
||||
const cur = props.picks[props.slot.slotId] || null;
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const [draft, setDraft] = useState(cur ? { ...cur } : null);
|
||||
return DiyPickerView({ ...props, 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 }) {
|
||||
const D = diyData();
|
||||
const cur = picks[slot.slotId] || null;
|
||||
const options = pickOptions(slot.slotId, picks, D, kitted);
|
||||
const q = query.trim().toLowerCase();
|
||||
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 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 });
|
||||
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>
|
||||
<span class="diy-pick__note">${slot.tier === 5 ? t('6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)') : t('6★ 原型干员与持有的 6★ 干员')}</span>
|
||||
<button type="button" class="diy-pick__x" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
|
||||
</header>
|
||||
<div class="diy-pick__bar">
|
||||
<div class="lo-chips" role="group" aria-label=${t('筛选')}>
|
||||
${[['all', t('全部')], ['proto', t('原型')], ['owned', t('已持有')]].map(([k, text]) => html`<button key=${k} type="button" class=${cx('lo-chip', filter === k && 'is-on')}
|
||||
aria-pressed=${filter === k ? 'true' : 'false'} data-filter=${k} onClick=${() => setFilter(k)}>${text}</button>`)}
|
||||
</div>
|
||||
<input type="search" class="diy-pick__search" value=${query} placeholder=${t('搜索干员 / 职业 / 盟约')} aria-label=${t('搜索干员 / 职业 / 盟约')}
|
||||
onInput=${(e) => setQuery(String(e.currentTarget.value).slice(0, 24))} />
|
||||
</div>
|
||||
<div class="diy-pick__main">
|
||||
<div class="diy-pick__list" role="listbox" aria-label=${t('可选干员')}>
|
||||
${kitted == null ? html`<p class="lo-empty t-dim">${t('连接服务器后才能选择自选干员')}</p>`
|
||||
: list.length ? list.map((o) => html`<${OptionCard} key=${o.charId} m=${m} opt=${o} selected=${draft?.charId === o.charId} onSel=${sel} />`)
|
||||
: html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
|
||||
</div>
|
||||
<div class="diy-pick__detail">
|
||||
${ch && unit ? html`
|
||||
<div class="diy-pick__who">
|
||||
<span class="diy-pick__ava"><${Img} src=${avatarOf(m, unit, true)} fallback=${html`<b>${[...(unit.name || '?')][0]}</b>`} /></span>
|
||||
<span class="diy-pick__wtxt"><b>${unit.name}</b><small>${classLine(unit)}</small></span>
|
||||
<${KindTag} proto=${ch.proto} />
|
||||
</div>
|
||||
<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}
|
||||
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
|
||||
</div>
|
||||
<h4 class="diy-pick__sec">${t('模组')}<small>${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}</small></h4>
|
||||
<div role="radiogroup" aria-label=${t('选择模组')} class="diy-pick__choices">
|
||||
${ch.proto
|
||||
? html`<${ModuleRow} id=${modOn} rec=${modOn ? ch.elite.modules?.find((x) => x.uniEquipId === modOn) || null : null} on=${true} locked=${true} onPick=${() => {}} />`
|
||||
: [html`<${ModuleRow} key="none" id=${null} rec=${null} on=${modOn == null} locked=${false} onPick=${() => setDraft({ ...draft, uniEquipId: null })} />`,
|
||||
...ch.modules.map((x) => html`<${ModuleRow} key=${x.uniEquipId} id=${x.uniEquipId} rec=${x.rec} on=${modOn === x.uniEquipId} locked=${false}
|
||||
onPick=${(id) => setDraft({ ...draft, uniEquipId: id })} />`)]}
|
||||
</div>` : html`<p class="lo-empty t-dim">${t('从左侧选择一名干员')}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="diy-pick__foot">
|
||||
<${Button} variant="ghost" onClick=${onClose}>${t('取消')}<//>
|
||||
<${Button} variant="primary" icon="check" data-testid="diy-confirm" disabled=${!ch} onClick=${() => onDone(ch.proto ? { charId: draft.charId } : draft)}>${t('确认')}<//>
|
||||
</footer>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function DiyPanel(props) {
|
||||
const [picking, setPicking] = useState(null);
|
||||
return DiyPanelView({ ...props, picking, onPicking: setPicking });
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
const slots = diySlotList(diyData());
|
||||
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
|
||||
const tiers = [...new Set(slots.map((s) => s.tier))];
|
||||
return html`<main class="diy" data-testid="diy">
|
||||
<div class="own__bar">
|
||||
<p class="own__lead">${t('5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。')}</p>
|
||||
</div>
|
||||
<div class="diy__body">
|
||||
${tiers.map((tier) => html`<section key=${tier} class="own-tier diy-tier" data-tier=${tier} aria-label=${t('{tier}阶', { tier })}>
|
||||
<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)} />`)}
|
||||
</div>
|
||||
</section>`)}
|
||||
</div>
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)}
|
||||
onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
|
||||
</main>`;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -236,9 +236,13 @@ function MatchScreen() {
|
||||
// the field the own pieces are deployed on: the own board, or the player's half of the boss field in a boss round's
|
||||
// prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server)
|
||||
const deployField = deployFieldOf(pub, myId);
|
||||
// the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range,
|
||||
// name; gameLogic/diy.js), every other chess its data record
|
||||
const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; };
|
||||
const placeCtx = useMemo(() => placementContext({
|
||||
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
|
||||
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
|
||||
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, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
|
||||
@@ -719,12 +723,12 @@ function MatchScreen() {
|
||||
// the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit —
|
||||
// user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel)
|
||||
const ptr = { released: false, tile: null };
|
||||
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) };
|
||||
const lookups = { getChess: ownChess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) };
|
||||
const runIntent = async (intent) => {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
// both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent)
|
||||
const request = replaceRequest(L.placeCtx, intent, gd.chess, gd.item);
|
||||
const request = replaceRequest(L.placeCtx, intent, ownChess, gd.item);
|
||||
if (request) {
|
||||
const uid = await openReplaceRef.current(request);
|
||||
if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; }
|
||||
@@ -750,7 +754,7 @@ function MatchScreen() {
|
||||
/** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */
|
||||
const openFacing = (entry, t) => {
|
||||
const piece = entry.piece;
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownChess(piece.id);
|
||||
holdPiece(view, piece.uid, { row: t.row, col: t.col });
|
||||
setSel(null);
|
||||
setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' });
|
||||
@@ -889,8 +893,8 @@ 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: gd.chess, getToken: gd.token, getItem: gd.item,
|
||||
chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]);
|
||||
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);
|
||||
@@ -1200,7 +1204,7 @@ function MatchScreen() {
|
||||
<div class="gm__vignette" aria-hidden="true"></div>
|
||||
${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing}
|
||||
ready=${phase === PHASE.PREP && !!priv?.ready} />` : null}
|
||||
${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} />` : null}
|
||||
${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} diyData=${{ chess: data.get('chess'), backups: data.get('backups') }} />` : null}
|
||||
|
||||
<div class="gm__hud" ref=${hudElRef}>
|
||||
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer}
|
||||
@@ -1270,7 +1274,7 @@ function MatchScreen() {
|
||||
|
||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||
row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy}
|
||||
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : gd.chess(selEntry.piece.id))?.name || ''}
|
||||
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownChess(selEntry.piece.id))?.name || ''}
|
||||
onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// public/js/screens/game/standInTags.js — 0.2.0 补位: the 「替补:X」 tag under the player's own prep pieces (hand,
|
||||
// 临时整备区, board) of a chess they do not own (m.private.standIns). The hand keeps the chess's own model (the card shows
|
||||
// the original operator); on the board the model is the stand-in's (render/app.js pieceInfo) — the tag says which
|
||||
// chess it is either way. Anchored to each piece's drawn body (view.pieceScreenRect, followed every frame like the
|
||||
// direction wheel follows its tile); never takes the pointer, so the pieces stay draggable.
|
||||
// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
|
||||
// (m.private.diy) — the model is the operator's everywhere. Anchored to each piece's drawn body (view.pieceScreenRect,
|
||||
// followed every frame like the direction wheel follows its tile); never takes the pointer, so the pieces stay
|
||||
// draggable.
|
||||
|
||||
import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js';
|
||||
import { html } from '../../ui/components.js';
|
||||
import { fieldsStandIn, standInOf, standInLabel } from '../../ui/gameLogic.js';
|
||||
import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js';
|
||||
import { t } from '../../../../shared/i18n.js';
|
||||
|
||||
const rawOf = (view) => (view && view.raw) || view || null;
|
||||
|
||||
@@ -30,9 +33,27 @@ export function standInPieces(priv, getChess, backups) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any }} props */
|
||||
export function StandInTags({ view, priv, getChess, backups }) {
|
||||
const items = standInPieces(priv, getChess, backups);
|
||||
/**
|
||||
* The player's own prep pieces of a DIY slot it filled (0.2.0 自选编队): [{ uid, label, name, area, diy: true }].
|
||||
* @param {any} priv m.private @param {(id: string) => any} getChess @param {{ chess: any, backups: any }} data
|
||||
*/
|
||||
export function diyPieces(priv, getChess, data) {
|
||||
const out = [];
|
||||
if (!priv || !priv.diy || typeof priv.diy !== 'object' || !Object.keys(priv.diy).length) return out;
|
||||
const add = (p, area) => {
|
||||
if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return;
|
||||
const rec = ownDiyRecord(getChess(p.id), priv, data);
|
||||
if (rec) out.push({ uid: p.uid, label: t('自选'), name: rec.name, area, diy: true });
|
||||
};
|
||||
for (const p of Array.isArray(priv.hand) ? priv.hand : []) add(p, 'hand');
|
||||
for (const p of Array.isArray(priv.temp) ? priv.temp : []) add(p, 'temp');
|
||||
for (const p of Array.isArray(priv.board) ? priv.board : []) add(p, 'board');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any, diyData?: { chess: any, backups: any } | null }} props */
|
||||
export function StandInTags({ view, priv, getChess, backups, diyData = null }) {
|
||||
const items = [...standInPieces(priv, getChess, backups), ...(diyData ? diyPieces(priv, getChess, diyData) : [])];
|
||||
const [pos, setPos] = useState({});
|
||||
const last = useRef('');
|
||||
const key = items.map((it) => it.uid).join(',');
|
||||
@@ -64,7 +85,8 @@ export function StandInTags({ view, priv, getChess, backups }) {
|
||||
return html`<div class="sitags" aria-hidden="true" data-testid="standin-tags">
|
||||
${items.map((it) => {
|
||||
const p = pos[it.uid];
|
||||
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}`} data-uid=${it.uid} title=${`未持有:由 ${it.name} 上场`}
|
||||
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}${it.diy ? ' sitag--diy' : ''}`} data-uid=${it.uid} data-diy=${it.diy ? '1' : undefined}
|
||||
title=${it.diy ? t('自选编队:{name}', { name: it.name }) : `未持有:由 ${it.name} 上场`}
|
||||
style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
|
||||
})}
|
||||
</div>`;
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
// 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 导出 / 导入 / 全部持有.
|
||||
// The third tab, 自选编队 (0.2.0 DIY, screens/diy.js): the operators of the four DIY slots (room.diy; ui/diyModel.js) —
|
||||
// with its own 导出 / 导入 / 全部清空.
|
||||
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field (干员调配 tab).
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
@@ -29,9 +31,12 @@ import {
|
||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.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 { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
|
||||
@@ -71,7 +76,7 @@ function readFileText(file) {
|
||||
});
|
||||
}
|
||||
|
||||
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */
|
||||
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list, `stronghold-diy-…` for 自选编队) */
|
||||
function exportFilename(now = new Date(), what = 'loadout') {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
@@ -383,10 +388,12 @@ const SYNC_TEXT = {
|
||||
};
|
||||
/** The 干员持有 tab's status line: the setting never applies to a running match. */
|
||||
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] };
|
||||
/** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */
|
||||
const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT };
|
||||
|
||||
/** The overlay screen. */
|
||||
function LoadoutScreen({ st }) {
|
||||
const ready = useData('chess', 'bonds', 'assets', 'local');
|
||||
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
|
||||
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
|
||||
@@ -411,9 +418,18 @@ function LoadoutScreen({ st }) {
|
||||
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
|
||||
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
|
||||
|
||||
const tab = st.tab === 'ownership' ? 'ownership' : 'loadout';
|
||||
const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
|
||||
const ownRoster = useOwnershipRoster(ready);
|
||||
const nNotOwned = notOwnedCount(st.notOwned, ownRoster);
|
||||
// 自选编队 (0.2.0 DIY): the stored picks, those the server would keep (the kit list of the last welcome)
|
||||
const nDiy = diyCount(st.diy);
|
||||
const diyLegal = ready ? sanitizeDiyPicks(st.diy, diyData(), st.diyKitted || []) : {};
|
||||
const setDiySlot = (slotId, pick) => setDiyPicks(setPick(loadoutStore.get().diy, slotId, pick));
|
||||
const clearDiy = async () => {
|
||||
if (!nDiy) return;
|
||||
const ok = await confirmDialog({ title: t('全部清空'), text: t('清空全部 {n} 个自选名额?', { n: nDiy }), okText: t('全部清空') });
|
||||
if (ok) setDiyPicks({});
|
||||
};
|
||||
const setTab = (t) => loadoutStore.set({ tab: t });
|
||||
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
|
||||
const ownAll = async () => {
|
||||
@@ -434,27 +450,38 @@ function LoadoutScreen({ st }) {
|
||||
// the clipboard, or the textarea); `io.kind` says which
|
||||
const ioText = io?.text ?? '';
|
||||
const ioOwn = io?.kind === 'ownership';
|
||||
const ioDiy = io?.kind === 'diy';
|
||||
const openExport = () => setIo(tab === 'ownership'
|
||||
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
|
||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
|
||||
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
|
||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
|
||||
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
|
||||
const ioCopy = async () => {
|
||||
const ok = await copyText(ioText);
|
||||
toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn');
|
||||
};
|
||||
const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText);
|
||||
const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : ioDiy ? 'diy' : 'loadout'), ioText);
|
||||
const ioPick = () => fileRef.current?.click();
|
||||
const ioFile = async (e) => {
|
||||
const f = e.currentTarget.files && e.currentTarget.files[0];
|
||||
e.currentTarget.value = ''; // picking the same file twice must fire again
|
||||
if (!f) return;
|
||||
// refuse a huge pick before reading it into memory (a real payload is a few KB)
|
||||
if (ioDiy && f.size > DIY_IMPORT_MAX_BYTES) { toast(t('文件过大,请选择「导出」下载的自选编队文件'), 'error'); return; }
|
||||
if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(`文件过大,请选择「导出」下载的${ioOwn ? '干员持有' : '调配'}文件`, 'error'); return; }
|
||||
try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
|
||||
};
|
||||
const ioApply = () => {
|
||||
// an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
|
||||
if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; }
|
||||
if (ioDiy) {
|
||||
const r = parseDiyImport(ioText);
|
||||
if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; }
|
||||
const { applied, dropped } = applyDiyImport(r.picks, diyData(), loadoutStore.get().diyKitted || []);
|
||||
setIo(null);
|
||||
toast(applied ? t('已导入 {n} 个自选名额', { n: applied }) + (dropped ? t('(另有 {n} 项不可用,未导入)', { n: dropped }) : '') : t('已导入:全部名额为空'), dropped ? 'warn' : 'success');
|
||||
return;
|
||||
}
|
||||
if (ioOwn) {
|
||||
const r = parseOwnershipImport(ioText);
|
||||
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; }
|
||||
@@ -482,7 +509,7 @@ 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;
|
||||
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership') {
|
||||
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);
|
||||
if (!ids.length) return;
|
||||
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
|
||||
@@ -503,12 +530,15 @@ function LoadoutScreen({ st }) {
|
||||
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
|
||||
}, [selId]);
|
||||
|
||||
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
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' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
|
||||
// 干员持有 is out of match: a running match keeps the list its seat had at its start
|
||||
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
|
||||
const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
|
||||
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
|
||||
const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效')
|
||||
: t('局外设置,下一局生效 · 联机时只进入你自己的商店');
|
||||
|
||||
return html`<${Fragment}>
|
||||
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
|
||||
@@ -518,15 +548,23 @@ function LoadoutScreen({ st }) {
|
||||
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回<//>
|
||||
</div>
|
||||
<div class="lo-top__center">
|
||||
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}<//>
|
||||
<div class="lo-tabs" role="tablist" aria-label="干员调配 / 干员持有">
|
||||
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : tab === 'diy' ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'}<//>
|
||||
<div class="lo-tabs" role="tablist" aria-label=${t('干员调配 / 干员持有 / 自选编队')}>
|
||||
<button type="button" role="tab" aria-selected=${tab === 'loadout' ? 'true' : 'false'} data-tab="loadout" class=${cx('lo-tab', tab === 'loadout' && 'is-on')} onClick=${() => setTab('loadout')}>
|
||||
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</button>
|
||||
<button type="button" role="tab" aria-selected=${tab === 'ownership' ? 'true' : 'false'} data-tab="ownership" class=${cx('lo-tab', tab === 'ownership' && 'is-on')} onClick=${() => setTab('ownership')}
|
||||
title="标记未持有的干员:由官方指定的替补干员上场">干员持有${nNotOwned ? html`<span class="lo-tab__n num" aria-label=${`${nNotOwned} 名未持有`}>${nNotOwned}</span>` : null}</button>
|
||||
<button type="button" role="tab" aria-selected=${tab === 'diy' ? 'true' : 'false'} data-tab="diy" class=${cx('lo-tab', tab === 'diy' && 'is-on')} onClick=${() => setTab('diy')}
|
||||
title=${t('5阶、6阶的自选名额:持有的 6★ 干员或原型干员')}>${t('自选编队')}${nDiy ? html`<span class="lo-tab__n num" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })}>${nDiy}</span>` : null}</button>
|
||||
</div>
|
||||
</div>
|
||||
${tab === 'ownership' ? html`<div class="lo-top__right">
|
||||
${tab === 'diy' ? html`<div class="lo-top__right">
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="diy-sync">${t(syncText)}</span>` : null}
|
||||
<span class="lo-count">${t('已选')} <b class="num">${nDiy}</b><span class="num t-dim">/4</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="diy-export" disabled=${!nDiy} onClick=${openExport} title=${t('导出自选编队(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="diy-import" disabled=${!ready} onClick=${openImport} title=${t('导入自选编队(粘贴或选择文件)')}>${t('导入')}<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-reset" disabled=${!nDiy} onClick=${clearDiy}>${t('全部清空')}<//>
|
||||
</div>` : tab === 'ownership' ? html`<div class="lo-top__right">
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="ownership-sync">${syncText}</span>` : null}
|
||||
<span class="lo-count">未持有 <b class="num">${nNotOwned}</b><span class="num t-dim">/${ownRoster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出<//>
|
||||
@@ -541,10 +579,14 @@ function LoadoutScreen({ st }) {
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
|
||||
</div>`}
|
||||
</header>
|
||||
${tab === 'ownership'
|
||||
${tab === 'diy'
|
||||
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${diyText}</p>`
|
||||
: tab === 'ownership'
|
||||
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${ownText}</p>`
|
||||
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>`}
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : tab === 'ownership'
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : tab === 'diy'
|
||||
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
|
||||
: tab === 'ownership'
|
||||
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
|
||||
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
||||
<section class="lo-roster">
|
||||
@@ -562,7 +604,8 @@ function LoadoutScreen({ st }) {
|
||||
</main>`}
|
||||
</div>
|
||||
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
|
||||
title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}
|
||||
title=${ioDiy ? t(io.mode === 'export' ? '导出自选编队' : '导入自选编队') : `${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`}
|
||||
micro=${ioOwn ? 'OPERATOR ROSTER' : ioDiy ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'}
|
||||
actions=${io.mode === 'export'
|
||||
? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭<//>
|
||||
<${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制<//>
|
||||
@@ -570,7 +613,11 @@ function LoadoutScreen({ st }) {
|
||||
: html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消<//>
|
||||
<${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件<//>
|
||||
<${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入<//>`}>
|
||||
<p class="lo-io__hint">${ioOwn
|
||||
<p class="lo-io__hint">${ioDiy
|
||||
? (io.mode === 'export'
|
||||
? t('共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。', { n: nDiy })
|
||||
: t('把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。'))
|
||||
: ioOwn
|
||||
? (io.mode === 'export'
|
||||
? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`)
|
||||
@@ -578,7 +625,7 @@ function LoadoutScreen({ st }) {
|
||||
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
|
||||
<textarea class="lo-io__text" data-testid="loadout-io-text" spellcheck=${false} readOnly=${io.mode === 'export'} value=${ioText}
|
||||
placeholder=${io.mode === 'export' ? '' : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'}
|
||||
placeholder=${io.mode === 'export' ? '' : ioDiy ? t('在此粘贴导出的自选编队内容…') : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'}
|
||||
onInput=${(e) => setIo({ ...io, text: e.currentTarget.value })}></textarea>
|
||||
<input type="file" accept=".json,application/json,text/plain" class="lo-io__file" ref=${fileRef} onChange=${ioFile} />
|
||||
<//>` : null}
|
||||
@@ -632,14 +679,18 @@ export function LoadoutButton({ from, size = 'md', variant = 'secondary', class:
|
||||
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
|
||||
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
|
||||
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
|
||||
const diy = useStore((s) => s.diy, Object.is, loadoutStore);
|
||||
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : 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
|
||||
const nDiy = diyCount(diy);
|
||||
return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
|
||||
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组 · 干员持有">
|
||||
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-entry__icon" fallback=${html`<${Icon} name="edit" class="btn__icon" />`} />
|
||||
<span class="btn__label">${label}</span>
|
||||
${n ? html`<span class="lo-entry__n num" aria-label=${`${n} 名干员已调整`}>${n}</span>` : null}
|
||||
${off ? html`<span class="lo-entry__own" aria-label=${`${off} 名干员未持有,由替补干员上场`} title=${`${off} 名干员未持有(由替补干员上场)`}>替补 <b class="num">${off}</b></span>` : null}
|
||||
${nDiy ? html`<span class="lo-entry__own lo-entry__diy" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })} title=${t('{n} 个自选名额已选择', { n: nDiy })}>${t('自选')} <b class="num">${nDiy}</b></span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
// { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?,
|
||||
// players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?,
|
||||
// title: 'comment_1' | { id, name? } | null,
|
||||
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId] }],
|
||||
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId],
|
||||
// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card
|
||||
// draws the operator (gameLogic diyRecordFor) */ }],
|
||||
// bonds?: [{ bondId, layers, active }],
|
||||
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
|
||||
// activatedLayers?, lpLost?, perfectRounds? } }] }
|
||||
@@ -14,7 +16,8 @@
|
||||
import { useEffect } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
|
||||
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
|
||||
import { normalizeResult, fmtNum } from '../ui/gameLogic.js';
|
||||
import { normalizeResult, fmtNum, diyRecordFor } from '../ui/gameLogic.js';
|
||||
import { data } from '../data.js';
|
||||
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
|
||||
import { store, useStore, emptyMatch } from '../store.js';
|
||||
import { audio } from '../audio.js';
|
||||
@@ -46,7 +49,8 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||||
</div>
|
||||
<div class="rcard__mid">
|
||||
<div class="rcard__lineup">
|
||||
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" />`)
|
||||
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm"
|
||||
rec=${u.diy ? diyRecordFor(gd.chess(u.id), u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null} />`)
|
||||
: html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
|
||||
</div>
|
||||
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>
|
||||
|
||||
Reference in new issue
Block a user