1112 lines
72 KiB
Markdown
1112 lines
72 KiB
Markdown
# cam-desktop 重构设计:The Division 风格 UI/UX · 比赛控制 · 直播包装 · .zip 固件包
|
||
|
||
> 版本:草案 v0.1 | 日期:2026-10-19
|
||
> 范围:`desktop/`(React + Tauri v2)、`desktop/core`、`desktop/src-tauri`、`protocol/`、`scripts/`、`docs/firmware-maintenance.md`
|
||
> 约束:保留视频、血量、装甲板、录制、固件维护;Tauri v2 + React + 现有 Rust 后端;中文界面;跨平台。
|
||
> 规则依据:[SIMBA ΣDOCS · 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/)
|
||
>
|
||
> **实施状态(2026-10-19)**:`.zip` 打包/读取、F11/Alt+Enter 全屏、SHD 视觉 token 已落地;
|
||
> 比赛状态机/判负/技术暂停/直播页按 §7.1 的 M2–M4 分阶段推进,尚待 §7.4 Q1–Q4 确认。
|
||
|
||
---
|
||
|
||
## 0. 现状基线(读代码后的结论)
|
||
|
||
| 层 | 现状 | 对本次重构的意义 |
|
||
| --- | --- | --- |
|
||
| 页面 | `App.tsx` 单一分支树:`MaintenancePage` → 致命错误 → 等待快照 → 身份选择 → 设密码 → `TeamPage` / `RefereePage` | 没有路由、没有 Shell、没有直播视图;需先抽出 `AppShell` 与视图枚举 |
|
||
| 样式 | `styles/global.css` 单文件 1687 行,`:root` 20 个 token,深底 + 荧光绿强调 | token 体系要替换,且**中文排版规则已踩过坑**(见 1.3),不能推倒重来 |
|
||
| 状态 | 后端 5 Hz 推整份 `Snapshot`,前端 `useSyncExternalStore` 单例 | 比赛状态机应作为快照的一部分下发,前端不做第二份真相 |
|
||
| 视频 | WebSocket 二进制 JPEG + canvas 绘制,缩放/平移/网格/瞄准器/命中反馈全在 `VideoSurface` | 动效不得侵入 canvas 绘制路径(否则抢帧) |
|
||
| HUD | 5 格斜切血条,左上自己/右上对手,数字默认显示 | 5 格 = 20 点/格,而规则是**每发扣 3 点** → 数字必须保留(见 6.2) |
|
||
| 裁判端 | 双路等宽 + 队名编辑 + 装甲板表 + 录制/回放 + 底部 250px 固定高度 | 无计时/比分/暂停/判负;底部 Dock 要重排 |
|
||
| 后端 | UDP `5600` 视频 / `5601` 装甲板;命令集中在 `commands.rs`;密码闸门在 `state.rs` | 新命令同样要过闸门;比赛状态需要 `role == referee` 的限制 |
|
||
| 协议 | `MsgType`: Video=1, Subscribe=2, Hello=3, Heartbeat=4, ArmorHealth=5,**只有上行/广播,没有下行控制** | 客户端判负/暂停**无法**改变硬件血量(见 3.2、R1) |
|
||
| 固件包 | `bundle.json` + `esp32s3-app.bin` + `partition-table.bin`(+`bootloader.bin`),客户端选**目录**,`format: 1`,逐项 SHA256 | 改成 `.zip` 只需改 `maintenance::load_bundle` 的取数入口,校验逻辑可原样复用 |
|
||
| 产物 | `target/firmware/` 已有 `esp32s3-app.bin`(1,143,504 B)、`partition-table.bin`(3072=0xC00)、`bootloader.bin`、`BUILD-INFO.json`、`bundle.json`、`SHA256SUMS.txt` | 打包环节有现成输入,缺的是"构建后自动产出 zip" |
|
||
| 现场 | 适应性训练 **10-25**,决赛 **11-01** | 距决赛 13 天 → 优先级必须围绕这一天排(见 7.1) |
|
||
|
||
---
|
||
|
||
## 1. 设计系统(可落地)
|
||
|
||
### 1.1 六条设计原则(附取舍)
|
||
|
||
| # | 原则 | 具体约束 | 被否决的做法 |
|
||
| --- | --- | --- | --- |
|
||
| P1 | **画面优先** | 视频窗格内除 HUD 外零装饰;HUD 只贴四角与上沿;中心半径 18% 区域**永不**放常驻元素 | 半透明斜纹背景铺满、常驻扫描线 |
|
||
| P2 | **三秒可读** | 血量/时间/比分/状态四类信息位置固定、字号 ≥ 24px@1080p、对比 ≥ 4.5:1;3 m 外单眼可读 | 用颜色/图标单独表达状态 |
|
||
| P3 | **状态永不含糊** | 无数据不显示 0;「离线/演示/暂停/已判定」必须全屏可见;同一时刻只有**一个**主结论 | 用 toast 表达比赛状态 |
|
||
| P4 | **误操作成本 > 操作成本** | 破坏性动作(判负、清空配置、烧录)一律 `长按 ≥ 1000ms` + 理由 + 可撤销/可审计;高频只读动作零确认 | 用 `confirm()` 敷衍 |
|
||
| P5 | **同源渲染** | 用户端 HUD / 裁判端 / 直播三层共用同一 `Snapshot` + `MatchView` 与同一套安全区 | 给直播另写一套数据拼装 |
|
||
| P6 | **无障碍与光敏安全** | 闪烁 ≤ 3 Hz 且面积 < 屏幕 25%;`prefers-reduced-motion` 全局降级;色不单独承载信息 | 无限 glitch、随机闪白 |
|
||
|
||
The Division 能迁移过来的视觉语言只有四样:**近黑底 + 单一强调色 + 细线切角 + 等宽数字**。它的地图、装备详情、粒子特效属于"非实时、可暂停"的场景,搬进 3 分钟一局的比赛界面会直接损害 P1/P2。
|
||
|
||
### 1.2 色彩
|
||
|
||
**色板**(可直接替换 `global.css` 的 `:root`):
|
||
|
||
```css
|
||
:root {
|
||
/* 底色:三层就够,第四层用"提亮 4%"而不是再加一个变量 */
|
||
--bg-void: #07080a; /* 窗口最底 / 直播黑场 */
|
||
--bg-base: #0b0d10; /* 页面底 */
|
||
--bg-panel: #11151a; /* 面板 / 顶栏 / 状态行 */
|
||
--bg-raised: #171c22; /* 卡片 / 行 */
|
||
--bg-input: #090b0e; /* 输入框 / 日志 */
|
||
--bg-scrim: rgba(6, 8, 10, 0.78); /* 遮罩 */
|
||
|
||
/* 线:Division 的"1px 亮边"是主要体积感来源,阴影只做兜底 */
|
||
--line: #232a33;
|
||
--line-strong: #333c48;
|
||
--line-accent: rgba(255, 122, 26, 0.45);
|
||
|
||
/* 文本 */
|
||
--text: #e9ecf1; /* 15.2:1 on --bg-base */
|
||
--text-dim: #98a2b0; /* 6.4:1 */
|
||
--text-faint: #5e6875; /* 3.2:1 —— 只用于非信息性文字 */
|
||
--text-invert: #07080a;
|
||
|
||
/* 强调:SHD 橙。唯一强调色,用于焦点/当前项/CTA/直播描边 */
|
||
--accent: #ff7a1a;
|
||
--accent-hot: #ff9a4d;
|
||
--accent-deep: #b44e08;
|
||
--accent-veil: rgba(255, 122, 26, 0.12);
|
||
|
||
/* 语义 */
|
||
--danger: #ff3b30; /* 判负 / 录制点 / 错误 */
|
||
--warn: #f5b13d; /* 低血 / 链路抖动 / 未上锁 */
|
||
--ok: #3ddc84; /* 通过 / 在线 */
|
||
--info: #35d6e6; /* 技术暂停 / 扫描 / 遥测 */
|
||
|
||
/* 队伍:规则"编号小=红方、编号大=蓝方" → 队号 0 固定红、1 固定蓝 */
|
||
--team-0: #ff4d4f;
|
||
--team-1: #3e9bff;
|
||
--team-0-veil: rgba(255, 77, 79, 0.14);
|
||
--team-1-veil: rgba(62, 155, 255, 0.14);
|
||
}
|
||
```
|
||
|
||
**用色规则**
|
||
|
||
1. 强调色**不用于血量**——血量只用队伍色,低血时切 `--danger`(现有 `HealthBar` 行为,保留)。
|
||
2. 每个屏幕最多 **1 个** `--accent` 元素做主 CTA;第二个强调改用 `--line-accent` 细边。
|
||
3. 红色只出现在三处:判负、录制中、错误;**不要**用它做"取消"。
|
||
4. 直播层禁止使用 `--accent-veil` 以上的大面积橙(压缩后发脏),装饰靠线不靠面。
|
||
5. 纯黑 `#000` 只用于视频信箱区;面板底色不低于 `#0b0d10`,避免 OLED/编码 banding。
|
||
6. 对比度自检清单:所有正文 ≥ 4.5:1、所有 ≥18px 的数字 ≥ 3:1、队伍色文字必须叠 `text-shadow: 0 1px 3px rgba(0,0,0,.9)`(现有做法保留)。
|
||
|
||
### 1.3 字体与排版
|
||
|
||
```css
|
||
:root {
|
||
--font-ui: "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
|
||
"Source Han Sans SC", ui-sans-serif, system-ui, sans-serif;
|
||
--font-mono: ui-monospace, "JetBrains Mono", "Cascadia Mono",
|
||
"SF Mono", Consolas, monospace;
|
||
|
||
--fs-micro: 10px; --fs-small: 11px; --fs-body: 13px;
|
||
--fs-title: 15px; --fs-h2: 17px; --fs-h1: 24px;
|
||
--fs-hud: clamp(18px, 1.8vmin, 28px); /* 血量数字 */
|
||
--fs-clock: clamp(28px, 3.4vmin, 44px); /* 比赛计时 */
|
||
--fs-score: clamp(40px, 5vmin, 64px); /* 比分 */
|
||
--fs-display:clamp(44px, 8vmin, 96px); /* 判定大字(仅直播/结果卡) */
|
||
}
|
||
```
|
||
|
||
规则:
|
||
|
||
- **中文不加 `letter-spacing`**。`global.css` 已注明"表头是中文,加字距只会让它看起来散架"——这条继续执行,只对拉丁文(`LIVE`、`TECH PAUSE`、`SERIES`)用 `letter-spacing: .14em` + `text-transform: uppercase`。
|
||
- 所有数字(血量、时间、比分、ID、帧率)走 `--font-mono` + `font-variant-numeric: tabular-nums`,避免数字跳动时宽度变化引起布局抖动。
|
||
- 字号阶梯只有 6 档正文 + 4 档显示级;禁止在组件里写 `font-size: 12.5px` 这种一次性值。
|
||
- 字体不打包(现场离线,中文字体体积又大)。若要 Division 味的拉丁字形,可后续单独引入 OFL 的 `Barlow Condensed SemiBold`(约 40 KB),**仅用于数字与拉丁标签**,并保留系统字体作为 fallback。
|
||
- 行高:正文 1.7、标题 1.3、数字 1.0。
|
||
|
||
### 1.4 层级(z-index / elevation / 几何)
|
||
|
||
```css
|
||
:root {
|
||
--z-stage: 0; /* 视频 canvas */
|
||
--z-hud: 10; /* 画面内 HUD */
|
||
--z-overlay: 20; /* 暂停/判定覆盖层 */
|
||
--z-chrome: 30; /* 顶栏/状态行/悬浮徽标 */
|
||
--z-drawer: 40; /* 设置抽屉 */
|
||
--z-dialog: 50; /* 模态框 */
|
||
--z-toast: 60; /* 提示 */
|
||
--z-critical: 70; /* 链路断开/致命错误 */
|
||
}
|
||
```
|
||
|
||
- **面板体积感** = `1px var(--line)` + `inset 0 1px 0 rgba(255,255,255,.05)`,**不用**大阴影;阴影只用于浮动层(抽屉/对话框/toast)。
|
||
- **切角**:`--cut: 8px`。只在按钮、徽标、比分板、判定卡上用 `clip-path`;面板不要用(`clip-path` 会吃掉 border 和 box-shadow,改完还得补 SVG 边框,收益不抵成本)。
|
||
```css
|
||
.cut { clip-path: polygon(var(--cut) 0, 100% 0, 100% calc(100% - var(--cut)),
|
||
calc(100% - var(--cut)) 100%, 0 100%, 0 var(--cut)); }
|
||
```
|
||
- 圆角:`--r-xs 2 / --r-sm 4 / --r-md 8 / --r-lg 12`。Division 几乎全直角,只在小控件上保留 2–4px;`10px` 的大圆角(现有 `--radius`)改到 4px。
|
||
- 角标(bracket):面板四角用 `::before/::after` 画 8×1px 亮线,只在"当前焦点面板"上出现一个角,用来替代发光描边。
|
||
|
||
### 1.5 HUD 规格
|
||
|
||
| 元素 | 位置 | 尺寸 | 规则 |
|
||
| --- | --- | --- | --- |
|
||
| 我方血条 | 画面左上,距边 `3.5%` 宽 | 宽 36%(现 42%)、高 16px、5 格、`skewX(-15deg)` | 数字必显;`available=false` 时显示"装甲板未接入",**不显示 0** |
|
||
| 对手血条 | 画面右上,镜像 | 同上 | 同上;标签"我方/对手"保留 |
|
||
| 瞄准器 | 跟随图像 | 大小不随缩放 | 现有 `drawCrosshair` 不动,仅颜色默认值改为 `#ff7a1a`(可配置) |
|
||
| 倍率 | 画面右下角 | 11px mono | 仅在 zoom > 1 时出现 |
|
||
| 命中提示条 | 画面顶部 `top: 56px` 居中 | 高 26px | 避开准星(已踩坑,保留) |
|
||
| **比赛计时**(新) | 画面顶部居中 `top: 12px` | 32px mono | 只在 `Running/Paused/Ended` 出现;`< 30s` 变 `--danger` 且每秒脉冲一次(**不闪**) |
|
||
| **暂停/判负遮罩**(新) | 整画面 `--z-overlay` | — | 半透明 + 居中文字;不遮血条与计时(保留可读性) |
|
||
| 网格 | 画面内 | 现有 | 默认关;`opacity ≤ .22` |
|
||
| 技术读数 | 底部状态行 | 11px mono | 用户端 release 下不显示(`TEAM_DIAGNOSTICS` 已有门控) |
|
||
|
||
**HUD 熄灯规则**(新增):非关键 HUD(网格、倍率、帧率、界面数)在 3 s 无指针输入后降到 `opacity: .35`;`prefers-reduced-motion: reduce` 时**直接隐藏**而不做透明度过渡(避免持续重绘)。
|
||
|
||
### 1.6 动效原则
|
||
|
||
```css
|
||
:root {
|
||
--t-fast: 90ms;
|
||
--t-base: 140ms;
|
||
--t-slow: 220ms;
|
||
--t-emphatic: 320ms;
|
||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||
--ease-in: cubic-bezier(0.7, 0, 1, 0.6);
|
||
--motion-scale: 1; /* 0 = 降级 */
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
:root { --motion-scale: 0; --t-fast: 1ms; --t-base: 1ms;
|
||
--t-slow: 1ms; --t-emphatic: 1ms; }
|
||
}
|
||
```
|
||
|
||
**动效只分三类,不另设表外时长**
|
||
|
||
| 类别 | 时长 | 用途 | 属性 |
|
||
| --- | --- | --- | --- |
|
||
| 状态确认 | 90–140 ms | 按钮按下、开关、页签、hover | `transform`/`opacity`/`background-color` |
|
||
| 状态迁移 | 220–320 ms | 抽屉、对话框、覆盖层、全屏切换、比分变化 | `transform`/`opacity` |
|
||
| 连续数据 | ≤ 200 ms 插值 | 血量格、数字滚动、计时 | 只改文本与宽度,不改布局 |
|
||
|
||
**动效清单**(实现时逐条对表)
|
||
|
||
| 组件 | 触发 | 动效 | 时长/曲线 |
|
||
| --- | --- | --- | --- |
|
||
| `Button` | press | `scale(.98)` | 90ms / standard |
|
||
| `Button` hover | hover | 边框转 `--line-accent` + 背景 +4% | 140ms |
|
||
| 页签 | 切换 | 底线 2px 从左滑到新页签 | 220ms / out |
|
||
| `HealthBar` 格 | 掉血 | 该格 `background` 140ms 淡出;数字 160ms 计数 | 140/160ms |
|
||
| 命中闪红 | hit | 620ms 全屏径向渐变淡出(现有 `hit-fade`) | 保留 |
|
||
| 暂停遮罩 | 进入/退出 | `opacity 0→1` + 文字 `translateY(6px)→0` | 220ms / out |
|
||
| 判定卡 | 结束 | `scale(.94)→1` + `opacity` | 320ms / out |
|
||
| 计时最后 30 s | 每秒 | 数字 `scale(1→1.06→1)` 一次 | 300ms |
|
||
| 键盘提示 | Tab 按住 | 底部面板 `translateY(8px)→0` | 220ms |
|
||
| 全屏切换 | F11 | 无自定义动画(由窗口管理器负责) | — |
|
||
|
||
**禁止清单**
|
||
|
||
1. 全屏抖动(`HitFeedbackConfig.shake` 已废弃,代码里仍然不要复活)。
|
||
2. 任何 > 3 Hz 的闪烁;任何占屏 > 25% 的红色闪烁(判负的 320 ms 一次性淡入除外)。
|
||
3. 常驻循环动画(扫描线、呼吸背景、粒子)。
|
||
4. 遮住准星中心 18% 半径的动画。
|
||
5. 会触发 layout 的动画(`width`/`height`/`top`/`left` 的 transition)——血条格子用 `background`,进度用 `transform: scaleX`。
|
||
6. 依赖 `requestAnimationFrame` 的新增长循环(现有 `VideoSurface` 的绘制循环是唯一允许的一个)。
|
||
|
||
**降级开关**:设置里加"低动效模式"(`ui.reduceMotion`),等价于强制 `prefers-reduced-motion`,给低配裁判机与"现场怕闪"的场合用。
|
||
|
||
---
|
||
|
||
## 2. 页面与组件重构方案
|
||
|
||
### 2.1 应用外壳(新增 `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` 分支。
|
||
|
||
### 2.2 用户端 `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`)保留,但提示从底部横幅改到画面内角标,避免遮挡。
|
||
|
||
### 2.3 裁判端 `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` 占固定高度;现有 `referee-bottom` 的 250px 改成 **Dock 可拖拽高度**(默认 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 取消",避免一直占一行高。
|
||
|
||
### 2.4 直播 `BroadcastPage`(新)
|
||
|
||
见第 4 节。要点:一个 1920×1080 虚拟画布 + 4 个模板 + 安全区参考线 + 只读数据。
|
||
|
||
### 2.5 维护页 `MaintenancePage`
|
||
|
||
现在是一个 2 列网格的长页面(设备卡 / 固件包卡 / 分区表 / 报告),信息密度高但"下一步做什么"不明显。改成 **5 步 Stepper**:
|
||
|
||
```
|
||
① 设备 → ② 固件包(.zip) → ③ 烧录计划 → ④ 烧录 → ⑤ 结果
|
||
──────────────────────────────────────────────────────────────
|
||
左:步骤栏(已完成 ✓ / 当前 ▶ / 未开始 ○)
|
||
右:当前步骤卡(大字号 + 一个主按钮 + 次级信息折叠)
|
||
```
|
||
|
||
- 步骤 ② 接受 **`.zip` 与目录**(目录收在"高级 / 兼容旧包"里),支持拖拽。
|
||
- 步骤 ③ 用 diff 表显示"factory: 0x070000 → 0x200000",并把 `warnings` 提到主区域(现在埋在下面)。
|
||
- 步骤 ④ 全屏进度 + 不可关闭 + `取消`(已有 `maintenance_cancel`)+ 实时日志。
|
||
- 步骤 ⑤ 报告卡:结果、坏字节/坏扇区、备份目录、`恢复旧分区表`、`复制报告`。
|
||
- 保留全部诊断细节(分区表、候选槽位、回读不一致列表),只是默认折叠。
|
||
|
||
### 2.6 组件清单
|
||
|
||
**新增**
|
||
|
||
| 文件 | 职责 |
|
||
| --- | --- |
|
||
| `components/ui/Panel.tsx` | 带切角/角标的容器(`tone`/`dense`/`flush`) |
|
||
| `components/ui/HoldButton.tsx` | 长按确认按钮(1000ms 环形进度,松手取消,Esc 取消) |
|
||
| `components/ui/KeyHint.tsx` | 键帽样式(`<KeyHint keys="F11" />`) |
|
||
| `components/ui/Stepper.tsx` | 维护页步骤栏 |
|
||
| `components/ui/EmptyState.tsx` | 统一空状态(图标 + 一句 + 一个动作) |
|
||
| `components/match/MatchBar.tsx` | 裁判端比赛条 |
|
||
| `components/match/MatchClock.tsx` | 计时显示(插值 + 最后 30 s 强调) |
|
||
| `components/match/ScoreBoard.tsx` | 比分(局/系列赛) |
|
||
| `components/match/ForfeitDialog.tsx` | 判负对话框(败方 + 理由 + 长按) |
|
||
| `components/match/TechPauseDialog.tsx` | 技术暂停(申请方 + 理由 + 已批准勾选) |
|
||
| `components/match/EventPanel.tsx` | 事件时间轴 + 导出 |
|
||
| `components/match/PausedOverlay.tsx` / `JudgedOverlay.tsx` | 覆盖层 |
|
||
| `components/replay/ReplayView.tsx` | 双路同步回放 + 事件定位 |
|
||
| `components/broadcast/BroadcastStage.tsx` | 直播画布 |
|
||
| `components/broadcast/SafeGuides.tsx` | 安全区参考线(仅预览) |
|
||
| `components/broadcast/{TitleBar,ScoreBug,RoundClock,TalentCard,LowerThird,AlertCard,SponsorRail,StandbyCard}.tsx` | 包装层 |
|
||
| `components/firmware/FirmwareDropZone.tsx` | .zip 拖拽/选择 |
|
||
| `hooks/useFullscreen.ts` / `useHotkeys.ts` / `useIdle.ts` / `useServerClock.ts` | 全屏 / 快捷键 / 空闲 / 服务器时钟 |
|
||
|
||
**修改**
|
||
|
||
`App.tsx`(Shell + 视图)、`TopBar`(比赛条位、直播入口、工具收纳)、`TeamPage`、`RefereePage`、`MaintenancePage`、`MediaPanel`(按局分组/回放)、`ArmorTable`(双队/迷你条)、`HealthBar`(宽 36%、字号 token)、`SettingsPanel`(新增"界面/动效/直播"段)、`global.css` → 拆成 `tokens.css` + `base.css` + `components.css`(或保持单文件但按 1–7 节分区)。
|
||
|
||
### 2.7 状态模型(前端)
|
||
|
||
```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` 在窗口失焦时会被节流,而比赛计时绝不能因为切窗口而变慢。
|
||
|
||
---
|
||
|
||
## 3. 交互逻辑
|
||
|
||
### 3.1 F11 全屏
|
||
|
||
**实现**
|
||
|
||
```ts
|
||
// hooks/useFullscreen.ts
|
||
const win = getCurrentWindow();
|
||
const [full, setFull] = useState(false);
|
||
useEffect(() => { win.isFullscreen().then(setFull); }, []);
|
||
const toggle = useCallback(async () => {
|
||
const next = !(await win.isFullscreen());
|
||
await win.setFullscreen(next);
|
||
setFull(next);
|
||
}, []);
|
||
```
|
||
|
||
**权限**:`capabilities/default.json` 现在只有 `core:default` + `dialog:default`,**不含**全屏。需要追加(identifier 已在本仓库 `gen/schemas/acl-manifests.json` 中确认存在):
|
||
|
||
```json
|
||
"permissions": [
|
||
"core:default",
|
||
"dialog:default",
|
||
"core:window:allow-set-fullscreen",
|
||
"core:window:allow-is-fullscreen"
|
||
]
|
||
```
|
||
|
||
**按键**
|
||
|
||
| 平台 | 按键 | 备注 |
|
||
| --- | --- | --- |
|
||
| Windows / Linux | `F11` | `keydown` capture + `preventDefault()` |
|
||
| macOS | `⌃⌘F` | 同时支持 `F11`(部分外接键盘有) |
|
||
| 全平台 | `Alt+Enter` | 备用,游戏玩家肌肉记忆 |
|
||
| 全平台 | `Esc` | 退出全屏 / 关闭顶层对话框(按层级,一次只关一个) |
|
||
|
||
**细节**
|
||
|
||
- 切换后 400 ms 内忽略再次触发(部分窗口管理器会发出重复 keydown)。
|
||
- 全屏切到 `immersive`:chrome 隐藏、`broadcast` 模式下同时隐藏安全区参考线与光标。
|
||
- 退出通道必须**看得见**:全屏时右上角保留一个 1px 高热区,指针进入显示"退出全屏 F11",3 s 后淡出。
|
||
- 不做"全屏时禁止关闭窗口"这类硬锁——现场需要能立刻退出。
|
||
- 多显示器:P2 再加 `setFullscreenOnMonitor`(`core:window:allow-set-fullscreen-on-monitor` 已存在),让直播输出指定显示器。
|
||
|
||
### 3.2 一键判负(最危险的动作)
|
||
|
||
**入口**:`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 }
|
||
```
|
||
|
||
否则"回放/统计"里会分不清这局是怎么结束的。
|
||
|
||
### 3.3 技术暂停
|
||
|
||
**规则对齐**:手册写"任意一方…可向场边裁判申请发起时长为 **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.4 计时 / 比分
|
||
|
||
**两个不同的 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` 自己减。
|
||
|
||
### 3.5 回放
|
||
|
||
**入口**:`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` 收尾)。
|
||
|
||
---
|
||
|
||
## 4. 直播 UI:安全区与包装层
|
||
|
||
### 4.1 基准画布与安全区
|
||
|
||
基准 **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 的数字。
|
||
|
||
### 4.2 分区与坐标(模板 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 亮边 + 切角,**不做**全屏红闪。
|
||
|
||
### 4.3 包装层结构(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 绘制。
|
||
|
||
### 4.4 输出方式(推荐路径与坑)
|
||
|
||
| 方案 | 做法 | 评价 |
|
||
| --- | --- | --- |
|
||
| **A(推荐)** | 同一窗口切 `broadcast` 视图 + F11 全屏;OBS「窗口采集」+ 裁到 16:9 | 零新权限、零新进程,最稳 |
|
||
| B | 第二个 `WebviewWindow`(`label: broadcast`,无边框、置顶) | 需要 `core:webview:allow-create-webview-window`;双窗口抢 GPU,P2 再做 |
|
||
| 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,动效会"飘"在画面上。所以包装层只做**状态迁移**动画,不做连续动画(与 1.6 的禁止清单一致)。
|
||
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。
|
||
|
||
### 4.5 直播视图的安全边界(必须做)
|
||
|
||
- `broadcast` 视图**不渲染**:设置按钮、维护入口、`cameras[].addr`(IP)、`stream_info.url`(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
|
||
- 所有管理命令在广播态禁用(不是隐藏,是 `disabled` + `aria-hidden`,防止误触与 Tab 聚焦)。
|
||
- 演示模式(`demo`)在直播里显示**红色角标**"演示画面",与现有 `tag--demo` 同一语义(现在只在 TopBar,直播态必须另有一处)。
|
||
- 断流时 `StandbyCard` 明确写"信号中断 · 正在重连",不显示上一帧冻结画面冒充直播(冻结画面会被观众当成"画面卡住")。
|
||
|
||
---
|
||
|
||
## 5. `.zip` 固件包:格式、打包、读取
|
||
|
||
### 5.1 为什么改成 zip
|
||
|
||
| 现状(目录) | zip |
|
||
| --- | --- |
|
||
| 选目录,容易选错上一层/子目录;缺文件要到"生成计划"才报错 | 单文件,扩展名即契约;打开即校验 |
|
||
| 分发要压缩后发,现场再解压(解压位置随便) | 分发即成品,不用解压 |
|
||
| 无法整包校验(只校验 app/分区表) | 可做整包 SHA256,便于赛后追溯"烧的是哪一版" |
|
||
| 无版本标识 | 文件名 + manifest 双版本 |
|
||
|
||
### 5.2 包格式(**保持 `format: 1`,不要翻版本**)
|
||
|
||
```
|
||
camfw-esp32s3-vga30-fb3-realtime-tx15-4794bed-20261024.zip
|
||
├── bundle.json ← 必需,沿用现有 BundleManifest(format: 1)
|
||
├── esp32s3-app.bin ← 必需,文件名由 bundle.json 的 "app" 指定
|
||
├── partition-table.bin ← 必需,必须 0xC00 字节
|
||
├── bootloader.bin ← 可选(客户端默认不写)
|
||
├── BUILD-INFO.json ← 可选,仅用于展示版本/构建信息
|
||
└── SHA256SUMS.txt ← 可选,人读用;客户端以 bundle.json 里的 sha 为准
|
||
```
|
||
|
||
理由:**`format: 1` + 新增可选字段**,旧客户端读目录包照旧,新客户端两种都能读;翻成 `format: 2` 只会让"旧客户端打不开新包",而我们本来就要同时支持两种来源,没有收益。
|
||
|
||
`bundle.json` 建议新增一个**可选**块(`deny_unknown_fields` 只出现在其他结构上,`BundleManifest` 没有,可直接加字段):
|
||
|
||
```json
|
||
{
|
||
"package": {
|
||
"kind": "zip",
|
||
"createdAt": "2026-10-24T18:02:11+08:00",
|
||
"file": "camfw-esp32s3-vga30-fb3-realtime-tx15-4794bed-20261024.zip",
|
||
"sha256": "…(zip 整包)",
|
||
"entryCount": 5
|
||
}
|
||
}
|
||
```
|
||
|
||
> 注意:`BundleManifest` 目前**没有** `deny_unknown_fields`,加字段不会破坏旧客户端;但**不要**给它会读的字段改名。
|
||
|
||
### 5.3 校验规则(客户端必须在读的时候就挡住)
|
||
|
||
| 项 | 规则 | 失败文案(面向现场) |
|
||
| --- | --- | --- |
|
||
| 容器 | 目录 / `*.zip` / `bundle.json`;zip 需 magic `PK\x03\x04` | "不是有效的固件包(既不是 .zip,也没有 bundle.json)" |
|
||
| 条目路径 | 拒绝 `..`、绝对路径、盘符、`\\` 开头、UNC、空名、重复名、符号链接 | "固件包内有非法路径:…" |
|
||
| 条目数 | ≤ 32 | "固件包条目过多" |
|
||
| 单文件 | `bundle.json` ≤ 64 KiB;app ≤ `appPartitionSize`;分区表 = 0xC00 | "bundle.json 过大 / App 超过分区大小" |
|
||
| 解压上限 | 总解压 ≤ 8 MiB(App 现 1.09 MiB) | "固件包解压后过大" |
|
||
| 完整性 | zip CRC(`zip` crate 自动校验)+ `appSha256` / `partitionTableSha256` | "App SHA256 不匹配:期望 …,实际 …" |
|
||
| 版本 | `format == 1` | "不支持的 bundle 格式版本:N" |
|
||
| 设备 | `chip` / `flashSize` / Secure Boot / Flash Encryption(现有 `check_bundle_against_device`) | 沿用现有文案 |
|
||
|
||
### 5.4 编译后打包环节
|
||
|
||
**现状链路**
|
||
|
||
```
|
||
scripts/build.ps1|build.sh → cargo build --release --locked
|
||
└─ target/xtensa-esp32s3-espidf/release/esp32(ELF)
|
||
target/firmware/ → esp32s3-app.bin / partition-table.bin / bootloader.bin
|
||
BUILD-INFO.json / bundle.json / SHA256SUMS.txt(人工或脚本产出,未进 build)
|
||
```
|
||
|
||
**新链路(在编译之后加一节,不改编译本身)**
|
||
|
||
```
|
||
build.sh|build.ps1 [--package]
|
||
├─ cargo build --release --locked
|
||
└─ python scripts/package_firmware.py
|
||
① 定位 App 镜像
|
||
优先 <esp-idf-sys out/build>/esp32s3-app.bin
|
||
否则 espflash save-image --chip esp32s3 --format esp-idf <elf> <out>/esp32s3-app.bin
|
||
否则回退 target/firmware/esp32s3-app.bin(mtime 早于 ELF 则警告"可能是旧镜像")
|
||
② 复制 partition-table.bin(必须 0xC00)、可选 bootloader.bin
|
||
③ 生成 BUILD-INFO.json(git rev / dirty / 时间 / variant / rustc / idf)
|
||
④ 生成 bundle.json(复用 make_bundle.py 的逻辑)
|
||
⑤ 打包 zip(deflate, 固定 mtime, 固定条目顺序)
|
||
⑥ 写 <zip>.sha256 + 打印摘要
|
||
```
|
||
|
||
**产物**
|
||
|
||
```
|
||
target/firmware/
|
||
├── camfw-esp32s3-<variant>-<sha7>-<yyyymmdd>.zip
|
||
├── camfw-esp32s3-<variant>-<sha7>-<yyyymmdd>.zip.sha256
|
||
├── bundle.json (保留,供旧的"选目录"路径与排障)
|
||
└── BUILD-INFO.json
|
||
```
|
||
|
||
**可复现性**:固定 zip 条目顺序、固定 mtime(用 `SOURCE_DATE_EPOCH` 或构建时间)、固定 deflate level(9),保证同一份输入产出同一个 SHA256——这样"两个 U 盘里的包是不是同一版"可以一眼核对。
|
||
|
||
**接入点**
|
||
|
||
- `scripts/build.ps1`:加 `[switch]$Package`,构建成功后调用 `python scripts/package_firmware.py`(找不到 python 时给出明确提示,不静默跳过)。
|
||
- `scripts/build.sh`:`--package` 透传(现有 `"$@"` 已经把参数传给 cargo,要单独解析 `--package` 并从 cargo 参数里剔除)。
|
||
- `scripts/make_bundle.py`:保留(不动它的 CLI),`package_firmware.py` 以 subprocess 调用它,避免两套 manifest 生成逻辑。
|
||
- CI:**先不进**。加一个固件构建 job 要装 Xtensa 工具链 + ESP-IDF(体积大、耗时长),而决赛在 11-01;等赛后再评估。若要做,放 `workflow_dispatch` 手动触发。
|
||
|
||
### 5.5 客户端读取流程(`maintenance.rs`)
|
||
|
||
**命令签名不变**(前端只改选文件对话框):
|
||
|
||
```rust
|
||
#[tauri::command]
|
||
pub fn maintenance_load_bundle(path: String) -> Result<BundleInfo, String>
|
||
```
|
||
|
||
**内部改动**
|
||
|
||
```rust
|
||
enum BundleSource { Dir(PathBuf), Manifest(PathBuf), Zip(PathBuf) }
|
||
|
||
fn sniff(path: &Path) -> Result<BundleSource>;
|
||
|
||
fn load_bundle(path: &Path) -> Result<BundleLoaded> {
|
||
match sniff(path)? {
|
||
BundleSource::Zip(zip) => load_from_zip(&zip),
|
||
dir_or_manifest => load_from_dir(dir_or_manifest), // 现有逻辑,原样保留
|
||
}
|
||
}
|
||
|
||
fn load_from_zip(path: &Path) -> Result<BundleLoaded> {
|
||
let mut archive = zip::ZipArchive::new(File::open(path)?)?;
|
||
let manifest = read_entry_json::<BundleManifest>(&mut archive, "bundle.json", 64 << 10)?;
|
||
// 路径规范化 + 大小上限 + CRC 由 zip crate 校验
|
||
let app = read_entry_limited(&mut archive, &manifest.app, manifest_parse_app_limit(&manifest))?;
|
||
let table = read_entry_limited(&mut archive, &manifest.partition_table, 0xC00)?;
|
||
// 之后与目录分支完全共用:SHA256 校验、尺寸校验、bootloader 校验
|
||
build_loaded(manifest, app, table, SourceInfo::Zip { path, sha256 })
|
||
}
|
||
```
|
||
|
||
要点:
|
||
|
||
1. **不落地解压**:`BundleLoaded.app` 本来就是全内存的 `Vec<u8>`,直接复用;只在需要备份时把 `bundle.json` 文本写进备份目录(`fs::copy` 对 zip 路径不适用,改成写文本)。
|
||
2. `BundleInfo` 新增字段(`#[serde(rename_all = "camelCase")]`):
|
||
|
||
```rust
|
||
pub source_kind: String, // "zip" | "dir"
|
||
pub source_path: String, // zip 文件路径(原 path 字段保留:展示用)
|
||
pub source_sha256: Option<String>, // zip 整包 sha256
|
||
pub entry_count: Option<u32>,
|
||
```
|
||
|
||
前端 `BundleInfo` 同步加这三个字段(`desktop/src/lib/maintenance.ts`)。
|
||
|
||
3. 依赖:`zip = { version = "2", default-features = false, features = ["deflate"] }`(约新增 `flate2`/`miniz_oxide`/`crc32fast`)。**必须同时提交更新后的 `Cargo.lock`**,否则 CI 的 `cargo test --locked` 会失败。
|
||
|
||
4. 错误信息统一成"给现场的人看的一句话"(现有风格),并且**区分"包坏了"与"包是好的、设备不对"**——后者不要让人去重新拷包。
|
||
|
||
**前端改动**
|
||
|
||
- `selectBundleDirectory()` → `selectBundlePackage()`:
|
||
```ts
|
||
open({ multiple: false, filters: [{ name: "固件包", extensions: ["zip"] }] })
|
||
```
|
||
目录选择收进"高级 / 兼容旧包(目录)"。
|
||
- 新增 `FirmwareDropZone`:拖拽 `.zip` 进来(Tauri v2 需窗口 `dragDropEnabled: true`,默认即为 true;实现时用 `getCurrentWebview().onDragDropEvent` 验证一次)。
|
||
- 展示:包名 / 版本 / chip / flash / App 大小 / 分区大小 / 整包 SHA 前 12 位 / 来源(zip/目录)。
|
||
- 维护页步骤 ② 的三态:未选 → 已加载(打勾)→ 校验失败(红框 + 一句话原因 + "重新选择")。
|
||
|
||
**CLI 兼容**
|
||
|
||
`--maintenance-plan <串口> <固件包>` 现在只接受目录;改为接受 zip 或目录,帮助文本同步更新(`main.rs` 的 `USAGE`)。
|
||
|
||
---
|
||
|
||
## 6. 校内赛规则要点与判罚流程
|
||
|
||
### 6.1 规则事实(来自官方手册,不改写)
|
||
|
||
| 条目 | 规定 | 来源 |
|
||
| --- | --- | --- |
|
||
| 队伍 | 每队严格 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/) |
|
||
|
||
### 6.2 对客户端的直接影响(不是装饰性结论)
|
||
|
||
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. **"最快开始移动"没有数据源**。图传只给画面,协议里没有位移/速度;因此这条规则**只能人工判定**(见 3.4)。不要试图从画面里推算。
|
||
6. **赛前 3 分钟**是"确认客户端连上了"的窗口,所以用户端需要一个**准备检查卡**:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 `Ready` 阶段自动出现,`Running` 后消失。
|
||
7. **红蓝配色固定**:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 `teamColor` 已是这个语义,只改色值)。
|
||
|
||
### 6.3 判罚流程(客户端可执行的部分)
|
||
|
||
```
|
||
┌──────────────────────────────┐
|
||
│ 单局结束条件 │
|
||
└──────────────┬───────────────┘
|
||
┌───────────────────────────┼───────────────────────────┐
|
||
│ │ │
|
||
血量归零 时间耗尽 人工裁定
|
||
(自动,双方一致) (自动比血量) (裁判操作)
|
||
│ │ │
|
||
│ ┌──────┴──────┐ ┌─────────┼─────────┐
|
||
│ 血量不等 血量相等 设备故障 违规/弃权 平血仲裁
|
||
│ │ │ │ │ │
|
||
▼ ▼ ▼ ▼ ▼ ▼
|
||
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 的更新日志提到"添加了详细的扣血、判罚规则与单局时间",但当前公开页面只给出了 6.1 表列出的内容(扣血 3 点、技术暂停 5 分钟、四条胜负条件)。**如果组委会另有一份判罚细则(红黄牌、犯规等级),需要提供原文**,否则界面不写死任何罚则。
|
||
|
||
---
|
||
|
||
## 7. 实施优先级、风险与待确认问题
|
||
|
||
### 7.1 时间线(关键约束:11-01 决赛)
|
||
|
||
| 日期 | 里程碑 | 交付物 |
|
||
| --- | --- | --- |
|
||
| 10-19 ~ 10-21 | **M0 + M1** 视觉基线与外壳 | `tokens.css`、`AppShell`、F11、TopBar/StatusBar 换肤、用户端/裁判端布局不回归 |
|
||
| 10-21 ~ 10-23 | **M2** 比赛状态机 + 比赛条 | `core/match.rs`、`match_*` 命令、`MatchBar`/`Clock`/`ScoreBoard`、事件落盘 |
|
||
| 10-23 ~ 10-24 | **M3** 判负 / 技术暂停 | `ForfeitDialog`、`TechPauseDialog`、覆盖层、撤销与审计 |
|
||
| 10-24 ~ 10-25 | **M4-lite** 直播最小可用 | `BroadcastPage`(模板 A + D)、安全区、F11 输出、`StandbyCard` |
|
||
| **10-25** | **适应性训练(冻结线)** | 只允许修 bug,不再进新功能 |
|
||
| 10-26 ~ 10-27 | **M6** `.zip` 固件包 | `package_firmware.py`、`maintenance_load_bundle` 支持 zip、维护页步骤 ② |
|
||
| 10-27 ~ 10-29 | **M5** 回放(双路同步)+ **M7** 维护页 Stepper | 按局分组、事件时间轴、5 步流程 |
|
||
| 10-29 ~ 10-31 | **M8** 无障碍/性能/回归/演示彩排 | `prefers-reduced-motion`、低动效开关、跨平台验证、演练脚本 |
|
||
| 11-01 | 决赛 | — |
|
||
|
||
> 现实是:**M1 的"整个界面重构"和"11-01 前必须能用"是冲突的**。建议把视觉重构限定在"token 替换 + 布局调整",不动 `VideoSurface`/`stream.ts`/`recorder` 这些已稳定的链路代码。
|
||
|
||
### 7.2 优先级矩阵
|
||
|
||
| 优先级 | 事项 | 理由 | 依赖 |
|
||
| --- | --- | --- | --- |
|
||
| **P0** | F11 全屏 + 沉浸模式 + 快捷键表 | 现场投影/操作位必需,成本低 | capabilities 权限 |
|
||
| **P0** | 比赛条(计时/比分/局次)+ 状态机 | 没有它就没有"比赛",只有"两个画面" | core/match.rs |
|
||
| **P0** | 一键判负(长按 + 理由 + 审计 + 撤销) | 用户明确要求;现场一定会用到 | M2 |
|
||
| **P0** | 技术暂停(申请/批准/5 分钟/恢复) | 规则明文;复现手册流程 | M2 |
|
||
| **P0** | 视觉基线(token/顶栏/血条/按钮/表格) | "按 Division 重构"的主诉求;纯 CSS,风险低 | — |
|
||
| **P0** | 直播安全区 + 模板 A/D + 待机卡 | 直播包装的最小闭环 | 状态机 |
|
||
| **P1** | `.zip` 固件包读取 + 打包脚本 | 用户明确要求;影响赛前烧录效率 | `zip` 依赖 |
|
||
| **P1** | 维护页 Stepper 化 | 与 zip 同批,避免两次改同一页 | M6 |
|
||
| **P1** | 装甲板表双队化 + 迷你血条 | 裁判现场核对效率 | — |
|
||
| **P1** | 事件面板 + 导出 | 判罚留痕,赛后申诉依据 | M2 |
|
||
| **P1** | 回放双路同步 + 事件定位 | 判定后复盘 | 录制 label |
|
||
| **P2** | 直播模板 B(PIP)/ C 细化 / 赞助位 | 观感提升,非比赛必需 | — |
|
||
| **P2** | 跨端同步(裁判判定 → 用户端) | 需要协议 + 信任模型(见 R2) | 协议扩展 |
|
||
| **P2** | 多显示器全屏、第二窗口输出 | 现场单屏也能用 | — |
|
||
| **P2** | 系列赛完整状态(BO5 编排/对阵表) | 赛程由人排,客户端只记比分 | — |
|
||
| **P3** | 低动效开关、字体包、深色/浅色 | 可选 | — |
|
||
|
||
### 7.3 风险
|
||
|
||
| # | 风险 | 概率/影响 | 缓解 |
|
||
| --- | --- | --- | --- |
|
||
| **R1** | **客户端判负 ≠ 硬件血量**:协议只有上行(`MsgType` 1–5),没有下行控制,判负后对方装甲板仍报 100 | 高 / **高** | 界面明确写"只改变判定与记录,不改变硬件血量";不伪造 HP;若要硬件生效必须另做固件+协议(Q2) |
|
||
| **R2** | **跨端不同步**:裁判端判负/暂停,操作位用户端看不到 | 高 / 高 | v1 只作用于裁判端与直播;v2 用 `5601` 广播新 `MsgType`(见下)并加共享码签名;现场先用对讲机兜底 |
|
||
| **R3** | Tauri 全屏权限/快捷键冲突(macOS `⌃⌘F` 与系统全屏叠加) | 中 / 中 | 显式加两个 permission;`preventDefault`;按 400 ms 去抖;三平台各验一遍 |
|
||
| **R4** | zip 恶意/畸形输入(zip slip、zip bomb) | 中 / 高 | 路径规范化白名单 + 条目数/大小上限 + 只读内存 + CRC |
|
||
| **R5** | 新增 `zip` 依赖打断离线/锁文件构建 | 中 / 中 | 提交 `Cargo.lock`;CI 用 `--locked` 会立刻暴露;备选:只支持 store(不压缩)条目 + 自研 150 行 reader |
|
||
| **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` 降级、低动效开关 |
|
||
| **R12** | 1690 行 CSS 重构引入视觉回归 | 高 / 中 | 分两步:先"加 token + 保留旧类名"(零风险),再逐组件替换;每步截图对比 |
|
||
|
||
**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 不会让板子异常"。
|
||
|
||
### 7.4 需要用户确认的问题
|
||
|
||
**比赛语义(最关键,答案决定方向)**
|
||
|
||
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、选手照片/队徽?由谁提供、什么格式?
|
||
|
||
**固件包**
|
||
|
||
9. **Q9** `.zip` 是否需要**签名**(Ed25519)?如果需要,私钥谁保管、公钥怎么随客户端发布?
|
||
10. **Q10** 是否必须同时兼容**旧的目录包**(检录现场可能只发了目录)?我建议兼容(成本几乎为零)。
|
||
11. **Q11** 打包环节只做本地脚本,还是要进 CI?(进 CI 需要 Xtensa 工具链,赛前不建议)
|
||
12. **Q12** zip 里的 `partition-table.bin` 仍只是"模板 + 校验"(客户端实际写的是设备当前分区的 patch 版本)——这个语义要不要在维护页显式写出来?
|
||
|
||
**交互与现场**
|
||
|
||
13. **Q13** 用户端是否允许 F11 全屏?是否记忆"上次是否全屏"?(操作手误触退出全屏会很慌,建议:允许,但全屏状态落盘,且退出时有 1.5 s 提示条)
|
||
14. **Q14** 录制是否随比赛状态**自动起停**?文件名要不要带"红队 vs 蓝队 + 第 N 局"?
|
||
15. **Q15** 目标平台优先级:现场是 Windows(NSIS)?macOS 只用于开发?(`desktop/.cargo/config.toml` 的默认 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:变更文件清单
|
||
|
||
**后端(Rust)**
|
||
|
||
| 文件 | 改动 |
|
||
| --- | --- |
|
||
| `desktop/core/src/match.rs`(新) | `MatchState` / `MatchPhase` / `MatchEvent` / `MatchConfig` / 状态迁移 + 单测 |
|
||
| `desktop/core/src/lib.rs` | `pub mod match;` |
|
||
| `desktop/core/src/snapshot.rs` | `Snapshot` 增加 `now_ms`、`match: MatchView`;`MatchView` 等 DTO |
|
||
| `desktop/src-tauri/src/server.rs` | `WireMsg` 增加 `MatchEvent`(与 `Hit` 同一条推送路径) |
|
||
| `desktop/src-tauri/src/state.rs` | 持有 `Mutex<MatchState>`;`tick()` 推进;`snapshot()` 填充;`match_*` 方法;审计写盘 |
|
||
| `desktop/src-tauri/src/commands.rs` | `match_configure/start/pause/resume/end/undo/next_round/set_tiebreak/export`;`require_referee()`;`save_png` |
|
||
| `desktop/src-tauri/src/main.rs` | 注册新命令;`USAGE` 更新(zip 包路径) |
|
||
| `desktop/src-tauri/src/maintenance.rs` | `BundleSource` / `load_from_zip` / 路径与大小校验 / `BundleInfo` 新字段 / 备份写文本 |
|
||
| `desktop/src-tauri/src/recording.rs` | `start(..., label: Option<&str>)`,文件名带局次 |
|
||
| `desktop/src-tauri/capabilities/default.json` | 加 `core:window:allow-set-fullscreen`、`core:window:allow-is-fullscreen`(直播第二窗口再加 `core:webview:allow-create-webview-window`) |
|
||
| `desktop/src-tauri/Cargo.toml` | `zip` 依赖;同时提交根目录更新后的 `Cargo.lock` |
|
||
|
||
**前端(React/CSS)**
|
||
|
||
| 文件 | 改动 |
|
||
| --- | --- |
|
||
| `styles/tokens.css`(新) | 1.2–1.6 的全部 token |
|
||
| `styles/global.css` | 保留结构,按节替换为 token;拆分可选 |
|
||
| `App.tsx` | `AppShell` + `view` 状态 + 快捷键注册 + 全屏 hook |
|
||
| `components/TopBar.tsx` | 工具收纳、直播入口、状态徽标 |
|
||
| `components/match/*`(新) | 比赛条、计时、比分、判负/暂停对话框、覆盖层、事件面板 |
|
||
| `components/broadcast/*`(新) | 直播画布与包装层 |
|
||
| `components/replay/ReplayView.tsx`(新) | 双路同步回放 |
|
||
| `pages/TeamPage.tsx` | 计时条、覆盖层、准备检查卡、底部提示淡出 |
|
||
| `pages/RefereePage.tsx` | MatchBar + 可拖拽 Dock + 双队装甲板 + 事件页签 |
|
||
| `pages/MaintenancePage.tsx` | Stepper 重构 + zip/拖拽 |
|
||
| `pages/BroadcastPage.tsx`(新) | 直播视图 |
|
||
| `components/{HealthBar,ArmorTable,MediaPanel,SettingsPanel}.tsx` | 尺寸/布局/新段 |
|
||
| `lib/types.ts` | `MatchView`、`nowMs`、`WireMsg.matchEvent`、`BundleInfo` 新字段 |
|
||
| `lib/ipc.ts` | `match*` 命令、`savePng` |
|
||
| `lib/maintenance.ts` | `selectBundlePackage`、新字段 |
|
||
| `lib/store.ts` | `ui`、`events`、`clockSkewMs` |
|
||
| `lib/dev-preview.ts` | 合成 `match` 状态(否则浏览器预览会白屏) |
|
||
|
||
**脚本/文档**
|
||
|
||
| 文件 | 改动 |
|
||
| --- | --- |
|
||
| `scripts/package_firmware.py`(新) | 编译后打包(5.4) |
|
||
| `scripts/build.ps1` / `build.sh` | `--package` 开关 |
|
||
| `docs/firmware-maintenance.md` | zip 格式、打包命令、读取流程 |
|
||
| `docs/desktop-ui-redesign.md` | 本文 |
|
||
|
||
## 附录 C:验收标准(可测量)
|
||
|
||
| 项 | 标准 | 验证方法 |
|
||
| --- | --- | --- |
|
||
| 全屏 | F11 切换 < 350 ms;切 10 次不卡死、不丢键 | 手工 |
|
||
| 计时 | 3 分钟误差 < 200 ms;切窗口/最小化不失速 | 与手机秒表对比 3 次 |
|
||
| 判负 | 必须长按 ≥ 1000 ms;< 1000 ms 不生效;撤销窗口 5 s | 自动化 + 手工 |
|
||
| 技术暂停 | 申请表单不勾"已批准"无法开始;5:00 倒计时准确 | 手工 |
|
||
| 直播 | 安全区内无越界元素;不出现 IP/token/设置入口 | 截图核对清单 |
|
||
| .zip | 5 MiB 包加载 < 300 ms;篡改 1 字节被拒且文案指出是哪个文件 | 单测 + 手工 |
|
||
| 动效 | 30 min 双路运行,主线程不掉帧(≥ 25 fps) | 性能面板 |
|
||
| 无障碍 | 关键文字对比 ≥ 4.5:1;`prefers-reduced-motion` 下无位移动画 | 对比度工具 + 开关 |
|
||
| 回归 | 视频/血量/装甲板/录制/维护五项原功能全部可用 | 回归清单 |
|
||
|
||
## 附录 D:不做的事(明确边界)
|
||
|
||
1. **不做**网络 OTA / 设备自升级(与现有 `docs/firmware-maintenance.md` 的边界一致)。
|
||
2. **不做**修改固件源码;打包只组装已有产物。
|
||
3. **不做**从画面推算"移动先后"、命中位置、弹道等规则数据。
|
||
4. **不做**直播的透明背景/虚拟摄像头/NDI。
|
||
5. **不做**多语言(中文界面为唯一语言,符合约束)。
|
||
6. **不做**用户账号/云端同步。
|
||
|