Files
simba-rmic-2026/docs/desktop/client.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

529 lines
37 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.
# RMIC::2026
黑龙江大学 602 实验室 · Simba Robotics 的裁判系统客户端。同一进程提供用户端与裁判端两种界面。
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|---|---|---|---|---|
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
前端在 WebView 中运行 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为独立模块,可分别运行单元测试。
> 显示名是 `RMIC::2026`;安装包和可执行文件使用 `RMIC-2026`,因为 Windows 文件名不允许 `:`。配置、录制与日志沿用 `cam-desktop` 目录,以保留已有密码哈希与绑定,见“配置”。
本文的路径以仓库根目录为起点,构建命令会注明执行目录。目录树及命令注释中的测试数量是原记录的快照,当前测试结果以实际运行输出为准。
```text
desktop/
core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试)
src-tauri/ Tauri 后端:推流、命令、录制(40 个测试)
src/ React 前端:两套页面 + HUD
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化、准星绘制、界面小节拍
src/components/ 取景器、装甲条、命中、结算、装甲板表、队名、录制、设置抽屉、启动序列、门、提示
src/pages/ 用户端 / 裁判端 / 固件维护 / 延迟校准
src/styles/ tokens(令牌)、base(原子)、chrome(外壳)、stage(舞台)、console、settings、maintenance
public/fonts/ 自托管的 HarmonyOS Sans SC 与 JetBrains Mono
src-tauri/icons/ 队标(由 simba-robotics.com/logo.svg 生成,含光学尺寸分级)
```
## 界面:一套终端语言
### 配色:墨黑蓝打底,只有一支红
| 角色 | 值 | 用在哪 |
|---|---|---|
| 底色 | `--void #05070d` → `--panel-3 #172032` | 全部面积。五级低饱和冷蓝黑,靠 1px 线分层,不靠阴影 |
| 文字 | `--ink #e7ecf5` → `--ink-4 #3a4457` | 四档亮度,第四档用于弱化显示 |
| **强调** | `--primary #e8232b` | **只有**:焦点、选中、录制中、错误、临界血量、结算 |
| 状态 | `--error` / `--warn #f2b33d` / `--ok #3fc98a` | 各有语义,不与强调色混用 |
| 队伍 | `--team-0 #ff6b5e` / `--team-1 #4a9bff` | **硬件约束**:装甲板 LED 就是红与蓝 |
红色用于需要注意的状态。开启的开关为白色,滑杆填充为钢灰色,分段控件以底部 2px 线标记选中,分区编号为灰色,顶栏不加装饰强调线。
队伍红色比强调红更暖、更浅,以免两条血量并列时看起来像系统在报警。
### 形状:矩形,唯一的斜边给装甲条
界面采用矩形,不使用圆角、切角、药丸或阴影卡片。斜边只用于装甲条的平行四边形,使血量与其他控件在形状上保持区别。
### 动效:严格照 Material 3 的曲线与时长
`styles/tokens.css` 定义了 M3 的七条缓动曲线与 short/medium/long 四组时长:
| 令牌 | 曲线 | 语义 |
|---|---|---|
| `--m3-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | 需要存在感的转场 |
| `--m3-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | **进入**(快起慢收) |
| `--m3-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | **退出**(慢起快走) |
| `--m3-standard` / `-decelerate` / `-accelerate` | `(0.2,0,0,1)` / `(0,0,0,1)` / `(0.3,0,1,1)` | 常规位移 |
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为指向 M3 的别名,相关规则均引用这些曲线,没有自定义贝塞尔曲线。两种循环效果另行处理:`steps()` 用于录制红点与终端光标的离散闪烁,`linear` 用于“没信号”状态下恒速转动的虚线环。M3 曲线描述有起止的转场,不适用于这些无限循环。
### 动效:每一页只有一个签名动作
各页共用缓动曲线和时长,动效随操作场景区分:
| 界面 | 签名动作 | 它解决什么 |
|---|---|---|
| 启动序列 | 一行行亮起的自检 + 真实进度 | 区分加载中与启动失败 |
| 首启门 | 两张模块卡,悬停浮起并露出角括号 | 提示选择将保存到本机 |
| 用户端 | 逐格熄灭的装甲条、四周泛红、六秒休眠 | 静止时屏幕上的东西越少越好 |
| 裁判端 | 中缝可拖、鼠标停在哪一路下方就看哪一路 | 不用再开第三个页面去"看某一边" |
| 设置 | 常驻实时预览 + 竖向分节导航 | 改准星/网格/装甲条时立刻看得见结果 |
| 固件维护 | 步骤轨 + 环形进度 + 终端日志 | 烧录的哪一步、到哪儿了,一眼可读 |
| 解锁 | 一次性的校验扫描 + 错误时的一次抖动 | 明确"现在这一步是在验证身份" |
`prefers-reduced-motion` 将这些动效时长设为 0,功能保持可用。
### 字体:中文一套、拉丁一套,逐字回退
| | 字体 | 承担什么 |
|---|---|---|
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
两种字体都以 woff2 格式保存在 `public/fonts/`,随 `frontendDist` 打入安装包,运行时无需联网加载,CSP 的 `default-src 'self'` 也不允许从外部加载。中文字体由 `cn-font-split` 切成 82 个 unicode-range 分片 × 3 个字重,浏览器按需加载,避免启动时读取完整的 14 MB 字体。
混排依靠逐字回退,无需判断语言:`--font-ui` 将 JetBrains Mono 放在前面,遇到其中没有的汉字时,浏览器回退到 HarmonyOS。因此,“血量 87”中的数字使用等宽字体,汉字使用 HarmonyOS,同一条 `font-family` 即可处理两者。
启动页等待 `document.fonts.ready`,避免先显示系统字体、两百毫秒后切换到 HarmonyOS 引起布局重排。
### 队标
应用图标和启动页标记均来自队标(`https://simba-robotics.com/logo.svg`),整套 `src-tauri/icons/` 由此生成。原图画布为 1024×1024,标志只占中间一条,因此脚本先将 viewBox 收到图形本体,再居中放置。深色底板采用圆角,四角透明。小尺寸使用更大的缩放比(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%),避免 16/24/32 图标中的 1px 笔画模糊。`icon.ico` 手工封装各尺寸图像;Pillow 的写入器只能从一张主图缩出所有档位,而 Windows 任务栏只使用其中最小的几个尺寸。
图标小于 17px 时,笔画只剩 1px,因此顶栏只放字标 `RMIC::2026`(其中 `::` 是界面里唯一标红的两个字符),完整队标在启动页以 30px 显示。
图标的 PNG、ICO 与 ICNS,以及 Windows 安装程序的侧图和页眉图,由同一脚本生成。先在 `desktop/` 安装前端依赖,再准备 Python 3 与 Pillow:
```sh
python -m pip install Pillow
python scripts/generate-brand-assets.py
```
以上命令在 `desktop/` 执行。脚本使用已有 Tauri CLI 渲染 SVG,不新增应用依赖。图标源文件为 `src-tauri/icons/icon.svg`;安装图源文件为 `src-tauri/installer/header.svg` 和 `sidebar.svg`,采用 132×57 和 164×338 的布局,导出两倍分辨率的 24 位 BMP(264×114、328×676),另保留原尺寸 PNG 便于预览。侧栏使用 `installer/logo.svg` 中不带底板的原始队标;生成脚本按图片引用读取对应 SVG,不把它替换为应用图标。安装图采用 `AspectFitHeight` 等比缩放,侧栏比例适配简体中文字体的对话框单位,避免 Logo 被水平压缩。页眉画布收窄,为标题区域保留间隔,防止图片等比放大后遮住标题首字。安装图片沿用墨黑蓝底、白色队标和红色强调色。默认使用 Windows 系统字体;其他平台可用 `--sans-font` 指定支持中文的字体文件、`--mono-font` 指定等宽字体。
### 每页最顺手的那个操作
- 用户端通过滚轮缩放、拖动平移、双击复位,无需额外点击按钮。六秒没有输入或受击时,角落标签与代号淡出;出现输入或受击后恢复。
- 裁判端点击左侧画面后,下方列表只显示该侧的装甲板;再次点击恢复显示全部,键盘 `1` / `2` / `0` 也可切换。拖动中间的竖线可调整两路画面的宽度,双击恢复等宽。拖动画面与控制台之间的横线可调整回放区域高度。
- 设置页左侧为竖向导航,右侧常驻实时预览。预览与实际画面共用绘制函数(`lib/reticle.ts`),保持绘制逻辑一致;预览血量滑杆用于查看低血阈值与脉动效果。
## 版本与改动日志:算出来的,不是写出来的
版本号、构建号和改动记录均从 Git 历史计算。提交规则、派生文件和固件版本通告见[版本维护](../development/versioning.md)。
## 快速开始
```sh
cd desktop
pnpm install
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
pnpm build # 只构建前端(tsc --noEmit && vite build)
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
```
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
```sh
cd desktop/core && cargo test # 83
cd desktop/src-tauri && cargo test # 40,其中几条真的会起 ffmpeg
```
### Windows x64 构建
需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。
在 `desktop/` 中执行:
```powershell
pnpm install --frozen-lockfile
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
```
`--target` 覆盖本目录原来的 macOS target,`--bundles nsis` 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 `build-std`。
- 程序:`src-tauri/target/x86_64-pc-windows-msvc/release/RMIC-2026.exe`(名字由 `tauri.conf.json` 的 `productName` 决定;直接 `cargo build` 出来的原始产物仍叫 `cam-desktop.exe`,那是内部构建物,不是交付物)。
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
Windows 打包需从普通终端或中完整性及以上的 CI runner 启动。`tauri.windows.conf.json` 在构建开始和打包前运行 `scripts/prepare-windows-bundle.ps1`,将输出目录、Cargo 宿主构建脚本、主程序及已有 NSIS 安装包的完整性标签设为 Medium。低完整性进程会被明确拒绝;CI 上传前还会只读检查产物标签。
如果已有安装包启动时报 `Error writing temporary file. Make sure your temp folder is valid.`,可先在普通 PowerShell 中检查标签:
```powershell
# 在 desktop/ 中执行。低完整性标签的 SID 为 S-1-16-4096。
icacls .\src-tauri\target\x86_64-pc-windows-msvc\release\bundle\nsis\*-setup.exe
# 修复本机已有主程序、安装包与打包输出目录,无需重新编译。
.\scripts\prepare-windows-bundle.ps1
# 只读复查,要求安装包存在。
.\scripts\prepare-windows-bundle.ps1 -CheckOnly
```
自定义 Cargo 输出路径时,脚本通过 `cargo metadata` 获取目录。单独修复其他目录可传 `-OutputDir <目标的 release 目录>`;调试产物则传相应的 `debug` 目录。
[Windows 创建进程时会取用户与 exe 文件完整性级别的较低值](https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control#process-creation),所以带 Low 标签的 exe 无法写入普通 `%TEMP%`。NSIS 在执行安装钩子前就会检查临时文件目录,仅重定向构建机的 `TEMP` 或放宽目录 DACL 不能解决分发后的问题。修复脚本只修改构建输出的完整性标签,不修改用户临时目录权限;安装包仍按当前用户安装。修复标签后若仍报错,再检查实际临时目录是否存在且可写。
权限回归检查:打包下载过 NSIS 工具后,在 `desktop/` 执行 `./scripts/test-windows-bundle.ps1`。它在独立样例目录验证 Low 进程拒绝、文件标签修复及重复执行,并编译一个仅写临时文件后退出的 NSIS 程序;不安装应用。工具不在默认缓存时可传 `-NsisCompiler <makensis.exe 路径>`。
NSIS 安装流程使用简体中文,包括欢迎、安装位置、安装完成、覆盖安装、卸载与 WebView2 错误提示。`src-tauri/installer/hooks.nsh` 定义页面提示和安装日志,`SimpChinese.nsh` 定义 Tauri 安装器的补充翻译;`tauri.windows.conf.json` 引用这些脚本和主题图片。沿用 Tauri 默认安装模板和当前用户安装方式;首次启动的模式选择与裁判系统绑定仍在客户端内完成。
默认完成页提示客户端与裁判系统连接同一网络,不内置 Wi-Fi 凭据。现场安装包如需显示热点信息,可将 `src-tauri/installer/network.example.nsh` 复制为同目录的 `network.local.nsh`,填写后重新打包。该本地文件已被 Git 忽略;不要提交它,分发安装包时也需确认其中的热点信息允许分享。
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
设备发现使用裁判系统的 Hello 广播。客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口,定向续订可避免“能发现并绑定,却始终等待画面”的情况。队伍端的绑定和画面过滤仍然生效。
### 演示模式:没有板子也能把整条链路跑起来
```sh
cd desktop
CAM_DEMO=1 pnpm app
```
演示模式合成协议数据报,交给 `Receiver::datagram` 与 `ArmorHealthStore::datagram`,沿正常接收路径进入界面:
```text
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
```
分片、重组、发现、统计、掉血派生与两项接收限制使用正式代码路径,截图和录制也无需专门分支。界面标注“演示模式”,设备 ID 使用 `0x0DE00001` 等人工值。演示数据不能作为现场状态。
### 命令行与环境变量
```text
--role <team|referee> 身份。默认看配置文件
--config <路径> 配置文件位置
--demo 演示模式
--list 打印发现的摄像头与装甲板后退出
-h, --help 帮助
CAM_DEMO=1 等价于 --demo
CAM_CONFIG=<路径> 等价于 --config <路径>
```
使用 `tauri dev` 时,应用参数需要通过两个 `--` 分隔符传递,如 `tauri dev -- -- --demo`。也可通过上面的环境变量设置。
未知参数只记录一条 debug 日志,不报错。macOS 从访达双击 .app 时,系统会附加 `-psn_0_123456`;若将它视为错误,用户看到的现象就是双击图标后程序没有启动。
## 为什么换成 Tauri,代价是什么
旧客户端使用 Rust + Slint 单二进制。改用 Tauri 是为了加快血条、瞄准器、拖拽校准、绑定控件、设置和回放等界面的开发,把更多工作时间留给 UDP、重组、发现与接收限制。
相应代价:
- **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
- **要联网装依赖**(前端 npm、Rust crates)。
- **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器,
路径更短。
`core/` 不依赖界面,重组、发现、帧率窗口、槽位分配、配置与密码均可单独测试。
## 视频怎么进 WebView
视频走一个只监听回环地址的 WebSocket,推二进制 JPEG。界面从 `stream_info` 命令拿到
`ws://127.0.0.1:<随机端口>/?token=...`。
### 为什么不是 Tauri 的 IPC
按早期两路各 22 fps、每帧约 22 kB 的场景估算,JPEG 流量约 1 MB/s。JSON IPC 需要序列化和反序列化,base64 还会增加约 1/3 体积。二进制 WebSocket 直接传递原始 JPEG,由浏览器原生解码,后端无需解码。
### 二进制帧的布局
```text
0 4 8 10 12 16
+--------------+--------------+------+------+--------------+---------+
| sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
+--------------+--------------+------+------+--------------+---------+
```
帧头为 16 字节,采用小端编码。`server.rs` 与 `stream.ts` 两侧均有布局测试;长度不一致的帧直接丢弃,防止投递不完整 JPEG。
### 前端侧的两个陷阱
`createImageBitmap` 异步解码,相邻帧可能乱序完成。每路记录最后绘制的帧号,只采用更新帧,避免画面回退。
旧的 `ImageBitmap` 必须显式调用 `close()` 释放像素资源,仅替换引用不够。早期两路 22 fps 场景下,未释放的资源在几分钟内可积累到几百 MB。
## 防作弊:两道闸门,不是一个
对抗赛中,两队各用这套系统查看本车图传,接收限制用于防止对手另开窗口连接另一队的板子。
限制在接收端执行。Hello 广播已包含由 MAC 派生的 `sender_id` 与 IP;CSS 只能隐藏显示,无法阻止接收与解码。
| 闸门 | 管什么 | 什么时候开 |
|---|---|---|
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
旧客户端共用一个开关控制发现和接收,改绑需要手动修改配置文件。现在设置界面可以列出候选摄像头,同时保持接收限制:其他裁判系统即使向本机端口推流或进入发现列表,其视频帧也不会进入重组器。
`only_sender` 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。
### 遥测按编译模式门控
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 构建不产出帧率和丢帧数据,`CameraStatsDto::build` 将相应字段置零或置空。是否提供这些数据由编译模式决定,不依赖现场命令行开关。
### 这挡不住什么
Hello 广播仍包含 IP。固件会向任何发送 `Subscribe` 的订阅者推流,本机客户端的接收限制无法阻止其他客户端订阅。若要限制无线订阅者,需要在固件侧实现配对 token,例如首次绑定时发放令牌,之后只接受带令牌的订阅;若令牌只保存在内存中,固件重启后需要重新配对。
回环通道令牌只限制本机程序连接:连接方需要先取得令牌。它不验证无线视频订阅者的身份。
## 密码:它挡的是谁
- 密码用于限制现场改绑和退出程序。配置只保存 Argon2id 哈希;修改配置中的绑定后,界面仍要求解锁。
- 拥有本机配置和源码的人可以删除哈希或修改客户端,因此密码无法约束这种操作。限制无线订阅仍需固件侧配对机制。
密码的作用是防止误触和现场随手修改,不能提供密码学意义上的对抗保护。
实现上:
- Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希
一起保存在配置中,之后调整参数无需迁移。短密码不应仅使用 SHA-256 等快速哈希保护。
- 用户端首次启动时必须设置密码(`Config::needs_password_setup`),最短 4 位,便于现场记忆与输入。
- 解锁在本次进程内有效,用户端可以手动“上锁”。
- 裁判端无需密码,便于现场调整槽位和录制。
- 离开用户端需要密码,避免通过切换到裁判端绕过绑定和配置限制。
Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从控制台调用 `invoke("bind_camera")`,也需要通过这项检查。
客户端移除了旧版的 `--camera-id` 命令行覆盖,避免绕过密码改绑。临时更换裁判系统时,在设置中解锁后修改,或用 `--config` 指定另一份配置文件。
忘记密码时,可删除配置文件中的 `password` 一行,或删除整个文件。旧客户端配置会在首次启动时导入,并备份为 `.bak`。
## 用户端 HUD
### 血条:两个上角,平行四边形,一条分 5 格
自己和对手的血条分别位于画面左上角与右上角,便于操作手在取景时对照。血条为平行四边形,分成 5 格,每格 20 点血;较大的分格便于远处辨认。血量减少时,从靠近画面中心的一侧开始熄灭。
血条使用绝对定位,设置 `pointer-events: none`,让鼠标仍能缩放和拖动画面。倍率与复位徽标放在下沿,为上方血条留出空间。
宽度、数字、队名、低血阈值与脉动均可配置。阈值以内的格子始终使用警示色,提前标出低血区域。
未接入装甲板时显示占位说明,避免将缺失数据误读为血量归零。
血量归零后,血条保留数字 0 和熄灭的格子,比赛结果由中央横幅显示。
### 胜负横幅
一方血量归零后,图传中央显示结果:获胜方为“胜利”,落败方为“落败”,双方同时归零为“平局”。提示带上对手名字,便于在裁判端两路画面中判断胜负。
- 结果由 Rust 侧计算(`snapshot::resolve_outcomes`,有单测)。自己归零即可判负,即使对手掉线;判胜则要求对手归零且自己仍有血,避免在本队从未上报时误判获胜。
- 横幅持续显示到血量重置(长按 BOOT / 下一局开始),供截图、回放和现场核对。
- 比赛结果放在画面中央;比赛中的命中反馈避开中央瞄准区域。
### 瞄准器
瞄准器标记相机画面中的一个点,直接绘制在 canvas 上。图像、网格、瞄准器和伤害数字共用同一组变换,避免 DOM 与 canvas 分别计算位置而产生偏移。
瞄准器的位置与大小采用不同的缩放规则:
| | 跟着缩放走吗 | 为什么 |
|---|---|---|
| 位置 | **是** | 它指的是画面里的一个点 |
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
偏移校准保存画面比例,不保存像素偏移。摄像头与枪管不同轴时,需要将瞄点调整到弹着点;若保存像素偏移,分辨率或窗口尺寸变化后就会失准。校准既可通过拖动画面完成,也可直接输入数值。
### 命中反馈
主板只上报血量,不单独发送受击事件。`HealthTracker` 根据相邻读数还原命中:
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
基线按板子记录,而非按 `(板子, index)` 记录。主板一次广播四条上报,`hp` 相同;若为每条分别记录基线,一次掉血就会产生四次命中反馈。
命中反馈只能反映两次读数之间的血量净差。心跳间隔为 200 ms,这段时间内的两次打击会合并为一次反馈,足以触发红色提示,但不足以单独用于判定;判定需看界面上的绝对血量。
反馈包括画面四周泛红和准星旁的伤害数字,中央画面保持原样。早期版本的数字曾遮住瞄点,因此移到了准星旁。
命中反馈不表示方向。主板每次广播四路相同血量,事件中的 `index` 只表示哪一路上报先被收到;到达顺序受 UDP 影响,不能据此判断受击方向。
早期版本会在命中时晃动画面和瞄准器。该效果会干扰观察,也容易被误认为车体晃动,因此已移除。
`HitFeedbackConfig::shake` 字段保留,但已不再生效。配置解析使用 `deny_unknown_fields`,删除该字段会让所有写有 `shake = true` 的配置解析失败,导致程序无法启动。设置面板也已移除这个开关。
## 裁判端
### 队名可以直接改
两路画面下方各有一个队名输入框(`TeamNameEditor`),按回车或点击其他位置生效,Esc 取消。队名随比赛变化,因此直接放在裁判查看画面的区域。修改会立即同步到血条、装甲板表和“等待图传”的占位文字中。
Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合并空白,按字符截断到 12 个字,留空时使用「队伍一 / 队伍二」。输入框也限制长度,避免提交后才截断;按字符处理可避免截断中文的字节序列。
`set_team_name(team, name)` 每次只修改一队。若提交整个队名数组,最多 200 ms 前的旧快照可能覆盖刚修改的另一队名称。命令仍经过配置解锁检查,避免新增不受检查的写配置入口;裁判端的 `Role::needs_password` 为假,现场编辑无需密码。
快照通过 `teamNames` 向界面传递两条队名,下标对应队伍号;两侧的 `TeamHealthDto.name` 也来自这里。若界面各处自行拼接 `team === 0 ? "队伍一" : "队伍二"`,改名后部分位置仍会保留旧名字,且不会报错。
### 两格等宽
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
旧客户端的 `HorizontalLayout` 先按内容需求分配宽度,再分配剩余空间。两侧占位文字长度不同时,曾测得 55% / 45% 的宽度。现在占位文字绝对定位,两格使用相同的宽度规则。
### 槽位分配:修掉了一个真 bug
规则:
1. 还在线的槽位不换人;
2. **候选里排除已经被另一个槽位占着的 id**;
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
4. 没有候选时保持槽位为空,避免两格重复显示同一块板。
旧实现只排除当前槽位原有的 id。一队掉线后,其槽位可能选中另一队仍在线的裁判系统,导致两格重复显示。规则 2 修复了这一问题,并有对应测试。
图传板按在线候选自动分配,装甲板按上报的 `team` 分配。若画面对应关系不符,或装甲板固件的队伍号烧反,可在设置中手动指定槽位。
### 装甲板发现列表
列表显示裁判系统数量、自报队伍、血量、距上次广播的时间、四路上报情况和槽位。裁判端未显示血量时,可据此区分裁判系统未上线、队伍号错误和上报不完整。
该功能不需要修改固件:主板原本就通过 `encode_from(sender_id)` 将 MAC 派生 id 写入帧头,但旧解码路径丢弃了这一字段。现在由 `ArmorHealth::decode_from` 将它交给上层。
### 截图与录制
- 截图存 PNG(`shots/` 下),文件名带板子 id 与毫秒时间戳。
- 录制存 mp4,走 ffmpeg 子进程:`-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0`
喂帧,x264 编码,`+faststart`。
- `image2pipe` 的 JPEG 序列没有时间戳,需要显式向 ffmpeg 提供帧率。客户端优先使用配置值,其次使用实测值;都不可用时使用默认值并记录警告。该值写入日志与 DTO,回放速度异常时应先核对它。
- 帧经长度为 64 的队列交给独立写盘线程,避免阻塞接收。队列写入失败时丢帧并计数,界面显示该计数。
- 开始新录制前先结束上一段,并检查文件名是否重复;重名时追加 `-2`、`-3`,避免 ffmpeg 的 `-y` 覆盖已有录制。
界面内回放使用 Tauri 资源协议,只能访问 `tauri.conf.json` 中 `assetProtocol.scope` 允许的目录。默认录制目录在范围内;其他目录中的 `<video>` 加载失败时,可选择“用系统播放器打开”。
## 配置
默认配置文件为 `~/.config/cam-desktop/config.toml`,可用 `--config` 指定其他位置。目录名沿用旧的 `cam-desktop`,原因见开头;删除整个目录可重置配置。
```toml
role = "team"
roleChosen = true
team = 0
teamNames = ["队伍一", "队伍二"]
cameraId = "0x0DE00001"
armorId = "0x0A200001"
mirror = false
password = "$argon2id$v=19$m=19456,t=2,p=1$..."
[recording]
dir = "/Users/you/Movies/cam-desktop"
ffmpeg = "ffmpeg"
crf = 20
preset = "veryfast"
[hud.crosshair]
style = "cross-dot"
size = 0.05
offsetX = 0.0
```
配置约定:
- 使用 serde + toml 处理 HUD 嵌套结构、密码哈希和录制参数,替代旧客户端针对四个标量字段的手写解析。
- 未知键名报错(`deny_unknown_fields`),避免拼写错误被静默忽略。
- 磁盘、IPC 与前端 TS 的字段名统一为 camelCase,减少字段转换遗漏。
- 先写入同目录临时文件再 `rename`,避免中断写入导致配置或密码哈希不完整。
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
- `teamNames` 放在配置根层级,供用户端和裁判端共用。
- `roleChosen` 单独记录是否选过模式。`role` 默认是裁判端,仅靠该字段无法判断是否需要显示首次选择页面。
首次启动时,客户端会导入旧版(Slint)配置中可对应的字段,并将旧文件改名为 `.bak`,供排查时核对。该迁移仅适用于默认路径;用 `--config` 指定其他路径时不修改常规配置。
## 浏览器预览(仅开发)
开发时可在浏览器中预览同一套 React 组件,无需启动 Tauri 后端。页面没有 Tauri 注入时,由 `src/lib/dev-preview.ts` 合成状态与画面。
```sh
pnpm dev # 用户端
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
open 'http://127.0.0.1:1420/?preview=first-run' # 首启的身份选择
open 'http://127.0.0.1:1420/?preview=password' # 首启的设密码
```
后两个参数强制显示对应的首次启动页面,便于检查界面,无需删除配置文件。
预览要求 `import.meta.env.DEV` 为真且没有 `__TAURI_INTERNALS__`。模块通过 `await import(...)` 动态加载,正式构建中不包含它。
## 测试
```sh
cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
cd desktop/src-tauri && cargo test # 40:帧头布局、录制、截图、演示画面、命令行
cd protocol && cargo test # 23
```
前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复、设备重启和旧帧释放。它不替代实际 WebView 的画面验证。
测试覆盖的关键行为:
- 录制测试启动 ffmpeg,写入 20 帧并停止,再用 `ffprobe` 检查帧数和分辨率,避免只检查文件存在而漏掉空容器。
- 截图测试写入 PNG 后读回尺寸,并检查无效输入会报错且不留下空文件。
- 帧率窗口测试验证帧间隔平均值的计算结果(约 30 fps),防止算法回退。
CSP 验证需要检查前端能否连接后端。前端先通过 `invoke` 获取通道地址,再连接 WebSocket;由前端连接触发的快照 `clients` 从 0 变为 1,可用于确认 JS 加载、IPC、WebSocket 和 React effect 均已执行。手动连接检查如下:
```sh
RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1
```
## 线程与数据流
| 线程 | 做什么 | 为什么单独 |
|---|---|---|
| `udp-reader` | 收包、解析、按发送方重组 | 不能让解码/写盘挤进数据报之间 |
| `armor-reader` | 收 5601 上的血量广播 | 与视频是两个端口、两种节奏 |
| `rec-writer-<id>` | 把队列里的帧写进 ffmpeg 的 stdin | 接收线程绝不能被写盘拖慢 |
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。
视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。
前端每路最多保留一次正在进行的解码和一张最新待解码帧。
每张本地 JPEG 前增加 `frameTiming` 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。
## 已知限制与没做的
- 同一队有两块装甲板广播时,`health(team)` 采用“最后写入者优先”,四格数据可能来自不同板子。真实比赛每队只有一块,因此该情况只在开发(演示 + 真板子)或烧错固件时出现。日志会警告一次,可通过绑定具体板子 id(`health_of_board`)消除歧义。
- 录制使用固定帧率,不使用可变帧率。源帧率变化较大时,回放速度会有偏差;采用的帧率写入日志与 DTO,便于排查。
- `csp` 使用收紧后的白名单,但还不是最小范围:`style-src` 带有 `'unsafe-inline'`,供 React 内联 `style` 属性使用;`connect-src` 允许整个 `ws://127.0.0.1:*`,因为端口在启动时随机分配,无法写死。通过前后端连接检查验证它是否可用,方法见“测试”一节最后一条。
- **证书与公证**没做,`.dmg` 只在本机可用。
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
## 排查
需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:
```powershell
.\RMIC-2026.exe --config .\referee.toml --latency-report .\latency-run.ndjson
```
报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 `VideoSurface` 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。
`pnpm test:latency` 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。
| 现象 | 原因 |
|---|---|
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
| 改了队名,某一处还是旧名字 | 队名应统一来自快照 `teamNames`,血条使用 `name`。检查是否有组件自行拼接默认队名 |
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
| 无法开始录制 | 检查 ffmpeg 是否在 PATH 中,或在设置中指定路径。启动失败时界面会显示错误 |
| 两路画面与队伍对应不符 | 在设置中核对图传板槽位,必要时手动指定;装甲板队伍号由编译时的 `ARMOR_TEAM` 决定 |
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |