Files
simba-rmic-2026/docs/design/match-and-broadcast.md
T
sloray 054379dc64
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
- fix(installer): 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- fix(firmware): 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- fix(installer): 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- style(client): 应用图标增加圆角,统一“裁判系统客户端”命名
- fix(config): 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- docs: 润色项目说明并统一裁判系统术语
- test(packaging): 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成

验证:Windows NSIS 构建及原生页面预览;通用与本地安装配置编译;固件 release 构建、重复打包和客户端离线校验;6 项固件打包回归与 Windows 权限回归;git diff --check。未执行实机烧录。
2026-10-21 09:25:17 +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. 不做用户账号/云端同步。