卫戍协议:盟约 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:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -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>`;
|
||||
}
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
Reference in new issue
Block a user