// In-match top bar (research 06 §11.1): exit + ping (left); round box, phase capsule (prep label / // kills n/m / boss HP bar), LP tower, match-info and enemy-preview buttons (centre, bracket frame); // 7-segment countdown with gauge and the 准备就绪 toggle (right). // The two 🔍 buttons follow the official HUD bindings (research 09 §2.1 / §6.2 item 3): // left btn_check_player — mint [🔍] opens the 本局信息 dialog (ui/enemyDrawer.js; its 敌方情报 tab is the secondary // enemy list); in the pen view it becomes [🔍◀◀] (btn_check_player_back) and returns the camera; // right btn_check_enemy — amber [🔍▶▶] pans the camera to the enemy preview pen (休整期 only); in the pen view (and // outside prep) it is the grey [🔍] (btn_check_enemy_unfold) — pressing it in the pen returns too. // Official sprites (local ui/battle extraction) when installed, CSS look-alikes otherwise. // Boss rounds (最终攻势 / 隐秘核心): the countdown is the level's 120 s maxPlayTime (m.public.deadline, gauge total from // gameLogic phaseTotalSeconds) and the red DOT overtime warning under it keys off m.public.overtimeAt (ui/matchStatus.js // overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the // merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause); // while m.public.paused every clock here is frozen at the pause moment (`frozenAt`). // Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate — // lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp; // the leaks come from the local battle runner, else m.public players[].pendingLp). 联防 (user playtest #6 item 7; PRTS // 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): a leaker's phase capsule carries the official // runner tag ×N (research 09 `tag_miss`; art ui/battle bg_miss_enemy [ASSUMED]) = its enemies still standing on the 联防 // field, uncapped and live — falling as the helpers kill them, rising when one splits or summons (the local 联防 // replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower shows lp − min(lpCapPerRound, N). // The same MissTag sits in the leakers' team rows (ui/teamPanel.js), its tooltip naming the teammate there (missTip). // 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button // (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice). import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { PHASE } from '../../../shared/constants.js'; import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, Modal, useTicker } from './components.js'; import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js'; import { localAsset } from '../data.js'; import { serverNow } from '../store.js'; import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, bossPctText, fmtNum, shopBlockReason } from './gameLogic.js'; import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js'; import { hotkeyLabelOf } from './settings.js'; import { t, tParts, N_ } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); /** * 观战席 capsule of the in-match top bar (GitHub #120, PR #120 by @salt-fishes; the spectator seats of community report #26): * the room's spectators, which the room screen lists in a strip but the game screen — the route after 开始 — used to hide * completely, so a spectator could only be removed before the match or after it. A small eye + the count beside the latency; * a tap opens the roster, with the host's ✕ on every row (room.removeSpectator: the server takes it at any time; the removed * seat gets room.closed {kicked} and no more frames). Renders nothing while no spectator seat is taken. * @param {{ spectators: any[]|null, myId: any, isHost: boolean, onRemove: ((playerId: any) => any)|null }} props */ export function SpectatorPill({ spectators, myId, isHost, onRemove }) { const [open, setOpen] = useState(false); const [busy, setBusy] = useState(null); const list = Array.isArray(spectators) ? spectators.filter((s) => s && typeof s === 'object') : []; // the roster closes with its last seat (the pill is gone: it must not pop up again for the next spectator) useEffect(() => { if (!list.length) setOpen(false); }, [list.length]); if (!list.length) return null; const remove = async (playerId) => { if (!onRemove) return; setBusy(playerId); try { await onRemove(playerId); } finally { setBusy(null); } }; return html`<${Button} variant="ghost" size="sm" icon="eye" class=${cx('specpill', list.some((s) => s.playerId === myId) && 'is-me')} onClick=${() => setOpen(true)} data-testid="spectators" title=${t('观战席')} aria-label=${t('观战席')}> ${list.length} ${open ? html`<${Modal} open=${true} title=${t('观战席')} micro="SPECTATORS" width="6.4rem" onClose=${() => setOpen(false)} actions=${html`<${Button} variant="secondary" onClick=${() => setOpen(false)}>${t('关闭')}`}>

