Files
simba-rmic-2026/docs/design/match-and-broadcast.md
T
sloray 6a0eb14b28
构建客户端 / 检查(类型 + 单元测试) (push) Waiting to run
构建客户端 / Linux x64(deb + AppImage) (push) Blocked by required conditions
构建客户端 / Windows x64(NSIS 安装包) (push) Blocked by required conditions
构建客户端 / macOS 通用(.app + .dmg) (push) Blocked by required conditions
docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。

- fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示
- test(client): 按文件名核对媒体类型,避免依赖时间戳排序

验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
2026-10-19 11:26:26 +08:00

535 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 比赛控制与直播提案
> 状态:待确认,尚未实现。保留自 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<String> } }
```
- 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。
- 用户端若已同步(见 R2):显示 `JudgedOverlay`「本局判负」;未同步则只影响裁判端与直播。
- 不改硬件血量:`ArmorHealthStore` 只接收上报,没有任何写入口(见 R1)。界面必须显式写明这一点,否则现场会以为"判负之后对方血量归零了"。
- 审计:事件 `{ at, actor, action:"forfeit", loser, reason, note, hp:[76,100], remainingMs:161000 }`,追加到 `matches/<yyyy-mm-dd>/<matchId>/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<RoundResult>`。先到 `ceil(bestOf/2)` 局的一方赢下系列赛,界面显示 `2:0 · 系列赛结束`。
计时实现(后端为准):
```rust
pub struct MatchClock {
started: Option<Instant>,
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 判负 │
└────────────────────────────────────────────────────────────────┘
```
- 两个 `<video>` 共用主时钟(以 A 为主,B 每秒校正一次,偏差 > 120 ms 才 `seek`,避免抖动)。
- 事件时间轴可点击跳转:`currentTime = (event.atMs - roundStartAtMs) / 1000`。
- 快捷键:`空格` 播放/暂停、`←/→` ±1 s、`Shift+←/→` ±5 s、`M` 打标记。
- 截图:现有 `api.screenshot(camera)` 只能截最新一帧,不能截回放帧。需要新增 `save_png { data: Vec<u8>, name: String }`(限制写入 `recordings_dir/shots`),前端用 `canvas.drawImage(video)` + `toBlob` 取帧。
- 录制文件命名建议:`<date>_<round>_<红队>_vs_<蓝队>_<cameraText>.mp4`;若比赛开始时自动开启录制,需要给 `Recorder::start` 增加一个 `label: Option<&str>`。
- 录制起停由 `match_start/end` 发出事件,是否自动则看用户配置(默认自动开、自动停;退出程序仍走现有 `stop_all` 收尾)。
---
## 直播 UI:安全区与包装层
### 基准画布与安全区
基准 1920×1080(16:9)。相机是 640×480(4:3),所以直播层必须自己决定是加信箱还是裁切,不能指望画面填满。
| 区域 | 距边距 | 1080p 像素 | 内容 |
| --- | --- | --- | --- |
| 画布 | — | 0 | 1920×1080 |
| Action safe(93%) | 3.5% | 左右 67px / 上下 38px | 视频、装饰边框 |
| Title safe(90%) | 5% | 左右 96px / 上下 54px | 比分、计时、队名、判定卡 |
| 可牺牲区(平台遮挡) | — | 底部 120px(y ≥ 960)、右侧 60px | 只放赞助条/次要字幕;比分、计时、判定卡一律不得进入 |
| 视频安全区(画面内 HUD) | 3.5% | 640×480 上约 22px 横 / 17px 纵 | 血条与命中提示条 |
> 若输出 720p,以上像素值 ×2/3;若必须 4:3 输出(1440×1080),安全区按宽高比各算一次,不要简单套 16:9 的数字。
### 分区与坐标(模板 A:双路对称,默认)
```
y=0 ┌──────────────────────────────────────────────────────────┐
│ [赛事名/校徽] ● LIVE BO3 第2局 │ 0–108 标题区
y=108 ├──────────────────────────────────────────────────────────┤
│ ┌────────────────┐ ┌────────────────┐ │
y=150 │ 红方 │ 视频 800×600 │ │ 视频 800×600 │ 蓝方 │ 150–750 媒体区
│ 队名 │ (640×480 放大)│ │ │ 队名 │ x:140–940 / 980–1780
y=750 ├──────────────────────────────────────────────────────────┤
│ 红 76 ━━━━━━ 02:41 ━━━━━━ 100 蓝 │ 770–930 比分/计时
y=930 ├──────────────────────────────────────────────────────────┤
│ [事件字幕] [赞助条] │ 940–1080 可牺牲区
y=960 │ - - - - - - - - - 平台遮挡区上沿(不放关键信息) - - - - - │
y=1080 └──────────────────────────────────────────────────────────┘
```
- 媒体区左右各留 140px:其中 100px 给队名,剩余给队伍色条(`--team-0/1` 4px 竖条)。
- 比分/计时带固定 y 770–930,底边 930 < 960,永远不进平台遮挡区;判定卡必须在 title safe 内(x 96–1824,y 54–1026)。
- 计时居中,字号 `--fs-clock`;比分 `--fs-score`。
- 4:3 → 800×600 是 1.25× 放大,VGA 源放大后仍可读;不要裁切(裁切会切掉装甲板或场地边线,判罚举证时没有依据)。
模板 B:主视角 + PIP(一方图传丢失时自动建议)
```
┌──────────────────────────────────────────────┐
│ 标题条 │
│ ┌────────────────────────────────────────┐ │
│ │ 主画面 1600×900(居中偏上) │ │
│ └────────────────────────────────────────┘ │
│ ┌──────────┐ │
│ │ PIP 480×360│ ← 右下,避开平台遮挡区
│ 比分板 └──────────┘ │
└──────────────────────────────────────────────┘
```
模板 C:待机/准备(`Idle/Ready`,或信号中断)
- 中央:赛事名 + 双方队名 + `准备 03:00`(大号)+ "信号恢复中"(画面缺失时显示)。
- 背景:`--bg-void` + 2% 噪点 + 细网格(`background-image: repeating-linear-gradient`,静态,不滚)。
- 永不显示纯黑屏(黑屏会被平台判为离线)。
模板 D:判定(`Ended`)
- 中央判定卡:`红方胜` / `本局判负`(`--fs-display`)+ 理由(设备故障/超出时间/血量归零)+ 比分。
- 卡背景 `rgba(8,8,12,.72)` + 1px 亮边 + 切角,不做全屏红闪。
### 包装层结构(z 序)
```
z0 BackgroundLayer 底色 + 静态网格 + 噪点
z1 MediaLayer 双路 VideoSurface(复用现有组件,crosshair=null)
z2 HudLayer 画面内血条(可选显示,直播默认显示)
z3 LowerLayer 比分板 / 计时 / 队名 / 事件字幕
z4 AlertLayer 暂停 / 判负 / 判定卡
z5 BrandLayer 赛事名、校徽、赞助、LIVE 徽标
z6 GuideLayer 安全区参考线(仅预览,输出时 display:none)
```
实现要点:
- `BroadcastStage` 用 1920×1080 的绝对坐标系 + `transform: scale(min(vw/1920, vh/1080))`,`transform-origin: top left`。这样所有设计稿坐标直接写进 CSS,无需换算为百分比。
- 参考线由 `GuideLayer` 画(`outline: 1px dashed`),受 `props.guides` 控制;全屏/输出时自动关闭。
- 视频源仍用 `VideoSurface`(`ViewTransform` 固定 `IDENTITY_VIEW`),只是容器换成 800×600 的固定框——不要为直播再写一套 canvas 绘制。
### 输出方式
| 方案 | 做法 | 评价 |
| --- | --- | --- |
| A(推荐) | 同一窗口切 `broadcast` 视图 + F11 全屏;OBS「窗口采集」+ 裁到 16:9 | 无需新增权限或进程,作为首选方案 |
| B | 第二个 `WebviewWindow`(`label: broadcast`,无边框、置顶) | 需要 `core:webview:allow-create-webview-window`;双窗口抢 GPU,留待后续实现 |
| C | NDI / 虚拟摄像头 | 明确不做(依赖重、跨平台成本高) |
实施时需检查:
1. WebView 背景:`transparent: true` 在 Windows WebView2 与 macOS WKWebView 行为不一致,直播场景下透明边缘会出黑边。结论:用不透明底色 `--bg-void` 铺满,不做透明。
2. 低帧画面 + 高帧包装:视频是 JPEG 序列,帧率取决于固件配置(`commands.rs` 的注释里记着"The sensor 实测 22 fps",`target/firmware/BUILD-INFO.json` 里 vga30 档实测 29.7 fps);无论哪个值都远低于包装层的 60 fps,动效会"飘"在画面上。所以包装层只做状态迁移动画,不做连续动画。
3. 纯黑编码:`#000` 在 H.264 里容易 banding,用 `#07080a` + 2% 噪点。
4. OBS 窗口采集会采到鼠标:全屏时 `cursor: none`(`immersive` 状态负责)。
5. 窗口标题:直播窗口标题里不要出现队名/端口等内部信息(`setTitle` 现在会写身份;broadcast 视图下改成 `cam-desktop — 直播`)。
6. 分辨率自适应:`transform: scale()` 后文字仍清晰(矢量),但如果 OBS 缩放窗口,字号会变成非整数像素 → 建议 OBS 采集时窗口保持 1920×1080 或 1280×720。
### 直播视图的安全边界(必须做)
- `broadcast` 视图不渲染:设置按钮、维护入口、`cameras[].addr`(IP)、`stream_info.url`(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
- 所有管理命令在广播态禁用(不是隐藏,是 `disabled` + `aria-hidden`,防止误触与 Tab 聚焦)。
- 演示模式(`demo`)在直播里显示红色角标"演示画面",与现有 `tag--demo` 同一语义(现在只在 TopBar,直播态必须另有一处)。
- 断流时 `StandbyCard` 明确写"信号中断 · 正在重连",不显示上一帧冻结画面冒充直播(冻结画面会被观众当成"画面卡住")。
---
## 校内赛规则要点与判罚流程
### 规则事实(来自官方手册,不改写)
| 条目 | 规定 | 来源 |
| --- | --- | --- |
| 队伍 | 每队严格 3 人,每人限一队;16 队取得比赛资格 | [赛事概览](https://docs.simba-robotics.com/zh-cn/competition/) |
| 赛制 | BO1 / BO3 / BO5;16→8 双败 BO3,8→4 双败 BO3,总决赛 BO5 | [对抗赛与奖项](https://docs.simba-robotics.com/zh-cn/competition/matches/) |
| 红蓝方 | 每场编号小者为红方,编号大者为蓝方 | 同上 |
| 检录 | 尺寸 / 遥控运动 / 发射 / 裁判系统安装 / 裁判系统(含图传)运作;通过后不得再烧录或改硬件直至准备阶段 | 同上 |
| 赛前准备 | 3 分钟;双方把机器人放到与装甲板颜色相同的部署点;确认裁判系统与操作位图传客户端连接、遥控连接正常 | 同上 |
| 技术暂停 | 5 分钟;任意一方遇突发问题向场边裁判申请并说明理由,主裁判批准后开始;期间双方均可检修 | 同上 |
| 血量 | 初始 100;每发有效攻击扣 3 点 | 同上 |
| 单局时长 | 3 分钟 | 同上 |
| 胜负 | ①血量先归零者负 ②时间耗尽血量多者胜 ③双方血量均等则以本局最快开始移动的一队获胜 | 同上 |
| 装甲板 | 每车 2 或 4 块,其中一块为主装甲板;检测周期 100 ms;任一触发后全车 100 ms 静默期;供电 5 V ± 1 V;UART 可向主控发键鼠指令 | [裁判系统](https://docs.simba-robotics.com/zh-cn/competition/referee-system/) |
| 图传 | 1 个模块,回传第一人称画面到裁判系统客户端屏幕;供电 3.3 V(与装甲板 5 V 不同) | 同上 |
| 日程 | 10-25 适应性训练;11-01 决赛(9:00–11:00 / 14:00–18:00) | [赛事概览](https://docs.simba-robotics.com/zh-cn/competition/) |
### 对客户端的影响
1. 血量必须同时有数字和格子。100 点 / 5 格 = 20 点/格,而单发只扣 3 点——一格的粒度相当于 6.7 发。只给格子,操作手无法判断"再中两发就死"。→ 保留 `showNumbers` 默认开启,且数字 ≥ 18px。
2. 100 点 = 34 发(33 发后剩 1 点)。裁判端可以显示"已中 33 发"这类辅助读数(`100 - hp) / 3`),但不要把它当成权威值——静默期内的连击会被合并。
3. 100 ms 静默期决定命中动效下限:同一次攻击可能在 4 块板上各上报一次,`HealthTracker` 按血量差派生,已经能合并;UI 侧不要为每个 board 事件各弹一次伤害数字(否则一次命中会闪 4 下)。
4. 装甲板 2 块也要能显示:`ArmorBoardDto.reportsAllPlates` / `platesSeen` 已存在,界面在 `platesSeen < 4` 时显示"仅报 N 块",不要显示 4 个空槽让人以为板子坏了。
5. "最快开始移动"没有数据源。图传只给画面,协议里没有位移/速度;因此这条规则只能人工判定(见“计时 / 比分”)。不要试图从画面里推算。
6. 赛前 3 分钟是"确认客户端连上了"的窗口,所以用户端需要一个准备检查卡:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 `Ready` 阶段自动出现,`Running` 后消失。
7. 红蓝配色固定:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 `teamColor` 已是这个语义,只改色值)。
### 判罚流程(客户端可执行的部分)
```
┌──────────────────────────────┐
│ 单局结束条件 │
└──────────────┬───────────────┘
┌───────────────────────────┼───────────────────────────┐
│ │ │
血量归零 时间耗尽 人工裁定
(自动,双方一致) (自动比血量) (裁判操作)
│ │ │
│ ┌──────┴──────┐ ┌─────────┼─────────┐
│ 血量不等 血量相等 设备故障 违规/弃权 平血仲裁
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
Ended(by=auto, Ended(by=auto, AwaitingTiebreak ForfeitDialog(长按 1s + 理由)
reason=knockout) reason=timeout) → set_tiebreak → Ended(by=referee)
│
▼
Ended(by=referee, reason=tiebreak)
```
操作记录
```json
{
"id": "evt-0007",
"atMs": 1761998400000,
"actor": "referee",
"action": "forfeit",
"loser": 0,
"reason": "equipment",
"note": "图传模块断电",
"hp": [76, 100],
"remainingMs": 161000,
"round": 2,
"score": [1, 0]
}
```
- 落盘:`<app_data>/matches/<yyyy-mm-dd>/<matchId>/events.ndjson`(追加写,不覆盖),另有 `match.json` 存最终结果。
- 导出:`match_export` 把 `result.csv` 与 `events.ndjson` 放到录制目录,供组委会归档。
- 更正:`match_undo`(5 s 内)或 `match_reopen_round`(带理由);任何更正都新增记录,不删除原记录。
规则缺口(手册未定义,客户端不臆造)
| 缺口 | 客户端默认行为 | 需要谁定 |
| --- | --- | --- |
| 警告/红黄牌累计 | 不实现累计逻辑,只有"理由 + 备注" | 组委会 |
| 判负后是否重赛 | 只记录 `reason`,不自动重开 | 主裁判 |
| 平血"最快开始移动"的判定依据 | 人工选择,必填备注 | 主裁判 |
| 设备故障(图传中断)导致无法比赛 | 人工裁定:技术暂停 / 判负 / 重赛 | 主裁判 |
| 技术暂停超时(>5 分钟) | 提示超时,不自动判负 | 主裁判 |
> 手册 1.0.0 的更新日志提到"添加了详细的扣血、判罚规则与单局时间",但当前公开页面只给出了 上方规则表列出的内容(扣血 3 点、技术暂停 5 分钟、四条胜负条件)。如果组委会另有一份判罚细则(红黄牌、犯规等级),需要提供原文,否则界面不写死任何罚则。
---
## 风险与待确认事项
### 风险
| # | 风险 | 概率/影响 | 缓解 |
| --- | --- | --- | --- |
| R1 | 客户端判负 ≠ 硬件血量:协议只有上行(`MsgType` 1–5),没有下行控制,判负后对方装甲板仍报 100 | 高 / 高 | 界面明确写"只改变判定与记录,不改变硬件血量";不伪造 HP;若要硬件生效必须另做固件+协议(Q2) |
| R2 | 跨端不同步:裁判端判负/暂停,操作位用户端看不到 | 高 / 高 | v1 只作用于裁判端与直播;v2 用 `5601` 广播新 `MsgType`(见下)并加共享码签名;现场先用对讲机兜底 |
| R6 | 动效拖慢低配裁判机(双路 canvas + CSS 动画) | 中 / 中 | 只用 `transform/opacity`;禁连续动画;低动效开关;实测 30 min 不掉帧 |
| R7 | 录制与比赛状态机耦合(自动起停误伤手动录制) | 中 / 中 | 状态机只发事件;"随比赛自动录制"做成开关,默认开 |
| R8 | 直播泄密(IP/token/密码入口出现在画面里) | 中 / 高 | broadcast 视图白名单渲染;不渲染 `addr`/`url`;只读态;截图核对清单 |
| R9 | 规则变更/规则缺口(平血、超时、罚则) | 中 / 中 | 规则参数可配置(`MatchConfig`);缺省行为全部"人工裁定 + 记录" |
| R10 | 中文排版回归(字距、字重、字号) | 中 / 低 | 沿用"中文不加字距";只换 token 不动排版规则;逐页截图对比 |
| R11 | 光敏/无障碍(红闪、脉动) | 低 / 中 | ≤ 3 Hz、面积 < 25%、`prefers-reduced-motion` 降级、低动效开关 |
R2 的可选技术方案(若用户要跨端同步)
```rust
// protocol/src/lib.rs 新增(不改动已有枚举值)
pub enum MsgType { Video = 1, Subscribe = 2, Hello = 3, Heartbeat = 4, ArmorHealth = 5, MatchControl = 6 }
// 载荷:version(1) op(1) team(1) seq(4) issued_unix_ms(8) mac(16)
// op: 1=pause 2=resume 3=forfeit 4=reset 5=state-sync
```
- 裁判端以 `REFEREE_ID` 广播到 `5601`;用户端在装甲板监听里增加一个 `MsgType::MatchControl` 分支(不改相机订阅逻辑)。
- 安全:局域网里任何人都能发 UDP,所以必须带共享码派生 MAC(检录时裁判端生成 6 位码,双方在客户端输入),否则队伍可以伪造"对方判负"。
- 固件兼容性待验证:装甲板固件只认 `MsgType::ArmorHealth`,未知类型按现有 `decode_header` 逻辑返回 `None` 被忽略;但上线前必须在实机上验证"未知 MsgType 不会让板子异常"。
### 需要用户确认的问题
比赛语义
1. Q1 客户端"一键判负"要不要让用户端(操作位)也看到?如果需要,是否接受"局域网广播 + 共享码"的方案(R2)?
2. Q2 判负/技术暂停是否需要改变装甲板硬件血量(例如判负后立即把对方 HP 打到 0)?这需要改固件 + 协议,属于另一个工程。
3. Q3 技术暂停期间的命中:如实显示并标红(当前建议),还是冻结/忽略?谁来确定"暂停开始"的时间点——客户端按钮,还是场边裁判口头通知后由裁判端按下?
4. Q4 平血时"本局最快开始移动的一队"由谁录入?是否接受"裁判端手动点选一方 + 必填备注"?
5. Q5 是否需要 BO3/BO5 的系列赛比分与"第 N 局"编排?队伍名/编号从哪来(手工输入 vs 导入名单)?
直播
6. Q6 输出分辨率与帧率(1080p30 / 720p60?)、推流平台(B 站 / 抖音 / 校内大屏 / 录播)?安全区按哪个标准?
7. Q7 是否用 OBS 窗口采集(推荐,方案 A)?还是需要独立输出窗口 / 透明背景(方案 B)?
8. Q8 包装层需要哪些固定素材:赛事名、校徽、赞助商 LOGO、选手照片/队徽?由谁提供、什么格式?
录制与部署
- 录制是否随比赛状态自动起停?文件名是否包含双方队名与局次?
- 现场优先支持哪个平台?当前默认 Rust target 为 `aarch64-apple-darwin`;Windows 构建需要显式指定宿主 target。
## 附录 A:热键总表
| 键 | 范围 | 动作 | 备注 |
| --- | --- | --- | --- |
| `F11` / `⌃⌘F` / `Alt+Enter` | 全局 | 全屏切换 | 400 ms 去抖 |
| `Esc` | 全局 | 关最上层(对话框 → 抽屉 → 全屏) | 逐层关闭 |
| `Ctrl/Cmd+K` | 全局 | 命令面板(设置/维护/直播/回放) | 替代主界面按钮 |
| `Ctrl/Cmd+Shift+B` | 全局 | 切换直播视图 | 仅裁判端 |
| `Ctrl/Cmd+Enter` | 裁判端 | 开始本局 | `phase ∈ {Idle, Ready}` |
| `Ctrl/Cmd+Shift+P` | 裁判端 | 技术暂停 | 弹对话框 |
| `Ctrl/Cmd+Shift+F` | 裁判端 | 判负 | 仍需长按确认 |
| `Ctrl/Cmd+1` / `+2` | 裁判端 | 选中红/蓝(对话框内) | — |
| `Space` | 仅回放 | 播放/暂停 | 比赛中禁止 |
| `←/→`、`Shift+←/→` | 仅回放 | ±1 s / ±5 s | — |
| `M` | 仅回放 | 打标记 | 写入事件 |
| `Tab`(按住) | 全局 | 显示当前视图快捷键 | 半透明面板 |
| `Ctrl/Cmd+R` | 全局 | 刷新快照(现有 `refresh`) | — |
## 附录 B:验收标准(提议)
| 项 | 标准 | 验证方法 |
| --- | --- | --- |
| 全屏 | F11 切换 < 350 ms;切 10 次不卡死、不丢键 | 手工 |
| 计时 | 3 分钟误差 < 200 ms;切窗口/最小化不失速 | 与手机秒表对比 3 次 |
| 判负 | 必须长按 ≥ 1000 ms;< 1000 ms 不生效;撤销窗口 5 s | 自动化 + 手工 |
| 技术暂停 | 申请表单不勾"已批准"无法开始;5:00 倒计时准确 | 手工 |
| 直播 | 安全区内无越界元素;不出现 IP/token/设置入口 | 截图核对清单 |
| 动效 | 30 min 双路运行,主线程不掉帧(≥ 25 fps) | 性能面板 |
| 无障碍 | 关键文字对比 ≥ 4.5:1;`prefers-reduced-motion` 下无位移动画 | 对比度工具 + 开关 |
| 回归 | 视频/血量/装甲板/录制/维护五项原功能全部可用 | 回归清单 |
## 附录 C:范围
1. 不做网络 OTA / 设备自升级(与[固件维护](../maintenance/firmware.md) 的边界一致)。
2. 不做修改固件源码;打包只组装已有产物。
3. 不做从画面推算"移动先后"、命中位置、弹道等规则数据。
4. 不做直播的透明背景/虚拟摄像头/NDI。
5. 不做多语言(中文界面为唯一语言,符合约束)。
6. 不做用户账号/云端同步。