安装完成页默认展示现场统一的 SSID 与密码,便于裁判系统连接和现场调试;本地覆盖配置继续用于其他部署。 - fix(installer): 使用共享热点默认值,并修正本地覆盖配置的包含路径 - docs(network): 明确设备按固件中的固定热点参数连接,客户端电脑需接入同一网络,更换热点后需重新编译烧录 - docs(agents): 区分现场统一热点约定与个人 Wi-Fi 配置,保留前者及对应安装说明 验证:无本地配置与带本地覆盖两种 NSIS 配置均编译通过;预处理检查确认热点名称和密码按预期显示;git diff --check。未连接或烧录设备。
37 KiB
RMIC::2026
黑龙江大学 602 实验室 · Simba Robotics 的裁判系统客户端。同一进程提供用户端与裁判端两种界面。
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|---|---|---|---|---|
用户端(team) |
一路 | 双方(左上角自己 / 右上角对手) | 自己那块 | 要,改动需密码 |
裁判端(referee) |
两路并排 | 双方(各自贴画面角上,队名可改) | 发现列表 + 槽位 | 不需要 |
前端在 WebView 中运行 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为独立模块,可分别运行单元测试。
显示名是
RMIC::2026;安装包和可执行文件使用RMIC-2026,因为 Windows 文件名不允许:。配置、录制与日志沿用cam-desktop目录,以保留已有密码哈希与绑定,见“配置”。
本文的路径以仓库根目录为起点,构建命令会注明执行目录。目录树及命令注释中的测试数量是原记录的快照,当前测试结果以实际运行输出为准。
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:
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 历史计算。提交规则、派生文件和固件版本通告见版本维护。
快速开始
cd desktop
pnpm install
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
pnpm build # 只构建前端(tsc --noEmit && vite build)
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
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/ 中执行:
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 中检查标签:
# 在 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 文件完整性级别的较低值,所以带 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 默认安装模板和当前用户安装方式;首次启动的模式选择与裁判系统绑定仍在客户端内完成。
现场裁判系统采用固定热点约定:SSID 为 SR-602-2G4,密码为 12345678。热点需提供 2.4 GHz Wi-Fi;运行客户端的电脑也应接入同一网络,并允许设备间互访。固定这组参数是为了便于现场连接和调试,安装完成页会直接显示它们。
裁判系统只会连接固件中配置的热点。WIFI_SSID 和 WIFI_PASS 在编译时写入固件,名称和密码必须匹配;设备不会自动改连其他热点。客户端中的模式选择和设备绑定不修改设备的 Wi-Fi 参数。更换热点时,需修改固件构建配置、重新编译并烧录。
共享现场热点信息保存在 src-tauri/installer/network.nsh,随项目维护,普通构建和 CI 构建都会显示。其他部署如需更换安装提示,可将 network.example.nsh 复制为同目录的 network.local.nsh 并填写;本地覆盖文件已被 Git 忽略。这个覆盖只改变安装提示,不会改变设备固件的连接参数,两边需要同步核对。
视频录制另需可从 PATH 找到的 ffmpeg,也可以在配置的 [recording] ffmpeg 中指定路径;基础图传显示不依赖它。
设备发现使用裁判系统的 Hello 广播。客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口,定向续订可避免“能发现并绑定,却始终等待画面”的情况。队伍端的绑定和画面过滤仍然生效。
演示模式:没有板子也能把整条链路跑起来
cd desktop
CAM_DEMO=1 pnpm app
演示模式合成协议数据报,交给 Receiver::datagram 与 ArmorHealthStore::datagram,沿正常接收路径进入界面:
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
分片、重组、发现、统计、掉血派生与两项接收限制使用正式代码路径,截图和录制也无需专门分支。界面标注“演示模式”,设备 ID 使用 0x0DE00001 等人工值。演示数据不能作为现场状态。
命令行与环境变量
--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,由浏览器原生解码,后端无需解码。
二进制帧的布局
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(
argon2crate,默认参数 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
规则:
- 还在线的槽位不换人;
- 候选里排除已经被另一个槽位占着的 id;
- 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
- 没有候选时保持槽位为空,避免两格重复显示同一块板。
旧实现只排除当前槽位原有的 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,原因见开头;删除整个目录可重置配置。
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 合成状态与画面。
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(...) 动态加载,正式构建中不包含它。
测试
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 均已执行。手动连接检查如下:
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 解码和画面显示仍需浏览器预览及真机运行验证。
排查
需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:
.\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 没改成宿主三元组 |