${t('观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。')}

` : null}`; } /** * Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds). * @param {{ pub:any, hud:any }} props */ export function PhaseCapsule({ pub, hud, miss = null }) { const phase = pub?.phase; if (isBossPhase(phase)) { // the battle on screen first: its snapshot carries the live pool (the local simulation's own damage on top of the // server's b.pool, or the server's 20 Hz stream); m.public.bossHp refreshes at ~1 Hz and lags the leader's death const boss = hud?.boss || pub.bossHp || null; const frac = bossFrac(boss); return html`
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} /> ${hud?.total != null ? html`${hud.resolved ?? hud.killed ?? 0}/${hud.total}` : null}
${frac == null ? t('敌方领袖') : bossPctText(frac)}
`; } if (isCombatPhase(phase) || (phase === PHASE.SETTLE && hud?.total != null)) { return html`
<${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon" fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} /> ${hud?.resolved ?? hud?.killed ?? 0}/${hud?.total ?? '--'} ${phase === PHASE.UNITE ? html`${t('联防')}` : null} ${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
`; } return html`
<${Sprite} k="hudPanel/icon_rest" class="capsule__icon" fallback=${html`<${Icon} name="rook" class="capsule__icon" />`} /> ${prepCapsuleLabel(phase)}
`; } /** * Tooltip of a MissTag: the own tag speaks to the player ("你漏过的…"), a teammate's row names that teammate (the viewer * may be one of the helpers fighting those enemies). * @param {number} n enemies still standing @param {string|null} [name] the leaker's name (null = the viewer) */ export function missTip(n, name = null) { if (n > 0) return name ? t('{name} 漏过的敌人还剩 {n} 个(联防中)', { name, n }) : t('你漏过的敌人还剩 {n} 个(队友正在迎战)', { n }); return name ? t('{name} 漏过的敌人已全部被击倒', { name }) : t('你漏过的敌人已全部被击倒'); } /** * The official escaped-enemy tag (research 09: the 联防 capsule's orange runner tag ×N, node `tag_miss`; drawn with * ui/battle `bg_miss_enemy` — [ASSUMED] that sprite is its art: an orange pill with a runner and ×, the number after it): * during 联防 a leaker's enemies still standing on the 联防 field (user playtest #6 item 7) — the own in the phase capsule * and the own team row, a teammate's in that teammate's row (`name`). CSS look-alike without the art. * @param {{ n: number, name?: string|null }} props name: the leaker's name on a teammate's row (null = the viewer) */ export function MissTag({ n, name = null }) { const v = Math.max(0, Math.trunc(Number(n) || 0)); const art = localAsset('ui/battle', 'bg_miss_enemy'); return html` ${art ? null : html``}${v} `; } // ---- live LP of the own battle (user playtest #3 item 2) -------------------------------------------------------- /** Phases whose leaks are still to be charged at settlement (normal rounds: the own battle, then 联防). */ const LEAK_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE]); /** * LP a round's leaks cost at settlement — the server rule (server/match/match/settle.js): min(lpCapPerRound, counted * leaks), never negative. * @param {number} leaks counted leaks so far * @param {number} [cap] data/config.json lpCapPerRound */ export function pendingLoss(leaks, cap = 10) { const n = Math.max(0, Math.trunc(Number(leaks) || 0)); const c = Number(cap) > 0 ? Math.trunc(Number(cap)) : 10; return Math.min(c, n); } /** * Counted leaks of the own battle so far from its two sources: the local runner's count (state().leaks of the own * field — authoritative or a display replica) and the server's m.public players[].pendingLp (the authority's b.progress, * ~1 Hz; the recorded result in 联防). Both only grow during a round, so the further one wins: a display replica stands * still while the player watches a teammate's field (the server's count moves on), the server's lags the local one by * up to a second. Missing / invalid values count as 0. * @param {any} local @param {any} server */ export function ownLeaks(local, server) { const n = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0); return Math.max(n(local), n(server)); } /** * A leaker's enemies still standing on the 联防 field from its two sources (user playtest #6 item 7): the local 联防 * replica's count (runner state().uniteLeft[own id] — the same deterministic battle the player is watching, so the tag * matches the field on screen) and the server's m.public players[].uniteLeft (the authority's b.progress, ~1 Hz; exact * once the field is done). The count falls as the helpers strike enemies down and rises when one splits or summons, so * neither source bounds the other: the replica wins while it runs, the server's value fills in without one (not loaded * yet, not on screen). null when neither is known (not a leaker, or nothing yet). * @param {any} local @param {any} server * @returns {number|null} */ export function uniteRemaining(local, server) { for (const v of [local, server]) if (Number.isFinite(v) && v >= 0) return Math.trunc(v); return null; } /** * The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N) * instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the * pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is * dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the * SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice. * 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them); `uniteLeft` (its * enemies still standing, uniteRemaining — user playtest #6 item 7) replaces the own battle's count, so the loss falls * live as the helpers kill them, marked `unite` (联防中) with `left` = that uncapped number for the ×N tag. Without * `uniteLeft` (not a leaker, or no count yet) the own battle's count stays on show. The pending part goes when the * settlement lands. Boss rounds are not handled here (the merged team LP moves live through b.pool / m.public.teamLp). * @param {{ round: any, lp: number, statsLeaks: number|null } | null} base * @param {{ phase: string, round: any, lp: any, statsLeaks?: any, leaks?: any, cap?: number, alive?: boolean, uniteLeft?: number|null }} s * @returns {{ base: { round: any, lp: number, statsLeaks: number|null } | null, pending: number, shown: number|null, unite: boolean, left: number|null }} */ export function liveLp(base, { phase, round, lp, statsLeaks = null, leaks = 0, cap = 10, alive = true, uniteLeft = null }) { if (!Number.isFinite(lp)) return { base: null, pending: 0, shown: null, unite: false, left: null }; if (!LEAK_PHASES.has(phase) || alive === false) return { base: null, pending: 0, shown: lp, unite: false, left: null }; const sl = Number.isFinite(statsLeaks) ? statsLeaks : null; const b = base && base.round === round ? base : { round, lp, statsLeaks: sl }; const landed = lp !== b.lp || (sl != null && b.statsLeaks != null && sl !== b.statsLeaks); const left = phase === PHASE.UNITE && Number.isFinite(uniteLeft) && uniteLeft >= 0 && !landed ? Math.trunc(uniteLeft) : null; const pending = landed ? 0 : Math.min(lp, pendingLoss(left != null ? left : leaks, cap)); return { base: b, pending, shown: lp - pending, unite: phase === PHASE.UNITE && (pending > 0 || left != null), left }; } /** * Tooltip of an LP tower with a pending loss (null without one). * @param {number} lp settled LP @param {number} pending @param {{ unite?: boolean, cap?: number }} [opts] */ export function pendingTip(lp, pending, { unite = false, cap = 10, left = null } = {}) { if (!(pending > 0)) return null; if (unite && Number.isFinite(left)) { const each = left > cap ? t('剩余不足 {cap} 个后,队友每击倒一个少扣 1 点', { cap }) : t('队友每击倒一个就少扣 1 点'); return t('目标生命值 {lp}:联防中,你漏过的敌人还剩 {left} 个,{each};按现在结算扣除 {pending} 点(每回合至多 {cap} 点)', { lp, left, each, pending, cap }); } return unite ? t('目标生命值 {lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 {pending} 点)', { lp, pending }) : pending >= cap ? t('目标生命值 {lp}:本回合已有 {cap} 个以上敌人进入蓝门,结算时扣除 {pending} 点(每回合至多 {cap} 点)', { lp, pending, cap }) : t('目标生命值 {lp}:本回合已有 {pending} 个敌人进入蓝门,结算时扣除 {pending} 点(每回合至多 {cap} 点)', { lp, pending, cap }); } // ---- temp overflow row (临时整备区, user playtest #3 item 3) --------------------------------------------------- /** * Pieces waiting in the temp overflow row (m.private temp): how many, and how many are items. * @param {any} priv m.private * @returns {{ count: number, items: number }} */ export function tempInfo(priv) { const list = (Array.isArray(priv?.temp) ? priv.temp : []).filter((p) => p && typeof p === 'object'); return { count: list.length, items: list.filter((p) => p.kind === 'item').length }; } /** What the temp row asks of the player (the ready button's reason, the row's label). */ export const TEMP_RULE = N_('放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁'); /** Why 准备就绪 is refused while the temp row holds pieces (null when it is empty). */ export function tempReadyReason(priv) { const info = tempInfo(priv); return info.count ? t('临时整备区还有 {count} 个单位:{rule}', { count: info.count, rule: t(TEMP_RULE) }) : null; } /** * Ready toggle (PREP only). Disabled while the temp row holds pieces or a personal choice (教鞭) is open — the reason shows * under it (not only on hover): "临时整备区 N 个单位待处理" (user playtest #3 item 3), "请先完成教鞭选择". * @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props */ export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) { const ready = !!priv?.ready; const temp = tempInfo(priv); const choice = !!priv?.personalChoice; const reason = !ready ? (choice ? shopBlockReason('ready', { priv, editable: true }) : tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true })) : null; const why = !ready && (choice || temp.count > 0); const btn = html``; return html`
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}` : btn} ${why ? html` <${Icon} name="warn" />${choice ? html`${reason}` : html`${tParts('临时整备区 {n} 个单位待处理', { n: html`${temp.count}`, count: temp.count })}`}` : null} ${Number.isFinite(total) && total > 1 ? html`${t('已就绪')} ${readyCount}/${total}` : null}
`; } /** * Sprite + look of the two 🔍 HUD buttons for a state (pure; tested in test/ui/uiFixes.test.js). * @param {{ pen: boolean, penAvail: boolean, infoOpen: boolean }} st * @returns {{ left: { sprite: string, back: boolean, label: string, tip: string }, right: { sprite: string, grey: boolean, label: string, tip: string } }} */ export function checkButtons({ pen, penAvail, infoOpen }) { const left = pen ? { sprite: 'btn_check_player_back', back: true, label: t('返回'), tip: t('返回战场') } : { sprite: infoOpen ? 'btn_check_player_unfold' : 'btn_check_player_normal', back: false, label: t('本局信息'), tip: t('本局信息(策略 / 禁用盟约 / 干员)') }; const right = pen ? { sprite: 'btn_check_enemy_unfold', grey: true, label: t('返回'), tip: t('返回') } : penAvail ? { sprite: 'btn_check_enemy', grey: false, label: t('敌方情报'), tip: t('查看即将迎击的敌方单位') } : { sprite: 'btn_check_enemy_unfold', grey: true, label: t('敌方情报'), tip: t('休整期可以查看即将迎击的敌方单位') }; return { left, right }; } /** One official 🔍 button: the sprite when installed, a CSS look-alike (icon + chevrons) otherwise. */ function CheckBtn({ sprite, cls, label, chev, on, disabled, onClick, testid }) { const url = localAsset('ui/battle', sprite); return html``; } /** * The red overtime (DOT) warning of a boss round (pure view of ui/matchStatus.js overtimeState). * @param {{ ot: ReturnType }} props */ export function OvertimeWarning({ ot }) { if (!ot) return null; if (ot.state === 'pending') { return html``; } return html``; } /** Two-bar pause glyph (the official battle pause button). */ function PauseGlyph() { return html``; } /** * Solo pause / resume control (g.pause): pressed while paused. * @param {{ paused: boolean, busy?: boolean, onToggle: () => void }} props */ export function PauseButton({ paused, busy = false, onToggle }) { const label = paused ? t('继续作战') : t('暂停'); const key = hotkeyLabelOf('ready'); // the ready key pauses a solo battle (Space unless rebound: 设置 → 快捷键) return html`<${Tooltip} text=${paused ? t('继续作战({key})', { key }) : t('暂停作战({key})', { key })} placement="bottom"> `; } /** * Top bar. * @param {{ pub:any, priv:any, conn:any, hud:any, total:number|null, drawer:string|null, onExit:Function, onDrawer:(tab:string)=>void, * onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number, * pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null, * pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null, * live?: { pending: number, unite: boolean, left?: number|null } | null, * spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props * spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes) * frozenAt: the server time every clock shows while the solo match is paused (null = live) * live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防; * `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag */ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {}, config = null, frozenAt = null, pause = null, live = null, spectator = false, spectators = null, myId = null, isHost = false, onRemoveSpectator = null }) { const phase = pub?.phase; const boss = isBossPhase(phase); const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null; // normal rounds: the leaks of the own battle so far (boss rounds: the team LP above already moves live) const pending = !boss && Number.isFinite(lp) && live && live.pending > 0 ? Math.min(lp, live.pending) : 0; const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound); const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--'; // a spectator seat (no m.private, community report #26) never readies const showReady = phase === PHASE.PREP && !spectator && priv?.alive !== false; // boss rounds: the overtime warning follows the clock (4 Hz while live; frozen while paused) const otLive = boss && Number(pub?.overtimeAt) > 0; useTicker(otLive && frozenAt == null ? 250 : 0); const now = Number.isFinite(frozenAt) ? frozenAt : serverNow(); const ot = otLive ? overtimeState(pub, now, { perSec: overtimeDrainPerSec(config) }) : null; const draining = ot?.state === 'drain'; const lowLp = (Number.isFinite(lp) && lp - pending <= 5) || draining; const cap = Number(config?.lpCapPerRound) > 0 ? Number(config.lpCapPerRound) : 10; const frozenSecs = Number.isFinite(frozenAt) ? remainAt(pub?.deadline, frozenAt) : null; const btn = checkButtons({ pen, penAvail, infoOpen: !!drawer }); const onLeft = () => (pen ? onPen(false) : onDrawer('info')); const onRight = () => (pen ? onPen(false) : penAvail ? onPen(true) : null); return html`
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} /> <${SpectatorPill} spectators=${spectators} myId=${myId} isHost=${isHost} onRemove=${onRemoveSpectator} />
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
<${Tooltip} text=${btn.left.tip} placement="bottom"> <${CheckBtn} sprite=${btn.left.sprite} cls=${cx('gtop__iconbtn', btn.left.back && 'is-back')} label=${btn.left.label} chev=${btn.left.back ? '◀◀' : null} on=${!!drawer && !pen} onClick=${onLeft} testid="check-player" />
${t('回合')} ${roundText}
<${PhaseCapsule} pub=${pub} hud=${hud} miss=${!boss && Number.isFinite(live?.left) ? live.left : null} /> <${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} pending=${pending} note=${pending > 0 && live?.unite ? t('联防中') : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap, left: live?.left ?? null })} /> <${Tooltip} text=${btn.right.tip} placement="bottom"> <${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label} chev=${btn.right.grey ? null : '▶▶'} disabled=${btn.right.grey && !pen} onClick=${onRight} testid="check-enemy" />
${frozenSecs != null ? html`<${Countdown} seconds=${frozenSecs} total=${total ?? undefined} size="md" label="PAUSED" />` : html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="md" />`} ${pause && (pause.show || pause.paused) ? html`<${PauseButton} paused=${!!pause.paused} busy=${pause.busy} onToggle=${pause.onToggle} />` : null}
<${OvertimeWarning} ot=${ot} /> ${showReady ? html`<${ReadyToggle} priv=${priv} onToggle=${onReady} busy=${readyBusy} readyCount=${readyCount} total=${playerCount} />` : null}
`; } /** DP counter shown at the right edge during combat. */ export function DpCounter({ dp }) { if (!Number.isFinite(dp)) return null; return html`
<${GIcon} name="dp" class="dpbox__icon" />${Math.floor(dp)}<${MicroLabel}>COST
`; } /** Hook: stable callback ref helper. */ export function useLatest(v) { const r = useRef(v); r.current = v; return r; }