// 干员调配 (Operator loadout, DESIGN §16): choose the equipped skill of every chess and the module of its elite before a // match (official rule: "开始游戏前无法调整干员的等级,但可调整其所携带的技能和模组"). A full-screen overlay opened // from the lobby, the room and the briefing (INFO_CHECK) — `openLoadout(from)` / ; is // mounted once by main.js. Styles: css/screens/loadout.css (official preset sprites from the local-client art when // 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, 默认, // SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7) and the elite's modules (不装备 / X / Y … // with the stat bonus, the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. // The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js. // Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field. import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline } from '../ui/components.js'; import { Img, RichText, UnitThumb } from '../ui/gameComponents.js'; import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js'; import { data, useData, localAsset } 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, } from '../ui/loadoutModel.js'; import { loadoutStore, openLoadout, closeLoadout, setEntries } from '../ui/loadoutSync.js'; export { openLoadout, closeLoadout }; const cx = (...p) => p.flat().filter(Boolean).join(' '); const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI']; /** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */ function profGlyphUrl(m, prof) { const k = String(prof || '').toLowerCase(); const v = m && m.prof && m.prof.large ? m.prof.large[k] : null; return typeof v === 'string' ? v : null; } /** Skill icon URL for a SkillRecord (assets manifest `skills[iconId]` / `skillsById`), null when absent. */ function skillIconOf(m, rec) { const skills = m && typeof m === 'object' ? m.skills : null; if (!skills || !rec) return null; const id = rec.iconId || rec.skillId; if (id && typeof skills[id] === 'string') return skills[id]; const alt = m.skillsById && rec.skillId ? m.skillsById[rec.skillId] : null; return alt && typeof skills[alt] === 'string' ? skills[alt] : null; } /** * Module icon URL: the official type icon from the local-client art (`local-assets.json` groups.module, matched * case-insensitively by typeName), else manifest `modules[icon]` / `uniequip[icon]` when the asset pipeline provides one. */ function moduleIconOf(m, rec) { if (!rec) return null; const local = moduleTypeIconUrl(data.get('local'), rec.typeName || rec.type); if (local) return local; if (!m) return null; for (const group of ['modules', 'uniequip', 'equip']) { const g = m[group]; if (g && typeof g === 'object') { const v = g[rec.icon] || g[rec.uniEquipId] || g[rec.typeIcon]; if (typeof v === 'string') return v; } } return null; } /** Skill icon with the official selection outline; falls back to a lettered tile. */ function SkillIcon({ m, rec, index, on = false, size = 'md' }) { const src = skillIconOf(m, rec); return html` <${Img} src=${src} fallback=${html`${skillLabel(index)}`} /> ${on && size !== 'xs' ? html`<${Img} src=${localAsset('ui/outer', 'image_skill_select_outline')} class="lo-sicon__outline" fallback=${html``} />` : null} `; } /** Module badge tile: the official type icon when the local-client art has it, else the type letter; ⊘ for 不装备. */ export function ModuleGlyph({ m, rec, id, size = 'md' }) { const src = rec ? moduleIconOf(m, rec) : null; if (id === MODULE_NONE || !rec) { return html` <${Img} src=${localAsset('ui/outer', 'icon_equip_non')} fallback=${html``} /> `; } return html` <${Img} src=${src} fallback=${html`${moduleBadge(rec)}`} /> `; } // ---- roster card --------------------------------------------------------------------------------------------------- function RosterCard({ m, chess, golden, entries, selected, onPick }) { 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``; } // ---- detail -------------------------------------------------------------------------------------------------------------- function SkillOption({ m, opt, on, level, onPick }) { const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite; const tags = skillTags(rec); return html``; } function ModuleInfo({ m, golden, opt }) { if (!golden) return null; const rec = opt.id === MODULE_NONE ? null : opt.rec; if (!rec) { const traitBase = golden.traitBase || null; return html`

不装备模组:精锐干员以基础属性、特性与天赋作战。

