# 比赛控制与直播提案 > 状态:待确认,尚未实现。保留自 2026-10-19 的界面重构草案;以下布局、状态、命令、快捷键与验收数字均为提议,不是当前能力或测试结果。 > 比赛判负、技术暂停、计时比分、直播页和双路同步回放仍需确认。当前客户端说明见[桌面端](../desktop/client.md),已实现的 USB 维护与固件包格式见[固件维护](../maintenance/firmware.md)。 > 原草案中的旧色板、字体方案、单文件 CSS 基线、烧录实现计划和排期已被当前实现取代,不再作为开发依据。 > 规则依据:[2026 校内赛参赛手册](https://docs.simba-robotics.com/zh-cn/competition/)、[对抗赛与奖项](https://docs.simba-robotics.com/zh-cn/competition/matches/)、[裁判系统](https://docs.simba-robotics.com/zh-cn/competition/referee-system/)。规则摘录沿用原稿,实施前需核对手册。 ## 页面方案 ### 应用外壳(新增 `AppShell`) ``` ┌──────────────────────────────────────────────────────────────────────┐ │ TopBar 44px cam-desktop · 裁判端 · 链路● │ 比赛条(裁判/直播) │ 工具 │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ Stage:视频 + 画面内 HUD + 覆盖层(暂停/判定/校准) │ │ │ ├──────────────────────────────────────────────────────────────────────┤ │ StatusBar 28px 链路 / 帧率 / 丢帧 / 提示 │ └──────────────────────────────────────────────────────────────────────┘ ``` 三种外壳状态(`data-shell`): | 状态 | 触发 | 行为 | | --- | --- | --- | | `normal` | 默认 | 全部 chrome 可见 | | `immersive` | F11 | chrome 隐藏;指针移到上沿 8px 时 TopBar 150ms 淡入,3 s 后淡出;指针静止 2 s 隐藏光标 | | `broadcast` | 视图切换 `Ctrl+Shift+B` | 只渲染 `BroadcastStage`;禁用设置/维护/发现列表;不渲染任何 IP/token | 结构落点:`App.tsx` 里现有的门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码)不动,只在最后一段把 `TeamPage | RefereePage` 包进 `AppShell`,并新增 `BroadcastPage` 分支。 ### 用户端 `TeamPage`(操作手视角) 使用场景:操作手位于布帘后,单手操作、不看键盘,单局 3 分钟。 ``` ┌──────────────────────────────────────────────┐ │ 02:41 (计时) │ ← 仅比赛进行中出现 │ ┌────────────┐ ┌────────────┐│ │ │我方 ████ 76│ │100 ████ 对手││ ← 血条贴角,宽 36% │ └────────────┘ └────────────┘│ │ │ │ + (准星)│ │ │ │ (命中时:上中"命中 -3" + 全屏淡红 620ms) │ └──────────────────────────────────────────────┘ │ 链路 · 28.6 fps · 丢 0.4% │ ← release 下只留"链路" ``` 改动点: 1. 顶部加计时条(仅 `match.phase ∈ {running, paused}` 且本队已绑定);不显示比分细节,局间才显示"第 2 局 · 1:1"。 2. 底部状态行从"滚轮缩放 · 拖动平移…"改成首次提示:进入页面 5 s 后自动淡出,鼠标移动再出现(作为新交互提议,时长仍需确认)。 3. 新增 `PausedOverlay`:技术暂停时整屏 86% 遮罩 + "技术暂停 03:12 · 由裁判发起",血条/计时保留可见。 4. 新增 `JudgedOverlay`:判负/结束时显示"本局判负(设备故障)"或"胜利",与 `MatchBanner` 合并成一个组件(避免两套结论 UI 同时出现)。 5. 设置入口、录制入口不进主界面(现在 TopBar 里有"设置/固件维护",用户端误触风险高):改为 `Ctrl+K` 命令面板或长按品牌名 2 s。 6. 校准模式(现有 `calibrating`)保留,但提示从底部横幅改到画面内角标,避免遮挡。 ### 裁判端 `RefereePage` 三区 + 一条比赛条: ``` ┌ TopBar ────────────────────────────────────────────────────────────────┐ │ cam-desktop · 裁判端 · 链路● · 录制×2 [直播] [设置] │ ├ MatchBar 52px ─────────────────────────────────────────────────────────┤ │ BO3 第2局 │ 02:41 │ 红 76 ╍╍╍╍ 100 蓝 │ ⏸ 技术暂停 │ ⚑ 判负 │ ⋯ 更多 │ ├────────────────────────────────────────────────────────────────────────┤ │ ┌─ 红方(左)──────────────┐ ┌─ 蓝方(右)──────────────┐ │ │ │ video + HUD │ │ video + HUD │ │ │ │ [队名 可编辑] [fps] │ │ [队名 可编辑] [fps] │ │ │ └─────────────────────────┘ └──────────────────────────┘ │ ├ Dock ─────────────────────────────────────────────────────────────────┤ │ [装甲板 8] [录制与回放 3] [事件 27] [连接诊断] [刷新] [⟳] │ │ …内容(表格/列表/日志)… │ └────────────────────────────────────────────────────────────────────────┘ ``` 改动点: 1. `MatchBar`(新,常驻,高 52px):局次、计时、比分、暂停、判负、更多菜单。这是裁判端最重要的新增组件;判定后整条切成"结果态"(红/蓝底 + 理由 + 撤销入口)。 2. `referee-stage` 保持 `flex: 1`,新增的 `MatchBar` 占固定高度;下方控制台采用可拖拽高度(提议默认 240px,最小 120,最大 45vh);加一行比赛条后,900px 高的窗口约剩 560px 用于画面。 3. `ArmorTable` 改为两队并列(左红右蓝),每行左侧加 5 格迷你血条(与 HUD 同源,便于核对);主装甲板加 `主` 徽标;离线行 `opacity .55`(保留);`reportsAllPlates=false` 时显示"仅报 2 块"——规则允许 2 或 4 块装甲板。 4. 新增 `EventPanel`(Dock 第 3 个页签):命中/暂停/判负/录制开始/结束的时间轴列表,可导出。 5. `MediaPanel` 保留全部功能,新增两个东西:按局分组(`matchId` 目录)与 "回放本局"(双路同步 + 事件定位)。 6. 队名编辑保留(现在是内联输入),但改成"点击铅笔 → 行内编辑 → Enter 保存 / Esc 取消",避免一直占一行高。 ### 直播 `BroadcastPage`(新) 见下文直播方案。要点:一个 1920×1080 虚拟画布 + 4 个模板 + 安全区参考线 + 只读数据。 ## 状态与交互 ### 状态模型(前端) ```ts interface AppStore { snapshot: Snapshot | null; connected: boolean; lastHit: HitPulseRecord | null; confirmQuit: number; notes: string[]; channelError: string | null; // 新增 ui: { shell: "normal" | "immersive" | "broadcast"; guides: boolean; idle: boolean }; events: MatchEventView[]; // 由 WireMsg::MatchEvent 累积(本地最多 500 条) clockSkewMs: number; // serverNow - localNow,用于计时插值 } ``` 事件传输:比赛事件走独立推送,与现有的 `hit` 同一条路(`server::WireMsg` 新增 `MatchEvent`),前端累积;`Snapshot.match` 只带最近 12 条用于重连后补齐。理由:快照 5 Hz × 200 条事件会让每份快照增加到几十 KB,事件单独推送可避免重复传输。 计时以后端为准:`Snapshot` 增加 `nowMs: u64`(后端 Unix 毫秒),前端用 `useServerClock` 每帧算 `serverNow = localNow + clockSkewMs`,再用 `match.remainingMs - (serverNow - snapshotAt)` 插值。`setInterval` 在窗口失焦时会被节流,不能作为比赛计时依据。 ### 一键判负 入口:`MatchBar` 右侧 `⚑ 判负`,或 `Ctrl/Cmd+Shift+F`。只在裁判端、`phase ∈ {running, paused}` 时可用。 三步,不可合并 ``` ① 点按 → ForfeitDialog ┌─────────────────────────────────────────┐ │ 判定本局结果 │ │ 当前:红 76 · 蓝 100 | 剩余 02:41 | 第2局│ │ 败方: (●) 红方 ( ) 蓝方 │ │ 理由: [设备故障 ▾] │ │ 设备故障 / 违规 / 弃权 / 未到场 / 其他 │ │ 备注: [________________________] │ │ ⚠ 本操作只改变比赛判定与记录,不改变硬件血量 │ │ [取消] [长按 1 秒确认判负] │ └─────────────────────────────────────────┘ ② 长按 1000ms(HoldButton,松手即取消,Esc 取消) ③ 5s 内 toast 提供「撤销」 ``` 执行语义 ```rust match_end { reason: Forfeit { loser: 0|1, kind: ForfeitKind, note: Option } } ``` - 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。 - 用户端若已同步(见 R2):显示 `JudgedOverlay`「本局判负」;未同步则只影响裁判端与直播。 - 不改硬件血量:`ArmorHealthStore` 只接收上报,没有任何写入口(见 R1)。界面必须显式写明这一点,否则现场会以为"判负之后对方血量归零了"。 - 审计:事件 `{ at, actor, action:"forfeit", loser, reason, note, hp:[76,100], remainingMs:161000 }`,追加到 `matches///events.ndjson`,同时进 `snapshot.match.events`。 - 撤销:`match_undo { eventId }`,5 s 内可直接撤销;之后只能 `match_reopen_round { reason }`,在历史里留下"更正"两条记录(不删除原记录)。 与自动判定共存:`resolve_outcomes` 已经能在血量归零时给出 `Victory/Defeat/Draw`。两者必须用同一字段建模: ```ts end: { by: "auto" | "referee"; reason: "knockout" | "timeout" | "forfeit" | "equipment" | "tiebreak"; loser?: 0|1 } ``` 回放和统计据此区分结束原因。 ### 技术暂停 规则对齐:手册写"任意一方…可向场边裁判申请发起时长为 5 分钟 的技术暂停并说明理由,待主裁判批准后技术暂停开始。技术暂停期间,双方均可在该段时间内检修机器人。" 四步流程 ``` 申请方(红/蓝) → 理由 → ✅ 主裁判已批准 → 开始计时(05:00) → 恢复 ``` - 入口:`MatchBar` 的 `⏸ 技术暂停`。对话框必填:申请方、理由(枚举+备注)、"主裁判已批准"复选框(不勾不能开始——把规则写进交互里)。 - 开始后:`phase = Paused { kind: Tech, endsAt, applicant }`,比赛计时冻结,技术暂停独立倒计时 5:00。 - 用户端/直播:`PausedOverlay` 显示"技术暂停 · 剩余 04:12",不遮挡血条与计时。 - 结束: - 手动「恢复比赛」→ 3-2-1 倒数 3 s(`Resuming` 过渡态)→ `Running`。 - 05:00 到点 → 状态 `PausedTimeout`,提示"技术暂停已超时",按钮变成「恢复」/「判负」二选一,不自动判负(规则没写超时即判负)。 - 录制不停(证据链需要),但写入 `bookmark` 事件;视频文件里保留暂停期间的画面。 - 暂停期间的命中:硬件照常上报,客户端如实显示并把事件标红(不隐藏、不改血量)。理由见 R1/Q3——客户端没有写硬件的能力,做"冻结基线"只会制造两份互相矛盾的血量。 ### 计时 / 比分 两个不同的 3 分钟,界面必须分开写 | 阶段 | 时长 | 文案 | | --- | --- | --- | | 赛前准备 | 3:00 | `准备 02:41`(浅色,不计入比赛) | | 单局比赛 | 3:00 | `比赛 02:41`(主色,进入最后 30 s 变红) | | 技术暂停 | 5:00 | `技术暂停 04:12`(青色) | 状态机 ``` Idle ──start_round──▶ Running ──pause──▶ Paused ──resume──▶ Running ▲ │ │ │ ├──hp=0 / timeout──▶ Ended(by=auto) │ └──forfeit─────────▶ Ended(by=referee) └──reset/next_round─────┴──────────────────┘ Ended(timeout 且血量相等) ──▶ AwaitingTiebreak ──set_tiebreak──▶ Ended(by=referee, reason=tiebreak) ``` 到点判定(严格照手册四条): 1. 血量先减为 0 的一方落败 → 已有 `resolve_outcomes`,直接复用。 2. 3 分钟耗尽且无人归零 → 血量多者胜。 3. 双方血量均等 → 不以"平局"结束,进入 `AwaitingTiebreak`,由裁判选择"本局最快开始移动的一队"。 - 客户端没有运动遥测(协议里没有位置/速度),所以只能人工录入:`set_tiebreak { winner: 0|1 }`。 - 这条必须显眼提示裁判"需要人工判定",不能默认算平局,否则会静默改变比赛结果。 4. 两边同时归零 → `Draw`(现有 `MatchOutcome::Draw` 逻辑保留,仅用于显示)。 比分:`bestOf ∈ {1,3,5}`(对应手册 BO1/BO3/BO5),`score: [u8;2]`,`history: Vec`。先到 `ceil(bestOf/2)` 局的一方赢下系列赛,界面显示 `2:0 · 系列赛结束`。 计时实现(后端为准): ```rust pub struct MatchClock { started: Option, frozen_elapsed: Duration, // 暂停累计 limit: Duration, } impl MatchClock { fn remaining(&self) -> Duration; fn pause(&mut self); fn resume(&mut self); } ``` 在 `AppState::tick()`(5 Hz)里推进;`Snapshot.match.remaining_ms` 为权威值;`Snapshot.now_ms` 用于前端插值。不允许前端 `setInterval` 自己减。 ### 回放 入口:`Ended` 后结果卡 →「回放本局」;或 Dock 的「录制与回放 → 按局分组」。 布局 ``` ┌ 回放 · 2026-11-01 第2局 红 vs 蓝 ─────────────── [导出] [返回] ┐ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ │ 红方录屏 │ │ 蓝方录屏 │ │ │ └──────────────────────┘ └──────────────────────┘ │ │ ▶ ━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 01:12/03:00 │ │ ⚑ 00:00 开始 · ● 00:41 命中 · ⏸ 01:12 技术暂停 · ⚑ 02:33 判负 │ └────────────────────────────────────────────────────────────────┘ ``` - 两个 `