# 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 后端:推流、命令、录制(32 个测试) 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 历史**算,不靠人记着去改。 ```sh node scripts/version.mjs --write # 计算并写回所有派生文件 node scripts/version.mjs --check # 只校验:有未归档的提交就退 1(CI 里跑这个) node scripts/version.mjs --print # 把算出来的版本打到 stdout ``` ### 规则 提交标题用 Conventional Commits(`type(scope): 说明`): | 这批提交里有 | 升哪一位 | |---|---| | `BREAKING CHANGE:` 或 `type!:` | major | | `feat` | minor | | `fix` / `perf` | patch | | 其它 | 不动版本,只加构建号 | 构建号是 `git rev-list --count HEAD`:单调递增、不会撞车、不用手工维护。正文里以 `- ` 开头且本身写成 `type(scope): 说明` 的行算**额外条目**——这样一次 squash 提交也能把几件事 分别归类,不必为了日志好看去拆提交。 ### 派生出来的东西 | 文件 | 谁用 | |---|---| | `version.json`(仓库根) | 固件 `build.rs` 读它,编译期注入 `RMIC_VERSION*` | | `CHANGELOG.md` / `CHANGELOG.json` | 人读的那份与机器读的那份,同一次计算产出 | | `desktop/src/lib/version.ts` | 界面上的版本标签与协议号 | | `desktop/package.json`、`tauri.conf.json` 的 `version` | 装包文件名与 Windows 的「应用和功能」 | 建这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](../docs/CHANGELOG-legacy.md)。 ### 固件与客户端怎么对上 同一条链贯穿到底:`git → version.json → 固件二进制 → Hello 报文 → 客户端界面`。 板子每 2 秒广播的 `Hello` 里带着**语义化版本、构建号、以及它编译进去的线路协议号** (布局见 `protocol/README.md`)。客户端读出来之后: - 设置面板的绑定下拉里显示 `固件 v0.2.0 · 34`; - 裁判端每一格下方显示这块板子的固件版本; - 协议对不上时,用户端画面正中给出**具体原因**("它是协议 2,本客户端是协议 1"), 而不是只表现为没有画面——它的视频帧会被严格解析丢弃,但那句解释正是靠通告读出来的。 **为什么值得占这 10 个字节**:一块烧错固件的板子,原来只表现为"没画面",现场会去查网线、 AP、供电。现在同一个现象会直接指向"重新烧固件"。 ## 快速开始 ```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 # 32,其中几条真的会起 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 单二进制,能用。换掉它的理由是**界面这一侧的生产力**:血条、 瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 CSS/HTML 写是 几十分钟的事,用即时模式 UI 工具写要自己排版、自己画控件、自己处理焦点。这个项目的重心 在链路(UDP、重组、发现、防作弊),界面应当越快改完越好。 代价说清楚: - **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。 - **要联网装依赖**(前端 npm、Rust crates)。 - **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器, 路径更短。 换来的另一样东西是**可测性**:`core/` 不认识界面,所以重组、发现、帧率窗口、槽位分配、 配置与密码全都能用单元测试盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。 ## 视频怎么进 WebView **一个只监听回环地址的 WebSocket,推二进制 JPEG。** 界面从 `stream_info` 命令拿到 `ws://127.0.0.1:<随机端口>/?token=...`。 ### 为什么不是 Tauri 的 IPC 两路 22fps、每帧约 22 kB,每秒约 1 MB 的裸 JPEG。走 IPC 要序列化成 JSON(base64 再涨 1/3),前端再解回来——CPU 花在没人需要的编解码上,延迟白涨一截。二进制 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` 返回 Promise,两个相邻帧的解码 可能乱序完成。所以每路记住最后画过的帧号,只有更新的那帧才采用——没有这一步,画面会偶尔 往回跳一帧,看起来像"卡了一下",极难归因。 **`ImageBitmap` 必须显式 `close()`。** 它持有解码后的像素,不吃 GC 的账。每收到新帧就 替换旧的那张并不够,必须把旧的那张关掉,否则两路 22fps 跑几分钟就是几百 MB。 ## 防作弊:两道闸门,不是一个 对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。 **关键是这件事做在接收端,不是界面里。** 广播的 Hello 里就有 MAC 派生的 `sender_id` 与 IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/CSS 只控制显示。 | 闸门 | 管什么 | 什么时候开 | |---|---|---| | `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 | | `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 | 旧客户端把两件事绑在同一道闸门上,于是只能在"能列出候选"和"挡住对手"之间二选一,改绑 只能靠手改配置文件。现在分开了:设置界面能列出候选摄像头(否则无从选起),但**画面不进 来**——对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧也进不了重组器。 四种情况不限制:裁判端(中立方)、debug 构建、用户端还没绑定、`--camera-id` 显式覆盖。 ### 遥测按编译模式门控 `TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些 数字——不是"界面藏起来",是 DTO 里就不存在(`CameraStatsDto::build` 直接把它们置零)。 由编译模式决定而不是命令行开关:开关意味着二进制里带着那套东西,忘开一次就泄露了。 ### 这挡不住什么 诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后可以自己发一个 `Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。真要挡住 需要固件侧做配对 token(首次绑定发一个令牌,固件只接受带令牌的订阅),代价是固件重启就 失配。 回环通道上那个 token 也不是防对手的:它只让"本机另一个程序顺手连上来看画面"变成一件需要 先读到那个 token 的事。 ## 密码:它挡的是谁 必须说清楚,否则会被当成一把它并不是的锁。 - **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;围观 的人翻不到明文(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——改了之后界面会 要求先解锁。**退出/关闭程序**同样要密码,因为"随手关掉"也是一种要挡的误操作。 - **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置与二进制都在他手上,删掉哈希 或者直接改源码都行。真要封住得回固件加配对 token。 所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。 实现上: - Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希 一起躺在配置里,以后调参不需要迁移。用快哈希(SHA-256 之类)对 4 位数字密码等于没有 保护。 - 首次启动**强制**设置密码(`Config::needs_password_setup`),最短 4 位。长度下限只要 4 位是刻意的:一个 8 位强密码在这里换不来任何实际安全增益,却会让现场每次改绑都要掏 手机查一遍。 - 解锁一次管到进程结束(会话内)。用户端可以手动"上锁"。 - **裁判端不要密码**:它没有"绑定到自己那块板子"的动作可被滥用,而现场裁判要能最快地改 槽位、开录。 - **离开用户端要密码**:否则切到裁判端就能看两路画面、还能随便改配置,闸门形同虚设。 执行点在 Rust 侧:每个改变配置的命令都过 `AppState::require_unlocked`。界面上的解锁 对话框只是入口——即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。 **故意没有 `--camera-id` 这类命令行覆盖。** 旧客户端有一个,用来"临时把一台机器当另一台 用";在这里它等于一条不需要密码就能改绑的路,所以去掉。要临时换一块板子,就在设置里改 (要密码),或者用 `--config` 指一份另外的配置文件。 忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时 导入并备份成 `.bak`。 ## 用户端 HUD ### 血条:两个上角,平行四边形,一条分 5 格 **画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数, 放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格 20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。 少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。 (条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、 指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。) 宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区 在哪"是一直看得见的,而不是等真的低血了才第一次出现。 **未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经 没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。 血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留 一个 0 与灭掉的格子。 ### 胜负横幅 一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是 「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接 看出是哪一边赢了。 - 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是 输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的 自家主板会让人"凭空胜利"。 - 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有 "这局结束了"的痕迹,截图、回放与现场的争议都无从对照。 - 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**: 它是一局的结束,此刻不再需要瞄准。 ### 瞄准器 画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。 用 DOM 摆一个 `` 再叠一层 `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而 两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换 只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。 尺寸与缩放的关系也是刻意分开的: | | 跟着缩放走吗 | 为什么 | |---|---|---| | 位置 | **是** | 它指的是画面里的一个点 | | 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 | | 网格 | 是 | 它标的是画面里的比例,用来估距离 | **偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素 偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。 ### 命中反馈 主板**不报**"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的 (`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` 先按内容的**最大**需求分配,剩下的才按 stretch 分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"(5 字),于是左边多要 十几个像素——实测 55% / 45%。现在占位文字绝对定位、两格需求相同。 ### 槽位分配:修掉了一个真 bug 规则: 1. 还在线的槽位不换人; 2. **候选里排除已经被另一个槽位占着的 id**; 3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位; 4. 没有候选就不动——只有一块板在场时另一个槽位空着,空着比显示一份复制品诚实。 规则 2 是修 bug。旧实现只排除"这个槽位自己原来那个 id",于是**队伍一的板子一离线,它的 槽位就会去拿队伍二那块,两格显示同一块板子**——看起来像图传串了,现场的排查方向会是网线、 AP、板子,而不是二十行分配代码。现在它有一个直接对着这个场景写的测试。 有烧错固件的板子(`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`。 - **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来 就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放 时第一个要看的就是它。 - 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是 链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。 - 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的 `-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。 界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的 目录生效。默认录制目录在名单里;改到别处时 `