# 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 身份。默认看配置文件 --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 -i pipe:0` 喂帧,x264 编码,`+faststart`。 - `image2pipe` 的 JPEG 序列没有时间戳,需要显式向 ffmpeg 提供帧率。客户端优先使用配置值,其次使用实测值;都不可用时使用默认值并记录警告。该值写入日志与 DTO,回放速度异常时应先核对它。 - 帧经长度为 64 的队列交给独立写盘线程,避免阻塞接收。队列写入失败时丢帧并计数,界面显示该计数。 - 开始新录制前先结束上一段,并检查文件名是否重复;重名时追加 `-2`、`-3`,避免 ffmpeg 的 `-y` 覆盖已有录制。 界面内回放使用 Tauri 资源协议,只能访问 `tauri.conf.json` 中 `assetProtocol.scope` 允许的目录。默认录制目录在范围内;其他目录中的 `