docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。 - fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示 - test(client): 按文件名核对媒体类型,避免依赖时间戳排序 验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
# 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。
|
||||
|
||||
### 每页最顺手的那个操作
|
||||
|
||||
- 用户端通过滚轮缩放、拖动平移、双击复位,无需额外点击按钮。六秒没有输入或受击时,角落标签与代号淡出;出现输入或受击后恢复。
|
||||
- **裁判端让控制台跟着鼠标走。** 按下左格,下方列表就只显示那一边的装甲板(再按一次回到
|
||||
全部,键盘 `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=[]'`。
|
||||
|
||||
视频录制另需可从 `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 没改成宿主三元组 |
|
||||
Reference in New Issue
Block a user