desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端

界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。

用户端:
- 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0;
- 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟,
  像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效;
- 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中:
  第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血");
- 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。

裁判端:
- 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放;
- 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位;
- 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。

密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的
解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。

其它:
- 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名,
  原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做);
- 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来;
- 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证);
- CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明
  JS 加载、IPC、WebSocket、React effect 四件事。

删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了
(槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名
同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。

测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
This commit is contained in:
2026-10-14 19:28:49 +08:00
parent a89d7ae5fb
commit 4f7a142a39
36 changed files with 4234 additions and 8739 deletions
+4
View File
@@ -10,3 +10,7 @@ dist/
# Tauri 生成的权限 schema(构建产物,随 tauri-build 版本变化)。
/src-tauri/gen/schemas/
# 本机开发用的状态:dev 配置(里面有密码哈希)与演示/录制的输出、界面截图。
/.dev/
/.shots/
+418
View File
@@ -0,0 +1,418 @@
# desktop
桌面端。**两种身份,两套页面,一个进程。**
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|---|---|---|---|---|
| 用户端(`team`) | 一路 | 自己那边 + 血条 | 自己那块 | **要,改动需密码** |
| 裁判端(`referee`) | 两路并排 | 双方 | 发现列表 + 槽位 | 不需要 |
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进
一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。
```text
desktop/
core/ cam-core:不含任何界面与 Tauri 的东西(69 个测试)
src-tauri/ Tauri 后端:推流、命令、录制(30 个测试)
src/ React 前端:两套页面 + HUD
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
src/components/ 视频窗格、血条、瞄准器、装甲板表、录制面板、设置抽屉
src/pages/ 用户端 / 裁判端
```
## 快速开始
```sh
cd desktop
pnpm install
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
pnpm build # 只构建前端(tsc --noEmit && vite build)
pnpm tauri build # 出 .app 与 .dmg
```
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
```sh
cd desktop/core && cargo test # 69
cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg
```
### 演示模式:没有板子也能把整条链路跑起来
```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 单二进制,能用。换掉它的理由是**界面这一侧的生产力**:血条、
瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 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
### 血条
画面**下面**一条窄带里的胶囊条,不挡画面。宽度、是否显示数字/队名、低血阈值与脉动都可配。
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
### 瞄准器
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
用 DOM 摆一个 `<img>` 再叠一层 `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而
两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换
只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。
尺寸与缩放的关系也是刻意分开的:
| | 跟着缩放走吗 | 为什么 |
|---|---|---|
| 位置 | **是** | 它指的是画面里的一个点 |
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
### 命中反馈
主板**不报**"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的
(`HealthTracker`):
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
基线会让一次掉血被报成四次命中。
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
反馈有三样:整屏闪红、画面抖动、准星旁的伤害数字。**大号数字不放在画面正中**——第一版
放了,结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。
## 裁判端
### 两格等宽
`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 <fps> -i pipe:0`
喂帧,x264 编码,`+faststart`。
- **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来
就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放
时第一个要看的就是它。
- 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是
链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。
- 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的
`-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。
界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的
目录生效。默认录制目录在名单里;改到别处时 `<video>` 会加载失败,界面上给出"用系统播放器
打开"的退路——而不是让人以为录下来的文件坏了。
## 配置
`~/.config/cam-desktop/config.toml`,`--config` 可改。
```toml
role = "team"
roleChosen = true
team = 0
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`)。打错一个键名却"看起来生效了",在现场是
要浪费时间的。
- **字段名统一 camelCase**,磁盘、IPC、前端 TS 一个名字。TOML 里写 camelCase 不算最漂亮,
但换来的是不需要任何一层做名字转换——而那种转换漏掉一个字段时不会报错,只会让某一项设置
看起来"改了没生效"。
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
文件会让"解锁"永久失败。
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
旧客户端(Slint 版)的配置在首次启动时导入能对应的字段,然后改名成 `.bak`(不删:现场
出问题时"旧的那份到底写了什么"是第一个要看的东西)。只有用**默认**路径时才做这件事——
`--config` 指定了别的路径说明这次运行是有意分开的,那时去动常规配置是越界的。
## 浏览器预览(仅开发)
Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建。所以 dev 模式下、且页面
里没有 Tauri 注入时,同一套 React 组件可以在浏览器里跑起来:状态与画面由
`src/lib/dev-preview.ts` 合成。
```sh
pnpm dev # 用户端
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
```
两重门保证它进不了正式构建:`import.meta.env.DEV` 为真**且**没有 `__TAURI_INTERNALS__`,
并且调用点是 `await import(...)`——条件为假时整个模块不会被打进产物。
## 测试
```sh
cd desktop/core && cargo test # 69:重组、发现、两道闸门、帧率窗口、槽位、配置、密码
cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行
cd protocol && cargo test # 15
```
三条值得单独说的:
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
存在且非空"是不够的:ffmpeg 参数写错时也会留下一个空的容器,而那正是最难在现场发现的
一种失败。
- **截图**那条真写一个 PNG 再读回来对尺寸,另外验证坏输入会报错而不是留下一个空文件。
- **帧率窗口**那条把"对帧间隔取平均会给出什么"也钉住了(约 30fps),防止有人"顺手修一下"
改回错误算法。
CSP 的验证不靠"看一眼没报错",而是靠**前端能不能连上后端**:前端的第一步是先 `invoke`
拿通道地址,再连 WebSocket。所以快照里的 `clients` 从 0 变成 1 同时证明了四件事——JS 加载
成功、IPC 通、WebSocket 通、React 的 effect 跑到了。CSP 一旦挡住其中任何一环,它就会停在
0。手动核对的办法:
```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 |
跨线程只有两样东西:`Arc` 与广播通道。帧在接收线程里编码一次(16 字节头 + JPEG),
所有界面共用同一份 `Bytes`。
## 已知限制与没做的
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
DTO,所以至少能解释清楚。
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端"
——见下面"测试"一节最后一条。
- **证书与公证**没做,`.dmg` 只在本机可用。
- 前端没有自动化测试。界面这一侧的验证手段是浏览器预览 + 真机运行;逻辑都在 `core/` 里
有测试。
## 排查
| 现象 | 原因 |
|---|---|
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
| 两块画面串了 | 检查是不是手动指定了槽位,或者两块板子烧了同一个 `ARMOR_TEAM` |
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
+44 -1
View File
@@ -122,6 +122,8 @@ struct Inner {
boards: HashMap<u32, ArmorBoard>,
/// 已经打过"这块板子在线"的板子 id,避免刷屏。
announced: Vec<u32>,
/// 已经警告过"同一队有多块板子"的队伍,避免刷屏。
warned_multi_board: Vec<u8>,
}
/// 相邻两次读数之差还原出来的掉血事件。
@@ -304,8 +306,12 @@ impl ArmorHealthStore {
// 血量变化也要能看见:窗口是给别人看的,抓日志的人得知道掉血没有。整车一条
// 血、四条同值,所以只对 index 0 报一次。
//
// 上一次的读数取自**这块板子自己**的记录,而不是 `readings[team][0]`:那个
// 槽位是"这一队"的,同队有两块板子在广播时会被互相覆盖,于是日志会把另一块
// 板子的血量当成"这块板子之前的血量"——一条读起来像真事的假线索。
if index == 0 {
if let Some(previous) = inner.readings[team][0].map(|reading| reading.hp) {
if let Some(previous) = inner.boards.get(&board).map(|entry| entry.hp) {
if previous != health.hp {
log::info!(
"armor: board 0x{board:08X} team {team} hp {previous} -> {}",
@@ -315,6 +321,21 @@ impl ArmorHealthStore {
}
}
// 同一队出现第二块板子:说清楚后果,并提醒用绑定/指定板子 id 来消歧。
let same_team = inner
.boards
.values()
.any(|other| other.id != board && other.team == health.team && other.is_online());
if same_team && !inner.warned_multi_board.contains(&health.team) {
inner.warned_multi_board.push(health.team);
log::warn!(
"armor: 队伍 {team} 上有不止一块装甲板在广播(现在这是 0x{board:08X})。\
`health(team)` 是**最后写入者优先**,四条上报可能来自不同的板子;\
要确定是谁的血,请在界面上绑定/指定具体的板子 id。"
);
}
inner.readings[team][index] = Some(Reading {
hp: health.hp,
at: Instant::now(),
@@ -498,6 +519,28 @@ mod tests {
assert_eq!(seen.lock().unwrap().len(), ARMOR_BOARDS);
}
#[test]
fn two_boards_on_the_same_team_share_the_per_team_slot() {
// 这是 `health(team)` 的已知限制,不是意外:一个队里同时有两块板子广播时,那
// 四个格子是"最后写入者优先",可能混着两块板子的数。真实比赛里一队只有一块,
// 所以这条路径只在开发(演示 + 真板子)或烧错固件时才会走到。
//
// 消歧的办法是绑定具体的板子 id,走 `health_of_board`。
let store = ArmorHealthStore::new();
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
tick(&store, 0xBBBB_0002, 0, 40, [192, 168, 1, 60]);
// 后写的那块板子赢了。
assert_eq!(store.health(0), Some([40, 40, 40, 40]));
// 按板子取就没这个歧义。
assert_eq!(store.health_of_board(0xAAAA_0001), Some(100));
assert_eq!(store.health_of_board(0xBBBB_0002), Some(40));
// 两块板子都还在发现列表里,界面才能让人选。
assert_eq!(store.boards().len(), 2);
}
#[test]
fn a_falling_hp_is_exactly_one_hit() {
let mut tracker = HealthTracker::new();
+12 -1
View File
@@ -691,7 +691,18 @@ pub fn parse_id(text: &str) -> Option<u32> {
/// 返回值里的 `Vec<String>` 是要显示给用户/写进日志的说明。旧文件**不删**:现场出问题
/// 时,"旧的那份到底写了什么"是第一个要看的东西。
pub fn load_with_migration(path: &Path) -> Result<(Config, Vec<String>)> {
load_with_migration_at(path, &legacy_config_path())
// 只有用**默认**路径时才去动旧配置。
//
// `--config` 指定了别的路径,说明使用者有意让这次运行与"这台机器的常规配置"分开
// (开发、临时当另一台机器用)。那种情况下把 ~/.config/cam-client 挪走是越界的:
// 它改的是常规运行的东西,而这一次不是常规运行。
let legacy = if path == default_config_path() {
legacy_config_path()
} else {
PathBuf::from("/nonexistent/never-import-legacy-config")
};
load_with_migration_at(path, &legacy)
}
/// 与 [`load_with_migration`] 相同,但旧配置的路径由调用方给。
+13
View File
@@ -125,6 +125,19 @@ mod tests {
assert!(phc.contains("$v=19$"), "got {phc}");
}
#[test]
fn a_known_phc_string_still_verifies() {
// 一条固定的 Argon2id PHC 串,密码是 1234。
//
// 作用是把"已经写进某个人的配置文件里的哈希,将来还能不能验证"钉下来:换库、
// 换默认参数、或者手滑把 salt 的编码改成别的形式时,这条会立刻失败——而它失败的
// 那一天,现场的表现会是"密码明明对,却一直说密码错"。
const PHC: &str = "$argon2id$v=19$m=19456,t=2,p=1$rBxlGFBYgghR/PPmKlx++A$B7J4JeKgDzv4JdNycjf7rb+5UPgO9JY+KhURaZtn5R0";
assert!(verify_password("1234", PHC).expect("verifies"));
assert!(!verify_password("1235", PHC).expect("the hash itself is fine"));
}
#[test]
fn a_corrupt_stored_hash_is_an_error_not_a_wrong_password() {
// 这两种情况必须能分开:一个是输错了,一个是配置文件坏了。
+25 -13
View File
@@ -263,7 +263,7 @@ pub fn set_recording_options(
// 截图与录制
// ---------------------------------------------------------------------------
/// 把某一路最新的一帧存成 PNG。
/// 把某一路最新的一帧存成 PNG(存在录制目录的 `shots/` 下)。
#[tauri::command]
pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
let frame = state
@@ -273,19 +273,9 @@ pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
let config = state.config();
let dir = config.recordings_dir().join("shots");
std::fs::create_dir_all(&dir).map_err(|err| format!("建不了截图目录 {dir:?}:{err}"))?;
let path = dir.join(format!(
"shot-{camera:08X}-{}.png",
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
));
let image = image::load_from_memory_with_format(&frame.data, image::ImageFormat::Jpeg)
.map_err(|err| format!("这一帧不是能解码的 JPEG:{err}"))?;
image
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|err| format!("写不了 {}:{err}", path.display()))?;
let path = crate::recording::save_screenshot(&frame.data, &dir, camera)
.map_err(|err| format!("{err:#}"))?;
log::info!(
"screenshot of 0x{camera:08X} (frame {}) -> {}",
@@ -386,6 +376,28 @@ pub fn reveal(path: String, state: Shared<'_>) -> Result<(), String> {
.map_err(|err| format!("打开 {path} 失败:{err}"))
}
/// 用系统默认程序打开一个文件。
///
/// 界面内回放走的是 Tauri 的资源协议,而那条协议只对白名单目录生效(见
/// `tauri.conf.json` 里的 `assetProtocol.scope`)。录制目录被改到白名单之外时,`<video>`
/// 会加载失败——那时这个命令是退路,而不是让现场以为"录下来的文件坏了"。
#[tauri::command]
pub fn open_file(path: String, state: Shared<'_>) -> Result<(), String> {
let dir = state.config().recordings_dir();
let target = PathBuf::from(&path);
if !is_within(&dir, &target) {
return Err(format!("只能打开录制目录({})里的文件", dir.display()));
}
if !target.exists() {
return Err(format!("{} 不存在了", target.display()));
}
tauri_plugin_opener::open_path(&target, None::<&str>)
.map_err(|err| format!("打开 {path} 失败:{err}"))
}
/// `child` 在 `parent` 里面吗。
///
/// 逐段比较而不是字符串前缀:`/a/bc` 以 `/a/b` 为前缀,但它不在 `/a/b` 里面。
+15 -1
View File
@@ -49,7 +49,12 @@ const USAGE: &str = "\
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
用户端的绑定改动需要密码;裁判端不需要。
环境变量 CAM_DEMO=1 与 --demo 等价。
环境变量:
CAM_DEMO=1 等价于 --demo
CAM_CONFIG=<路径> 等价于 --config <路径>
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
(`tauri dev -- -- --demo`),而写在一行环境变量里不会有人记错。
";
fn main() {
@@ -82,6 +87,7 @@ impl Args {
fn parse(argv: impl Iterator<Item = String>) -> Self {
let mut args = Args {
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
config: std::env::var_os("CAM_CONFIG").map(PathBuf::from),
..Default::default()
};
@@ -162,6 +168,13 @@ fn run() -> Result<(), Box<dyn Error>> {
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
state.install_sinks();
// 视频通道的地址。
//
// 带 token 的那一版只在 debug 级:它是给本机开发者用 `wscat` 连上去看这条通道用的
// ("画面不动"时能立刻分清是后端没推还是前端没接)。info 级只报端口。
log::info!("stream: listening on ws://127.0.0.1:{}", state.port);
log::debug!("stream: {}", state.stream_info().url);
if args.demo {
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
demo::spawn(Arc::clone(&state));
@@ -228,6 +241,7 @@ fn run() -> Result<(), Box<dyn Error>> {
commands::start_recording,
commands::stop_recording,
commands::reveal,
commands::open_file,
commands::clear_config,
commands::quit_app,
commands::refresh,
+84 -2
View File
@@ -229,18 +229,54 @@ fn unique_take_path(dir: &Path, camera: u32) -> PathBuf {
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
);
let mut candidate = dir.join(format!("{base}.mp4"));
unique_path(dir, &base, "mp4")
}
/// 挑一个还没被占用的路径:`<base>.<ext>` → `<base>-2.<ext>` → `<base>-3.<ext>`。
///
/// 录制与截图共用它。时间戳只到秒(截图到毫秒),所以"同一秒停一次再开一次"这种事
/// 会挑到同一个名字——而 ffmpeg 的 `-y` 与图片写入都会**静默覆盖**。现场丢掉的那一段
/// 往往正好是要复盘的那一段。
fn unique_path(dir: &Path, base: &str, ext: &str) -> PathBuf {
let mut candidate = dir.join(format!("{base}.{ext}"));
for n in 2..1000 {
if !candidate.exists() {
break;
}
candidate = dir.join(format!("{base}-{n}.mp4"));
candidate = dir.join(format!("{base}-{n}.{ext}"));
}
candidate
}
/// 把一张 JPEG 存成 PNG。
///
/// 从命令里抽出来是为了能测:这条路径里唯一可能出错的是"JPEG 解不开"与"PNG 写不出去",
/// 两者都值得有一个直接对着它们写的测试——现场截图失败却不报错,等于证据没留下来。
pub fn save_screenshot(jpeg: &[u8], dir: &Path, camera: u32) -> Result<PathBuf> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating the screenshot directory {}", dir.display()))?;
let path = unique_path(
dir,
&format!(
"shot-{camera:08X}-{}",
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S-%3f")
),
"png",
);
let image = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg)
.map_err(|err| anyhow!("这一帧不是能解码的 JPEG:{err}"))?;
image
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|err| anyhow!("写不了 {}:{err}", path.display()))?;
Ok(path)
}
/// 写入线程的主体:把队列里的帧写进 ffmpeg 的 stdin,队列关闭后收尾。
fn pump(
rx: &std::sync::mpsc::Receiver<Arc<Frame>>,
@@ -660,6 +696,52 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_screenshot_is_a_readable_png_of_the_same_size() {
let dir = temp_dir("shots");
let path = save_screenshot(&jpeg(64, 48, 7), &dir, 0x3D4D_8A9A).expect("saves");
let decoded = image::open(&path).expect("the PNG is readable");
assert_eq!((decoded.width(), decoded.height()), (64, 48));
assert!(
path.file_name().unwrap().to_string_lossy().starts_with("shot-3D4D8A9A-"),
"文件名要能看出是哪一路截的:{path:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn two_screenshots_never_land_on_the_same_file() {
let dir = temp_dir("shots-twice");
let first = save_screenshot(&jpeg(32, 32, 1), &dir, 1).unwrap();
let second = save_screenshot(&jpeg(32, 32, 2), &dir, 1).unwrap();
assert_ne!(first, second, "两次截图不能落在同一个文件上");
assert!(first.to_string_lossy().contains("shot-00000001-"));
assert!(second.to_string_lossy().contains("shot-00000001-"));
// 时间戳到毫秒,所以这两次通常落在不同的毫秒上,各自拿到自己的名字。真正要挡的
// 是**同一毫秒**内连按两次,那由 `unique_path` 的后缀兜住——下面直接验证它。
let forced = unique_path(&dir, "shot-00000001-fixed", "png");
std::fs::write(&forced, b"").unwrap();
let forced2 = unique_path(&dir, "shot-00000001-fixed", "png");
assert!(forced2.to_string_lossy().ends_with("-2.png"), "got {forced2:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_broken_frame_is_an_error_not_an_empty_png() {
let dir = temp_dir("shots-bad");
let err = save_screenshot(b"not a jpeg at all", &dir, 1).unwrap_err();
assert!(err.to_string().contains("JPEG"), "got {err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn media_listing_ignores_anything_that_is_not_a_take() {
let dir = temp_dir("listing");
+1 -1
View File
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": null,
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' asset: http://asset.localhost blob:; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'none'",
"assetProtocol": {
"enable": true,
"scope": [
+299
View File
@@ -0,0 +1,299 @@
/**
* 应用外壳:连通道、拿状态、按身份分流、管住解锁流程。
*
* # 状态来自哪里
*
* 后端每 200 ms 推一份完整快照,前端不轮询。唯一在本地维护的是 **HUD 草稿**:滑杆要即时
* 反馈,等 300 ms 的后端往返会很黏手。草稿改动后节流保存,保存的结果就是后端的配置。
*
* # 解锁流程
*
* 需要密码的动作有三处入口:打开设置面板、点关闭窗口(后端会推 `confirmQuit`)、以及
* 任何一次被后端拒绝的改动。三条路径都汇到同一个 `UnlockDialog`,解锁成功后执行挂起的
* 那个动作。后端才是真正的闸门,这里只负责把话说清楚。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { SettingsPanel } from "./components/SettingsPanel";
import { SetupPassword, RolePicker } from "./components/Gate";
import { Toast } from "./components/TopBar";
import { UnlockDialog } from "./components/UnlockDialog";
import { api } from "./lib/ipc";
import { startStream, streamClient, useAppStore } from "./lib/store";
import type { ConfigView, HudView, Snapshot } from "./lib/types";
import { RefereePage } from "./pages/RefereePage";
import { TeamPage } from "./pages/TeamPage";
/** 解锁之后要做的那件事。 */
type Pending = { kind: "settings" } | { kind: "quit" } | { kind: "retry"; run: () => void };
export default function App() {
const { snapshot, connected, confirmQuit } = useAppStore();
const [url, setUrl] = useState<string | null>(null);
const [fatal, setFatal] = useState<string | null>(null);
const [hud, setHud] = useState<HudView | null>(null);
const [drawer, setDrawer] = useState(false);
const [calibrating, setCalibrating] = useState(false);
const [unlockReason, setUnlockReason] = useState<string | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const hudInitialised = useRef(false);
// ---- 通道 ----
useEffect(() => {
void api.streamInfo().then(
(stream) => setUrl(stream.url),
(err) => setFatal(err instanceof Error ? err.message : String(err)),
);
}, []);
useEffect(() => {
if (!url) return;
const client = startStream(url);
return () => client.close();
}, [url]);
// ---- HUD 草稿 ----
useEffect(() => {
if (!snapshot || hudInitialised.current) return;
hudInitialised.current = true;
setHud(snapshot.config.hud);
}, [snapshot]);
const onConfig = useCallback((config: ConfigView) => {
// 绑定/身份改了:HUD 之外的东西都由快照带回来,这里只需要让 HUD 跟上(例如换了身份)。
setHud((current) => current ?? config.hud);
}, []);
/** 校准时拖动画面:偏移是按比例的,直接累加。 */
const onCalibrate = useCallback(
(dx: number, dy: number) => {
setHud((current) => {
if (!current) return current;
const clamp = (value: number) => Math.max(-0.5, Math.min(0.5, value));
const next: HudView = {
...current,
crosshair: {
...current.crosshair,
offsetX: clamp(current.crosshair.offsetX + dx),
offsetY: clamp(current.crosshair.offsetY + dy),
},
};
return next;
});
},
[],
);
// 校准改动也要落盘。和设置面板里的滑杆共用同一条节流路径。
useEffect(() => {
if (!hud || !calibrating) return;
const timer = window.setTimeout(() => {
void api.saveHud(hud).catch(() => undefined);
}, 600);
return () => window.clearTimeout(timer);
}, [hud, calibrating]);
// ---- 解锁 ----
const needsUnlock = useCallback(
(reason: string, next: Pending) => {
setUnlockReason(reason);
setPending(next);
},
[],
);
const openSettings = useCallback(() => {
if (!snapshot) return;
if (snapshot.role !== "referee" && !snapshot.unlocked) {
needsUnlock("改摄像头/装甲板绑定、切换队伍号都要密码。", { kind: "settings" });
return;
}
setDrawer(true);
}, [snapshot, needsUnlock]);
// 后端拦下了一次"关窗口/退出"。
useEffect(() => {
if (confirmQuit === 0) return;
needsUnlock("退出程序需要密码。", { kind: "quit" });
}, [confirmQuit, needsUnlock]);
const finishUnlock = useCallback(() => {
setUnlockReason(null);
const action = pending;
setPending(null);
switch (action?.kind) {
case "settings":
setDrawer(true);
break;
case "quit":
void api.quit().catch((err) =>
setError(err instanceof Error ? err.message : String(err)),
);
break;
case "retry":
action.run();
break;
default:
break;
}
void api.refresh();
}, [pending]);
// ---- 窗口标题 ----
useEffect(() => {
if (!snapshot) return;
const suffix =
snapshot.role === "team" ? "用户端" : "裁判端";
const extra = snapshot.demo ? "(演示模式)" : "";
void import("@tauri-apps/api/window")
.then(({ getCurrentWindow }) => getCurrentWindow().setTitle(`cam-desktop — ${suffix}${extra}`))
.catch(() => undefined);
}, [snapshot?.role, snapshot?.demo]);
// ---- 分支 ----
if (fatal) {
return (
<div className="gate">
<h1 className="gate__title">起不来</h1>
<p className="gate__error">{fatal}</p>
<p className="gate__note">
端口可能被另一个客户端占着(旧的 Slint 版或另一份本程序)。关掉那一份再试。
</p>
</div>
);
}
if (!snapshot) {
return (
<div className="gate">
<h1 className="gate__title">cam-desktop</h1>
<p className="gate__hint">
{connected ? "正在等待后端的第一份状态…" : "正在连接后端…"}
</p>
</div>
);
}
if (!snapshot.config.roleChosen) {
return <RolePicker />;
}
if (snapshot.role === "team" && !snapshot.config.passwordSet) {
return <SetupPassword />;
}
return (
<div className="app">
{snapshot.role === "team" ? (
<TeamPage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
calibrating={calibrating}
onCalibrate={onCalibrate}
/>
) : (
<RefereePage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
onError={setError}
onInfo={setInfo}
/>
)}
{drawer && (
<SettingsPanel
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onHud={setHud}
calibrating={calibrating}
onCalibrating={(active) => {
setCalibrating(active);
if (active) setDrawer(false);
}}
onClose={() => {
setCalibrating(false);
setDrawer(false);
}}
onNeedsUnlock={(reason) => needsUnlock(reason, { kind: "settings" })}
onError={setError}
onInfo={setInfo}
onConfig={onConfig}
/>
)}
{unlockReason && (
<UnlockDialog
reason={unlockReason}
onUnlocked={finishUnlock}
onCancel={() => {
setUnlockReason(null);
setPending(null);
}}
/>
)}
{error && <Toast kind="error" message={error} onClose={() => setError(null)} />}
{info && !error && <Toast kind="info" message={info} onClose={() => setInfo(null)} />}
{/* 校准提示:视频区域在页面里,离开设置面板之后还能看到它。 */}
{calibrating && (
<div className="calibrate-banner">
校准中:拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
<button onClick={() => setCalibrating(false)}>结束校准</button>
</div>
)}
<ChannelWatchdog snapshot={snapshot} />
</div>
);
}
/**
* 通道看门狗:后端在推、前端没连上时给一句明确的话。
*
* 这一条存在的理由很实际:画面不动时,第一件要分清的事是"后端没推"还是"前端没接上"。
* 后端快照里有 `clients`,所以这两件事可以分开说。
*/
function ChannelWatchdog({ snapshot }: { snapshot: Snapshot }) {
const { connected } = useAppStore();
if (connected) return null;
let detail = "正在重连后端的视频通道…";
try {
detail += `(已收到 ${streamClient().counters.frames} 帧,重连 ${streamClient().counters.reconnects} 次)`;
} catch {
// 通道还没建起来。
}
if (snapshot.clients === 0 && snapshot.uptimeMs > 5000) {
detail += " 后端报告没有任何界面连着,这一条是前端侧的问题。";
}
return <div className="channel-warning">{detail}</div>;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* 裁判端的装甲板发现列表。
*
* 场上到底连上了几块板子、分别自报哪一队、现在多少血、多久没广播了——现场"裁判端没显示
* 血量"时,这一张表就是第一眼看的地方:它能区分"板子根本没上线"、"上线了但队伍号烧反了"
* 和"只有一条上报在发"。
*/
import { elapsed, teamColor, teamName } from "../lib/format";
import type { ArmorBoardDto, SlotsDto } from "../lib/types";
interface Props {
boards: ArmorBoardDto[];
slots: SlotsDto;
}
export function ArmorTable({ boards, slots }: Props) {
return (
<div className="armor-table">
<div className="armor-table__head">
<span>装甲板</span>
<span>地址</span>
<span>自报队伍</span>
<span>血量</span>
<span>四条</span>
<span>静默</span>
<span>槽位</span>
</div>
{boards.length === 0 && (
<div className="armor-table__empty">
还没有收到任何装甲板上报。主板每 200 ms 广播一次,确认它已上电、和这台机器在同一个
网络里。
</div>
)}
{boards.map((board) => {
const assigned =
slots.armorA === board.id ? "左(队伍一)" : slots.armorB === board.id ? "右(队伍二)" : "—";
return (
<div
key={board.id}
className={`armor-table__row${board.online ? "" : " armor-table__row--offline"}`}
>
<span className="mono">{board.idText}</span>
<span className="mono dim">{board.addr}</span>
<span style={{ color: teamColor(board.team) }}>{teamName(board.team)}</span>
<span className={`mono${board.hp === 0 ? " danger" : ""}`}>{board.hp}</span>
<span className={board.reportsAllPlates ? "dim" : "warn"}>
{board.platesSeen}/4
{!board.reportsAllPlates && " ⚠"}
</span>
<span className="dim">{board.online ? `${elapsed(board.silenceMs)}前` : "离线"}</span>
<span className="dim">{assigned}</span>
</div>
);
})}
</div>
);
}
/** 一行只读读数,裁判端每格下面用。 */
export function StatsLine({
stats,
diagnostics,
}: {
stats:
| {
status: string;
online: boolean;
hasFrames: boolean;
fps: number;
width: number;
height: number;
framesShown: number;
framesDropped: number;
lossPercent: number;
datagrams: number;
lastFrameBytes: number;
gapMs: number | null;
}
| undefined;
diagnostics: boolean;
}) {
if (!stats) {
return <div className="stats-line stats-line--empty">还没有这一路的数据</div>;
}
const parts: string[] = [];
if (diagnostics) {
if (stats.hasFrames) {
parts.push(`${stats.fps.toFixed(1)} fps`);
parts.push(`${stats.width}×${stats.height}`);
parts.push(`${(stats.lastFrameBytes / 1024).toFixed(1)} kB/帧`);
parts.push(`丢 ${stats.framesDropped}(${stats.lossPercent.toFixed(1)}%)`);
}
}
return (
<div className="stats-line">
<span className={stats.online ? "stats-line__status" : "stats-line__status warn"}>
{stats.status}
</span>
{parts.length > 0 && <span className="dim">{parts.join(" · ")}</span>}
</div>
);
}
+8 -1
View File
@@ -48,8 +48,15 @@ export function HealthBar({ health, config, compact = false }: Props) {
const total = 100;
const ratio = health.available ? Math.max(0, Math.min(1, (health.hp ?? 0) / total)) : 0;
// 宽度按配置收窄并居中。它挂在画面**下面**,所以不需要通栏——通栏反而像一条分隔线,
// 而不是一个可以一眼扫到的仪表。
const width = `${Math.round(config.width * 100)}%`;
return (
<div className={`healthbar${compact ? " healthbar--compact" : ""}`}>
<div
className={`healthbar${compact ? " healthbar--compact" : ""}`}
style={compact ? undefined : { width, margin: "0 auto" }}
>
<div
className="healthbar__track"
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
+9 -6
View File
@@ -1,8 +1,11 @@
/**
* 命中反馈的整屏层:闪红 + 中央提示。
* 命中反馈的整屏层:闪红 + 顶部一条文字。
*
* 抖动与伤害数字由 `VideoSurface` 画在 canvas 上(它们要跟着瞄准点走),这里只负责
* 那一层全屏的、不需要对准任何东西的视觉反馈。
* # 大号数字刻意不放在画面正中
*
* 第一版把 `-7 / 100 → 93` 放在正中,结果它正好压在瞄准点上——被打中的那一下恰恰是最
* 需要看着准星的时候。现在数字由 `VideoSurface` 画在**准星旁边**(跟着瞄准点走,不挡
* 目标),这里只留闪红与顶部一条信息。
*
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
@@ -26,9 +29,9 @@ export function HitFlash({ hit, config, relevant, label }: Props) {
return (
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
{label && <div className="hit-flash__label">{label}</div>}
<div className="hit-flash__value">
-{hit.damage}
<div className="hit-flash__banner">
{label && <span className="hit-flash__label">{label}</span>}
<span className="hit-flash__value">-{hit.damage}</span>
<span className="hit-flash__sub">
{hit.from} → {hit.to}
</span>
+201
View File
@@ -0,0 +1,201 @@
/**
* 录制、截图与录下来的东西。
*
* # 回放为什么可能失败
*
* 界面内回放走 Tauri 的资源协议,而那条协议只对 `tauri.conf.json` 里
* `assetProtocol.scope` 白名单的目录生效。默认录制目录在白名单里;如果现场把输出目录
* 改到别处,`<video>` 会加载失败——那时退回到"用系统播放器打开",而不是让人以为文件坏了。
*/
import { useCallback, useEffect, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { api } from "../lib/ipc";
import { bytes, since } from "../lib/format";
import type { MediaFileDto, Snapshot } from "../lib/types";
import { Button } from "./controls";
interface Props {
snapshot: Snapshot;
onError: (message: string) => void;
onInfo: (message: string) => void;
}
export function MediaPanel({ snapshot, onError, onInfo }: Props) {
const [files, setFiles] = useState<MediaFileDto[]>([]);
const [playing, setPlaying] = useState<MediaFileDto | null>(null);
const [playbackFailed, setPlaybackFailed] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(() => {
void api.mediaFiles().then(setFiles, () => undefined);
}, []);
useEffect(() => {
refresh();
// 录制中每 2 秒刷一次列表:刚录完的那一段应该自己出现。
const timer = window.setInterval(refresh, 2000);
return () => window.clearInterval(timer);
}, [refresh]);
const active = snapshot.recordings.filter((recording) => recording.active);
const cameras = snapshot.cameras.filter((camera) => camera.online).map((camera) => camera.id);
const guard = (action: Promise<unknown>) => {
setBusy(true);
void action
.then(() => api.refresh())
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false));
};
return (
<div className="media-panel">
<div className="media-panel__actions">
<Button
variant="primary"
disabled={busy || cameras.length === 0}
title={cameras.length === 0 ? "还没有在线的摄像头" : undefined}
onClick={() => {
guard(api.startRecording(cameras));
onInfo("开始录制。停止之前请留在这台机器上——退出会先收尾再走。");
}}
>
录制全部在线画面
</Button>
<Button
disabled={busy || active.length === 0}
onClick={() => {
guard(api.stopRecording([]));
refresh();
}}
>
停止全部录制
</Button>
{[snapshot.slots.cameraA, snapshot.slots.cameraB]
.filter((id): id is number => id !== null)
.map((camera) => (
<Button
key={camera}
variant="ghost"
disabled={busy}
onClick={() => {
guard(api.screenshot(camera));
onInfo(`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(存在录制目录的 shots/ 下)。`);
refresh();
}}
>
截图 {`0x${camera.toString(16).toUpperCase().padStart(8, "0")}`}
</Button>
))}
<Button
variant="ghost"
onClick={() => {
void api
.reveal(snapshot.config.recordingDir)
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
}}
>
打开录制目录
</Button>
</div>
{active.length > 0 && (
<div className="media-panel__active">
{active.map((recording) => (
<div key={recording.camera} className="recording">
<span className="recording__dot" />
<span className="mono">{recording.cameraText}</span>
<span>
{recording.framesWritten} 帧 · {since(recording.startedMs)} · {recording.inputFps.toFixed(1)} fps
输入
</span>
{recording.framesDropped > 0 && (
<span className="warn" title="写盘跟不上时丢的帧。丢帧不报出来,回放里少半秒会被当成相机的问题。">
丢 {recording.framesDropped} 帧
</span>
)}
{recording.error && <span className="danger">ffmpeg:{recording.error}</span>}
<Button variant="ghost" onClick={() => { guard(api.stopRecording([recording.camera])); refresh(); }}>
停止
</Button>
</div>
))}
</div>
)}
<div className="media-panel__list">
{files.length === 0 && <p className="dim">录制目录里还没有文件。</p>}
{files.map((file) => (
<div key={file.path} className="media-row">
<span className={`media-row__kind media-row__kind--${file.kind}`}>
{file.kind === "video" ? "MP4" : "PNG"}
</span>
<span className="media-row__name" title={file.path}>
{file.name}
</span>
<span className="dim">{bytes(file.bytes)}</span>
<Button
variant="ghost"
onClick={() => {
setPlaybackFailed(false);
setPlaying(file.kind === "video" ? file : null);
if (file.kind === "image") {
void api.openFile(file.path).catch(() => undefined);
}
}}
>
{file.kind === "video" ? "播放" : "打开"}
</Button>
<Button
variant="ghost"
onClick={() => {
void api
.reveal(file.path)
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
}}
>
在访达里显示
</Button>
</div>
))}
</div>
{playing && (
<div className="playback">
<div className="playback__head">
<span className="mono">{playing.name}</span>
<Button variant="ghost" onClick={() => setPlaying(null)}>
关闭
</Button>
</div>
{playbackFailed ? (
<div className="playback__fallback">
<p className="warn">
界面内回放失败——多半是这个目录不在资源协议的白名单里(改成默认的录制目录,或者
直接看下面这一条)。
</p>
<Button variant="primary" onClick={() => void api.openFile(playing.path).catch(() => undefined)}>
用系统播放器打开
</Button>
</div>
) : (
<video
className="playback__video"
src={convertFileSrc(playing.path)}
controls
autoPlay
onError={() => setPlaybackFailed(true)}
/>
)}
</div>
)}
</div>
);
}
+767
View File
@@ -0,0 +1,767 @@
/**
* 设置抽屉。两端的"改东西"都收在这里,所以密码闸门也只有一个入口。
*
* # 打开面板 ≠ 解锁
*
* 面板本身由 `App` 在**解锁之后**才挂载,但面板里的每个改动仍然各自过一次后端闸门
* (`bind_camera` 等都会校验)。所以即使有人把这里改成"不需要密码",配置也改不动。
*
* # 发现列表按需放开
*
* 挂载时调 `set_discovery_open(true)`,卸载时关掉。这是用户端唯一会列出别的图传板的
* 时候——**画面不会**因此进来,接收端那道闸门只管帧,不管发现(见 `cam_core::receiver`)。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../lib/ipc";
import { teamName } from "../lib/format";
import type {
ConfigView,
CrosshairConfig,
HudView,
Snapshot,
} from "../lib/types";
import { Button, Field, Section, Select, Slider, Switch } from "./controls";
interface Props {
snapshot: Snapshot;
hud: HudView;
onHud: (hud: HudView) => void;
calibrating: boolean;
onCalibrating: (active: boolean) => void;
onClose: () => void;
onNeedsUnlock: (reason: string) => void;
onError: (message: string) => void;
onInfo: (message: string) => void;
/** 配置变了(身份/绑定)。App 用它立刻刷新顶层状态。 */
onConfig: (config: ConfigView) => void;
}
/** HUD 的滑杆每动一下都会触发保存,节流一下,免得写盘几十次。 */
const SAVE_DEBOUNCE_MS = 350;
export function SettingsPanel({
snapshot,
hud,
onHud,
calibrating,
onCalibrating,
onClose,
onNeedsUnlock,
onError,
onInfo,
onConfig,
}: Props) {
const config = snapshot.config;
const locked = !snapshot.unlocked && config.role !== "referee";
// 打开面板时放开发现列表(后端会校验是否已解锁)。
useEffect(() => {
if (locked) return;
void api.setDiscoveryOpen(true).catch((err) => {
onNeedsUnlock(err instanceof Error ? err.message : String(err));
});
return () => {
void api.setDiscoveryOpen(false).catch(() => undefined);
};
}, [locked, onNeedsUnlock]);
// HUD 的保存节流。滑杆一停就写。
const [pending, setPending] = useState<HudView | null>(null);
useEffect(() => {
if (!pending) return;
const timer = window.setTimeout(() => {
setPending(null);
void api.saveHud(pending).then(onConfig).catch((err) => {
onError(err instanceof Error ? err.message : String(err));
});
}, SAVE_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [pending, onConfig, onError]);
const update = useCallback(
(change: (draft: HudView) => void) => {
const next = structuredClone(hud);
change(next);
onHud(next);
setPending(next);
},
[hud, onHud],
);
const run = async (action: () => Promise<ConfigView>) => {
try {
onConfig(await action());
await api.refresh();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("密码")) {
onNeedsUnlock(message);
return;
}
onError(message);
}
};
const cameraOptions = useMemo(
() =>
snapshot.cameras.map((camera) => ({
value: camera.id,
label: camera.idText,
hint: `${camera.addr} · ${camera.online ? "在线" : "离线"}${
snapshot.stats.find((s) => s.camera === camera.id)?.hasFrames ? "" : " · 无画面"
}`,
})),
[snapshot.cameras, snapshot.stats],
);
const armorOptions = useMemo(
() =>
snapshot.armorBoards.map((board) => ({
value: board.id,
label: board.idText,
hint: `${board.addr} · ${teamName(board.team)} · ${board.hp} 血 · ${
board.online ? "在线" : "离线"
}`,
})),
[snapshot.armorBoards],
);
return (
<aside className="drawer">
<header className="drawer__head">
<h2 className="drawer__title">设置</h2>
<div className="drawer__head-actions">
{config.role === "team" && (
<Button
variant="ghost"
onClick={() => {
void api.lock().then(() => {
onInfo("已重新上锁。");
onClose();
});
}}
>
上锁
</Button>
)}
<Button variant="ghost" onClick={onClose}>
关闭
</Button>
</div>
</header>
{locked && (
<p className="drawer__locked">
这个面板的改动需要密码。<button onClick={() => onNeedsUnlock("改动设置需要密码")}>立即解锁</button>
</p>
)}
<div className="drawer__body">
<Section title="身份">
<Field label="这台机器是哪一端">
<Select
value={config.role}
options={[
{ value: "team", label: "用户端", hint: "一路画面 + 血条 + 瞄准器" },
{ value: "referee", label: "裁判端", hint: "两路 + 双方血量 + 录制" },
]}
onChange={(role) => void run(() => api.setRole(role))}
/>
</Field>
<p className="section__note">
离开用户端要密码:否则切到裁判端就能看两路画面、也能随便改配置。
</p>
</Section>
{config.role === "team" && (
<>
<Section
title="绑定"
hint="图传板与装甲板都用 MAC 派生的 id 标识,重启不变,所以绑定是稳的。改动需要密码。"
>
<Field label="摄像头(图传板)">
<Select
value={config.cameraId}
options={cameraOptions}
onChange={(id) => void run(() => api.bindCamera(id))}
/>
</Field>
<Field label="装甲板主板" hint="决定血条读谁的血量。下拉里是场上正在广播的板子。">
<Select
value={config.armorId}
options={armorOptions}
onChange={(id) => void run(() => api.bindArmor(id))}
/>
</Field>
<Field label="自己属于哪一边" hint="改错队号会让血条显示对手的血量。">
<Select
value={config.team}
options={[
{ value: 0, label: "队伍一(红)" },
{ value: 1, label: "队伍二(蓝)" },
]}
onChange={(team) => void run(() => api.setTeam(team))}
/>
</Field>
<Switch
checked={config.mirror}
label="左右镜像画面(摄像头装反了)"
onChange={(mirror) => void run(() => api.setMirror(mirror))}
/>
{cameraOptions.length === 0 && (
<p className="section__note section__note--warn">
现在没有发现任何图传板。板子每 2 秒广播一次,确认它已上电、和这台机器在同一个
网络里,然后稍等。
</p>
)}
</Section>
<Section title="瞄准器" hint="位置跟着画面走,大小不跟着缩放走——放大后准星更精确。">
<Switch
checked={hud.crosshair.visible}
label="显示瞄准器"
onChange={(visible) =>
update((draft) => {
draft.crosshair.visible = visible;
})
}
/>
<Field label="样式">
<Select
value={hud.crosshair.style}
options={[
{ value: "cross", label: "四臂十字" },
{ value: "cross-dot", label: "十字 + 中心点" },
{ value: "circle-dot", label: "圆环 + 中心点" },
{ value: "dot", label: "只有点" },
]}
onChange={(style) =>
update((draft) => {
draft.crosshair.style = style;
})
}
/>
</Field>
<Field label="大小" hint="画面短边的比例">
<Slider
value={hud.crosshair.size}
min={0.01}
max={0.2}
step={0.005}
format={(value) => `${(value * 100).toFixed(1)}%`}
onChange={(size) =>
update((draft) => {
draft.crosshair.size = size;
})
}
/>
</Field>
<Field label="线宽">
<Slider
value={hud.crosshair.thickness}
min={0.001}
max={0.02}
step={0.0005}
format={(value) => value.toFixed(4)}
onChange={(thickness) =>
update((draft) => {
draft.crosshair.thickness = thickness;
})
}
/>
</Field>
<Field label="中心空隙">
<Slider
value={hud.crosshair.gap}
min={0}
max={0.1}
step={0.002}
format={(value) => `${(value * 100).toFixed(1)}%`}
onChange={(gap) =>
update((draft) => {
draft.crosshair.gap = gap;
})
}
/>
</Field>
<Field label="颜色">
<input
className="color"
type="color"
value={hud.crosshair.color}
onChange={(event) =>
update((draft) => {
draft.crosshair.color = event.target.value;
})
}
/>
</Field>
<Calibration
crosshair={hud.crosshair}
calibrating={calibrating}
onCalibrating={onCalibrating}
onChange={(offsetX, offsetY) =>
update((draft) => {
draft.crosshair.offsetX = offsetX;
draft.crosshair.offsetY = offsetY;
})
}
/>
</Section>
<Section title="叠加与提示">
<Switch
checked={hud.grid.visible}
label="显示网格"
onChange={(visible) =>
update((draft) => {
draft.grid.visible = visible;
})
}
/>
{hud.grid.visible && (
<>
<Field label="网格样式">
<Select
value={hud.grid.style}
options={[
{ value: "thirds", label: "三分线" },
{ value: "squares", label: "等分方格" },
{ value: "cross", label: "中心十字" },
]}
onChange={(style) =>
update((draft) => {
draft.grid.style = style;
})
}
/>
</Field>
{hud.grid.style === "squares" && (
<Field label="等分数">
<Slider
value={hud.grid.divisions}
min={2}
max={16}
step={1}
format={(value) => `${value} 格`}
onChange={(divisions) =>
update((draft) => {
draft.grid.divisions = divisions;
})
}
/>
</Field>
)}
<Field label="网格明显程度">
<Slider
value={hud.grid.opacity}
min={0.05}
max={1}
step={0.01}
format={(value) => value.toFixed(2)}
onChange={(opacity) =>
update((draft) => {
draft.grid.opacity = opacity;
})
}
/>
</Field>
</>
)}
<Switch
checked={hud.showZoom}
label="显示放大倍率"
onChange={(showZoom) =>
update((draft) => {
draft.showZoom = showZoom;
})
}
/>
<Switch
checked={hud.hitFeedback.flash}
label="被打中时闪红"
onChange={(flash) =>
update((draft) => {
draft.hitFeedback.flash = flash;
})
}
/>
<Switch
checked={hud.hitFeedback.shake}
label="被打中时画面抖动"
onChange={(shake) =>
update((draft) => {
draft.hitFeedback.shake = shake;
})
}
/>
<Switch
checked={hud.hitFeedback.marker}
label="在瞄点旁显示伤害数字"
onChange={(marker) =>
update((draft) => {
draft.hitFeedback.marker = marker;
})
}
/>
<Field label="反馈强度">
<Slider
value={hud.hitFeedback.intensity}
min={0.2}
max={2}
step={0.1}
format={(value) => `${value.toFixed(1)}×`}
onChange={(intensity) =>
update((draft) => {
draft.hitFeedback.intensity = intensity;
})
}
/>
</Field>
</Section>
<Section title="血条">
<Switch
checked={hud.health.visible}
label="显示血条"
onChange={(visible) =>
update((draft) => {
draft.health.visible = visible;
})
}
/>
<Switch
checked={hud.health.showNumbers}
label="显示血量数字"
onChange={(showNumbers) =>
update((draft) => {
draft.health.showNumbers = showNumbers;
})
}
/>
<Switch
checked={hud.health.showTeamName}
label="显示队伍名"
onChange={(showTeamName) =>
update((draft) => {
draft.health.showTeamName = showTeamName;
})
}
/>
<Switch
checked={hud.health.pulse}
label="低血时脉动"
onChange={(pulse) =>
update((draft) => {
draft.health.pulse = pulse;
})
}
/>
<Field label="低血阈值">
<Slider
value={hud.health.lowThreshold}
min={5}
max={100}
step={5}
format={(value) => `${value} 血`}
onChange={(lowThreshold) =>
update((draft) => {
draft.health.lowThreshold = lowThreshold;
})
}
/>
</Field>
<Field label="血条宽度" hint="占画面宽度的比例">
<Slider
value={hud.health.width}
min={0.15}
max={0.9}
step={0.01}
format={(value) => `${(value * 100).toFixed(0)}%`}
onChange={(width) =>
update((draft) => {
draft.health.width = width;
})
}
/>
</Field>
</Section>
</>
)}
{config.role === "referee" && (
<Section
title="裁判端槽位"
hint="默认按装甲板自报的队伍自动分左右。有烧错固件的板子时打开手动指定。"
>
<Switch
checked={snapshot.slots.manual}
label="手动指定槽位"
onChange={(manual) =>
void run(() =>
api.setRefereeSlots({
cameraA: snapshot.slots.cameraA,
cameraB: snapshot.slots.cameraB,
armorA: snapshot.slots.armorA,
armorB: snapshot.slots.armorB,
manual,
}),
)
}
/>
{snapshot.slots.manual && (
<>
<Field label="左格:队伍一摄像头">
<Select
value={snapshot.slots.cameraA}
options={cameraOptions}
onChange={(cameraA) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, cameraA, manual: true }),
)
}
/>
</Field>
<Field label="左格:队伍一装甲板">
<Select
value={snapshot.slots.armorA}
options={armorOptions}
onChange={(armorA) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, armorA, manual: true }),
)
}
/>
</Field>
<Field label="右格:队伍二摄像头">
<Select
value={snapshot.slots.cameraB}
options={cameraOptions}
onChange={(cameraB) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, cameraB, manual: true }),
)
}
/>
</Field>
<Field label="右格:队伍二装甲板">
<Select
value={snapshot.slots.armorB}
options={armorOptions}
onChange={(armorB) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, armorB, manual: true }),
)
}
/>
</Field>
</>
)}
</Section>
)}
<Section title="录制与截图" hint="截图存 PNG,录制存 mp4(需要 ffmpeg)。">
<Field label="输出目录">
<input
className="input"
type="text"
defaultValue={config.recordingDir}
onBlur={(event) => {
const value = event.target.value.trim();
if (value !== config.recordingDir) {
void run(() => api.setRecordingOptions({ dir: value, ffmpeg: null, crf: null, preset: null }));
}
}}
/>
</Field>
<Field label="ffmpeg 路径" hint="默认从 PATH 找。找不到就录不了,但界面照常用。">
<input
className="input"
type="text"
defaultValue={config.ffmpeg}
onBlur={(event) => {
const value = event.target.value.trim();
if (value && value !== config.ffmpeg) {
void run(() => api.setRecordingOptions({ dir: null, ffmpeg: value, crf: null, preset: null }));
}
}}
/>
</Field>
<p className="section__note">
界面内回放要目录在资源协议的白名单里(默认的 ~/Movies/cam-desktop 就在)。
改到别处时回放会失败,界面上会给出"用系统播放器打开"的退路。
</p>
</Section>
<Section title="安全" hint="密码只挡现场随手改绑;配置文件与程序都在本机,愿意改的人绕得过去。">
<ChangePassword onDone={onInfo} onError={onError} />
<div className="row">
<Button
variant="danger"
onClick={() => {
if (!window.confirm("清空配置?旧文件会被改名成 .bak,然后需要重启程序。")) return;
void api.clearConfig().then(
(message) => onInfo(message),
(err) => {
const text = err instanceof Error ? err.message : String(err);
if (text.includes("密码")) onNeedsUnlock(text);
else onError(text);
},
);
}}
>
清空配置
</Button>
<Button
variant="danger"
onClick={() => {
if (!window.confirm("退出程序?正在进行的录制会先收尾。")) return;
void api.quit().catch((err) => {
const text = err instanceof Error ? err.message : String(err);
if (text.includes("密码")) onNeedsUnlock(text);
else onError(text);
});
}}
>
退出程序
</Button>
</div>
</Section>
</div>
</aside>
);
}
/** 瞄准器偏移:拖画面校准 + 数值微调 + 归零。 */
function Calibration({
crosshair,
calibrating,
onCalibrating,
onChange,
}: {
crosshair: CrosshairConfig;
calibrating: boolean;
onCalibrating: (active: boolean) => void;
onChange: (offsetX: number, offsetY: number) => void;
}) {
return (
<div className="calibration">
<Switch
checked={calibrating}
label="拖动画面校准瞄准点"
onChange={onCalibrating}
/>
<p className="section__note">
摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是**画面比例**,所以换分辨率、
换窗口大小都不影响它。
</p>
<Field label="水平偏移" hint={`${(crosshair.offsetX * 100).toFixed(2)}%`}>
<Slider
value={crosshair.offsetX}
min={-0.3}
max={0.3}
step={0.001}
format={(value) => `${(value * 100).toFixed(2)}%`}
onChange={(value) => onChange(value, crosshair.offsetY)}
/>
</Field>
<Field label="垂直偏移" hint={`${(crosshair.offsetY * 100).toFixed(2)}%`}>
<Slider
value={crosshair.offsetY}
min={-0.3}
max={0.3}
step={0.001}
format={(value) => `${(value * 100).toFixed(2)}%`}
onChange={(value) => onChange(crosshair.offsetX, value)}
/>
</Field>
<Button variant="ghost" onClick={() => onChange(0, 0)}>
偏移归零
</Button>
</div>
);
}
/** 改密码。 */
function ChangePassword({
onDone,
onError,
}: {
onDone: (message: string) => void;
onError: (message: string) => void;
}) {
const [current, setCurrent] = useState("");
const [fresh, setFresh] = useState("");
const [busy, setBusy] = useState(false);
return (
<div className="password-change">
<input
className="input"
type="password"
placeholder="当前密码"
autoComplete="off"
value={current}
onChange={(event) => setCurrent(event.target.value)}
/>
<input
className="input"
type="password"
placeholder="新密码(≥4 位)"
autoComplete="new-password"
value={fresh}
onChange={(event) => setFresh(event.target.value)}
/>
<Button
disabled={busy || fresh.length < 4}
onClick={() => {
setBusy(true);
void api
.setPassword(fresh, current)
.then(() => {
onDone("密码已更新。");
setCurrent("");
setFresh("");
})
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false));
}}
>
{busy ? "保存中…" : "修改密码"}
</Button>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* 顶部状态条:身份、连接、演示模式、说明、以及进入设置的入口。
*
* 演示模式的标记**必须显眼**:那一整屏画面与血量都是本机合成的,截图或验收时把它当成
* 真实链路会得出完全错误的结论。
*/
import type { ReactNode } from "react";
import { pushNote, useAppStore } from "../lib/store";
import type { Snapshot } from "../lib/types";
import { Button } from "./controls";
interface Props {
snapshot: Snapshot;
onOpenSettings: () => void;
onOpenMedia?: () => void;
extra?: ReactNode;
}
export function TopBar({ snapshot, onOpenSettings, onOpenMedia, extra }: Props) {
const { connected, notes } = useAppStore();
return (
<header className="topbar">
<div className="topbar__left">
<span className="topbar__brand">cam-desktop</span>
<span className={`tag tag--${snapshot.role}`}>
{snapshot.role === "team" ? "用户端" : "裁判端"}
</span>
{snapshot.demo && (
<button
className="tag tag--demo tag--button"
title="所有画面与血量都是本机合成的假数据,不代表场上的任何一块板子。"
onClick={() =>
pushNote("演示模式:画面与血量由本机合成,不是真实链路。截图或验收时别把它当成现场数据。")
}
>
演示模式 · 合成画面
</button>
)}
{!connected && <span className="tag tag--warn">通道断开,重连中</span>}
{snapshot.recordings.length > 0 && (
<span className="tag tag--recording">
<span className="recording__dot" /> 录制中 ×{snapshot.recordings.length}
</span>
)}
{extra}
</div>
<div className="topbar__right">
{notes.slice(-1).map((note, index) => (
<span key={index} className="topbar__note">
{note}
</span>
))}
<span className="dim mono" title="连着这个后端的界面数">
界面 {snapshot.clients}
</span>
{onOpenMedia && (
<Button variant="ghost" onClick={onOpenMedia}>
录制与回放
</Button>
)}
<Button variant="ghost" onClick={onOpenSettings}>
设置
</Button>
</div>
</header>
);
}
/** 浮动提示(错误/信息)。 */
export function Toast({
message,
kind,
onClose,
}: {
message: string;
kind: "info" | "error";
onClose: () => void;
}) {
return (
<div className={`toast toast--${kind}`} onClick={onClose} role="alert">
<span>{message}</span>
<span className="toast__close">✕</span>
</div>
);
}
+498
View File
@@ -0,0 +1,498 @@
/**
* 仅开发用的浏览器预览。
*
* # 它解决什么问题
*
* Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建;而且截图要靠 macOS 的
* 屏幕录制权限。预览模式让同一套 React 组件在浏览器里跑起来:状态由本文件合成,画面用
* `createImageBitmap` 从一块 canvas 上取——`VideoSurface`、血条、瞄准器、命中反馈全都是
* 真代码,只有"后端"是假的。
*
* # 它绝不可能进到正式构建里
*
* 两重门:
*
* 1. `import.meta.env.DEV` —— 只在 `vite dev` 下为真;`vite build` 时是 false,
* 2. 并且页面里没有 Tauri 注入的 `__TAURI_INTERNALS__`。
*
* 调用点还用 `await import(...)`:条件为假时整个模块不会被打进产物,而不是打进去再靠
* 运行时不走。
*
* # 用法
*
* ```sh
* pnpm dev # 用户端
* open 'http://127.0.0.1:1420/?preview=referee' # 裁判端
* ```
*/
import type { StreamClient } from "./stream";
import type {
ConfigView,
HudView,
Snapshot,
WireMsg,
} from "./types";
/** 页面里有没有 Tauri 注入。 */
function hasTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** 现在是不是在预览模式。 */
export function isPreview(): boolean {
return import.meta.env.DEV && !hasTauri();
}
const params = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
const startAsReferee = params.get("preview") === "referee";
// ---------------------------------------------------------------------------
// 可变状态:设置面板里的改动会落到这里,界面因此是可交互的
// ---------------------------------------------------------------------------
const DEFAULT_HUD: HudView = {
health: {
visible: true,
showNumbers: true,
showTeamName: true,
lowThreshold: 30,
pulse: true,
width: 0.42,
},
crosshair: {
visible: true,
style: "cross-dot",
size: 0.045,
thickness: 0.004,
gap: 0.012,
color: "#3dff88",
offsetX: 0,
offsetY: 0,
},
grid: { visible: false, style: "thirds", divisions: 6, opacity: 0.22 },
hitFeedback: { flash: true, shake: true, marker: true, intensity: 1 },
showZoom: true,
showStats: true,
};
const CAMERA_A = 0x0de0_0001;
const CAMERA_B = 0x0de0_0002;
const ARMOR_A = 0x0a20_0001;
const ARMOR_B = 0x0a20_0002;
interface PreviewState {
role: "team" | "referee";
hud: HudView;
cameraId: number | null;
armorId: number | null;
team: number;
mirror: boolean;
unlocked: boolean;
password: string | null;
calibrating: boolean;
recordings: { camera: number; frames: number; startedMs: number }[];
hp: [number, number];
}
const state: PreviewState = {
role: startAsReferee ? "referee" : "team",
hud: structuredClone(DEFAULT_HUD),
cameraId: startAsReferee ? null : CAMERA_A,
armorId: startAsReferee ? null : ARMOR_A,
team: 0,
mirror: true,
// 预览里直接是已解锁状态,免得每次刷新都要输密码。
unlocked: false,
password: "1234",
calibrating: false,
recordings: [],
hp: [100, 100],
};
// ---------------------------------------------------------------------------
// 假的命令实现
// ---------------------------------------------------------------------------
/** 供 `ipc.ts` 在有 Tauri 时不会走到的分支上用。 */
export async function previewInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const value = handle(command, args ?? {});
return value as T;
}
function handle(command: string, args: Record<string, unknown>): unknown {
switch (command) {
case "stream_info":
return { url: "preview://", port: 0, clients: 1 };
case "get_snapshot":
return snapshot();
case "get_config":
return configView();
case "list_media_files":
return [];
case "config_path":
return "/预览模式/没有真实配置文件";
case "unlock":
state.unlocked = String(args.password) === state.password;
return state.unlocked;
case "lock":
state.unlocked = false;
return undefined;
case "set_password":
state.password = String(args.fresh);
state.unlocked = true;
return configView();
case "set_discovery_open":
return undefined;
case "bind_camera":
guard();
state.cameraId = (args.cameraId as number | null) ?? null;
return configView();
case "bind_armor":
guard();
state.armorId = (args.armorId as number | null) ?? null;
return configView();
case "set_team":
guard();
state.team = Number(args.team);
return configView();
case "set_role":
state.role = args.role as "team" | "referee";
return configView();
case "set_mirror":
state.mirror = Boolean(args.mirror);
return configView();
case "save_hud":
state.hud = args.hud as HudView;
return configView();
case "set_referee_slots":
return configView();
case "set_recording_options":
return configView();
case "screenshot":
return "/预览模式/没有截图";
case "start_recording": {
const cameras = (args.cameras as number[]) ?? [];
const startedMs = Date.now();
for (const camera of cameras) {
if (!state.recordings.some((r) => r.camera === camera)) {
state.recordings.push({ camera, frames: 0, startedMs });
}
}
return recordings();
}
case "stop_recording": {
const cameras = (args.cameras as number[]) ?? [];
state.recordings = cameras.length
? state.recordings.filter((r) => !cameras.includes(r.camera))
: [];
return recordings();
}
case "refresh":
case "reveal":
case "open_file":
return undefined;
case "clear_config":
return "预览模式:没有真实配置可清空";
case "quit":
return undefined;
default:
throw new Error(`预览模式没有实现命令 ${command}`);
}
}
/** 预览里模拟后端的密码闸门,好让解锁流程也能被看到。 */
function guard() {
if (state.role === "team" && state.password !== null && !state.unlocked) {
throw new Error("这个改动需要密码。先解锁(预览模式里密码是 1234)。");
}
}
function configView(): ConfigView {
return {
role: state.role,
roleChosen: true,
team: state.team,
cameraId: state.cameraId,
cameraIdText: state.cameraId === null ? null : hex(state.cameraId),
armorId: state.armorId,
armorIdText: state.armorId === null ? null : hex(state.armorId),
mirror: state.mirror,
passwordSet: state.password !== null,
hud: structuredClone(state.hud),
recordingDir: "/预览模式/recordings",
ffmpeg: "ffmpeg",
};
}
function recordings() {
return state.recordings.map((recording) => ({
camera: recording.camera,
cameraText: hex(recording.camera),
active: true,
path: `/预览模式/rec-${recording.camera.toString(16)}.mp4`,
inputFps: 20,
framesWritten: recording.frames,
framesDropped: 0,
startedMs: recording.startedMs,
error: null,
}));
}
function hex(id: number): string {
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
}
function snapshot(): Snapshot {
const cameras = [
{ id: CAMERA_A, team: 0 },
{ id: CAMERA_B, team: 1 },
].map(({ id, team }) => ({
id,
idText: hex(id),
addr: team === 0 ? "192.168.1.41" : "192.168.1.42",
online: true,
silenceMs: 120,
firmware: 3,
}));
const boards = [
{ id: ARMOR_A, team: 0 },
{ id: ARMOR_B, team: 1 },
].map(({ id, team }, index) => ({
id,
idText: hex(id),
addr: team === 0 ? "192.168.1.51" : "192.168.1.52",
online: true,
team,
hp: state.hp[index],
platesSeen: 4,
reportsAllPlates: true,
silenceMs: 90,
}));
const stats = cameras.map((camera) => ({
camera: camera.id,
cameraText: camera.idText,
online: true,
status: camera.idText,
hasFrames: true,
fps: 20,
width: 640,
height: 480,
framesShown: 1000,
framesDropped: 3,
lossPercent: 0.3,
datagrams: 17000,
lastFrameBytes: 22000,
gapMs: 50,
}));
const healthFor = (team: number) => {
const hp = state.hp[team];
const board = team === 0 ? ARMOR_A : ARMOR_B;
return {
team,
name: team === 0 ? "队伍一" : "队伍二",
available: true,
placeholder: "",
hp,
hps: [hp, hp, hp, hp],
board,
boardText: hex(board),
critical: hp === 0 || hp <= state.hud.health.lowThreshold,
};
};
return {
role: state.role,
team: state.team,
mirrored: state.mirror,
demo: true,
diagnostics: true,
unlocked: state.unlocked || state.role === "referee",
discoveryOpen: true,
uptimeMs: performance.now(),
clients: 1,
config: configView(),
cameras,
armorBoards: boards,
stats,
slots: {
cameraA: state.role === "referee" ? CAMERA_A : state.cameraId,
cameraB: state.role === "referee" ? CAMERA_B : null,
armorA: state.role === "referee" ? ARMOR_A : state.armorId,
armorB: state.role === "referee" ? ARMOR_B : null,
manual: false,
},
health: state.role === "referee" ? [healthFor(0), healthFor(1)] : [healthFor(state.team)],
recordings: recordings(),
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
};
}
// ---------------------------------------------------------------------------
// 假的画面与心跳
// ---------------------------------------------------------------------------
/** 启动预览的数据源:每 200ms 一份快照,每 50ms 一帧画面。 */
export function startPreview(client: StreamClient, emit: (msg: WireMsg) => void) {
let frameId = 0;
const started = performance.now();
// 快照。
const snapshotTimer = window.setInterval(() => {
emit({ t: "snapshot", ...snapshot() } as WireMsg);
}, 200);
// 两路画面,各 20fps。
const canvases = new Map<number, HTMLCanvasElement>();
const render = async (camera: number, team: number) => {
const canvas = canvases.get(camera) ?? makeCanvas();
canvases.set(camera, canvas);
draw(canvas, team, frameId);
const bitmap = await createImageBitmap(canvas);
client.injectFrame(camera, frameId, canvas.width, canvas.height, bitmap);
};
const frameTimer = window.setInterval(() => {
frameId += 1;
const cameras =
state.role === "referee"
? [
[CAMERA_A, 0],
[CAMERA_B, 1],
]
: [[state.cameraId ?? CAMERA_A, state.team]];
for (const [camera, team] of cameras as [number, number][]) {
void render(camera, team);
}
for (const recording of state.recordings) recording.frames += 1;
}, 50);
// 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。
let tick = 0;
const hitTimer = window.setInterval(() => {
tick += 1;
const team = tick % 2;
const damage = 3 + ((tick * 7) % 20);
const from = state.hp[team];
const to = from === 0 ? 100 : Math.max(0, from - damage);
// 打空之后重置成满血:界面上"已击毁"的状态要真的能看到。
state.hp[team] = to;
if (to >= from) return;
emit({
t: "hit",
board: team === 0 ? ARMOR_A : ARMOR_B,
team,
index: 0,
from,
to,
damage: from - to,
atMs: Date.now(),
} as WireMsg);
}, 3000);
// 第一帧不必等 200ms。
emit({ t: "snapshot", ...snapshot() } as WireMsg);
return () => {
window.clearInterval(snapshotTimer);
window.clearInterval(frameTimer);
window.clearInterval(hitTimer);
void started;
};
}
function makeCanvas(): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
return canvas;
}
/** 与 `src-tauri/src/demo.rs` 画的是同一类东西:底色区分两路、中心靶心、二进制帧号。 */
function draw(canvas: HTMLCanvasElement, team: number, frameId: number) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { width, height } = canvas;
const tint = team === 0 ? [0, 46, 40] : [58, 30, 0];
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, `rgb(${18 + tint[0] / 3}, ${18 + tint[1] / 3}, ${22 + tint[2] / 3})`);
gradient.addColorStop(1, `rgb(${78 + tint[0] / 3}, ${78 + tint[1] / 3}, ${82 + tint[2] / 3})`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = "rgba(255,255,255,0.07)";
ctx.lineWidth = 1;
for (let x = 0; x < width; x += 40) {
ctx.beginPath();
ctx.moveTo(x + 0.5, 0);
ctx.lineTo(x + 0.5, height);
ctx.stroke();
}
for (let y = 0; y < height; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y + 0.5);
ctx.lineTo(width, y + 0.5);
ctx.stroke();
}
const cx = width / 2;
const cy = height / 2;
ctx.strokeStyle = "#c8c8d2";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, 60, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#96969f";
ctx.beginPath();
ctx.arc(cx, cy, 28, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#78788a";
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
ctx.beginPath();
ctx.moveTo(cx + dx * 40, cy + dy * 40);
ctx.lineTo(cx + dx * 90, cy + dy * 90);
ctx.stroke();
}
for (let bit = 0; bit < 16; bit += 1) {
const on = ((frameId >> bit) & 1) === 1;
ctx.fillStyle = on ? "#78ffa0" : "#34343c";
ctx.fillRect(12 + bit * 14, 12, 10, 10);
}
const t = frameId / 20;
const phase = team === 0 ? 0 : 1.7;
ctx.fillStyle = "#ffd25a";
ctx.beginPath();
ctx.arc(cx + 250 * Math.sin(t * 0.9 + phase), cy + 150 * Math.cos(t * 1.3 + phase), 26, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#5ac8ff";
ctx.fillRect(((t * 60) % (width - 80)) | 0, height - 24, 60, 8);
ctx.font = "600 20px ui-monospace, monospace";
ctx.fillStyle = "#e8e8ec";
ctx.fillText(team === 0 ? "PREVIEW · TEAM 1" : "PREVIEW · TEAM 2", 14, height - 40);
ctx.font = "500 16px ui-monospace, monospace";
ctx.fillStyle = "#8a8a95";
ctx.fillText(`frame ${frameId}`, 14, height - 16);
}
+12 -1
View File
@@ -23,8 +23,18 @@ import type {
StreamInfo,
} from "./types";
/** 把后端的字符串错误变成真正的 `Error`。 */
/**
* 把后端的字符串错误变成真正的 `Error`。
*
* 在没有 Tauri 的 `vite dev` 会话里(`?preview=` 那种),这条走预览实现——它用
* `await import()` 引入,所以正式构建里那段代码根本不会被打进产物(见 `dev-preview.ts`)。
*/
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) {
const preview = await import("./dev-preview");
return preview.previewInvoke<T>(command, args);
}
try {
return await invoke<T>(command, args);
} catch (raw) {
@@ -77,6 +87,7 @@ export const api = {
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
reveal: (path: string) => call<void>("reveal", { path }),
openFile: (path: string) => call<void>("open_file", { path }),
// ---- 退出与重置 ----
clearConfig: (password?: string) =>
+10
View File
@@ -111,6 +111,16 @@ export function startStream(url: string): StreamClient {
}),
});
// 浏览器预览:没有 WebSocket,状态与画面由 dev-preview.ts 合成。
// 动态引入让这段代码在正式构建里被完全剔掉。
if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) {
emit({ connected: true });
const current = client;
void import("./dev-preview").then((preview) => preview.startPreview(current, handleMessage));
return client;
}
client.connect(url);
return client;
}
+33 -6
View File
@@ -117,11 +117,6 @@ export class StreamClient {
this.counters.frames += 1;
const previous = this.frames.get(camera);
// 乱序完成时丢弃更旧的那一帧。用带符号差处理 u32 回绕。
if (previous && ((frameId - previous.frameId) | 0) <= 0) return;
const jpeg = new Uint8Array(buffer, FRAME_HEADER_LEN, length);
let bitmap: ImageBitmap;
@@ -132,8 +127,28 @@ export class StreamClient {
return;
}
// 解码期间可能已经来了更新的帧:那就把刚解出来的这张丢掉。
this.store(camera, frameId, width, height, bitmap);
}
/**
* 放进一帧。
*
* 解码是异步的,所以这里要再判一次"更新的一帧有没有先到":`createImageBitmap` 的
* 完成顺序不保证与发起顺序一致,不判的话画面会偶发地往回跳一帧。
*
* 预览模式(`dev-preview.ts`)直接调它,绕过 WebSocket——两条路径共用同一套存储逻辑,
* 所以预览里看到的画面行为与真机一致。
*/
private store(
camera: number,
frameId: number,
width: number,
height: number,
bitmap: ImageBitmap,
) {
const current = this.frames.get(camera);
// 带符号差处理 u32 回绕。
if (current && ((frameId - current.frameId) | 0) <= 0) {
bitmap.close();
return;
@@ -151,6 +166,18 @@ export class StreamClient {
});
}
/** 给预览模式用的入口:直接塞一帧。 */
injectFrame(
camera: number,
frameId: number,
width: number,
height: number,
bitmap: ImageBitmap,
) {
this.counters.frames += 1;
this.store(camera, frameId, width, height, bitmap);
}
private scheduleReconnect() {
if (this.closed || this.retryTimer !== null) return;
+15
View File
@@ -0,0 +1,15 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.css";
const container = document.getElementById("root");
if (!container) {
throw new Error("index.html 里没有 #root");
}
// 不用 StrictMode:这个界面的关键路径是命令式的(WebSocket、requestAnimationFrame 绘制
// 循环、canvas 尺寸)。StrictMode 在开发下会把每个 effect 跑两遍,于是连接与绘制循环会
// 短暂地建两份——排查起来的噪音大于它带来的收益。生产构建的行为与这里一致。
createRoot(container).render(<App />);
+164
View File
@@ -0,0 +1,164 @@
/**
* 裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
*
* 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量并排对称、
* 缩放置互相独立(滚轮只作用于鼠标所在的那一路)。
*
* 两格等宽这件事在旧客户端上是踩过坑的:`HorizontalLayout` 先按内容的**最大**需求分配,
* 剩下的才按 stretch 分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"
* (5 字),于是左边多要十几个像素——实测 55% / 45%。解法是让两格的需求只由画面决定:
* 统一的 `preferred-width` 加 `min-width`,文字用绝对定位不参与布局。CSS 的 flex 里
* 用 `flex: 1 1 0` + `min-width: 0` 达到同一件事。
*/
import { useMemo, useState } from "react";
import { ArmorTable, StatsLine } from "../components/ArmorTable";
import { HealthBar } from "../components/HealthBar";
import { HitFlash } from "../components/HitFlash";
import { MediaPanel } from "../components/MediaPanel";
import { TopBar } from "../components/TopBar";
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
import { Button } from "../components/controls";
import { teamName } from "../lib/format";
import { api } from "../lib/ipc";
import { useAppStore } from "../lib/store";
import type { HudView, Snapshot } from "../lib/types";
interface Props {
snapshot: Snapshot;
hud: HudView;
onOpenSettings: () => void;
onError: (message: string) => void;
onInfo: (message: string) => void;
}
/** 底部区域的两个页签。 */
type Tab = "armor" | "media";
export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }: Props) {
const { lastHit } = useAppStore();
const [tab, setTab] = useState<Tab>("armor");
// 两路各自独立的缩放与平移。
const [viewA, setViewA] = useState<ViewTransform>(IDENTITY_VIEW);
const [viewB, setViewB] = useState<ViewTransform>(IDENTITY_VIEW);
const panes = useMemo(
() =>
[
{
key: "a",
camera: snapshot.slots.cameraA,
armor: snapshot.slots.armorA,
team: 0,
view: viewA,
onView: setViewA,
},
{
key: "b",
camera: snapshot.slots.cameraB,
armor: snapshot.slots.armorB,
team: 1,
view: viewB,
onView: setViewB,
},
].map((pane) => ({
...pane,
stats: snapshot.stats.find((entry) => entry.camera === pane.camera),
health: snapshot.health.find((entry) => entry.team === pane.team) ?? snapshot.health[0],
})),
[snapshot.slots, snapshot.stats, snapshot.health, viewA, viewB],
);
return (
<div className="page">
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
<div className="referee-stage">
{panes.map((pane) => {
// 这一下落在哪一格:指定了装甲板就比板子 id,否则退回比队伍号。
const hitRelevant = Boolean(
lastHit &&
(pane.armor !== null ? lastHit.board === pane.armor : lastHit.team === pane.team),
);
return (
<section key={pane.key} className="pane">
<VideoSurface
camera={pane.camera}
emptyText={
pane.stats?.hasFrames
? ""
: pane.stats?.online
? "已连接,等待画面…"
: `等待${teamName(pane.team)}的图传`
}
mirrored={snapshot.mirrored}
// 裁判端不给瞄准器:这里没有人在瞄。网格留着,用来比对两路的取景。
crosshair={null}
grid={hud.grid}
view={pane.view}
onView={pane.onView}
hit={
hitRelevant && lastHit
? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt }
: null
}
hitFeedback={hud.hitFeedback}
showZoom
>
<HitFlash
hit={lastHit}
config={hud.hitFeedback}
relevant={hitRelevant}
label={`${teamName(pane.team)}被打中`}
/>
</VideoSurface>
<div className="pane__foot">
<HealthBar health={pane.health} config={hud.health} compact />
<StatsLine stats={pane.stats} diagnostics={snapshot.diagnostics} />
</div>
</section>
);
})}
</div>
<div className="referee-bottom">
<div className="tabs">
<button
className={`tab${tab === "armor" ? " tab--active" : ""}`}
onClick={() => setTab("armor")}
>
装甲板({snapshot.armorBoards.length})
</button>
<button className={`tab${tab === "media" ? " tab--active" : ""}`} onClick={() => setTab("media")}>
录制与回放{snapshot.recordings.length > 0 ? ` · 进行中 ${snapshot.recordings.length}` : ""}
</button>
<span className="tabs__spacer" />
<Button
variant="ghost"
onClick={() =>
void api
.refresh()
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
}
>
刷新
</Button>
</div>
<div className="referee-bottom__body">
{tab === "armor" ? (
<ArmorTable boards={snapshot.armorBoards} slots={snapshot.slots} />
) : (
<MediaPanel snapshot={snapshot} onError={onError} onInfo={onInfo} />
)}
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
/**
* 用户端:一路画面 + 血条 + 瞄准器。
*
* 界面上的东西按"操作手需要什么"排:
*
* - 画面占满除底部一条窄带以外的全部空间(一个像素都不浪费在装饰上);
* - 血条在画面**下方**,不挡画面;
* - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走;
* - 被打中时整屏闪一下红、画面抖一下、瞄点旁跳一个伤害数字。
*/
import { useMemo, useState } from "react";
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
import { HealthBar } from "../components/HealthBar";
import { HitFlash } from "../components/HitFlash";
import { StatsLine } from "../components/ArmorTable";
import { TopBar } from "../components/TopBar";
import { Button } from "../components/controls";
import { useAppStore } from "../lib/store";
import type { HudView, Snapshot } from "../lib/types";
interface Props {
snapshot: Snapshot;
hud: HudView;
onOpenSettings: () => void;
/** 是否在瞄准器校准模式(开关在设置面板里)。 */
calibrating: boolean;
/** 校准模式下拖动画面:把屏幕位移换算成画面比例位移。 */
onCalibrate: (dxFraction: number, dyFraction: number) => void;
}
export function TeamPage({
snapshot,
hud,
onOpenSettings,
calibrating,
onCalibrate,
}: Props) {
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
const { lastHit } = useAppStore();
const camera = snapshot.config.cameraId;
const stats = useMemo(
() => snapshot.stats.find((entry) => entry.camera === camera),
[snapshot.stats, camera],
);
const health = snapshot.health[0];
// 这一下是不是打在我身上:绑定了装甲板就比板子 id,没绑定就退回比队伍号。
const hitIsMine = Boolean(
lastHit &&
(health?.board != null ? lastHit.board === health.board : lastHit.team === snapshot.team),
);
const pulse = hitIsMine && lastHit ? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt } : null;
if (camera === null) {
return (
<div className="page">
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
<div className="page__center">
<h2>还没有绑定摄像头</h2>
<p className="dim">
用户端要先选一块图传板(就是装在这台车上的那一块)。绑定会写进配置文件,重启后
仍然生效;以后要改需要密码。
</p>
<Button variant="primary" onClick={onOpenSettings}>
选择摄像头
</Button>
</div>
</div>
);
}
return (
<div className="page">
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
<div className="team-stage">
<VideoSurface
camera={camera}
emptyText={stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:这块板子还没广播"}
mirrored={snapshot.mirrored}
crosshair={hud.crosshair}
grid={hud.grid}
view={view}
onView={setView}
calibrating={calibrating}
onCalibrate={calibrating ? onCalibrate : undefined}
hit={pulse}
hitFeedback={hud.hitFeedback}
showZoom={hud.showZoom}
>
<HitFlash hit={lastHit} config={hud.hitFeedback} relevant={hitIsMine} />
</VideoSurface>
</div>
{hud.health.visible && (
<div className="hud-bottom">
<HealthBar health={health} config={hud.health} />
</div>
)}
<footer className="statusbar">
<StatsLine stats={stats} diagnostics={snapshot.diagnostics} />
<span className="dim">
滚轮缩放 · 拖动平移(放大后) · 双击复位
{calibrating && " · 校准中:拖动画面挪动瞄准点"}
</span>
</footer>
</div>
);
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,12 +1,15 @@
{
"compilerOptions": {
// 被引用的工程(project reference)不能关掉 emit:tsc 需要它来产出 .tsbuildinfo,
// 否则会报 TS6310。构建产物落到 node_modules/.tmp,不进仓库。
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
"emitDeclarationOnly": true,
"outDir": "node_modules/.tmp/tsconfig.node"
},
"include": ["vite.config.ts"]
}