${traitBase?.desc ? html`
特性<${RichText} class="lo-minfo__v" text=${traitBase.descRaw || traitBase.desc} />
` : null}
`; } const rows = attrRows(rec.attr); const trait = rec.traitOverride; const traitText = trait ? (trait.moduleDescRaw || trait.moduleDesc || trait.descRaw || trait.desc) : null; const talents = (Array.isArray(rec.talentChanges) ? rec.talentChanges : []).filter((t) => t && (t.name || t.desc) && !t.hidden); return html`
<${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" />${rec.typeName || ''}${rec.name || rec.uniEquipId} ${opt.isDefault ? html`默认` : null}
属性 ${rows.length ? rows.map((r) => html`${r.label}${r.text}`) : html`无属性加成`}
${traitText ? html`
特性<${RichText} class="lo-minfo__v" text=${traitText} />
` : null} ${talents.map((t, i) => html`
天赋 ${t.name ? html`${t.name}` : null}<${RichText} text=${t.descRaw || t.desc || ''} />
`)}
`; } function Detail({ m, chess, golden, entries, onChange, onReset, locked }) { const [level, setLevel] = useState('normal'); const bodyRef = useRef(null); useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]); if (!chess) return html``; const opt = chessOptions(chess, golden); const choice = effectiveChoice(entries, chess, golden); const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null; const lv = (c) => c?.status?.skillLevel ?? '—'; return html``; } // ---- filters ------------------------------------------------------------------------------------------------------------- function Filters({ m, filters, onFilters, bonds }) { const set = (patch) => onFilters({ ...filters, ...patch }); return html`
${[1, 2, 3, 4, 5, 6].map((t) => html``)}
<${TextField} size="sm" icon="search" value=${filters.query} placeholder="搜索干员 / 职业 / 盟约" class="lo-search" onInput=${(v) => set({ query: String(v).slice(0, 24) })} />
${PROF_ORDER.map((p) => html``)}
`; } // ---- screen ------------------------------------------------------------------------------------------------------------- const SYNC_TEXT = { idle: ['', ''], pending: ['保存中…', 'is-busy'], sending: ['同步中…', 'is-busy'], synced: ['已同步', 'is-ok'], locked: ['本局已锁定 · 下一局生效', 'is-warn'], error: ['同步失败', 'is-bad'], }; /** The overlay screen. */ function LoadoutScreen({ st }) { const ready = useData('chess', 'bonds', 'assets', 'local'); 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 // match locks the loadout when it runs out (review fix: edits were silently only for the next match) const infoDeadline = useStore((s) => (s.match?.public?.phase === PHASE.INFO_CHECK ? s.match.public.deadline : 0)); const m = data.get('assets'); const getChess = (id) => data.lookup('chess', id); const getBond = (id) => data.lookup('bonds', id); const roster = useMemo(() => rosterOf(data.list('chess')), [ready]); const bonds = useMemo(() => { const used = new Set(roster.flatMap((c) => c.bonds || [])); 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 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 locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked'; const gridRef = useRef(null); const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); }; const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); }; const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); }; const resetAll = async () => { if (!nChanged) return; const ok = await confirmDialog({ title: '全部恢复默认', text: `将 ${nChanged} 名干员的技能与模组恢复为默认配置?`, okText: '恢复默认', danger: true }); if (ok) setEntries({}); }; // Esc closes; ←/→ browse the filtered roster (not while typing in the search field) useEffect(() => { const onKey = (e) => { if (e.ctrlKey || e.metaKey || e.altKey) return; if (document.querySelector('.modal')) return; // a confirm dialog handles its own keys 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') { 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)); const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length]; loadoutStore.set({ sel: next }); e.preventDefault(); } // swallow single-key game shortcuts (R / F / D / Space …) while the overlay is open if (e.key.length === 1 || e.key === ' ') { e.stopImmediatePropagation(); } }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, []); // keep the selected card in view useEffect(() => { const el = gridRef.current?.querySelector(`[data-chess="${selId}"]`); if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' }); }, [selId]); const [syncText, syncCls] = SYNC_TEXT[st.sync] || SYNC_TEXT.idle; const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整'; return html``; } /** * Whether the overlay must close because its context moved on: opened from the briefing and the match left INFO_CHECK * (the strategy draft needs the player), or opened from the lobby / room and a match started (its briefing takes over). * @param {{ open: boolean, from: string|null }} st @param {string|null} phase @param {boolean} inMatch @param {boolean} wasInMatch */ export function shouldAutoClose(st, phase, inMatch, wasInMatch) { if (!st || !st.open) return false; if (st.from === 'briefing') return !!phase && phase !== PHASE.INFO_CHECK; return inMatch && !wasInMatch; } /** Mounted once (main.js): renders the overlay while open. */ export function LoadoutHost() { const st = useStore((s) => s, Object.is, loadoutStore); const phase = useStore((s) => s.match?.public?.phase || null); const inMatch = useStore((s) => !!s.room?.inMatch); const wasInMatch = useRef(inMatch); useEffect(() => { if (shouldAutoClose(st, phase, inMatch, wasInMatch.current)) closeLoadout(); wasInMatch.current = inMatch; }, [phase, inMatch, st.open]); useEffect(() => { if (st.open) document.documentElement.classList.add('sp-loadout-open'); else document.documentElement.classList.remove('sp-loadout-open'); }, [st.open]); if (!st.open) return null; return html`<${LoadoutScreen} st=${st} />`; } /** * 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. * @param {Record} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded */ export function badgeCount(entries, getChess) { return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length; } /** * Entry button (lobby / room / briefing). * @param {{ from: 'lobby'|'room'|'briefing', size?: string, variant?: string, class?: string, label?: string }} props */ export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = '干员调配' }) { useData('local'); // the official preset icon (re-render once the local-art manifest arrives) const entries = useStore((s) => s.entries, Object.is, loadoutStore); const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null); return html``; }