卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+216
View File
@@ -0,0 +1,216 @@
// Band draft — BAND_DRAFT "2/2 选择策略" (research 06 §4.2, D1): left = draft order (avatar, name, state:
// … waiting / ⌛ 决策中 / chosen band ✓), current picker highlighted; centre = grid of every band allowed
// for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked
// 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right =
// detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择.
// A timeout assigns 「华法琳」, or — when a teammate already holds it — the first free strategy (timeoutBand; the tip
// under the order list names the one I would get). Solo: free pick, no timer.
import { useEffect, useMemo, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { actions } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { useStore } from '../store.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* Bands selectable in a mode (modeTypeList contains the mode's type), sorted by sortId.
* @param {any[]} bands
* @param {string|null} modeType 'SINGLE'|'MULTI'
*/
export function allowedBands(bands, modeType) {
const sid = (b) => (Number.isFinite(b.sortId) ? b.sortId : 99);
return (Array.isArray(bands) ? bands : [])
.filter((b) => b && (!modeType || !Array.isArray(b.modeTypeList) || b.modeTypeList.includes(modeType)))
.sort((a, b) => sid(a) - sid(b) || (a.bandId < b.bandId ? -1 : a.bandId > b.bandId ? 1 : 0));
}
/** The official default strategy of an automatic assignment (data/config.json bandDraft.timeoutBandId). */
export const DEFAULT_TIMEOUT_BAND = 'band_bldsk';
/**
* The strategy the server assigns me when my turn times out (server/match/Match.js defaultBand): the official default
* 「华法琳」 while no teammate holds it, else the first free strategy in draft order (sortId) — never one a teammate
* already picked (队友已选).
* @param {any[]} bands allowedBands(...) (sortId order)
* @param {Map<string, any>} taken teammateBands(...)
* @param {string} [defaultId]
* @returns {string|null}
*/
export function timeoutBand(bands, taken, defaultId = DEFAULT_TIMEOUT_BAND) {
const list = Array.isArray(bands) ? bands : [];
const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id));
if (defaultId && !has(defaultId) && (!list.length || list.some((b) => b.bandId === defaultId))) return defaultId;
return list.find((b) => !has(b.bandId))?.bandId || defaultId || null;
}
/**
* Bands taken by teammates (队友已选): bandId → the picking players (never the viewer).
* @param {Map<string, string>} picks normalizeDraft(...).picks (playerId → bandId)
* @param {string} myId
*/
export function teammateBands(picks, myId) {
const out = new Map();
for (const [pid, bid] of picks instanceof Map ? picks : []) {
if (pid === myId || typeof bid !== 'string') continue;
if (!out.has(bid)) out.set(bid, []);
out.get(bid).push(pid);
}
return out;
}
/**
* The band the detail pane shows: the current one, else my pick, else the first band no teammate took. When it is my
* turn and the shown band was taken meanwhile (队友已选), the first free one instead (confirm would be disabled).
* @param {string|null} sel
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean }} o
*/
export function draftSelection(sel, { bands, taken, myPick, myTurn }) {
if (!Array.isArray(bands) || !bands.length) return sel;
const free = (bands.find((b) => !taken.has(b.bandId)) || bands[0]).bandId;
if (!sel) return myPick || free;
if (!myPick && myTurn && taken.has(sel)) return free;
return sel;
}
/** BAND_DRAFT screen. */
export function BandDraftScreen() {
const pub = useStore((s) => s.match.public);
const priv = useStore((s) => s.match.private);
const myId = useStore((s) => s.me.playerId);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const gd = useGameData();
const [sel, setSel] = useState(null);
const [busy, setBusy] = useState(null);
const [exit, setExit] = useState(false);
const [skipped, setSkipped] = useState(false);
const mode = gd.config?.modes?.[pub?.modeId];
const solo = roomSolo || mode?.type === 'SINGLE' || String(pub?.modeId || '').includes('single');
const bands = useMemo(() => allowedBands(gd.list('bands'), mode?.type || (solo ? 'SINGLE' : 'MULTI')), [gd.ready, mode?.type, solo]);
const players = sortedPlayers(pub);
const draft = normalizeDraft(pub?.draft, players);
const myPick = draft.picks.get(myId) || priv?.bandId || null;
const myTurn = !myPick && (solo || draft.turnPid === myId);
const skipsLeft = draft.skipsLeft.has(myId) ? draft.skipsLeft.get(myId) : (skipped ? 0 : 1);
const canSkip = !solo && myTurn && skipsLeft > 0 && draft.order.length > 1;
const taken = solo ? new Map() : teammateBands(draft.picks, myId);
const pickers = new Map(); // bandId → players
for (const [pid, bid] of draft.picks) {
const p = players.find((x) => x.playerId === pid);
if (!pickers.has(bid)) pickers.set(bid, []);
pickers.get(bid).push(p || { playerId: pid, name: '?' });
}
// default selection: my pick, else the first band nobody else took; when my turn comes while the selected band has
// been taken by a teammate meanwhile (队友已选), move the selection to the first free one
const takenKey = [...taken.keys()].sort().join(',');
useEffect(() => {
const next = draftSelection(sel, { bands, taken, myPick, myTurn });
if (next !== sel) setSel(next);
}, [bands.length, myPick, myTurn, takenKey]);
// "your turn" cue
useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]);
// what a timeout gives me (the server never assigns a strategy a teammate holds — Match.js defaultBand)
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
const autoId = myPick ? null : timeoutBand(bands, taken, defaultId);
const defaultName = gd.band(defaultId)?.name || '华法琳';
const autoName = (autoId && gd.band(autoId)?.name) || defaultName;
const band = sel ? gd.band(sel) : null;
const selTaken = !!band && taken.has(band.bandId);
const confirm = async () => {
if (!band || busy || !myTurn || selTaken) return;
setBusy('pick');
await actions.band(band.bandId);
setBusy(null);
};
const skip = async () => {
if (busy || !canSkip) return;
setBusy('skip');
if (await actions.bandSkip()) setSkipped(true);
setBusy(null);
};
const turnName = players.find((p) => p.playerId === draft.turnPid)?.name;
const turnDeadline = Number(pub?.draft?.turnDeadline) || 0;
useTicker(!solo && turnDeadline > 0 ? 250 : 0);
const turnSecs = !solo && turnDeadline > 0 ? secondsLeft(turnDeadline) : null;
return html`<div class="screen draft">
<div class="brief__bg" aria-hidden="true"></div>
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${pub}
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
<main class="draft__main">
<aside class="draft-order">
<h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
${(solo ? players.filter((p) => p.playerId === myId) : draft.order.map((pid) => players.find((p) => p.playerId === pid)).filter(Boolean)).map((p, i) => {
const picked = draft.picks.get(p.playerId) || (p.playerId === myId ? myPick : p.bandId) || null;
const cur = !picked && (solo || draft.turnPid === p.playerId);
const pband = picked ? gd.band(picked) : null;
return html`<div key=${p.playerId} class=${cx('dorder', cur && 'is-cur', picked && 'is-done', p.playerId === myId && 'is-self')}>
${!solo ? html`<span class="dorder__idx num">${i + 1}</span>` : null}
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
<div class="dorder__text">
<b class="dorder__name">${p.name || '博士'}${p.isBot ? html`<span class="dorder__ai">AI</span>` : null}</b>
<span class="dorder__state">${picked ? html`<span class="t-mint">${pband?.name || '已选择'}</span>`
: cur ? html`<span class="t-gold"><${Icon} name="hourglass" />决策中${turnSecs != null ? html`<b class="num dorder__secs">${turnSecs}s</b>` : null}</span>`
: html`<span class="t-dim"><${Icon} name="dots" />等待中</span>`}</span>
</div>
<span class="dorder__box">
${picked ? html`<${BandIcon} bandId=${picked} size="sm" /><span class="dorder__check"><${Icon} name="check" /></span>`
: cur && p.playerId === myId ? html`<${Sprite} k="bandChoose/youturn_finger" class="dorder__finger" fallback=${html`<${Icon} name="chevronLeft" />`} />`
: null}
</span>
</div>`;
})}
${!solo ? html`<p class="draft-order__tip">联合模拟在选择策略时可以进行一次跳过;超时将自动选择「${autoName}」${autoId && autoId !== defaultId ? `(「${defaultName}」已被队友选择)` : ''}</p>` : null}
</aside>
<section class="draft-grid" role="listbox" aria-label="策略">
${bands.map((b) => {
const who = pickers.get(b.bandId) || [];
const isTaken = taken.has(b.bandId);
return html`<button key=${b.bandId} type="button" role="option" aria-selected=${sel === b.bandId ? 'true' : 'false'}
aria-disabled=${isTaken ? 'true' : 'false'} title=${isTaken ? '队友已选' : undefined}
class=${cx('dband', sel === b.bandId && 'is-sel', myPick === b.bandId && 'is-mine', isTaken && 'is-taken')} onClick=${() => { setSel(b.bandId); audio.sfx('tab', { volume: 0.5 }); }}>
<${BandIcon} bandId=${b.bandId} size="lg" />
<span class="dband__name">${b.name}</span>
<span class="dband__lp num"><i></i>${b.totalHp}</span>
${who.length ? html`<span class="dband__who">${who.slice(0, 4).map((p) => html`<${PlayerAvatar} key=${p.playerId} player=${p} size="sm" />`)}</span>` : null}
${isTaken ? html`<span class="dband__taken">队友已选</span>` : null}
</button>`;
})}
</section>
<aside class="draft-detail brackets">
${band ? html`
<div class="draft-detail__art">
<${BandIcon} bandId=${band.bandId} size="xl" />
</div>
<div class="draft-detail__hp"><span>初始生命值</span><${LpTower} value=${band.totalHp} size="lg" /></div>
<h2 class="draft-detail__name">${band.name}</h2>
<div class="draft-detail__eff">
<${MicroLabel} tone="mint">EFFECT</${MicroLabel}>
<b>${band.effectName || ''}</b>
<${RichText} as="p" text=${band.descRaw || band.desc} class="draft-detail__desc" />
</div>` : html`<p class="t-dim">选择一个策略查看详情</p>`}
<div class="draft-detail__actions">
${myPick ? html`<p class="draft-detail__status t-mint"><${Icon} name="check" />已选择「${gd.band(myPick)?.name || ''}」${!solo && !draft.done ? ',等待其他博士' : ''}</p>`
: selTaken ? html`<p class="draft-detail__status draft-detail__status--taken"><${Icon} name="close" />队友已选,请选择其他策略</p>`
: !myTurn ? html`<p class="draft-detail__status"><${Icon} name="hourglass" />${turnName ? `${turnName} 正在决策…` : '等待轮到你'}</p>` : null}
<div class="draft-detail__btns">
${!solo ? html`<${Button} variant="secondary" size="lg" icon="chevrons" disabled=${!canSkip} loading=${busy === 'skip'} onClick=${skip}
title=${skipsLeft > 0 ? '跳过本轮,稍后再选' : '跳过次数已用完'}>跳过${skipsLeft > 0 ? '' : '(已用)'}<//>` : null}
<${Button} variant="primary" size="lg" icon="check" disabled=${!myTurn || !band || selTaken} loading=${busy === 'pick'} onClick=${confirm}>${selTaken ? '队友已选' : '确认选择'}<//>
</div>
</div>
</aside>
</main>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
}
+136
View File
@@ -0,0 +1,136 @@
// Briefing — INFO_CHECK "1/2 确认本局信息" (research 06 §4.1, D1): enemy leader (silhouette, name,
// abilities), stage (+ its pool: 战场固定 / 战场随机(共N张)), 特训敌人 factions (icon, name, description), 核心盟约 / 附加盟约 rows of bond discs
// (disabled bonds greyed with the banned-member badge), 本局禁用干员 avatars, difficulty tag, the ready
// count x/N with person pips, the 准备就绪 button (g.infoReady) and the countdown.
import { useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, BondDisc, Tooltip } from '../ui/components.js';
import { useGameData, Img, UnitThumb, RichText } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { LoadoutButton } from './loadout.js';
import { actions } from '../ui/gameActions.js';
import { factionTypes, bannedPerBond, sortedPlayers, phaseTotalSeconds, disabledBondSets, briefingBondTip } from '../ui/gameLogic.js';
import { bondIconUrl, enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
import { useStore } from '../store.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
function BondRow({ title, micro, bonds, sets, perBond }) {
const m = data.get('assets');
return html`<div class="brief-bonds">
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
<div class="brief-bonds__row">
${bonds.map((b) => {
// greyed: the drawn set D (still activatable, roster incomplete) and the mode's static inactive bonds
const state = sets.off.has(b.bondId) ? 'off' : sets.drawn.has(b.bondId) ? 'drawn' : null;
const off = !!state;
const bannedN = perBond.get(b.bondId) || 0;
const tip = briefingBondTip(b.name, state, bannedN);
return html`<${Tooltip} key=${b.bondId} text=${tip}>
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')}>
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
${bannedN > 0 ? html`<span class="brief-bond__ban num"><${Icon} name="user" />${bannedN}</span>` : null}
</div>
<//>`;
})}
</div>
</div>`;
}
/** INFO_CHECK screen. */
export function BriefingScreen() {
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
const solo = useStore((s) => s.room?.mode === 'solo') || String(pub?.modeId || '').includes('single');
const gd = useGameData();
const [exit, setExit] = useState(false);
const [busy, setBusy] = useState(false);
if (!pub) return null;
const players = sortedPlayers(pub);
const me = players.find((p) => p.playerId === myId);
const readyN = players.filter((p) => p.ready).length;
const mode = gd.config?.modes?.[pub.modeId];
const sets = disabledBondSets(pub, mode?.inactiveBondIds);
const bonds = gd.list('bonds').sort((a, b) => (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || (a.identifier ?? 0) - (b.identifier ?? 0));
const core = bonds.filter((b) => b.isCore);
const addon = bonds.filter((b) => !b.isCore);
const banned = (Array.isArray(pub.bannedChess) ? pub.bannedChess : []).filter((id) => gd.chess(id));
banned.sort((a, b) => (gd.chess(a)?.tier ?? 0) - (gd.chess(b)?.tier ?? 0));
const perBond = bannedPerBond(bonds, banned);
const boss = pub.bossId ? gd.boss(pub.bossId) : null;
const bossEnemy = boss ? gd.enemy(boss.enemyKey) : null;
const stage = pub.stageId ? gd.stage(pub.stageId) : null;
// the mode's battlefield pool (config.json modes[].stages): 标准 is always 战场#01, 险境+ draw one at random
const poolN = Array.isArray(mode?.stages) ? mode.stages.length : 0;
const pool = poolN > 1 ? `战场随机(共${poolN}张)` : poolN === 1 ? '战场固定' : '';
const types = factionTypes(pub.factions);
const factions = gd.factions?.types || {};
const m = gd.m;
const ready = async () => {
if (busy || me?.ready) return;
setBusy(true);
await actions.infoReady();
setBusy(false);
};
return html`<div class="screen brief">
<div class="brief__bg" aria-hidden="true"></div>
<${StepHeader} step=${1} of=${2} title="确认本局信息" micro="BRIEFING // INFO CHECK" pub=${pub}
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
<main class="brief__main">
<section class="brief__left">
<div class="brief-boss brackets">
<${MicroLabel} tone="gold">ENEMY LEADER // 敌方领袖</${MicroLabel}>
<div class="brief-boss__art">
<div class="brief-boss__ring" aria-hidden="true"></div>
${bossEnemy ? html`<${Img} src=${enemyIconUrl(m, boss.enemyKey)} class="brief-boss__sil" fallback=${html`<span class="brief-boss__q">?</span>`} />`
: html`<span class="brief-boss__q">?</span>`}
</div>
<div class="brief-boss__text">
<h2 class="brief-boss__name">${boss?.name || bossEnemy?.name || '未知领袖'}</h2>
<span class="brief-boss__when">第 <b class="num">${mode?.bossRound ?? pub.lastRound ?? 14}</b> 回合 · 最终攻势</span>
${Array.isArray(boss?.abilities) && boss.abilities.length ? html`<ul class="brief-boss__abil">
${boss.abilities.slice(0, 3).map((a, i) => html`<li key=${i}><${RichText} text=${a} /></li>`)}
</ul>` : null}
</div>
</div>
<div class="brief-stage">
<span class="brief-stage__k"><${MicroLabel}>BATTLEFIELD</${MicroLabel}>${pool ? html`<span class="brief-stage__pool">${pool}</span>` : null}</span>
<span class="brief-stage__name"><${Icon} name="rook" />${stage?.name || pub.stageId || '—'}</span>
</div>
<div class="brief-factions">
<h3 class="brief-h"><span>特训敌人</span><${MicroLabel}>SPECIAL ENEMIES</${MicroLabel}></h3>
${types.length ? types.map((t) => html`<div key=${t} class="brief-faction">
<span class="brief-faction__icon"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} /></span>
<span class="brief-faction__text"><b>${factions[t]?.name || t}</b><span>${factions[t]?.desc || ''}</span></span>
</div>`) : html`<p class="t-dim">本局没有特训敌人</p>`}
</div>
</section>
<section class="brief__right">
<${BondRow} title="核心盟约" micro="CORE BONDS" bonds=${core} sets=${sets} perBond=${perBond} />
<${BondRow} title="附加盟约" micro="ADD-ON BONDS" bonds=${addon} sets=${sets} perBond=${perBond} />
<p class="brief-legend"><span class="brief-legend__off"></span>灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活)${sets.off.size ? ',或本模式禁用' : ''} · <span class="brief-legend__ban"><${Icon} name="user" /></span>该盟约中无法出现的干员数</p>
<div class="brief-banned">
<h3 class="brief-h"><span>本局禁用干员</span><${MicroLabel}>BANNED OPERATORS</${MicroLabel}><b class="num brief-banned__n">${banned.length}</b></h3>
${banned.length ? html`<div class="brief-banned__grid">
${banned.map((id) => html`<${UnitThumb} key=${id} kind="chess" id=${id} size="sm" dim=${true} />`)}
</div>` : html`<p class="t-dim">本局没有禁用干员</p>`}
</div>
</section>
</main>
<footer class="brief__foot">
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
<div class="brief-ready">
<span class="brief-ready__txt">已就绪 <b class="num">${readyN}</b><span class="num">/${players.length}</span></span>
<span class="brief-ready__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' : 'play'} active=${!!me?.ready} loading=${busy}
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : '准备就绪'}<//>
</footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
}
File diff suppressed because it is too large. Load diff
+434
View File
@@ -0,0 +1,434 @@
// 干员调配 (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)` / <LoadoutButton/>; <LoadoutHost/> 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, 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`<span class=${cx('lo-sicon', `lo-sicon--${size}`, on && 'is-on')}>
<${Img} src=${src} fallback=${html`<span class="lo-sicon__glyph num">${skillLabel(index)}</span>`} />
${on && size !== 'xs' ? html`<${Img} src=${localAsset('ui/outer', 'image_skill_select_outline')} class="lo-sicon__outline" fallback=${html`<i class="lo-sicon__ring"></i>`} />` : null}
</span>`;
}
/** 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`<span class=${cx('lo-mglyph', 'lo-mglyph--none', `lo-mglyph--${size}`)}>
<${Img} src=${localAsset('ui/outer', 'icon_equip_non')} fallback=${html`<i class="lo-mglyph__slash"></i>`} />
</span>`;
}
return html`<span class=${cx('lo-mglyph', `lo-mglyph--${size}`)} data-type=${moduleBadge(rec)}>
<${Img} src=${src} fallback=${html`<b class="lo-mglyph__t num">${moduleBadge(rec)}</b>`} />
</span>`;
}
// ---- 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`<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="已调整"></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}` : '不装备模组'}>
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
</span>` : null}
</span>
</button>`;
}
// ---- 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`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} class=${cx('lo-skill', on && 'is-on')}
data-skill=${opt.index} onClick=${() => onPick(opt.index)}>
${on ? html`<span class="lo-skill__deco" aria-hidden="true"><${Img} src=${localAsset('ui/outer', 'skill_select_deco')} fallback=${html`<i></i>`} /></span>` : null}
<${SkillIcon} m=${m} rec=${rec} index=${opt.index} on=${on} size="md" />
<span class="lo-skill__body">
<span class="lo-skill__head">
<span class="lo-skill__slot num">${skillLabel(opt.index)}</span>
<b class="lo-skill__name">${rec?.name || '未知技能'}</b>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">默认</span>` : null}
${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />已装备</span>` : null}
</span>
<span class="lo-skill__tags">
<span class=${cx('lo-sp', `lo-sp--${tags.spKind}`)}>${tags.sp}</span>
${tags.init != null ? html`<span class="lo-tag">初始 <b class="num">${tags.init}</b></span>` : null}
${tags.cost != null ? html`<span class="lo-tag">消耗 <b class="num">${tags.cost}</b></span>` : null}
${tags.duration ? html`<span class="lo-tag">持续 <b class="num">${tags.duration}</b></span>` : null}
${tags.charges ? html`<span class="lo-tag">充能 <b class="num">${tags.charges}</b></span>` : null}
</span>
<${RichText} as="span" class="lo-skill__desc" text=${rec?.descRaw || rec?.desc || ''} />
</span>
</button>`;
}
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`<div class="lo-minfo lo-minfo--none">
<p class="lo-minfo__lead">不装备模组:精锐干员以基础属性、特性与天赋作战。</p>
${traitBase?.desc ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${traitBase.descRaw || traitBase.desc} /></div>` : null}
</div>`;
}
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`<div class="lo-minfo">
<div class="lo-minfo__title"><${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" /><span class="lo-minfo__type num">${rec.typeName || ''}</span><b>${rec.name || rec.uniEquipId}</b>
${opt.isDefault ? html`<span class="lo-badge lo-badge--def">默认</span>` : null}</div>
<div class="lo-minfo__row">
<span class="lo-minfo__k">属性</span>
<span class="lo-minfo__v lo-attrs">${rows.length ? rows.map((r) => html`<span key=${r.key} class=${cx('lo-attr', r.positive ? 'is-up' : 'is-down')}>${r.label}<b class="num">${r.text}</b></span>`) : html`<span class="t-dim">无属性加成</span>`}</span>
</div>
${traitText ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">特性</span><${RichText} class="lo-minfo__v" text=${traitText} /></div>` : null}
${talents.map((t, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">天赋</span>
<span class="lo-minfo__v">${t.name ? html`<b class="lo-minfo__tname">${t.name}</b>` : null}<${RichText} text=${t.descRaw || t.desc || ''} /></span></div>`)}
</div>`;
}
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`<aside class="lo-detail lo-detail--empty"><p class="t-dim">没有符合条件的干员</p></aside>`;
const opt = chessOptions(chess, golden);
const choice = effectiveChoice(entries, chess, golden);
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
const lv = (c) => c?.status?.skillLevel ?? '—';
return html`<aside class="lo-detail" aria-label=${`${chess.name} 调配`}>
<div class="lo-dhead">
<div class=${cx('lo-dhead__art', `lo-dhead__art--t${chess.tier}`)}>
<${Img} src=${chessPortraitUrl(m, golden || chess)} fallback=${html`<${UnitThumb} kind="chess" id=${chess.chessId} size="lg" />`} />
</div>
<div class="lo-dhead__info">
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
${choice.changed ? html`<span class="lo-badge lo-badge--changed">已调整</span>` : html`<span class="lo-badge lo-badge--plain">默认配置</span>`}</div>
<h2 class="lo-dhead__name">${chess.name}</h2>
<span class="lo-dhead__en">${chess.appellation || ''}</span>
<span class="lo-dhead__class">
<${Img} src=${profGlyphUrl(m, chess.profession)} class="lo-dhead__prof lo-profglyph" />${PROF_NAME[chess.profession] || ''}
<i class="lo-sep"></i><${Img} src=${subProfIconUrl(m, chess)} class="lo-dhead__prof" />${chess.subProfessionName || ''}
</span>
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
</div>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>恢复默认<//>
</div>
<div class="lo-detail__body" ref=${bodyRef}>
<section class="lo-sec">
<header class="lo-sec__head">
<h3>技能<${MicroLabel}>SKILL<//></h3>
<div class="lo-seg" role="tablist" aria-label="技能等级">
<button type="button" role="tab" aria-selected=${level === 'normal' ? 'true' : 'false'} class=${cx(level === 'normal' && 'is-on')} onClick=${() => setLevel('normal')}>普通 <span class="num">Lv.${lv(chess)}</span></button>
<button type="button" role="tab" aria-selected=${level === 'elite' ? 'true' : 'false'} class=${cx(level === 'elite' && 'is-on')} disabled=${!golden} onClick=${() => setLevel('elite')}>精锐 <span class="num">Lv.${lv(golden)}</span></button>
</div>
</header>
<div class="lo-skills" role="radiogroup" aria-label="选择技能">
${opt.skillOptions.map((s) => html`<${SkillOption} key=${s.index} m=${m} opt=${s} level=${level} on=${s.index === choice.skill}
onPick=${(i) => onChange({ skill: i })} />`)}
</div>
</section>
${golden ? html`<section class="lo-sec lo-sec--mod">
<header class="lo-sec__head">
<h3>模组<${MicroLabel}>MODULE<//></h3>
<span class="lo-sec__note">仅精锐干员装备 · 模组等级 <b class="num">${golden.status?.equipLevel ?? 1}</b></span>
</header>
<div class="lo-mods" role="radiogroup" aria-label="选择模组">
${opt.moduleOptions.map((mo) => html`<button key=${mo.id} type="button" role="radio" aria-checked=${mo.id === choice.module ? 'true' : 'false'}
data-module=${mo.id} class=${cx('lo-mod', mo.id === choice.module && 'is-on', mo.id === MODULE_NONE && 'lo-mod--none')}
onClick=${() => onChange({ module: mo.id })}>
<${ModuleGlyph} m=${m} rec=${mo.rec} id=${mo.id} />
<span class="lo-mod__text">
<span class="lo-mod__type num">${mo.id === MODULE_NONE ? 'NONE' : mo.rec?.typeName || ''}</span>
<b class="lo-mod__name">${mo.id === MODULE_NONE ? '不装备' : mo.rec?.name || mo.id}</b>
</span>
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">默认</span>` : null}
</button>`)}
</div>
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
</section>` : null}
${locked ? html`<p class="lo-locknote"><${Icon} name="info" />本局的调配已锁定,修改将在下一局生效</p>` : null}
</div>
</aside>`;
}
// ---- filters -------------------------------------------------------------------------------------------------------------
function Filters({ m, filters, onFilters, bonds }) {
const set = (patch) => onFilters({ ...filters, ...patch });
return html`<div class="lo-filters">
<div class="lo-frow">
<div class="lo-chips" role="group" aria-label="阶级">
<button type="button" class=${cx('lo-chip', !filters.tier && 'is-on')} onClick=${() => set({ tier: null })}>全部</button>
${[1, 2, 3, 4, 5, 6].map((t) => html`<button key=${t} type="button" class=${cx('lo-chip', 'lo-chip--tier', `lo-chip--t${t}`, filters.tier === t && 'is-on')}
aria-pressed=${filters.tier === t ? 'true' : 'false'} title=${`${t}阶`} onClick=${() => set({ tier: filters.tier === t ? null : t })}><span class="num">${ROMAN[t]}</span></button>`)}
</div>
<${TextField} size="sm" icon="search" value=${filters.query} placeholder="搜索干员 / 职业 / 盟约" class="lo-search"
onInput=${(v) => set({ query: String(v).slice(0, 24) })} />
</div>
<div class="lo-frow">
<div class="lo-chips lo-chips--prof" role="group" aria-label="职业">
${PROF_ORDER.map((p) => html`<button key=${p} type="button" class=${cx('lo-chip', 'lo-chip--prof', filters.prof === p && 'is-on')}
aria-pressed=${filters.prof === p ? 'true' : 'false'} title=${PROF_NAME[p]} onClick=${() => set({ prof: filters.prof === p ? null : p })}>
<${Img} src=${profGlyphUrl(m, p)} class="lo-chip__icon lo-profglyph" fallback=${html`<span>${PROF_NAME[p][0]}</span>`} /><span class="lo-chip__lbl">${PROF_NAME[p]}</span></button>`)}
</div>
<label class="lo-select">
<span class="lo-select__k">盟约</span>
<select value=${filters.bond || ''} onChange=${(e) => set({ bond: e.currentTarget.value || null })} aria-label="按盟约筛选">
<option value="">全部盟约</option>
${bonds.map((b) => html`<option key=${b.bondId} value=${b.bondId}>${b.name}</option>`)}
</select>
</label>
<button type="button" class=${cx('lo-toggle', filters.changedOnly && 'is-on')} aria-pressed=${filters.changedOnly ? 'true' : 'false'}
onClick=${() => set({ changedOnly: !filters.changedOnly })}><i class="lo-toggle__box"><${Icon} name="check" /></i>仅看已调整</button>
</div>
</div>`;
}
// ---- 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`<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
<div class="lo__bg" aria-hidden="true"></div>
<header class="lo-top">
<div class="lo-top__left">
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回<//>
</div>
<div class="lo-top__center">
<${MicroLabel} tone="mint">OPERATOR LOADOUT<//>
<h1 class="lo-top__title"><${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</h1>
</div>
<div class="lo-top__right">
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null}
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${syncText}</span>` : null}
<span class="lo-count">已调整 <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
</div>
</header>
<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>
${!ready ? html`<div class="lo-loading"><span class="spinner"></span>正在读取干员数据…</div>` : html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster">
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
<div class="lo-grid" role="listbox" aria-label="干员列表" ref=${gridRef}>
${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} />`) : html`<p class="lo-empty t-dim">没有符合条件的干员</p>`}
</div>
</section>
<div class="lo-detail-wrap">
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />干员列表</button>
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} />
</div>
</main>`}
</div>`;
}
/**
* 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<string, any>} 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`<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}
</button>`;
}
+324
View File
@@ -0,0 +1,324 @@
// Lobby screen: pick 独立模拟 / 同盟模拟 and a difficulty (标准/险境/绝境/终极), create a room,
// or join one with a 同盟密钥 (recent codes remembered). Shows connection status + ping.
//
// Difficulty descriptions come from data/config.json `modes[modeId]` when present, else from the
// official act2autochess `modeDataDict` texts embedded below (desc + effectDescList), so the
// screen is complete before data is generated. Rounds: solo 标准 = 9, everything else 14 (+R15
// hidden core on 险境+), per research 00-INDEX §2. Battlefield pool (`modes[].stages`): 标准 always
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, modeIdFor } 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 { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
import { getConfig, getMode, getStage, useData } from '../data.js';
/** Official mode texts (activity_table act2autochess.modeDataDict), fallback when config.json is absent. */
export const MODE_TEXT = {
single: {
FUNNY: { code: 'AC-1', desc: '时长较短的模拟训练', effects: ['可以快速完成作战', '常规奖励'] },
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
},
multi: {
FUNNY: { code: 'AC-1', desc: '敌方攻击强度较低的模拟训练', effects: ['作战环境较为温和', '常规奖励'] },
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
},
};
/** Battlefield pool per difficulty when config.json is absent (the modes' `stages` lists; same for solo and co-op). */
export const STAGE_POOL = { FUNNY: ['act1autochess_m01'], NORMAL: 8, HARD: 7, ABYSS: 7 };
/**
* Display name of a stage: stages.json when it is loaded, else derived from the id (act1 m0N → 战场#0N, act2 m0N → 战场#0(N+4)).
* @param {string} id e.g. 'act1autochess_m01'
*/
export function stageLabel(id) {
const rec = getStage(id);
if (rec && typeof rec.name === 'string' && rec.name) return rec.name.split(/\s+/)[0];
const m = String(id || '').match(/^act(\d)autochess_m(\d+)$/);
return m ? `战场#${String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0')}` : '';
}
/**
* The battlefield note of a difficulty (official wording): a single-stage pool is fixed ("战场固定为 战场#01"), a larger
* one is drawn at random ("战场随机(共8张)").
* @param {string[] | number | null | undefined} stages the mode's `stages` list (or a count)
* @returns {string} '' when unknown
*/
export function stageNote(stages) {
if (Array.isArray(stages)) {
const ids = stages.filter((s) => typeof s === 'string' && s);
if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? `战场固定为 ${name}` : '战场固定'; }
return ids.length > 1 ? `战场随机(共${ids.length}张)` : '';
}
return Number.isInteger(stages) && stages > 1 ? `战场随机(共${stages}张)` : '';
}
const MODE_CARDS = [
{
id: 'solo', name: '独立模拟', en: 'SOLO SIMULATION', icon: 'user',
desc: '独自调配资金与干员,以自己的节奏完成整场模拟。',
points: ['1 名博士', '休整期与机变阶段不限时'],
},
{
id: 'coop', name: '同盟模拟', en: 'ALLIANCE SIMULATION', icon: 'users',
desc: `与至多 ${MAX_SEATS - 1} 名博士组成同盟,共享干员池,联防协作抵御敌潮。`,
points: [`1–${MAX_SEATS} 名博士 · 可由 AI 队友补位`, '联防阶段 · 最终攻势合并生命值'],
},
];
/**
* Text for a difficulty card, preferring data/config.json.
* @param {'solo'|'coop'} roomMode
* @param {string} difficulty
* @returns {{ code: string, desc: string, effects: string[], rounds: number, hidden: boolean, stageNote: string }}
*/
export function difficultyInfo(roomMode, difficulty) {
const fallback = MODE_TEXT[roomMode === 'solo' ? 'single' : 'multi'][difficulty] || { code: '', desc: '', effects: [] };
// modeIdFor() lower-cases the difficulty: never call it with a value the server did not validate.
const m = DIFFICULTIES.includes(difficulty) ? getMode(modeIdFor(roomMode, difficulty)) : null;
const effects = Array.isArray(m?.effectDescList)
? m.effectDescList.map((e) => String(e).replace(/^[·•\s]+/, '')).filter(Boolean)
: fallback.effects;
const rounds = Number.isFinite(m?.lastRound) ? m.lastRound : roomMode === 'solo' && difficulty === 'FUNNY' ? 9 : 14;
return {
code: typeof m?.code === 'string' ? m.code : fallback.code,
desc: typeof m?.desc === 'string' ? m.desc : fallback.desc,
effects,
rounds,
hidden: difficulty !== 'FUNNY',
stageNote: stageNote(Array.isArray(m?.stages) && m.stages.length ? m.stages : STAGE_POOL[difficulty]),
};
}
const CODE_RE = new RegExp(`^[A-Z0-9]{${ROOM_CODE_LEN}}$`);
/**
* Normalise user input into a room code: accepts a pasted invite link (`…?room=ABCD`), keeps
* upper-cased alphanumerics and clamps to the code length.
* @param {string} v
* @returns {string}
*/
export function normalizeCode(v) {
let s = String(v ?? '');
const m = s.match(/[?&]room=([A-Za-z0-9]+)/);
if (m) s = m[1];
return s.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, ROOM_CODE_LEN);
}
/**
* Room code from a deep link query string (`?room=CODE`), or null when absent/malformed.
* Accepts ROOM_CODE_LEN..ROOM_CODE_LEN+2 alphanumerics (the protocol's join limit).
* @param {string} search e.g. location.search
* @returns {string|null}
*/
export function parseRoomParam(search) {
try {
const raw = new URLSearchParams(search || '').get('room');
if (!raw) return null;
const code = raw.trim().toUpperCase();
if (!/^[A-Z0-9]+$/.test(code)) return null;
return code.length >= ROOM_CODE_LEN && code.length <= ROOM_CODE_LEN + 2 ? code : null;
} catch {
return null;
}
}
/** Recently joined/created co-op room codes (most recent first). */
export function recentRooms() {
const list = loadPref('recentRooms', []);
return Array.isArray(list) ? list.filter((c) => typeof c === 'string' && CODE_RE.test(c)).slice(0, 4) : [];
}
/** @param {string} code */
export function rememberRoom(code) {
if (!CODE_RE.test(code)) return;
savePref('recentRooms', [code, ...recentRooms().filter((c) => c !== code)].slice(0, 4));
}
const FALLBACK_TIPS = [
'联合模拟在选择策略时可以进行一次跳过',
'调度中心即使冻结,依然可以主动刷新',
'两件同名装备可以合成一件更强力的装备',
'只有达成完美作战的队友可以进行联防',
];
const TIP_ROTATE_MS = 5000; // matchingTipRotateInterval
/** Rotating tactical tips (config.json `tips`, weighted list of { tip, weight }). */
function TipsPanel() {
const cfg = getConfig();
const tips = Array.isArray(cfg?.tips)
? cfg.tips.map((t) => (typeof t === 'string' ? t : t?.tip)).filter((t) => typeof t === 'string' && t)
: FALLBACK_TIPS;
const list = tips.length ? tips : FALLBACK_TIPS;
const [idx, setIdx] = useState(() => Math.floor(Math.random() * list.length));
useEffect(() => {
const id = setInterval(() => setIdx((i) => i + 1), TIP_ROTATE_MS);
return () => clearInterval(id);
}, []);
const i = ((idx % list.length) + list.length) % list.length;
return html`<div class="tips brackets">
<div class="tips__head">
<${Icon} name="info" />
<span>作战提示</span>
<${MicroLabel}>TACTICAL TIPS<//>
<span class="tips__idx num">${String(i + 1).padStart(2, '0')}<span class="t-dim">/${String(list.length).padStart(2, '0')}</span></span>
<button type="button" class="tips__nav" onClick=${() => setIdx(i - 1 + list.length)} aria-label="上一条"><${Icon} name="chevronLeft" /></button>
<button type="button" class="tips__nav" onClick=${() => setIdx(i + 1)} aria-label="下一条"><${Icon} name="chevronRight" /></button>
</div>
<p key=${i} class="tips__text">${list[i]}</p>
</div>`;
}
function ModeCard({ card, selected, onSelect }) {
return html`<button type="button" class=${`mode-card brackets${selected ? ' is-selected' : ''}`} onClick=${() => onSelect(card.id)}
aria-pressed=${selected ? 'true' : 'false'}>
<span class="mode-card__bg" aria-hidden="true"></span>
<span class="mode-card__icon"><${Icon} name=${card.icon} /></span>
<span class="mode-card__text">
<${MicroLabel} tone=${selected ? 'mint' : undefined}>${card.en}<//>
<span class="mode-card__name">${card.name}</span>
<span class="mode-card__desc">${card.desc}</span>
<span class="mode-card__points">${card.points.map((p) => html`<span key=${p}>${p}</span>`)}</span>
</span>
<span class="mode-card__check" aria-hidden="true"><${Icon} name="check" />已选定</span>
</button>`;
}
function DifficultyCard({ roomMode, difficulty, selected, onSelect }) {
const info = difficultyInfo(roomMode, difficulty);
return html`<button type="button" class=${`diff-card${selected ? ' is-selected' : ''}`}
style=${`--d-color:${DIFFICULTY_COLORS[difficulty]}`} onClick=${() => onSelect(difficulty)} aria-pressed=${selected ? 'true' : 'false'}>
<span class="diff-card__bar" aria-hidden="true"></span>
<span class="diff-card__head">
<${DifficultyIcon} difficulty=${difficulty} class="diff-card__glyph" />
<span class="diff-card__name">${DIFFICULTY_NAMES[difficulty]}</span>
<span class="diff-card__code num">${info.code}</span>
<span class="diff-card__meta">
<span class="num">${info.rounds}</span> 回合${info.hidden ? html`<span class="diff-card__hidden">+ 隐秘核心</span>` : null}
</span>
</span>
<span class="diff-card__desc">${info.desc}</span>
<span class="diff-card__effects">${info.effects.map((e) => html`<span key=${e}>${e}</span>`)}${info.stageNote ? html`<span key="stage" class="diff-card__stage"><${Icon} name="rook" />${info.stageNote}</span>` : null}</span>
<span class="diff-card__check" aria-hidden="true"><${Icon} name="check" /><span>已选定</span></span>
</button>`;
}
/** Lobby screen component. */
export function LobbyScreen() {
const me = useStore((s) => s.me, shallowEqual);
const conn = useStore((s) => s.connection, shallowEqual);
useData('config');
const [roomMode, setRoomMode] = useState(() => (loadPref('lobby.mode', 'coop') === 'solo' ? 'solo' : 'coop'));
const [difficulty, setDifficulty] = useState(() => {
const d = loadPref('lobby.difficulty', 'FUNNY');
return DIFFICULTIES.includes(d) ? d : 'FUNNY';
});
const [code, setCode] = useState('');
const [busy, setBusy] = useState(null);
const [recent] = useState(recentRooms);
const alive = useRef(true);
const inFlight = useRef(false); // synchronous guard against double clicks (state updates are async)
useEffect(() => () => { alive.current = false; }, []);
const online = conn.status === 'online';
const codeOk = CODE_RE.test(code);
const pickMode = (m) => { setRoomMode(m); savePref('lobby.mode', m); };
const pickDifficulty = (d) => { setDifficulty(d); savePref('lobby.difficulty', d); };
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast('尚未连接到服务器,请稍候', 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const create = () => run('create', () => net.request('room.create', { mode: roomMode, difficulty }));
const join = (c = code) => {
const k = normalizeCode(c);
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
run('join', () => net.request('room.join', { code: k }));
};
const backToTitle = () => {
identity.setEntered(false);
store.set((s) => ({ session: { ...s.session, entered: false } }));
};
return html`<div class="screen lobby-screen">
<header class="topbar">
<div class="topbar__left">
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title="返回标题">返回<//>
<${PingPill} ms=${conn.ping} online=${online} />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
<h1 class="topbar__title">选择模拟协议</h1>
</div>
<div class="topbar__right">
<${GuideButton} class="lobby-guide" variant="secondary" />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" />
<div class="me-chip">
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
<div class="me-chip__text">
<span class="me-chip__name">${me.name || '博士'}</span>
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
</div>
</div>
</div>
</header>
<div class="lobby-body screen__scroll">
<section class="lobby-left">
<div class="section-label"><span class="section-label__idx num">01</span>模拟方式<${MicroLabel}>MODE<//></div>
<div class="mode-cards">
${MODE_CARDS.map((c) => html`<${ModeCard} key=${c.id} card=${c} selected=${roomMode === c.id} onSelect=${pickMode} />`)}
</div>
<div class="section-label"><span class="section-label__idx num">03</span>加入同盟<${MicroLabel}>JOIN WITH ALLIANCE KEY<//></div>
<${Panel} class="join-panel" tone="amber">
<div class="join-row">
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟<//>
</div>
<div class="join-foot">
${recent.length ? html`<span class="t-lo">最近的同盟</span>
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" onClick=${() => { setCode(c); join(c); }}>${c}</button>`)}`
: html`<span class="t-dim">向同伴索取 ${ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接</span>`}
</div>
<//>
<${TipsPanel} />
</section>
<section class="lobby-right">
<div class="section-label"><span class="section-label__idx num">02</span>模拟难度<${MicroLabel}>DIFFICULTY<//></div>
<div class="diff-list">
${DIFFICULTIES.map((d) => html`<${DifficultyCard} key=${d} roomMode=${roomMode} difficulty=${d} selected=${difficulty === d} onSelect=${pickDifficulty} />`)}
</div>
<div class="create-box">
<${Tooltip} block=${true} text=${online ? null : '正在连接服务器…'}>
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" loading=${busy === 'create'} disabled=${!online} onClick=${create}>
${roomMode === 'solo' ? '开始独立模拟' : '创建同盟'}
<//>
<//>
<div class="create-box__hint">
${online
? html`<span>${roomMode === 'solo' ? '创建后即可开始模拟' : '创建后可邀请好友或添加 AI 队友'}</span>`
: html`<${Spinner} size="sm" label="CONNECTING" />`}
</div>
</div>
</section>
</div>
</div>`;
}
+128
View File
@@ -0,0 +1,128 @@
// Result screen (research 06 §10.6): victory / defeat hero with rounds passed, boss medallions, and a
// card per player — band, final lineup avatars with elite marks, stats, title (评语) with its icon —
// plus 返回同盟.
//
// Expected m.result (free-form in DESIGN §8.2; fields read tolerantly, see gameLogic.normalizeResult):
// { 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] }],
// bonds?: [{ bondId, layers, active }],
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] }
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 { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
// The first seven present are shown (+ remaining LP = a 4×2 grid); the title (评语) stats come first.
// `gold` is the funds a player SPENT (server/match/PlayerState.js spend(); the 挥金如土 title stat).
const STAT_ROWS = [
['dmgDealt', '造成伤害'], ['kills', '击倒敌人'], ['bossDamage', '领袖伤害'], ['activatedLayers', '盟约层数'],
['merges', '晋升次数'], ['itemsEquipped', '配发装备'], ['gold', '消耗资金'], ['perfectRounds', '完美作战'],
['refreshes', '刷新次数'], ['leaks', '未击倒'], ['lpLost', '损失生命'],
];
function PlayerCard({ p, myId, titles, best, solo = false }) {
const gd = useGameData();
const titleRec = p.title ? titles.find((t) => t.id === p.title.id) || null : null;
const titleName = p.title?.name || titleRec?.name || null;
const stats = STAT_ROWS.filter(([k]) => Number.isFinite(p.stats[k])).slice(0, 7);
const band = p.bandId ? gd.band(p.bandId) : null;
const lineup = p.lineup.slice(0, 10);
const bonds = p.bonds.filter((b) => b.active || b.layers > 0).sort((a, b) => (b.layers || 0) - (a.layers || 0)).slice(0, 6);
return html`<article class=${cx('rcard', p.playerId === myId && 'is-self', p.alive === false && 'is-dead')}>
<header class="rcard__head">
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
<div class="rcard__who">
<b class="rcard__name">${p.name}${p.isBot ? html`<span class="rcard__ai">AI</span>` : null}${p.playerId === myId ? html`<span class="rcard__you">你</span>` : null}</b>
<span class="rcard__band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : '—'}</span>
</div>
<div class="rcard__mid">
<div class="rcard__lineup">
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" />`)
: 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}>
<${BondGlyph} bondId=${b.bondId} /><b class="num">${b.layers ?? 0}</b></span>`)}</div>` : null}
</div>
<div class="rcard__round"><${MicroLabel}>ROUNDS</${MicroLabel}><b class="num">${p.roundsPassed}</b>
${p.trophies > 0 || p.reward > 0 ? html`<span class="rcard__gain">${p.trophies > 0 ? html`<span title="获得奖杯"><${Icon} name="crown" /><b class="num">+${p.trophies}</b></span>` : null}${p.reward > 0 ? html`<span title="卫戍认证"><${Icon} name="shield" /><b class="num">+${p.reward}</b></span>` : null}</span>` : null}
</div>
${titleName ? html`<div class="rcard__title" title=${titleRec?.text || p.title?.text || ''}>
<${Img} src=${titleIconUrl(gd.m, titleRec?.picId || p.title?.picId || String(p.title.id || '').replace('comment_', 'comment_icon_'))} class="rcard__ticon" fallback=${html`<${Icon} name="crown" />`} />
<span><${MicroLabel} tone="gold">评语</${MicroLabel}><b>${titleName}</b></span>
</div>` : html`<span class="rcard__title rcard__title--none" aria-hidden="true"></span>`}
</header>
<div class="rcard__stats">
${stats.map(([k, label]) => html`<div key=${k} class=${cx('rstat', best[k] === p.playerId && 'is-best')}><span>${label}</span><b class="num">${fmtNum(p.stats[k])}</b></div>`)}
${Number.isFinite(p.lp) ? html`<div class="rstat" title=${p.lpShared && !solo ? '最终攻势起全队共享目标生命值' : ''}><span>${p.lpShared && !solo ? '同盟剩余生命' : '剩余生命'}</span><${LpTower} value=${p.lp} size="sm" /></div>` : null}
</div>
</article>`;
}
/** RESULT screen. */
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 gd = useGameData();
const r = normalizeResult(res, pub);
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
const best = {};
for (const [k] of STAT_ROWS) {
let top = null;
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() });
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;
const bg = uiUrl(gd.m, r.victory ? 'settle/settlemen_teamshow_success' : 'settle/settlemen_teamshow_fail');
const mins = r.durationMs ? Math.round(r.durationMs / 60000) : null;
return html`<div class=${cx('screen', 'result', r.victory ? 'is-win' : 'is-lose')}>
<div class="result__bg" aria-hidden="true" style=${bg ? `--result-bg:url("${bg}")` : undefined}></div>
<div class="result__grid" aria-hidden="true"></div>
<main class="result__main">
<section class="result__hero">
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
<h1 class="result__headline">${r.victory ? '模拟完成' : '模拟失败'}</h1>
<p class="result__sub">${r.victory ? '成功卫戍 · 敌方领袖已被击败' : '防线已被突破'}</p>
<div class="result__rounds">
<span class="result__rlabel">通过回合</span>
<b class="result__rnum num">${r.roundsPassed}</b>
${r.roundsPassed <= r.lastRound ? html`<span class="result__rof num">/${r.lastRound}</span>` : null}
</div>
<div class="result__medals">
${boss ? html`<div class=${cx('medal', r.victory && 'is-done')} title=${boss.name}>
<${Img} src=${enemyIconUrl(gd.m, boss.enemyKey)} /><span class="medal__check">${r.victory ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
<span class="medal__label">敌方领袖</span></div>` : null}
${hidden ? html`<div class=${cx('medal', 'medal--hidden', r.hiddenCleared && 'is-done')} title=${hidden.name}>
<${Img} src=${enemyIconUrl(gd.m, hidden.enemyKey)} /><span class="medal__check">${r.hiddenCleared ? html`<${Icon} name="check" />` : html`<${Icon} name="close" />`}</span>
<span class="medal__label">隐秘核心</span></div>` : null}
</div>
${mins ? html`<p class="result__time t-lo">本局耗时 <b class="num">${mins}</b> 分钟</p>` : null}
<footer class="result__foot">
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? '返回同盟' : '返回大厅'}<//>
</footer>
</section>
<section class="result__players">
<h2 class="brief-h"><span>同盟成员</span><${MicroLabel}>ALLIANCE REPORT</${MicroLabel}></h2>
${r.players.length ? r.players.map((p) => html`<${PlayerCard} key=${p.playerId} p=${p} myId=${myId} titles=${titles} best=${best} solo=${r.players.length < 2} />`)
: html`<p class="t-dim">暂无结算数据</p>`}
</section>
</main>
</div>`;
}
+315
View File
@@ -0,0 +1,315 @@
// 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.
//
// 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
// alone (no separate room.ready round trip that could leave the host "ready" after a failed start).
// Solo rooms show a single seat.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS } from '../../../shared/constants.js';
import {
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
} from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch } from '../store.js';
import { difficultyInfo } from './lobby.js';
/**
* Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record.
* @param {any} room room.state payload
* @returns {(null | {seat:number, playerId:any, name:string, isBot:boolean, ready:boolean, connected:boolean})[]}
*/
export function normalizeSeats(room) {
const cap = room?.mode === 'solo' ? 1 : MAX_SEATS;
const src = Array.isArray(room?.seats) ? room.seats : [];
const out = [];
for (let i = 0; i < cap; i++) {
const s = src[i];
out.push(s && typeof s === 'object' ? { ...s, seat: Number.isInteger(s.seat) ? s.seat : i } : null);
}
return out;
}
/**
* Derived room facts for the local player.
* @param {any} room
* @param {any} myId
*/
export function roomFacts(room, myId) {
const seats = normalizeSeats(room);
const occupied = seats.filter(Boolean);
const humans = occupied.filter((s) => !s.isBot);
const mine = occupied.find((s) => s.playerId === myId) || null;
const isHost = room?.hostId != null && room.hostId === myId;
const others = humans.filter((s) => s.playerId !== myId);
// The host never readies: starting the match is the host's ready (server rule), so the count
// treats the host as ready — "已就绪 0/1" next to "准许进入模拟" would contradict itself.
const isReady = (s) => !!s.ready || s.playerId === room?.hostId;
const readyHumans = humans.filter(isReady).length;
const othersReady = others.every((s) => s.ready && s.connected !== false);
return {
seats, occupied, humans, mine, isHost, readyHumans, isReady,
emptySeats: seats.filter((s) => !s).length,
canStart: isHost && othersReady && !!mine,
othersReady,
};
}
/** Invite link for a room code (current page URL with ?room=CODE). */
export function inviteLink(code) {
const loc = globalThis.location;
const base = loc ? `${loc.origin}${loc.pathname}` : '';
return `${base}?room=${encodeURIComponent(code)}`;
}
/**
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
* @param {string} text
* @returns {Promise<boolean>}
*/
export async function copyText(text) {
try {
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
// 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px';
document.body.appendChild(ta);
ta.select();
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
// navigator.clipboard, so this path is the one iPhones / iPads take)
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch {
return false;
}
}
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) {
const coop = room.mode !== 'solo';
if (!seat) {
const canAdd = coop && facts.isHost;
return html`<article class="seat seat--empty" style=${`--seat-i:${index}`}>
<header class="seat__head"><span class="seat__no num">P${index + 1}</span><${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//></header>
<div class="seat__art seat__art--empty">
<div class="seat__radar" aria-hidden="true"></div>
<span class="seat__wait">等待博士加入</span>
<${MicroLabel}>AWAITING DOCTOR<//>
</div>
<footer class="seat__foot">
${canAdd
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>添加 AI 队友<//>`
: html`<span class="seat__state t-dim">空位</span>`}
</footer>
</article>`;
}
const isMe = seat.playerId === myId;
const isHostSeat = seat.playerId === room.hostId;
const offline = seat.connected === false && !seat.isBot;
// The host never needs to toggle ready: starting the match readies them (server rule).
const state = offline ? 'offline' : seat.ready || seat.isBot ? 'ready' : isHostSeat ? 'host' : 'waiting';
return html`<article class=${`seat brackets${isMe ? ' is-me' : ''}${isHostSeat ? ' is-host' : ''}${seat.isBot ? ' is-bot' : ''} is-${state}`}
style=${`--seat-i:${index}`}>
<header class="seat__head">
<span class="seat__no num">P${index + 1}</span>
<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//>
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />创建者</span>` : null}
</header>
<div class="seat__art">
<div class="seat__stripes" aria-hidden="true"></div>
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} />
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />AI 队友</span>` : null}
</div>
<div class="seat__who">
<span class="seat__name">${seat.name || '博士'}</span>
${isMe ? html`<span class="seat__you">你</span>` : null}
</div>
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}<//>
<footer class="seat__foot">
<span class=${`seat__state seat__state--${state}`}>
${state === 'ready' ? html`<${Icon} name="check" />已就绪`
: state === 'offline' ? html`<${Icon} name="wifiOff" />连接中断`
: state === 'host' ? html`<${Icon} name="crown" />待命中`
: html`<${Icon} name="hourglass" />准备中`}
</span>
${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" />
<//>` : null}
</footer>
</article>`;
}
function InviteBox({ code }) {
const copy = async (what) => {
const ok = await copyText(what === 'code' ? code : inviteLink(code));
if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success');
else toast('复制失败,请手动复制', 'warn');
};
return html`<div class="invite brackets">
<div class="invite__label"><${Icon} name="key" /><span>同盟密钥</span><${MicroLabel}>ALLIANCE KEY<//></div>
<div class="invite__code num selectable" aria-label=${`同盟密钥 ${code}`}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
<div class="invite__btns">
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>复制密钥<//>
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>复制链接<//>
</div>
</div>`;
}
function DifficultyPicker({ room, isHost, busy, onPick }) {
if (!isHost) {
return html`<div class="dpick dpick--ro">
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} />
<span class="t-dim">由创建者选择</span>
</div>`;
}
return html`<div class="dpick" role="radiogroup" aria-label="模拟难度">
${DIFFICULTIES.map((d) => html`<button key=${d} type="button" role="radio" aria-checked=${room.difficulty === d ? 'true' : 'false'}
class=${`dpick__opt${room.difficulty === d ? ' is-active' : ''}`} style=${`--d-color:${DIFFICULTY_COLORS[d]}`}
disabled=${!!busy} onClick=${() => room.difficulty !== d && onPick(d)}>
<${DifficultyIcon} difficulty=${d} />${DIFFICULTY_NAMES[d].replace('模拟', '')}
</button>`)}
</div>`;
}
/** Room screen component. */
export function RoomScreen() {
const room = useStore((s) => s.room);
const me = useStore((s) => s.me, shallowEqual);
const conn = useStore((s) => s.connection, shallowEqual);
const [busy, setBusy] = useState(null);
const alive = useRef(true);
const inFlight = useRef(false); // synchronous guard against double clicks (state updates are async)
useEffect(() => () => { alive.current = false; }, []);
if (!room) return null;
const online = conn.status === 'online';
const coop = room.mode !== 'solo';
const facts = roomFacts(room, me.playerId);
const myReady = !!facts.mine?.ready;
const info = difficultyInfo(room.mode, room.difficulty);
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast('连接中断,请稍候重试', 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const toggleReady = () => run('ready', () => net.request('room.ready', { ready: !myReady }));
const start = () => run('start', () => net.request('room.start', {}));
const addBot = () => run('add', () => net.request('room.addBot', {}));
const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat }));
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
const leave = async () => {
if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
if (facts.isHost && othersHere) {
const ok = await confirmDialog({ title: '离开同盟', text: '你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?', okText: '离开', danger: true });
if (!ok) return;
}
inFlight.current = true;
setBusy('leave');
try {
await net.request('room.leave', {});
} catch (err) {
if (err?.code !== 'NOT_IN_ROOM') toastError(err);
} finally {
// Leaving locally is always safe: the server either confirmed or no longer has us in the room.
store.set({ room: null, match: emptyMatch() });
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
: !coop
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
: facts.isHost
? facts.canStart
? html`<span class="t-mint">*同盟人数达标,准许进入模拟</span>`
: html`<span class="t-lo">等待所有博士准备就绪</span>`
: myReady
? html`<span class="t-mint">已就绪 · 等待创建者开始模拟</span>`
: html`<span class="t-lo">准备就绪后,创建者即可开始模拟</span>`;
return html`<div class="screen room-screen">
<header class="topbar">
<div class="topbar__left">
<${Tooltip} text="离开同盟" placement="bottom">
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label="离开同盟" />
<//>
<div class="room-ping">
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>当前延迟<//>
</div>
<${GuideButton} class="room-guide" variant="secondary" />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}<//>
<h1 class="topbar__title">${coop ? '同盟模拟' : '独立模拟'}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
</div>
<div class="topbar__right">
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>仅限 1 名博士</span></div>`}
</div>
</header>
<main class=${`seats${coop ? '' : ' seats--solo'}`}>
${facts.seats.map((s, i) => html`<${SeatCard} key=${s ? `p${s.playerId}` : `e${i}`} seat=${s} index=${i} room=${room} facts=${facts}
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} />`)}
${coop ? null : html`<aside class="solo-brief brackets">
<${MicroLabel} tone="mint">BRIEFING<//>
<h2>${DIFFICULTY_NAMES[room.difficulty] || ''}<span class="num t-dim"> ${info.code}</span></h2>
<p>${info.desc}</p>
<ul>
${info.effects.map((e) => html`<li key=${e}>${e}</li>`)}
<li>共 <b class="num">${info.rounds}</b> 回合${info.hidden ? ',满足条件时进入隐秘核心' : ''}</li>
<li>独立模拟中休整期与机变阶段不限时</li>
</ul>
</aside>`}
</main>
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">模拟难度<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
<span class="t-lo">已就绪</span>
<b class="num">${facts.readyHumans}</b><span class="num t-dim">/${facts.humans.length}</span>
<span class="ready-count__icons" aria-hidden="true">
${facts.humans.map((s) => html`<${Icon} key=${s.playerId} name="user" class=${facts.isReady(s) ? 'is-on' : ''} />`)}
</span>
</div>
<div class="room-bar__status">${statusLine}</div>
</div>
<div class="room-bar__right">
<${LoadoutButton} from="room" size="lg" class="room-loadout" />
${facts.isHost
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//>
<//>`
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
</div>
</footer>
</div>`;
}
+271
View File
@@ -0,0 +1,271 @@
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始.
//
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
// sends `hello` (now, or as soon as the socket is open). The router then shows the lobby.
//
// Backdrop art: if data/assets.json lists a UI backdrop (`ui.titleBackdrop`, or one of the
// 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 { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.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';
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
// the client accepts is never rejected by the server's hello validation.
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
// Lone surrogates are removed by a scan, not a regex: the lookbehind such a regex needs is a *syntax error* in Safari
// < 16.4, which would stop the whole client from loading there.
export function stripLoneSurrogates(str) {
let out = '';
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
if (c >= 0xd800 && c <= 0xdbff) {
const n = i + 1 < str.length ? str.charCodeAt(i + 1) : 0;
if (n >= 0xdc00 && n <= 0xdfff) { out += str[i] + str[i + 1]; i++; }
continue;
}
if (c >= 0xdc00 && c <= 0xdfff) continue;
out += str[i];
}
return out;
}
/**
* Normalise a nickname like the server does (NFC, strip lone surrogates / control / invisible /
* bidi characters, collapse whitespace, trim), then clamp to NAME_MAX_LEN UTF-16 code units — the
* protocol's `hello.name` limit — without splitting a surrogate pair.
* @param {any} raw
* @returns {string}
*/
export function sanitizeName(raw) {
let s = String(raw ?? '');
try { s = s.normalize('NFC'); } catch { /* keep as is */ }
s = stripLoneSurrogates(s).replace(/\s+/g, ' ').replace(CONTROL_CHARS, '').replace(/ {2,}/g, ' ').trim();
if (s.length > NAME_MAX_LEN) {
s = s.slice(0, NAME_MAX_LEN);
// Don't leave half a surrogate pair at the end.
if (/[\ud800-\udbff]$/.test(s)) s = s.slice(0, -1);
s = s.trim();
}
return s;
}
/** @param {any} raw @returns {boolean} */
export const isValidName = (raw) => sanitizeName(raw).length > 0;
/**
* Enter the game shell with a nickname (title → lobby).
* @param {string} rawName
* @returns {boolean} false when the name is invalid
*/
export function enterSession(rawName) {
const name = sanitizeName(rawName);
if (!name) return false;
identity.saveName(name);
identity.setEntered(true);
store.set((s) => ({ me: { ...s.me, name }, session: { ...s.session, entered: true } }));
net.setName(name);
return true;
}
// data/assets.json `ui` keys are 'group/key' (docs/ASSETS.md).
const BACKDROP_KEYS = ['titleBackdrop', 'entry/bkg_01', 'entry/bkg_02'];
const RIDGE_KEYS = ['titleRidges', 'entry/bg_mountains_tiled'];
/**
* Find a UI image URL in data/assets.json (tolerant of a few plausible shapes).
* @param {any} assets
* @param {string[]} names
* @returns {string|null}
*/
export function findUiAsset(assets, names) {
if (!assets || typeof assets !== 'object') return null;
const asUrl = (v) => {
if (typeof v === 'string') return v;
if (v && typeof v === 'object') return v.url || v.path || v.src || null;
return null;
};
const ui = assets.ui;
if (ui && typeof ui === 'object' && !Array.isArray(ui)) {
for (const n of names) {
const u = asUrl(ui[n]);
if (u) return u;
}
}
const lists = [Array.isArray(ui) ? ui : null, Array.isArray(assets.files) ? assets.files : null].filter(Boolean);
for (const list of lists) {
for (const n of names) {
const hit = list.map(asUrl).find((u) => typeof u === 'string' && u.includes('/ui/') && u.toLowerCase().split('/').pop().startsWith(n.toLowerCase()));
if (hit) return hit;
}
}
return null;
}
// Dot-matrix watchtower emblem (13×14 bitmap; dots grow toward the base for depth).
const EMBLEM = [
'XXX..XXX..XXX',
'XXX..XXX..XXX',
'XXXXXXXXXXXXX',
'.XXXXXXXXXXX.',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'..XXXX.XXXX..',
'..XXXX.XXXX..',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'..XXXXXXXXX..',
'.XXXXXXXXXXX.',
'XXXXXXXXXXXXX',
'XXXXXXXXXXXXX',
];
function Emblem() {
const dots = useMemo(() => {
const out = [];
EMBLEM.forEach((row, r) => {
[...row].forEach((ch, c) => {
if (ch !== 'X') return;
const rad = 0.2 + (r / (EMBLEM.length - 1)) * 0.2;
const accent = (r === 6 || r === 7) && (c === 5 || c === 7);
out.push({ cx: c + 0.5, cy: r + 0.5, r: rad, accent, d: (r * 13 + c) % 7 });
});
});
return out;
}, []);
return html`<div class="emblem" aria-hidden="true">
<span class="emblem__bracket emblem__bracket--l"></span>
<svg class="emblem__svg" viewBox="-0.5 -0.5 14 15">
${dots.map((d, i) => html`<circle key=${i} cx=${d.cx} cy=${d.cy} r=${d.r} class=${d.accent ? 'is-accent' : `d${d.d}`} />`)}
</svg>
<span class="emblem__bracket emblem__bracket--r"></span>
</div>`;
}
function Ridges() {
return html`<svg class="title-bg__ridges" viewBox="0 0 1920 420" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="ridge-far" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#16231f" /><stop offset="1" stop-color="#0a0e0d" />
</linearGradient>
<linearGradient id="ridge-near" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f1714" /><stop offset=".6" stop-color="#080b0a" />
</linearGradient>
<linearGradient id="ridge-edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#17f9b7" stop-opacity="0" />
<stop offset=".3" stop-color="#17f9b7" stop-opacity=".55" />
<stop offset=".7" stop-color="#17f9b7" stop-opacity=".55" />
<stop offset="1" stop-color="#17f9b7" stop-opacity="0" />
</linearGradient>
</defs>
<path class="ridge ridge--far" fill="url(#ridge-far)" stroke="url(#ridge-edge)"
d="M0 420V250l120-40 90 30 90-70 60 20 80-70 80 55 80-25 90 70 90-20 80 50 100-15 90 25 90-55 90-55 70-55 70 45 80-20 90 65 80-20 100 55 100-20 100 40v180z" />
<path class="ridge ridge--near" fill="url(#ridge-near)" stroke="url(#ridge-edge)"
d="M0 420V322l160-32 100 20 120-50 90 40 130-20 120 50 140-30 140 35 120-35 140 20 140-50 120 30 120-20 120 40 160-20v147z" />
</svg>`;
}
const STATUS_TEXT = {
idle: '准备连接', connecting: '正在连接服务器', connected: '已连接服务器', handshaking: '正在验证身份',
online: '已连接服务器', reconnecting: '连接中断,正在重连', closed: '连接已关闭',
};
/** Title screen component. */
export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin);
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const assetsSettled = useData('assets');
const assets = data.get('assets');
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
const ridges = findUiAsset(assets, RIDGE_KEYS);
// Track load/fail per URL (not as booleans reset in effects: an image can load before an effect runs).
const [bgLoadedUrl, setBgLoadedUrl] = useState(null);
const [ridgesLoadedUrl, setRidgesLoadedUrl] = useState(null);
const [ridgesFailedUrl, setRidgesFailedUrl] = useState(null);
const bgLoaded = !!backdrop && bgLoadedUrl === backdrop;
const ridgesLoaded = !!ridges && ridgesLoadedUrl === ridges;
const ridgesFailed = !!ridges && ridgesFailedUrl === ridges;
// CSS ridgelines only when there is no ridge art (avoids a swap flash when the art arrives).
const cssRidges = assetsSettled && (!ridges || ridgesFailed);
const valid = isValidName(name);
const start = () => {
if (!valid) { toast('请输入博士代号', 'warn'); return; }
enterSession(name);
};
const online = conn.status === 'online' || conn.status === 'connected';
const dotClass = online ? 'is-on' : conn.status === 'reconnecting' || conn.status === 'connecting' || conn.status === 'handshaking' ? 'is-warn' : 'is-bad';
// touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view)
const touchUi = useMemo(() => detectFeatures().coarse, []);
return html`<div class="screen title-screen">
<div class=${`title-bg${bgLoaded ? ' has-art' : ''}${ridgesLoaded ? ' has-ridges' : ''}`} aria-hidden="true">
${backdrop ? html`<img class="title-bg__art" src=${backdrop} alt="" draggable=${false}
onLoad=${() => setBgLoadedUrl(backdrop)} />` : null}
<div class="title-bg__glow"></div>
<div class="title-bg__radar"><div class="title-bg__sweep"></div></div>
<div class="title-bg__target"></div>
${cssRidges ? html`<${Ridges} />` : null}
${ridges && !ridgesFailed ? html`<div class="title-bg__ridge-art" style=${`background-image:url("${ridges}")`}>
<img src=${ridges} alt="" hidden onLoad=${() => setRidgesLoadedUrl(ridges)} onError=${() => setRidgesFailedUrl(ridges)} />
</div>` : null}
<div class="title-bg__haze"></div>
<span class="cross" style="left:7%;top:22%"></span>
<span class="cross" style="left:93%;top:30%"></span>
<span class="cross" style="left:14%;top:70%"></span>
<span class="cross" style="left:88%;top:62%"></span>
<span class="cross" style="left:60%;top:12%"></span>
</div>
<div class="title-corner title-corner--tl">
<span class="title-corner__mark"></span>
<div><${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE<//><br /><${MicroLabel}>TACTICAL CO-OP NODE · 02<//></div>
</div>
<div class="title-corner title-corner--tr">
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
</div>
<main class="title-main">
<${Emblem} />
<div class="title-en">
<span class="title-en__a">STRONGHOLD PROTOCOL</span>
<span class="title-en__b">ALLIANCE</span>
</div>
<h1 class="title-cn">卫戍协议<span class="title-cn__colon">:</span><em>盟约</em></h1>
<p class="title-tag">调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。</p>
<div class="title-login">
${pendingJoin ? html`<div class="title-invite">
<${Icon} name="key" />
<span>收到同盟邀请</span><b class="num">${pendingJoin}</b><span class="t-lo">· 输入代号后将自动加入</span>
</div>` : null}
<${TextField} label="博士代号" micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
placeholder="输入你的代号(最多 ${NAME_MAX_LEN} 字)" autoFocus=${!touchUi}
onInput=${setName} onEnter=${start} />
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>开始<//>
<div class="title-conn">
<span class=${`status-dot ${dotClass}`}></span>
<span>${STATUS_TEXT[conn.status] || conn.status}</span>
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
<${GuideButton} class="title-guide" />
<${FullscreenButton} class="title-fs" />
</div>
</div>
</main>
<footer class="title-foot">
<span>非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有</span>
<${MicroLabel}>PROTOCOL v1 · WEB SIMULATION<//>
</footer>
</div>`;
}