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:
+7
-5
@@ -1,17 +1,16 @@
|
|||||||
# 构建产物。
|
# 构建产物。
|
||||||
#
|
#
|
||||||
# 四行都必要:根目录是 firmware crate,protocol/ 和 client/ 各自也是独立的
|
# 每一行都必要:根目录是 firmware crate,protocol/ 也是独立的 crate(有自己的
|
||||||
# crate(各有自己的 .cargo/config.toml 与 target 目录),armor/ 是一套独立
|
# .cargo/config.toml 与 target 目录),armor/ 是一套独立 workspace(协议层 +
|
||||||
# workspace(协议层 + ESP32-C3 固件),同理。所以只写 `target/` 匹配不到它们。
|
# ESP32-C3 固件),同理。所以只写 `target/` 匹配不到它们。
|
||||||
#
|
#
|
||||||
# 全部锚定在根目录(前置 /),这是有意的:components/esp32-camera/target/ 看着
|
# 全部锚定在根目录(前置 /),这是有意的:components/esp32-camera/target/ 看着
|
||||||
# 像构建产物,其实是上游组件的**按芯片分目录**(esp32/ esp32s2/ esp32s3/ …),
|
# 像构建产物,其实是上游组件的**按芯片分目录**(esp32/ esp32s2/ esp32s3/ …),
|
||||||
# 里面是 ll_cam.c 等源码,包含本项目打的补丁。不要把它一起忽略掉。
|
# 里面是 ll_cam.c 等源码,包含本项目打的补丁。不要把它一起忽略掉。
|
||||||
#
|
#
|
||||||
# desktop/ 自己还有一份 .gitignore(node_modules、dist、src-tauri/target)。
|
# desktop/ 自己还有一份 .gitignore(node_modules、dist、三个 crate 的 target)。
|
||||||
/target/
|
/target/
|
||||||
/protocol/target/
|
/protocol/target/
|
||||||
/client/target/
|
|
||||||
/armor/target/
|
/armor/target/
|
||||||
|
|
||||||
# 本机专属的 cargo 注册表缓存。
|
# 本机专属的 cargo 注册表缓存。
|
||||||
@@ -23,3 +22,6 @@
|
|||||||
|
|
||||||
# WiFi 凭据,编译期由 env! 读取。不要提交。
|
# WiFi 凭据,编译期由 env! 读取。不要提交。
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# pnpm 在本机把 store 落在了工作区里(沙箱下 HOME 里的 store 不可写)。
|
||||||
|
/.pnpm-store/
|
||||||
|
|||||||
@@ -3,16 +3,22 @@
|
|||||||
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
|
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
|
||||||
UDP 图传。
|
UDP 图传。
|
||||||
|
|
||||||
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;客户端是 Rust + Slint
|
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 **Tauri v2 +
|
||||||
桌面程序。
|
React**,用户端与裁判端两套页面(见 [`desktop/README.md`](desktop/README.md))。同一套
|
||||||
|
系统里还有一队一块的**装甲板**(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量,
|
||||||
|
并把血量广播给裁判终端。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
protocol/ 线路格式 + 帧重组 两端共用,无 ESP-IDF 依赖,13 个单元测试
|
protocol/ 线路格式 + 帧重组 三端共用,无 ESP-IDF 依赖,14 个单元测试
|
||||||
. 固件 -> ESP32-S3 交叉编译,Xtensa
|
. 图传固件 -> ESP32-S3 交叉编译,Xtensa
|
||||||
client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
|
armor/ 装甲板固件 -> ESP32-C3 独立 workspace,RISC-V;规则层 22 个单元测试
|
||||||
|
desktop/ 桌面端 Tauri v2 + React;用户端(血条 + 瞄准器)/ 裁判端
|
||||||
|
core/ 不含界面与 Tauri 的网络层、配置、密码(可单测)
|
||||||
|
src-tauri/ Tauri 后端:推流、命令、录制
|
||||||
|
src/ React 前端:两套页面 + HUD
|
||||||
```
|
```
|
||||||
|
|
||||||
三个 crate 各自独立构建,各有自己的 `.cargo/config.toml`。
|
五份产物各自独立构建,各有自己的 `.cargo/config.toml`。
|
||||||
|
|
||||||
## 拓扑
|
## 拓扑
|
||||||
|
|
||||||
@@ -25,16 +31,19 @@ client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
|
|||||||
┌────────────────────┼────────────────────┐
|
┌────────────────────┼────────────────────┐
|
||||||
│ │ │
|
│ │ │
|
||||||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||||||
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
|
│ 图传板 A │ │ 图传板 B │ │ 装甲板主板 │
|
||||||
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
|
│ (队伍一) │ │ (队伍二) │ │ ×2(每队一块)│
|
||||||
└───────────────┘ └───────────────┘ └───────────────┘
|
└───────────────┘ └───────────────┘ └───────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**两块板子不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
|
**图传板不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
|
||||||
客户端决定。烧同一份固件即可,配置全在客户端一侧。
|
客户端决定。烧同一份固件即可,配置全在客户端一侧。
|
||||||
|
|
||||||
裁判终端同时收两块板子。队伍客户端也收全部板子的视频,然后在**界面层**过滤到绑定的
|
**装甲板反过来:队伍是编译期定的**(`ARMOR_TEAM=0/1`,同时决定灯色红/蓝),因为血量
|
||||||
那一块。
|
上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。
|
||||||
|
|
||||||
|
裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收
|
||||||
|
全部板子的视频,然后在**界面层**过滤到绑定的那一块。
|
||||||
|
|
||||||
## 实测性能
|
## 实测性能
|
||||||
|
|
||||||
@@ -98,6 +107,9 @@ app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不
|
|||||||
WIFI_SSID=你的2.4G网络
|
WIFI_SSID=你的2.4G网络
|
||||||
WIFI_PASS=密码
|
WIFI_PASS=密码
|
||||||
|
|
||||||
|
# 装甲板(armor/)用这两个里的第一个;队伍号决定灯色与上报里的 team 字段:
|
||||||
|
#ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
|
||||||
|
|
||||||
# 以下都有默认值:
|
# 以下都有默认值:
|
||||||
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
|
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
|
||||||
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
|
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
|
||||||
@@ -106,29 +118,40 @@ WIFI_PASS=密码
|
|||||||
```
|
```
|
||||||
|
|
||||||
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
|
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
|
||||||
在板子上带着别人的密码。
|
在板子上带着别人的密码。`ARMOR_TEAM` 同理:换队伍重刷装甲板固件即可,不用擦 NVS。
|
||||||
|
|
||||||
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
|
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
|
||||||
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
|
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
|
||||||
挨个试等于在猜一个本该已知的值。
|
挨个试等于在猜一个本该已知的值。
|
||||||
|
|
||||||
### 2. 客户端
|
### 2. 装甲板固件(ESP32-C3)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd client && cargo build --release
|
scripts/flash_armor.sh # 编译 + 烧录 + 20 秒串口日志
|
||||||
./target/aarch64-apple-darwin/release/cam-client --list
|
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量
|
||||||
```
|
```
|
||||||
|
|
||||||
`--list` 列出发现的板子。然后:
|
接线表、血量规则、C3 的 RMT/ADC 踩坑都在 [`armor/README.md`](armor/README.md)。
|
||||||
|
|
||||||
|
### 3. 桌面端
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cam-client # 裁判端,无需配置
|
cd desktop
|
||||||
cam-client --role team --team 1 # 队伍端,首次运行问绑定
|
pnpm install
|
||||||
|
pnpm app # = tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
**`client/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
|
没有板子也能把整条链路跑起来:`CAM_DEMO=1 pnpm app` 会合成**协议数据报**喂进真实的接收
|
||||||
client/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配
|
路径(分片、重组、发现、掉血派生、两道闸门全在跑真代码),界面上标着"演示模式"。
|
||||||
置,然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
|
|
||||||
|
首次启动会问这台机器是哪一端:**用户端**要绑一块图传板与一块装甲板(改动需密码),
|
||||||
|
**裁判端**不需要任何配置就能看两路。
|
||||||
|
|
||||||
|
**`desktop/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
|
||||||
|
desktop/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配置,
|
||||||
|
然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
|
||||||
|
|
||||||
|
界面、视频通道、密码模型、录制与排查都在 [`desktop/README.md`](desktop/README.md)。
|
||||||
|
|
||||||
## 发现是双向的
|
## 发现是双向的
|
||||||
|
|
||||||
@@ -303,9 +326,9 @@ OV5640 的 JPEG 编码器**在传感器内部**。DVP 接口出来的一帧要
|
|||||||
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
|
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
|
||||||
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
|
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
|
||||||
|
|
||||||
`slint::Image` 不是 `Send`,所以 worker 线程只能处理纯数据(`Vec<u8>` + 尺寸),`Ima
|
现在桌面端把 JPEG **原样**交给 WebView,由浏览器的原生解码器解——后端连解都不解,
|
||||||
ge` 的构造必须留在事件循环。这条约束决定了整个客户端的线程结构,细节在
|
接收线程只做一次 16 字节头的拼接与一次广播。这一条比上面那条更彻底:省掉的不是几毫秒,
|
||||||
`client/README.md`。
|
而是一整个线程与一次内存拷贝。细节在 [`desktop/README.md`](desktop/README.md)。
|
||||||
|
|
||||||
## 排查
|
## 排查
|
||||||
|
|
||||||
@@ -315,11 +338,13 @@ ge` 的构造必须留在事件循环。这条约束决定了整个客户端的
|
|||||||
| 几帧后停止出图,报 `NO-EOI` | `PSRAM DMA` 被打开了 |
|
| 几帧后停止出图,报 `NO-EOI` | `PSRAM DMA` 被打开了 |
|
||||||
| 板子在线但 `0 clients subscribed` | 订阅包格式不对。`Subscribe` 的 `role` 字节紧跟 27 字节头,在偏移 27 |
|
| 板子在线但 `0 clients subscribed` | 订阅包格式不对。`Subscribe` 的 `role` 字节紧跟 27 字节头,在偏移 27 |
|
||||||
| 一直接到但画面偶尔解不出来 | 帧重组的问题。**先怀疑你的测量工具**:本项目的探测脚本就因为重组状态没按 `frame_id` 隔离,误报过 34 张坏帧 |
|
| 一直接到但画面偶尔解不出来 | 帧重组的问题。**先怀疑你的测量工具**:本项目的探测脚本就因为重组状态没按 `frame_id` 隔离,误报过 34 张坏帧 |
|
||||||
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||||||
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
|
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
|
||||||
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
|
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
|
||||||
| 裁判端两块画面宽窄不一 | 检查那两格是否都写了相同的 `preferred-width`,且占位文字是绝对定位(`x`/`y`)而不是普通布局 |
|
| 裁判端两块画面宽窄不一 | 两格必须是 `flex: 1 1 0` 且 `min-width: 0`:按内容分宽度时,占位文字长的那一格会多占十几个像素 |
|
||||||
| 放大后画面没变清晰 | `image-fit` 必须跟着 zoom 在 `contain`/`fill` 之间切。只用 `contain` 的话画面不会跟着放大 |
|
| 裁判端两格显示同一块板子 | 旧版槽位分配的真 bug。现在候选里要排除另一个槽位占着的 id |
|
||||||
|
| 有画面但状态栏说"离线" | 视频帧也必须算存活证据(`Discovery::touch`),不能只看 Hello |
|
||||||
|
| 改了绑定却"没生效" | 改动需要先解锁;后端每个改动命令都会校验,界面上的入口只是入口 |
|
||||||
|
|
||||||
独立核对线路格式:
|
独立核对线路格式:
|
||||||
|
|
||||||
@@ -330,20 +355,38 @@ python3 scripts/udp_probe.py 10
|
|||||||
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
|
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
|
||||||
有错,脚本会立刻报出不一致。
|
有错,脚本会立刻报出不一致。
|
||||||
|
|
||||||
## 三份文档
|
## 四份文档
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
|
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
|
||||||
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
|
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
|
||||||
| `client/README.md` | 命令行、绑定流程、Slint 的硬约束、线程模型 |
|
| `armor/README.md` | 装甲板接线表、血量规则、上报、C3 的 RMT/ADC 踩坑 |
|
||||||
|
| `desktop/README.md` | 两套页面、视频通道路径、两道闸门、密码模型、录制与排查 |
|
||||||
|
|
||||||
## 装甲板(预留)
|
## 装甲板(已实现)
|
||||||
|
|
||||||
`protocol::ArmorHealth`、`ARMOR_PORT`(5601)、`MsgType` 5 号都已就位。客户端有真实的
|
`armor/` 是一套独立的 Rust workspace(目标 **ESP32-C3**,RISC-V):
|
||||||
`ArmorSource` trait,当前实现是 `NoArmorYet`。
|
|
||||||
|
|
||||||
**界面显示占位符而不是 0。** 0 看起来像真值——现场看到两个 0 会以为对方已经没血了。
|
- `armor-protocol` —— 规则层:血量(100 起、每击 −3)、每板独立的 50 ms 受击冷却、
|
||||||
|
受击后**只有那一路**灯带全黑 50 ms、按键 FSM、上报编码。纯计算,主机上 22 个测试。
|
||||||
|
- `armor-firmware` —— 固件:四路压电片 40 kHz ADC 连续采样、四路 WS2812(C3 只有一个
|
||||||
|
RMT 通道可用,靠 `rmt_tx_switch_gpio` 轮流驱动)、NVS、BOOT 键、WiFi + UDP。
|
||||||
|
|
||||||
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端实现 `ArmorSource` 即可,界面和其余代
|
硬件是"一主三从":三块从板里没有 MCU,各自只有一路灯带和一路压电片,直接接线到主板。
|
||||||
码都不用改。
|
接线表与实现细节见 [`armor/README.md`](armor/README.md)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build_armor.sh # 编译(WIFI_SSID/WIFI_PASS/ARMOR_TEAM 取自 .env)
|
||||||
|
scripts/flash_armor.sh # 编译 + 烧录 + 串口日志
|
||||||
|
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量
|
||||||
|
```
|
||||||
|
|
||||||
|
桌面端侧:`desktop/core/src/armor.rs` 的 `ArmorHealthStore` 监听 5601,按 `(team, index)`
|
||||||
|
存最后一次读数,四条都在 1.5 s 内收到才显示——**没有数据时显示占位符而不是 0**,
|
||||||
|
0 看起来像真值,现场看到会以为对方已经没血了。界面上直接显示血量数字(整车一条血,
|
||||||
|
归零显示 `0 · 已击毁` 并转红);数字变化也会打进客户端日志。
|
||||||
|
|
||||||
|
同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板"
|
||||||
|
并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用
|
||||||
|
`encode_from(sender_id)` 把自己的 id 打进帧头。
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# This crate builds for the host, not the ESP32.
|
|
||||||
#
|
|
||||||
# It sits inside the firmware's directory tree, so cargo walks up and inherits
|
|
||||||
# ../.cargo/config.toml, which pins `target = "xtensa-esp32s3-espidf"` and an
|
|
||||||
# ldproxy linker that only work inside the ESP-IDF cross build. Without this file
|
|
||||||
# the desktop client is cross-compiled for Xtensa, which fails deep inside
|
|
||||||
# dependencies (libc not finding MAP_FAILED and friends, because it was being
|
|
||||||
# compiled for an embedded target that has no mmap).
|
|
||||||
#
|
|
||||||
# Overriding the target back to the host triple is the whole fix. Nothing else in
|
|
||||||
# the parent config applies to a non-ESP target.
|
|
||||||
[build]
|
|
||||||
target = "aarch64-apple-darwin"
|
|
||||||
Generated
-6101
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "cam-client"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
# Compiles ui/camera.slint into Rust before the crate itself builds. Kept separate
|
|
||||||
# from [dependencies] because it only runs on the build host, never on the target.
|
|
||||||
slint-build = "1"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Shared wire format with the firmware. Kept as a path dependency rather than
|
|
||||||
# copied, so the two sides cannot drift apart silently.
|
|
||||||
protocol = { path = "../protocol" }
|
|
||||||
|
|
||||||
# Slint for the UI. The default renderer on macOS is femtovg, which needs no
|
|
||||||
# extra system libraries; `backend-winit` provides the window integration.
|
|
||||||
slint = "1"
|
|
||||||
|
|
||||||
# JPEG decoding for display. Slint's Image can take raw RGBA, so the JPEG has to
|
|
||||||
# be decoded somewhere - `image` does that, and nothing on this side re-encodes.
|
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
|
||||||
|
|
||||||
anyhow = "1"
|
|
||||||
log = "0.4"
|
|
||||||
env_logger = "0.11"
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = 2
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
# client
|
|
||||||
|
|
||||||
Slint 桌面客户端。**两种身份,界面对应两个不同的组件。**
|
|
||||||
|
|
||||||
| 身份 | 画面 | 血量 | 需要绑定 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `--role referee`(默认) | 两块板左右并排 | 双方 | 否 |
|
|
||||||
| `--role team` | 一块画面 | 自己那一边 | **是,选定后不可更改** |
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd client && cargo build --release
|
|
||||||
./target/aarch64-apple-darwin/release/cam-client
|
|
||||||
```
|
|
||||||
|
|
||||||
产物路径带三重名(`target/aarch64-apple-darwin/release/`)是因为下面那节解释了原因。
|
|
||||||
|
|
||||||
## 命令行
|
|
||||||
|
|
||||||
```text
|
|
||||||
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
|
|
||||||
--team <0|1> 自己属于哪一边,仅 team 端用
|
|
||||||
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
|
|
||||||
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
|
|
||||||
--config <路径> 配置文件位置
|
|
||||||
--list 列出发现的摄像头后退出
|
|
||||||
-h, --help 显示这段帮助
|
|
||||||
```
|
|
||||||
|
|
||||||
常用:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cam-client --list # 先看看场上有几块板子
|
|
||||||
cam-client --role team --team 1 # 队伍一(首次运行会问绑定)
|
|
||||||
cam-client --role team --team 1 --camera-id 0x3D4D8A9A # 跳过选择直接绑
|
|
||||||
```
|
|
||||||
|
|
||||||
`--list` 会等 5 秒收广播周期再打印,所以它不是即时的。
|
|
||||||
|
|
||||||
## 配置文件
|
|
||||||
|
|
||||||
默认 `~/.config/cam-client/config.toml`,`--config` 可改。
|
|
||||||
|
|
||||||
```toml
|
|
||||||
role = "team"
|
|
||||||
team = 1
|
|
||||||
camera_id = 0x3D4D8A9A
|
|
||||||
```
|
|
||||||
|
|
||||||
**没有配置文件也能跑**——裁判端就是无配置启动的,直接显示两路。队伍端缺配置时进入
|
|
||||||
摄像头选择界面。
|
|
||||||
|
|
||||||
命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当另一
|
|
||||||
台用,不动文件。
|
|
||||||
|
|
||||||
TOML 的序列化是手写的(`config.rs`),没有引 `toml` crate。配置只有四个标量字段,
|
|
||||||
引一个解析器不值得;而手写解析换来的是零依赖,以及 `save` 与 `parse` 严格对称这一点
|
|
||||||
可以被一个测试覆盖。**忘记同步这两个方向的改动是这类代码最典型的 bug**,所以它们挨着
|
|
||||||
放,并且有 `parses_a_team_config` 盯着。
|
|
||||||
|
|
||||||
## 绑定流程
|
|
||||||
|
|
||||||
队伍端首次运行:
|
|
||||||
|
|
||||||
1. 接收线程开始收广播,板子每 2 秒发一次 Hello
|
|
||||||
2. 界面列出发现的所有板子(id + IP + 是否在线)
|
|
||||||
3. 点一个 → 写进配置文件
|
|
||||||
4. 此后配置里已有 `camera_id`,界面**不再出现选择页**
|
|
||||||
|
|
||||||
"选定不可更改"就落在这条路径上:没有 unbind 命令,`--camera-id` 是命令行覆盖而不是改
|
|
||||||
文件。
|
|
||||||
|
|
||||||
改绑的办法只有两种:手改配置文件,或 `rm ~/.config/cam-client/config.toml`。
|
|
||||||
|
|
||||||
### `Box::leak` 不是疏忽
|
|
||||||
|
|
||||||
绑定回调拿到的 `CallbackHandle` **必须活到窗口关闭**。它是 `Copy`,但 `Drop` 会注销回
|
|
||||||
调。
|
|
||||||
|
|
||||||
这里踩过一个坑:一开始用 `mem::forget`,编译器提示它对 `Copy` 类型无效——这个提示是
|
|
||||||
对的,`mem::forget` 传的是值,句柄被复制进闭包捕获后原值立刻 drop,回调在第一次点击
|
|
||||||
后就注销了,**第二次点击什么也不会发生**。`Box::leak` 让所有权真的离开作用域。
|
|
||||||
|
|
||||||
窗口里的 `Timer` 同理:被 drop 即停止,也泄漏掉。
|
|
||||||
|
|
||||||
## Slint 的三条硬约束
|
|
||||||
|
|
||||||
这三条都不是文档里写的,是撞出来的。
|
|
||||||
|
|
||||||
**`Weak<App>` 和 `slint::Image` 都不是 `Send`。**
|
|
||||||
|
|
||||||
`slint::Image` 尤其致命:JPEG 必须在别的线程解码(一张 VGA 的 JPEG 要几毫秒,放在收包
|
|
||||||
线程里会挤进数据报之间,造成链路上本不存在的丢帧),但解码出来的 RGBA 无法跨线程交给
|
|
||||||
UI。
|
|
||||||
|
|
||||||
解法是分两段:worker 线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在
|
|
||||||
事件循环里。`main.rs` 的 `decoded` 槽位就是这个用途,`take_decoded()` 是那段跨界。
|
|
||||||
|
|
||||||
**`ModelRc` 没有 `From<Vec<T>>`。**
|
|
||||||
|
|
||||||
只有 `From<Rc<M>>`、`From<&[T]>`、`From<[T; N]>`。所以要写:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
std::rc::Rc::new(slint::VecModel::from(v)).into()
|
|
||||||
```
|
|
||||||
|
|
||||||
而不是 `v.into()`。这个编译错误会在每个第一次用它的人身上重现一次。
|
|
||||||
|
|
||||||
**`.slint` 的语法限制。**
|
|
||||||
|
|
||||||
- `Rectangle` 这类基础元素**不能**挂 `callback`,要用 `TouchArea`
|
|
||||||
- `in` 属性不能从内部赋值
|
|
||||||
- 没有 `RowLayout`,用 `HorizontalLayout`
|
|
||||||
- 窗口不能同时给 `width`/`height` 和 `min-*`
|
|
||||||
|
|
||||||
## 线程
|
|
||||||
|
|
||||||
三个,加上 Slint 自己的:
|
|
||||||
|
|
||||||
| 线程 | 做什么 | 为什么单独 |
|
|
||||||
|---|---|---|
|
|
||||||
| `udp-reader` | 收包、解析、重组 | 不能让解码挤进数据报之间 |
|
|
||||||
| `jpeg-decode` | JPEG → RGBA | 几毫秒的活 |
|
|
||||||
| 事件循环 | 取 RGBA、造 `Image`、刷新界面 | `Image` 只能在拥有它的线程构造 |
|
|
||||||
| `slint::Timer` 回调(33ms) | 推进上面那条流水线 | 显示不需要 22fps 重绘 |
|
|
||||||
|
|
||||||
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
|
|
||||||
|
|
||||||
### 槽位一旦占定就不换人
|
|
||||||
|
|
||||||
裁判端有两个槽位,但**不要按 id 顺序重新对号入座**。
|
|
||||||
|
|
||||||
原来那版是:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
|
|
||||||
let wanted = ids.get(i).copied();
|
|
||||||
```
|
|
||||||
|
|
||||||
看起来稳定,其实不是:低 id 的板子一离线,`ids` 就缩短成 `[B]`,B 落到**左**槽 ——
|
|
||||||
画面左右互换。现场看非常像"两路串了",而实际上只是排序。
|
|
||||||
|
|
||||||
现在的规则:还**在线**的槽位保持不动;空槽或已掉线的槽位才从还没被占用的板子里按 id
|
|
||||||
顺序取第一个。
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let still_here = view.sender_id.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
|
||||||
if still_here { continue; }
|
|
||||||
```
|
|
||||||
|
|
||||||
换人时优先挑一个和当前不同的板子,否则会出现"板子短暂掉线又回来,槽位原地打转",
|
|
||||||
看起来像卡住。
|
|
||||||
|
|
||||||
`view.sender_id` 变了才重建 `View`(清零帧率窗口),否则每 33ms 重置一次统计,帧率
|
|
||||||
永远算不出来。
|
|
||||||
|
|
||||||
## 帧率:按计数除以墙钟
|
|
||||||
|
|
||||||
```rust
|
|
||||||
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
|
|
||||||
```
|
|
||||||
|
|
||||||
不是对帧间隔取平均。
|
|
||||||
|
|
||||||
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
|
|
||||||
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更糟**,
|
|
||||||
现场你会以为还有性能余量。
|
|
||||||
|
|
||||||
## 血量:接口就位,数据源没有
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
|
|
||||||
```
|
|
||||||
|
|
||||||
`ArmorSource` 是真的 trait,`protocol::ArmorHealth` 和 `ARMOR_PORT` 都已就位。装甲板接上
|
|
||||||
后只需换掉这一行,`render_health()` 会自动走 `Some(hps)` 分支,界面和其余代码都不用改。
|
|
||||||
|
|
||||||
现在没有数据源,所以**显示占位符而不是 0**。0 看起来像真值——现场看到两个 0 会以为
|
|
||||||
对方已经没血了,然后开始找裁判理论。
|
|
||||||
|
|
||||||
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
|
|
||||||
占位框消失,看起来像布局出错,而不是像"还没有数据"。
|
|
||||||
|
|
||||||
## 防作弊:release 队伍端只接收绑定的那一块
|
|
||||||
|
|
||||||
对抗赛里两队都用这套系统看自己小车的图传,所以要防止对手开第二个窗口连到另一队
|
|
||||||
的板子。
|
|
||||||
|
|
||||||
**关键是这件事必须做在接收端,而不是界面里。**
|
|
||||||
|
|
||||||
一开始我按"release 下不显示 id 和 IP"理解,那样等于没做:
|
|
||||||
|
|
||||||
- 广播的 Hello 里就有 MAC 派生的 `sender_id` 和 IP。对手 `tcpdump` 一下全都知道,
|
|
||||||
界面藏不藏毫无关系。
|
|
||||||
- 数据本来已经收下并解码了。`.slint` 只控制显示,隐藏一行文字不阻止任何事。
|
|
||||||
|
|
||||||
所以是 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、只统计
|
|
||||||
绑定的那一块。对手的板子即使把流推到了本机端口(IP 已泄露,`DEFAULT_PORT` 是公开
|
|
||||||
常量),数据也不进入任何管线。
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
|
||||||
config.camera_id.filter(|_| args.camera_id.is_none())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
四种情况不限制,各有理由:
|
|
||||||
|
|
||||||
| 情况 | 为什么不限制 |
|
|
||||||
|---|---|
|
|
||||||
| 裁判端 | 中立第三方,本来就要同时看两块板子 |
|
|
||||||
| debug 构建 | 开发时需要看到全场 |
|
|
||||||
| 队伍端未绑定 | 选择界面需要列出候选,否则无从选起 |
|
|
||||||
| `--camera-id` 覆盖了配置 | 显式指定了要看哪块,是有意的 |
|
|
||||||
|
|
||||||
### 遥测按编译模式门控
|
|
||||||
|
|
||||||
```rust
|
|
||||||
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
|
||||||
```
|
|
||||||
|
|
||||||
fps / 丢帧率 / 数据报数 / 帧间隔这些在队伍端 release 下**不产出**(空字符串,界面
|
|
||||||
也不显示),stdout 的诊断日志同样门控。
|
|
||||||
|
|
||||||
由编译模式决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开一次
|
|
||||||
就泄露了。
|
|
||||||
|
|
||||||
裁判端不受影响——双方遥测对判罚有用。
|
|
||||||
|
|
||||||
### `--list` 在 release 队伍端直接拒绝
|
|
||||||
|
|
||||||
它会打印场上所有板子的 id 和 IP,正是要防的那件事。与其列出来再叮嘱"别看",不如
|
|
||||||
不给:
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: "--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 选择界面保留
|
|
||||||
|
|
||||||
现场换机器时还要用它重新绑定。数据侧的闸门已经足够,界面列出候选不构成额外泄露
|
|
||||||
(对手需要的是实时画面,不是 id 字符串)。
|
|
||||||
|
|
||||||
### 这挡不住什么
|
|
||||||
|
|
||||||
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后,可以自己发一个
|
|
||||||
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。
|
|
||||||
|
|
||||||
真要挡住需要固件侧做配对(首次绑定发一个 token,固件只接受带 token 的订阅),
|
|
||||||
代价是固件重启就失配。这次没做,因为需求定的是"避免一个队伍通过开其他窗口连接到
|
|
||||||
其他队伍的图传",而这需要对手主动在自己机器上装这套客户端——纯 UI 或客户端侧的
|
|
||||||
措施挡不住它。**这一条如果要真正封住,得回固件加 token。**
|
|
||||||
|
|
||||||
## 缩放与自适应布局
|
|
||||||
|
|
||||||
### 滚轮缩放 + 拖动平移
|
|
||||||
|
|
||||||
Slint 的 `Image` 没有缩放因子属性,所以实现是:把 `Image` 的 `width`/`height` 按
|
|
||||||
`zoom` 放大后放进 `clip: true` 的容器,靠 `offset-x`/`offset-y` 平移。
|
|
||||||
|
|
||||||
- 滚轮改 `zoom`(每次 ×1.25,上限 8 倍)
|
|
||||||
- 拖动改 `offset`(只在 `zoom > 1` 时生效——此时才有可移动的空间)
|
|
||||||
- 双击复位
|
|
||||||
- 缩回 1.0 时自动归位
|
|
||||||
|
|
||||||
最后一条容易漏:缩回 1.0 后若保留偏移,下一次放大会从偏移位置开始,看起来像
|
|
||||||
"跳了一下"。
|
|
||||||
|
|
||||||
`image-fit` 必须跟着 `zoom` 切:
|
|
||||||
|
|
||||||
```
|
|
||||||
image-fit: root.zoom <= 1.0 ? contain : fill;
|
|
||||||
```
|
|
||||||
|
|
||||||
`contain` 在 `zoom = 1` 时保证完整可见且不变形,`fill` 才让画面跟着放大。**只用
|
|
||||||
`contain` 的话放大不会有任何视觉变化**——这个 bug 很容易写出来,因为界面上确实
|
|
||||||
变大了,只是画面没跟上。
|
|
||||||
|
|
||||||
裁判端两路的缩放**互相独立**:滚轮只作用于鼠标所在的那一路。
|
|
||||||
|
|
||||||
### 为什么两格必须等宽
|
|
||||||
|
|
||||||
裁判端曾经放大时左边占 55%、右边 45%;空的那一侧更极端时会被压成一条缝。
|
|
||||||
|
|
||||||
原因不是缺 `stretch`。`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按
|
|
||||||
stretch 分。左格写"已连接,等待画面…"(8 字),右格"未收到画面"(5 字),左格就
|
|
||||||
多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。
|
|
||||||
|
|
||||||
三处一起改:
|
|
||||||
|
|
||||||
1. **占位文字改成绝对定位**(给出 `x`/`y`),不参与父级尺寸计算。文字长短本不该
|
|
||||||
决定画面大小。
|
|
||||||
2. **两格统一 `preferred-width` + `horizontal-stretch: 1`**,需求变成相同,于是精确
|
|
||||||
平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
|
|
||||||
3. **缩放指示与复位按钮也绝对定位**。否则它们一出现就改变这一格的尺寸需求,把不
|
|
||||||
等宽重新引进来——而且是在用户放大之后才出现,最难排查。
|
|
||||||
|
|
||||||
`preferred-width` 只接受长度或字面量 `100%`,不接受 `50%`。
|
|
||||||
|
|
||||||
### 启动即最大化
|
|
||||||
|
|
||||||
```rust
|
|
||||||
ui.window().set_maximized(true);
|
|
||||||
ui.show()?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**必须在 `show()` 之前**——之后调用会被后端当成"用户点了最大化按钮",而不是初始
|
|
||||||
状态。
|
|
||||||
|
|
||||||
取 `set_maximized` 而不是 `set_fullscreen`:全屏会盖掉菜单栏和 Dock,切出去要按快
|
|
||||||
捷键。裁判终端偶尔要切到别的窗口核对数据。
|
|
||||||
|
|
||||||
## 内存:解码只留最新一张
|
|
||||||
|
|
||||||
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
|
|
||||||
|
|
||||||
一张 VGA 的 RGBA 是 1.2 MB。两路各留两张就是 5 MB,而延迟换清晰度在这里没有意义——
|
|
||||||
观战的人要的是最新的画面,不是最完整的那张已经过时的。
|
|
||||||
|
|
||||||
## 为什么客户端收全部板子的视频
|
|
||||||
|
|
||||||
队伍端也订阅了所有板子,然后在**界面层**过滤到自己那一个。
|
|
||||||
|
|
||||||
反过来的做法(板子按订阅者的 role 决定推给谁)会引出一个不对称:板子得维护"谁是队伍
|
|
||||||
一谁是队伍二"的认知,但那块知识本来就在客户端。裁判端同时要两路的事实说明这个
|
|
||||||
认知不能放在板子上。
|
|
||||||
|
|
||||||
代价是队伍端浪费一份几乎相同的图像数据——实测 264 kB/s,占链路 6–9%,可以接受。
|
|
||||||
|
|
||||||
## 换机器要改 `client/.cargo/config.toml`
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[build]
|
|
||||||
target = "aarch64-apple-darwin"
|
|
||||||
```
|
|
||||||
|
|
||||||
**这行必须改**,它是本文件唯一的存在理由。
|
|
||||||
|
|
||||||
client/ 嵌在固件的目录树里,cargo 会向上找到 `../.cargo/config.toml`,那里面
|
|
||||||
`target = "xtensa-esp32s3-espidf"` 加一个 ldproxy linker,只有在 ESP-IDF 交叉编译里才成立。
|
|
||||||
没有这个覆盖,桌面客户端会被交叉编译到 Xtensa,然后在依赖深处失败——`libc` 找不到
|
|
||||||
`MAP_FAILED` 之类的东西,因为它正在为一个没有 `mmap` 的嵌入式目标编译。
|
|
||||||
|
|
||||||
父配置的其它设置对非 ESP 目标都不适用,所以只需覆盖 target 一项。
|
|
||||||
|
|
||||||
## 线程安全清单
|
|
||||||
|
|
||||||
- 接收线程 → 其它:`Arc<Discovery>` / `Arc<LatestFrames>`,内部 `Mutex`
|
|
||||||
- 解码线程 → 事件循环:`Arc<Mutex<Vec<(u32, DecodedFrame)>>>`
|
|
||||||
- 事件循环 → 界面:直接调用(同一个线程)
|
|
||||||
- 绝不跨线程:`Weak<App>`、`slint::Image`、`ModelRc`
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
| crate | 为什么 |
|
|
||||||
|---|---|
|
|
||||||
| `slint` / `slint-build` | 界面。macOS 默认渲染器是 femtovg,不需要额外系统库 |
|
|
||||||
| `image` | 只为 `jpeg` feature,解码用 |
|
|
||||||
| `protocol`(path) | 共享线路格式。用 path 而不是复制,两边不可能悄悄分叉 |
|
|
||||||
|
|
||||||
`image` 关掉了 default features 只开 jpeg:这个客户端不读 PNG/TIFF/webp 以外的任何
|
|
||||||
东西,也不写任何文件。
|
|
||||||
|
|
||||||
这一侧**没有任何东西重新编码**。板子出的就是传感器里的 JPEG,客户端只解码。
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//! Compiles the Slint markup and writes the generated Rust.
|
|
||||||
//!
|
|
||||||
//! Without this, `slint::include_modules!` has nothing to expand to: it reads the
|
|
||||||
//! path to the generated file from an environment variable that only this build
|
|
||||||
//! script sets.
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let config = slint_build::CompilerConfiguration::new().with_style("fluent".to_owned());
|
|
||||||
slint_build::compile_with_config("ui/camera.slint", config).expect("compiling ui/camera.slint");
|
|
||||||
}
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
//! 绑定配置:这个客户端是什么身份,以及看哪块板子。
|
|
||||||
//!
|
|
||||||
//! 队伍的"选定后不可更改"落在 [`Config::camera_id`] 上:首次运行时从发现列表里选,
|
|
||||||
//! 结果写进配置文件,之后直接读,不再询问。
|
|
||||||
//!
|
|
||||||
//! 用 TOML 而不是 JSON,因为这个文件是**给人看的**——现场排查时要能一眼看懂改了什么。
|
|
||||||
//! 而它只有十来个字段,不值得为它引入整个序列化框架。
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
|
|
||||||
/// 客户端身份。
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Role {
|
|
||||||
/// 队伍终端:一块画面 + 自己那一边的血量。
|
|
||||||
Team,
|
|
||||||
/// 裁判终端:两块画面 + 双方血量。
|
|
||||||
Referee,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Role {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Role::Team => "team",
|
|
||||||
Role::Referee => "referee",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(s: &str) -> Option<Self> {
|
|
||||||
match s {
|
|
||||||
"team" => Some(Role::Team),
|
|
||||||
"referee" => Some(Role::Referee),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_protocol(self) -> protocol::SubscribeRole {
|
|
||||||
match self {
|
|
||||||
Role::Team => protocol::SubscribeRole::Team,
|
|
||||||
Role::Referee => protocol::SubscribeRole::Referee,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 磁盘上的配置。
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Config {
|
|
||||||
/// 客户端身份。
|
|
||||||
pub role: Role,
|
|
||||||
/// 队伍端:自己属于哪一边,0 = 队伍一,1 = 队伍二。
|
|
||||||
///
|
|
||||||
/// 决定血量栏显示哪一边。裁判端两个都要,所以忽略这个值。
|
|
||||||
pub team: u8,
|
|
||||||
/// 绑定的板子 id(MAC 派生,重启不变)。
|
|
||||||
///
|
|
||||||
/// `None` 表示还没选过,首次运行会在界面上询问。
|
|
||||||
pub camera_id: Option<u32>,
|
|
||||||
/// 板子的 IP。DHCP 可能变,所以这只是发现阶段的一个提示。
|
|
||||||
pub camera_addr: Option<std::net::Ipv4Addr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
/// 默认当裁判机:它不需要任何配置就能用,是现场最省事的起点。
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
role: Role::Referee,
|
|
||||||
team: 0,
|
|
||||||
camera_id: None,
|
|
||||||
camera_addr: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// 从磁盘读取。
|
|
||||||
///
|
|
||||||
/// 文件不存在时返回默认配置,这是正常路径——首次运行本来就没有配置。
|
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
|
||||||
if !path.exists() {
|
|
||||||
log::info!("no config at {}; starting with defaults", path.display());
|
|
||||||
return Ok(Self::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = std::fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("reading {}", path.display()))?;
|
|
||||||
|
|
||||||
Self::parse(&text).with_context(|| format!("parsing {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写入磁盘。
|
|
||||||
///
|
|
||||||
/// 手写序列化而不是引入 toml crate:这个文件只有十来个字段,且格式必须与
|
|
||||||
/// [`Config::parse`] 严格对称——两份代码比一个依赖更容易在 review 时看出不匹配。
|
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir)
|
|
||||||
.with_context(|| format!("creating {}", dir.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = String::new();
|
|
||||||
out.push_str("# cam-client 配置。\n");
|
|
||||||
out.push_str("#\n");
|
|
||||||
out.push_str("# role : team | referee\n");
|
|
||||||
out.push_str("# team = 一路画面 + 自己那一边的血量,需要绑定一块板子\n");
|
|
||||||
out.push_str("# referee = 两路画面 + 双方血量,不需要绑定\n");
|
|
||||||
out.push_str("# team : 0 = 队伍一, 1 = 队伍二(仅 team 端用)\n");
|
|
||||||
out.push_str("#\n");
|
|
||||||
out.push_str("# 绑定之后不要再改 camera_id:队伍端按它选画面,改了就换了视角。\n");
|
|
||||||
out.push_str("# camera_id 由板子的 MAC 派生,重启后不变,所以这个绑定是稳的。\n\n");
|
|
||||||
out.push_str(&format!("role = \"{}\"\n", self.role.as_str()));
|
|
||||||
out.push_str(&format!("team = {}\n", self.team));
|
|
||||||
|
|
||||||
if let Some(id) = self.camera_id {
|
|
||||||
out.push_str(&format!("camera_id = \"0x{id:08X}\"\n"));
|
|
||||||
}
|
|
||||||
if let Some(addr) = self.camera_addr {
|
|
||||||
out.push_str(&format!("camera_addr = \"{addr}\"\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?;
|
|
||||||
|
|
||||||
log::info!("saved config to {}", path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 TOML 的一个子集。
|
|
||||||
///
|
|
||||||
/// 只支持 `key = value`、字符串、`整数`、十六进制字符串和注释——恰好是这个文件
|
|
||||||
/// 需要的全部,遇到不认识的形式会明确报错而不是静默忽略,因为静默忽略会让一个
|
|
||||||
/// 打错的配置看起来像是生效了。
|
|
||||||
fn parse(text: &str) -> Result<Self> {
|
|
||||||
let mut config = Config::default();
|
|
||||||
let mut role_seen = false;
|
|
||||||
|
|
||||||
for (lineno, raw) in text.lines().enumerate() {
|
|
||||||
let line = strip_comment(raw).trim();
|
|
||||||
if line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((key, value)) = line.split_once('=') else {
|
|
||||||
anyhow::bail!("line {}: expected `key = value`, got {line:?}", lineno + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = key.trim();
|
|
||||||
let value = value.trim().trim_matches('"');
|
|
||||||
|
|
||||||
match key {
|
|
||||||
"role" => {
|
|
||||||
config.role = Role::parse(value).ok_or_else(|| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"line {}: role must be `team` or `referee`, got {value:?}",
|
|
||||||
lineno + 1
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
role_seen = true;
|
|
||||||
}
|
|
||||||
"team" => {
|
|
||||||
config.team = value
|
|
||||||
.parse::<u8>()
|
|
||||||
.with_context(|| format!("line {}: team must be 0 or 1", lineno + 1))?;
|
|
||||||
}
|
|
||||||
"camera_id" => {
|
|
||||||
config.camera_id = Some(parse_id(value).with_context(|| {
|
|
||||||
format!("line {}: camera_id must look like 0x1A2B3C4D", lineno + 1)
|
|
||||||
})?);
|
|
||||||
}
|
|
||||||
"camera_addr" => {
|
|
||||||
config.camera_addr = Some(value.parse().with_context(|| {
|
|
||||||
format!("line {}: camera_addr must be an IPv4 address", lineno + 1)
|
|
||||||
})?);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
anyhow::bail!(
|
|
||||||
"line {}: unknown key {key:?}; refusing to guess",
|
|
||||||
lineno + 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !role_seen {
|
|
||||||
// 缺 role 时按裁判端处理:它是唯一无需配置就能工作的模式。
|
|
||||||
config.role = Role::Referee;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 去掉 `#` 之后的内容。
|
|
||||||
fn strip_comment(line: &str) -> &str {
|
|
||||||
match line.find('#') {
|
|
||||||
Some(at) => &line[..at],
|
|
||||||
None => line,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 `0x1A2B3C4D` 或十进制形式的板子 id。
|
|
||||||
fn parse_id(text: &str) -> Result<u32> {
|
|
||||||
let text = text.trim();
|
|
||||||
|
|
||||||
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
|
||||||
u32::from_str_radix(hex, 16).with_context(|| format!("{text:?} is not hex"))
|
|
||||||
} else {
|
|
||||||
text.parse::<u32>()
|
|
||||||
.with_context(|| format!("{text:?} is not a number"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 默认配置文件位置。
|
|
||||||
///
|
|
||||||
/// 优先 `$XDG_CONFIG_HOME` 或 `~/.config`,与 Linux 上其它工具一致;macOS 上用同一个
|
|
||||||
/// 路径而不是 `~/Library/Application Support`,是为了让两台机器(裁判机和队伍机可能
|
|
||||||
/// 是不同系统)的配置结构一致。
|
|
||||||
pub fn default_config_path() -> PathBuf {
|
|
||||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.filter(|p| !p.as_os_str().is_empty())
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| {
|
|
||||||
PathBuf::from(".")
|
|
||||||
});
|
|
||||||
home.join(".config")
|
|
||||||
});
|
|
||||||
|
|
||||||
base.join("cam-client").join("config.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_a_team_config() {
|
|
||||||
let config = Config::parse(
|
|
||||||
r#"
|
|
||||||
# 队伍一的机器
|
|
||||||
role = "team"
|
|
||||||
team = 1
|
|
||||||
camera_id = "0x3D4D8A9A"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.expect("parses");
|
|
||||||
|
|
||||||
assert_eq!(config.role, Role::Team);
|
|
||||||
assert_eq!(config.team, 1);
|
|
||||||
assert_eq!(config.camera_id, Some(0x3D4D_8A9A));
|
|
||||||
assert!(config.is_bound());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn referee_needs_no_binding() {
|
|
||||||
let config = Config::parse("role = \"referee\"").expect("parses");
|
|
||||||
|
|
||||||
assert_eq!(config.role, Role::Referee);
|
|
||||||
assert!(config.is_bound(), "the referee watches everything");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_team_without_a_camera_is_unbound() {
|
|
||||||
let config = Config::parse("role = \"team\"\nteam = 0").expect("parses");
|
|
||||||
|
|
||||||
assert_eq!(config.role, Role::Team);
|
|
||||||
assert!(!config.is_bound(), "must ask which camera to watch");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn roundtrips_through_save_and_load() {
|
|
||||||
let mut dir = std::env::temp_dir();
|
|
||||||
dir.push(format!("cam-client-test-{}", std::process::id()));
|
|
||||||
let path = dir.join("nested").join("config.toml");
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
role: Role::Team,
|
|
||||||
team: 1,
|
|
||||||
camera_id: Some(0x1234_5678),
|
|
||||||
camera_addr: Some("192.168.1.50".parse().unwrap()),
|
|
||||||
};
|
|
||||||
|
|
||||||
config.save(&path).expect("saves");
|
|
||||||
let loaded = Config::load(&path).expect("loads");
|
|
||||||
|
|
||||||
assert_eq!(loaded.role, config.role);
|
|
||||||
assert_eq!(loaded.team, config.team);
|
|
||||||
assert_eq!(loaded.camera_id, config.camera_id);
|
|
||||||
assert_eq!(loaded.camera_addr, config.camera_addr);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_missing_file_is_the_default_not_an_error() {
|
|
||||||
let config = Config::load(Path::new("/nonexistent/cam-client/config.toml"))
|
|
||||||
.expect("a missing config is a normal first run");
|
|
||||||
|
|
||||||
assert_eq!(config.role, Role::Referee);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn refuses_garbage_instead_of_ignoring_it() {
|
|
||||||
// 静默忽略一个打错的 key,会让配置看起来生效了。
|
|
||||||
assert!(Config::parse("rol = \"team\"").is_err(), "unknown key");
|
|
||||||
assert!(Config::parse("role = \"camera\"").is_err(), "bad role value");
|
|
||||||
assert!(Config::parse("role = \"team\"\nteam = nine").is_err(), "bad team");
|
|
||||||
assert!(Config::parse("just some text").is_err(), "not key = value");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,757 +0,0 @@
|
|||||||
//! ESP32 图传查看器。
|
|
||||||
//!
|
|
||||||
//! 两种身份,由 [`Role`] 决定:
|
|
||||||
//!
|
|
||||||
//! - **队伍端** —— 一块画面 + 自己那一边的血量。需要绑定一块板子(选定后不可更改)。
|
|
||||||
//! - **裁判端** —— 两块画面左右并排 + 双方血量。不需要绑定。
|
|
||||||
//!
|
|
||||||
//! 用法:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! cam-client 默认裁判端
|
|
||||||
//! cam-client --role team --team 1 队伍一
|
|
||||||
//! cam-client --camera-id 0x3D4D8A9A 绑定到某块板子
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! 绑定与身份存在配置文件里(默认 `~/.config/cam-client/config.toml`)。首次运行
|
|
||||||
//! 队伍端会列出发现的摄像头,选定后写入,之后不再询问。
|
|
||||||
//!
|
|
||||||
//! # 为什么血量是占位符
|
|
||||||
//!
|
|
||||||
//! 装甲板接口已经预留([`receiver::ArmorSource`] 与 `protocol::ArmorHealth`),但
|
|
||||||
//! 现在没有实现也没有数据源。界面显示占位符而不是 0——0 看起来像真值,现场看到会
|
|
||||||
//! 以为对方已经没血了。
|
|
||||||
|
|
||||||
mod config;
|
|
||||||
mod receiver;
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
use std::net::Ipv4Addr;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use config::{Config, Role};
|
|
||||||
use receiver::{
|
|
||||||
decode_frame, ArmorSource, CameraInfo, DecodedFrame, NoArmorYet, Receiver,
|
|
||||||
SourceStats, ARMOR_BOARDS,
|
|
||||||
};
|
|
||||||
|
|
||||||
slint::include_modules!();
|
|
||||||
|
|
||||||
const USAGE: &str = "\
|
|
||||||
用法: cam-client [选项]
|
|
||||||
|
|
||||||
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
|
|
||||||
--team <0|1> 自己属于哪一边,仅 team 端用
|
|
||||||
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
|
|
||||||
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
|
|
||||||
--config <路径> 配置文件位置
|
|
||||||
--list 列出发现的摄像头后退出
|
|
||||||
-h, --help 显示这段帮助
|
|
||||||
";
|
|
||||||
|
|
||||||
/// 事件循环轮询新数据的间隔。
|
|
||||||
///
|
|
||||||
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
|
|
||||||
const UI_POLL: Duration = Duration::from_millis(33);
|
|
||||||
|
|
||||||
/// 队伍端在 release 下是否显示技术诊断信息。
|
|
||||||
///
|
|
||||||
/// 对抗赛里两队都用这套系统看自己小车的图传,所以 release 给现场用、debug 给开发
|
|
||||||
/// 用。这个开关由**编译模式**决定而不是命令行参数——命令行开关意味着二进制里带着
|
|
||||||
/// 那套东西,忘开一次就泄露了。
|
|
||||||
///
|
|
||||||
/// 裁判端不受它影响:裁判是中立方,双方遥测对判罚有用。
|
|
||||||
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
|
||||||
|
|
||||||
/// 帧率统计窗口。
|
|
||||||
///
|
|
||||||
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
|
|
||||||
/// 量的是**显示**的刷新快慢而不是**相机**的出帧快慢——那会把上限 22fps 的传感器
|
|
||||||
/// 报成 36fps。一个笃定的错误数字比没有数字更糟。
|
|
||||||
const FPS_WINDOW: Duration = Duration::from_secs(2);
|
|
||||||
|
|
||||||
/// 某一路的显示状态。
|
|
||||||
struct View {
|
|
||||||
/// 显示哪块板子(`None` = 还没有画面)。
|
|
||||||
sender_id: Option<u32>,
|
|
||||||
fps: f32,
|
|
||||||
window_started: Instant,
|
|
||||||
window_frames: u64,
|
|
||||||
reported: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl View {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
sender_id: None,
|
|
||||||
fps: 0.0,
|
|
||||||
window_started: Instant::now(),
|
|
||||||
window_frames: 0,
|
|
||||||
reported: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 累计窗口内的帧数,算出帧率。
|
|
||||||
fn update_fps(&mut self, stats: &SourceStats) {
|
|
||||||
let window = self.window_started.elapsed();
|
|
||||||
if window < FPS_WINDOW {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
|
|
||||||
self.window_frames = stats.frames_shown;
|
|
||||||
self.window_started = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解码后的画面缓存,供事件循环取用。
|
|
||||||
///
|
|
||||||
/// 解码线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在事件循环,
|
|
||||||
/// 因为 `slint::Image` 不是 `Send`。
|
|
||||||
type DecodedSlots = Arc<Mutex<Vec<(u32, DecodedFrame)>>>;
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
||||||
|
|
||||||
let args = Args::parse(std::env::args().skip(1))?;
|
|
||||||
|
|
||||||
if args.help {
|
|
||||||
print!("{USAGE}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
|
|
||||||
|
|
||||||
// 命令行覆盖配置文件,这样现场可以临时当另一台机器用而不用改文件。
|
|
||||||
let mut config = Config::load(&config_path)?;
|
|
||||||
|
|
||||||
if let Some(role) = args.role {
|
|
||||||
config.role = role;
|
|
||||||
}
|
|
||||||
if let Some(team) = args.team {
|
|
||||||
config.team = team;
|
|
||||||
}
|
|
||||||
if let Some(id) = args.camera_id {
|
|
||||||
config.camera_id = Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"role={} team={} camera_id={:?} config={}",
|
|
||||||
config.role.as_str(),
|
|
||||||
config.team,
|
|
||||||
config.camera_id,
|
|
||||||
config_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 队伍端在 release 下已经绑定时,把接收端限制到那一块。
|
|
||||||
//
|
|
||||||
// 这是本次改动里唯一有实际防护的一处:`.slint` 里不显示 id 只是藏起文字,而
|
|
||||||
// 广播的 Hello 早就把 MAC 派生的 id 和 IP 送出去了(对手 tcpdump 一下就有)。
|
|
||||||
// 在**接收端**拒绝才是真的:不重组、不解码、不进 discovery。
|
|
||||||
//
|
|
||||||
// 四种情况不限制:
|
|
||||||
// - 裁判端(中立第三方,本来就要两块板子)
|
|
||||||
// - debug 构建(开发时要能看到全场)
|
|
||||||
// - 队伍端还没绑定(选择界面需要列出候选)
|
|
||||||
// - 命令行临时用别的板子跑(--camera-id 覆盖配置)
|
|
||||||
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
|
||||||
config.camera_id.filter(|_| args.camera_id.is_none())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let rx = Arc::new(Receiver::start(
|
|
||||||
args.camera,
|
|
||||||
config.role.to_protocol(),
|
|
||||||
restrict,
|
|
||||||
)?);
|
|
||||||
let discovery = rx.discovery();
|
|
||||||
let frames = rx.frames();
|
|
||||||
|
|
||||||
if args.list {
|
|
||||||
// 队伍端 release 下 `--list` 会列出场上所有板子(包括对手的 id 和 IP),
|
|
||||||
// 正是要防的那件事。所以这里直接拒绝,而不是列出来再让人别看。
|
|
||||||
if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
|
||||||
return Err(
|
|
||||||
"--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。\
|
|
||||||
release 的队伍端请直接用已绑定的配置运行。"
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 等几个广播周期,让列表有内容。
|
|
||||||
let socket = bind_socket()?;
|
|
||||||
let for_list = Arc::clone(&rx);
|
|
||||||
let handle = std::thread::spawn(move || for_list.run(socket));
|
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_secs(5));
|
|
||||||
|
|
||||||
let cameras = discovery.online_cameras();
|
|
||||||
if cameras.is_empty() {
|
|
||||||
println!("没有发现摄像头。板子每 2 秒广播一次,再等等或检查网络。");
|
|
||||||
} else {
|
|
||||||
println!("发现 {} 块板子:", cameras.len());
|
|
||||||
for c in cameras {
|
|
||||||
println!(" 0x{:08X} {} (id 与 MAC 派生,重启不变)", c.id, c.addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rx.shutdown();
|
|
||||||
let _ = handle.join();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 血量目前没有数据源。接口已经就位,装甲板接上后换掉这一行即可。
|
|
||||||
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
|
|
||||||
|
|
||||||
let decoded: DecodedSlots = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
|
|
||||||
// 接收线程。
|
|
||||||
{
|
|
||||||
let rx = Arc::clone(&rx);
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("udp-reader".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let Ok(socket) = bind_socket() else {
|
|
||||||
log::error!("could not bind the socket for the receive loop");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
rx.run(socket);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解码线程:一张 VGA 的 JPEG 要几毫秒,放在收包线程里会挤进数据报之间,造成
|
|
||||||
// 链路上本不存在的丢帧。
|
|
||||||
{
|
|
||||||
let frames = Arc::clone(&frames);
|
|
||||||
let decoded = Arc::clone(&decoded);
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("jpeg-decode".into())
|
|
||||||
.spawn(move || {
|
|
||||||
loop {
|
|
||||||
if frames.pending_senders().is_empty() {
|
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for sender_id in frames.pending_senders() {
|
|
||||||
let Some(frame) = frames.take(sender_id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
match decode_frame(&frame.data) {
|
|
||||||
Some(pixels) => {
|
|
||||||
let mut slot = decoded.lock().expect("decoded mutex poisoned");
|
|
||||||
|
|
||||||
// 同一路只保留最新一张,避免解码已经过时的画面。
|
|
||||||
slot.retain(|(id, _)| *id != sender_id);
|
|
||||||
slot.push((sender_id, pixels));
|
|
||||||
}
|
|
||||||
None => log::warn!(
|
|
||||||
"frame {} from 0x{:08X} is not a decodable JPEG ({} bytes)",
|
|
||||||
frame.frame_id,
|
|
||||||
frame.sender_id,
|
|
||||||
frame.data.len()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer = slint::Timer::default();
|
|
||||||
|
|
||||||
// 每个身份一个闭包,而不是一个闭包里再 match 身份。
|
|
||||||
//
|
|
||||||
// 两种身份是两个不同的组件类型(TeamApp / RefereeApp),没有共同的超类可以
|
|
||||||
// 在一个闭包里统一调用 set_frame/set_frame_a。写成两个分支后每边都拿到具体
|
|
||||||
// 类型,属性赋值直接调用,不再需要 match 身份——那种 match 会让编译器在每个
|
|
||||||
// 赋值点都检查一遍身份,而它本来就是编译期就定死的。
|
|
||||||
match config.role {
|
|
||||||
Role::Team => {
|
|
||||||
let ui = TeamApp::new()?;
|
|
||||||
|
|
||||||
// 启动即最大化。必须在 show() 之前:之后调用会被后端当成"用户点了最大化
|
|
||||||
// 按钮"。取 set_maximized 而不是 set_fullscreen,因为全屏会盖掉菜单栏和
|
|
||||||
// Dock,裁判终端要切去别的窗口核对数据。
|
|
||||||
ui.window().set_maximized(true);
|
|
||||||
ui.show()?;
|
|
||||||
|
|
||||||
// 接收端的限制在构造 Receiver 时已经按配置设好了(见上面 `restrict`),
|
|
||||||
// 这里不重复设:只有选择界面上新选定的那一次需要改它。
|
|
||||||
let rx_for_pick = Arc::clone(&rx);
|
|
||||||
|
|
||||||
let weak = ui.as_weak();
|
|
||||||
let discovery = Arc::clone(&discovery);
|
|
||||||
let decoded = Arc::clone(&decoded);
|
|
||||||
let armor = Arc::clone(&armor);
|
|
||||||
let config_path = config_path.clone();
|
|
||||||
let team = config.team;
|
|
||||||
let bound = config.camera_id;
|
|
||||||
|
|
||||||
let mut view = View::new();
|
|
||||||
|
|
||||||
// 绑定回调与刷新定时器都要读这份列表,所以用锁共享,而不是让其中一个
|
|
||||||
// 把 `choices` 移走。
|
|
||||||
let choices: Arc<Mutex<Vec<CameraChoice>>> = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
|
|
||||||
// 绑定完成时写配置。"选定不可更改"就落在这里:此后配置里已有
|
|
||||||
// camera_id,界面不再出现选择页。
|
|
||||||
let choices_for_pick = Arc::clone(&choices);
|
|
||||||
|
|
||||||
let pick_handle = ui.on_pick(move |index| {
|
|
||||||
let Ok(index) = usize::try_from(index) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let picked = choices_for_pick
|
|
||||||
.lock()
|
|
||||||
.expect("choices mutex poisoned")
|
|
||||||
.get(index)
|
|
||||||
.map(|c| c.id_text.to_string());
|
|
||||||
|
|
||||||
let Some(id_text) = picked else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(id) = parse_id(&id_text).ok() else {
|
|
||||||
log::error!("could not read back the selected id {id_text:?}");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let saved = Config {
|
|
||||||
role: Role::Team,
|
|
||||||
team,
|
|
||||||
camera_id: Some(id),
|
|
||||||
camera_addr: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = saved.save(&config_path) {
|
|
||||||
log::error!("could not save the binding: {err}");
|
|
||||||
log::error!("画面仍会显示,但下次启动要重新选择");
|
|
||||||
} else {
|
|
||||||
log::info!("bound to camera 0x{id:08X}; saved in the config");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选定之后把接收端也限制到这一块。此后场上别人的板子即使把流推到
|
|
||||||
// 本机端口,也不会被重组或解码。
|
|
||||||
//
|
|
||||||
// 解除限制(传 None)要等到重启:配置里有了 camera_id 就会走绑定
|
|
||||||
// 分支,限制随之恢复,两者不会不一致。
|
|
||||||
rx_for_pick.restrict_to(Some(id));
|
|
||||||
});
|
|
||||||
|
|
||||||
let choices_for_poll = Arc::clone(&choices);
|
|
||||||
|
|
||||||
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
|
|
||||||
let Some(ui) = weak.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cameras = discovery.online_cameras();
|
|
||||||
|
|
||||||
// 未绑定:只显示选择界面。
|
|
||||||
let Some(bound) = bound else {
|
|
||||||
let updated: Vec<CameraChoice> = cameras
|
|
||||||
.iter()
|
|
||||||
.map(|c| CameraChoice {
|
|
||||||
id_text: format!("0x{:08X}", c.id).into(),
|
|
||||||
addr_text: format!("{} ({:?})", c.addr, c.silence()).into(),
|
|
||||||
online: c.is_online(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
*choices_for_poll.lock().expect("choices mutex poisoned") = updated.clone();
|
|
||||||
|
|
||||||
ui.set_choices(std::rc::Rc::new(slint::VecModel::from(updated)).into());
|
|
||||||
ui.set_choosing(true);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.set_choosing(false);
|
|
||||||
|
|
||||||
if view.sender_id != Some(bound) {
|
|
||||||
view = View::new();
|
|
||||||
view.sender_id = Some(bound);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(image) = take_decoded(&decoded, bound) {
|
|
||||||
ui.set_frame(image);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats = discovery.stats(bound);
|
|
||||||
view.update_fps(&stats);
|
|
||||||
|
|
||||||
// 遥测日志同样按编译模式门控:release 的队伍端不往 stdout 打帧率/丢帧这些
|
|
||||||
// 能被对手读到的数字。
|
|
||||||
if TEAM_DIAGNOSTICS && !view.reported && stats.frames_shown > 0 {
|
|
||||||
log_diagnostics(&stats, bound);
|
|
||||||
view.reported = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.set_stats(render_camera_stats(
|
|
||||||
&cameras,
|
|
||||||
bound,
|
|
||||||
&stats,
|
|
||||||
view.fps,
|
|
||||||
TEAM_DIAGNOSTICS,
|
|
||||||
));
|
|
||||||
ui.set_health(render_health(team, armor.as_ref()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 绑定回调必须活到窗口关闭。CallbackHandle 是 Copy,但 Drop 会注销
|
|
||||||
// 回调,所以必须真的把它泄漏掉——之前用 `mem::forget` 编译器提示它对
|
|
||||||
// Copy 类型无效,那确实无效:第一次点击后回调就被注销了,第二次点击
|
|
||||||
// 什么也不会发生。
|
|
||||||
std::boxed::Box::leak(Box::new(pick_handle));
|
|
||||||
}
|
|
||||||
|
|
||||||
Role::Referee => {
|
|
||||||
let ui = RefereeApp::new()?;
|
|
||||||
ui.window().set_maximized(true);
|
|
||||||
ui.show()?;
|
|
||||||
|
|
||||||
let weak = ui.as_weak();
|
|
||||||
let discovery = Arc::clone(&discovery);
|
|
||||||
let decoded = Arc::clone(&decoded);
|
|
||||||
let armor = Arc::clone(&armor);
|
|
||||||
|
|
||||||
let mut views: Vec<View> = (0..2).map(|_| View::new()).collect();
|
|
||||||
|
|
||||||
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
|
|
||||||
let Some(ui) = weak.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cameras = discovery.online_cameras();
|
|
||||||
|
|
||||||
// 槽位对应**队伍**,不是"第 N 块发现的板子"。
|
|
||||||
//
|
|
||||||
// 两条规则:
|
|
||||||
//
|
|
||||||
// 1. 一次只填一个空槽,按 id 顺序。之前两块都空时每轮都从
|
|
||||||
// `online_cameras()` 取前两个,于是只有一块板在场时两块槽位显示
|
|
||||||
// 同一块画面——右半边不再是"队伍二",而是队伍一的复制。裁判看到
|
|
||||||
// 两边一模一样,会以为两队的图传串了。
|
|
||||||
//
|
|
||||||
// 2. 已填的槽位不换人。原先是 `ids.take(2)` 后按下标对号入座,低 id
|
|
||||||
// 的板子一离线,后面那块就跳到前面,画面左右互换,看起来同样
|
|
||||||
// 像串线。掉线的槽位保留它的 id 并显示"离线",等它回来。
|
|
||||||
//
|
|
||||||
// 所以每轮最多消费一个候选 id。
|
|
||||||
let mut next_candidate = cameras.iter().map(|c| c.id);
|
|
||||||
|
|
||||||
for view in views.iter_mut() {
|
|
||||||
let still_here = view
|
|
||||||
.sender_id
|
|
||||||
.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
|
||||||
|
|
||||||
if still_here {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 槽位已占且板子还在场上 → 保留(上面已 continue)。到这里说明
|
|
||||||
// 槽位是空的,或板子掉线了。
|
|
||||||
let candidate = next_candidate
|
|
||||||
.next()
|
|
||||||
// 别把刚掉线的这块又立刻选回同一个槽位:它短暂离线又回来时,
|
|
||||||
// 画面会原地卡一下才恢复。
|
|
||||||
.filter(|id| Some(*id) != view.sender_id);
|
|
||||||
|
|
||||||
match candidate {
|
|
||||||
Some(id) => {
|
|
||||||
*view = View::new();
|
|
||||||
view.sender_id = Some(id);
|
|
||||||
}
|
|
||||||
// 没有可分配的了。掉线的槽位**保留 id**(显示"离线"而不是
|
|
||||||
// 变回空槽),这样它回来时还在原位。
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, view) in views.iter_mut().enumerate() {
|
|
||||||
let Some(sender_id) = view.sender_id else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(image) = take_decoded(&decoded, sender_id) {
|
|
||||||
if i == 0 {
|
|
||||||
ui.set_frame_a(image);
|
|
||||||
} else {
|
|
||||||
ui.set_frame_b(image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats = discovery.stats(sender_id);
|
|
||||||
view.update_fps(&stats);
|
|
||||||
|
|
||||||
if !view.reported && stats.frames_shown > 0 {
|
|
||||||
log_diagnostics(&stats, sender_id);
|
|
||||||
view.reported = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 裁判端始终显示诊断信息:它是中立方,双方遥测对判罚有用。
|
|
||||||
let view_stats =
|
|
||||||
render_camera_stats(&cameras, sender_id, &stats, view.fps, true);
|
|
||||||
|
|
||||||
if i == 0 {
|
|
||||||
ui.set_stats_a(view_stats);
|
|
||||||
} else {
|
|
||||||
ui.set_stats_b(view_stats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.set_health_a(render_health(0, armor.as_ref()));
|
|
||||||
ui.set_health_b(render_health(1, armor.as_ref()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timer 一旦被 drop 就停止,所以要活到进程结束。
|
|
||||||
let _timer = Box::leak(Box::new(timer));
|
|
||||||
|
|
||||||
slint::run_event_loop()?;
|
|
||||||
|
|
||||||
rx.shutdown();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从解码缓存里取出某一路的最新画面。
|
|
||||||
///
|
|
||||||
/// 解码线程只处理纯数据,`slint::Image` 的构造留在这里——`slint::Image` 不是
|
|
||||||
/// `Send`,跨不了线程。
|
|
||||||
fn take_decoded(
|
|
||||||
decoded: &DecodedSlots,
|
|
||||||
sender_id: u32,
|
|
||||||
) -> Option<slint::Image> {
|
|
||||||
let mut slot = decoded.lock().expect("decoded mutex poisoned");
|
|
||||||
|
|
||||||
let pos = slot.iter().position(|(id, _)| *id == sender_id)?;
|
|
||||||
let (_, pixels) = slot.remove(pos);
|
|
||||||
|
|
||||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
|
||||||
&pixels.rgba,
|
|
||||||
pixels.width,
|
|
||||||
pixels.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(slint::Image::from_rgba8(buffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 命令行参数。
|
|
||||||
struct Args {
|
|
||||||
role: Option<Role>,
|
|
||||||
team: Option<u8>,
|
|
||||||
camera_id: Option<u32>,
|
|
||||||
camera: Ipv4Addr,
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
list: bool,
|
|
||||||
help: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Args {
|
|
||||||
fn parse(argv: impl Iterator<Item = String>) -> Result<Self, Box<dyn Error>> {
|
|
||||||
let mut args = Args {
|
|
||||||
role: None,
|
|
||||||
team: None,
|
|
||||||
camera_id: None,
|
|
||||||
camera: protocol::BROADCAST_ADDR,
|
|
||||||
config: None,
|
|
||||||
list: false,
|
|
||||||
help: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut it = argv.peekable();
|
|
||||||
|
|
||||||
while let Some(arg) = it.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"-h" | "--help" => args.help = true,
|
|
||||||
"--list" => args.list = true,
|
|
||||||
"--role" => {
|
|
||||||
let v = it.next().ok_or("--role needs a value")?;
|
|
||||||
args.role =
|
|
||||||
Some(Role::parse(&v).ok_or("role must be `team` or `referee`")?);
|
|
||||||
}
|
|
||||||
"--team" => {
|
|
||||||
let v = it.next().ok_or("--team needs a value")?;
|
|
||||||
args.team = Some(v.parse().map_err(|_| "--team must be 0 or 1")?);
|
|
||||||
}
|
|
||||||
"--camera-id" => {
|
|
||||||
let v = it.next().ok_or("--camera-id needs a value")?;
|
|
||||||
args.camera_id = Some(parse_id(&v)?);
|
|
||||||
}
|
|
||||||
"--camera" => {
|
|
||||||
let v = it.next().ok_or("--camera needs a value")?;
|
|
||||||
args.camera = v
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "--camera must be an IPv4 address")?;
|
|
||||||
}
|
|
||||||
"--config" => {
|
|
||||||
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?));
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
return Err(format!("unknown argument {other:?}\n\n{USAGE}").into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 绑定 UDP 接收 socket。
|
|
||||||
///
|
|
||||||
/// 固定端口(而非临时端口)是必须的:板子从订阅的**源端口**学到推流目标,而这个
|
|
||||||
/// socket 同时承担收包。
|
|
||||||
fn bind_socket() -> std::io::Result<std::net::UdpSocket> {
|
|
||||||
let socket = std::net::UdpSocket::bind(std::net::SocketAddrV4::new(
|
|
||||||
Ipv4Addr::UNSPECIFIED,
|
|
||||||
protocol::DEFAULT_PORT,
|
|
||||||
))?;
|
|
||||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
|
||||||
|
|
||||||
// 广播是发现的前提:板子靠它宣告存在,客户端靠它发起订阅。
|
|
||||||
let _ = socket.set_broadcast(true);
|
|
||||||
Ok(socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 `0x1A2B3C4D` 或十进制形式的板子 id。
|
|
||||||
fn parse_id(text: &str) -> Result<u32, Box<dyn Error>> {
|
|
||||||
let text = text.trim();
|
|
||||||
|
|
||||||
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
|
||||||
Ok(u32::from_str_radix(hex, 16)?)
|
|
||||||
} else {
|
|
||||||
Ok(text.parse()?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一次性诊断,在某一路第一次出画面时记录。
|
|
||||||
///
|
|
||||||
/// 不再单列"无法解码"计数:那是链路正常但内容坏了的情形,会在解码线程里逐帧告警,
|
|
||||||
/// 汇总在这里反而会把它和普通的丢帧混在一起。
|
|
||||||
fn log_diagnostics(stats: &SourceStats, sender_id: u32) {
|
|
||||||
log::info!(
|
|
||||||
"camera 0x{sender_id:08X}: {} frames shown | {} dropped ({:.1}%) | \
|
|
||||||
{} datagrams | {}x{} {} bytes",
|
|
||||||
stats.frames_shown,
|
|
||||||
stats.frames_dropped,
|
|
||||||
stats.loss_percent(),
|
|
||||||
stats.datagrams,
|
|
||||||
stats.width,
|
|
||||||
stats.height,
|
|
||||||
stats.last_frame_len,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把某一路的统计渲染成界面结构。
|
|
||||||
///
|
|
||||||
/// `diagnostics` 为 false 时不产出技术字段——它们留空字符串,界面也不显示。留空而
|
|
||||||
/// 不是填 0 或假值:0 会被读成"丢帧 0"这种看起来像真值的东西。
|
|
||||||
fn render_camera_stats(
|
|
||||||
cameras: &[CameraInfo],
|
|
||||||
sender_id: u32,
|
|
||||||
stats: &SourceStats,
|
|
||||||
fps: f32,
|
|
||||||
diagnostics: bool,
|
|
||||||
) -> CameraStats {
|
|
||||||
let online = cameras
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.id == sender_id)
|
|
||||||
.map(CameraInfo::is_online)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
|
||||||
// 比让人以为板子挂了诚实。
|
|
||||||
//
|
|
||||||
// 有画面时显示 id —— 这对调试有用。裁判端和队伍端 debug 都显示;队伍端 release
|
|
||||||
// 下这一路只有自己绑定的板子,显示它不构成泄露(界面上根本没有别人的板子可选,
|
|
||||||
// 见 Receiver::restrict_to)。
|
|
||||||
let status = if stats.frames_shown == 0 {
|
|
||||||
if online {
|
|
||||||
"已连接,等待画面…".to_string()
|
|
||||||
} else {
|
|
||||||
"离线".to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("0x{:08X}", sender_id)
|
|
||||||
};
|
|
||||||
|
|
||||||
CameraStats {
|
|
||||||
online,
|
|
||||||
status_text: status.into(),
|
|
||||||
// 同理:没出过帧时 fps 是 0.0,读起来像"板子卡死了",而真相是"还没收到"。
|
|
||||||
// 状态行已经写了"已连接,等待画面…",这里不该再补一个矛盾的数字。
|
|
||||||
fps: (diagnostics && stats.frames_shown > 0)
|
|
||||||
.then(|| format!("{fps:.1}"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是显示成
|
|
||||||
// "0x0"——一个看起来像分辨率的假分辨率。用 frames_shown 把它挡掉。
|
|
||||||
resolution: (diagnostics && stats.frames_shown > 0)
|
|
||||||
.then(|| format!("{}x{}", stats.width, stats.height))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
frame_bytes: (diagnostics && stats.frames_shown > 0)
|
|
||||||
.then(|| format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
frames_shown: diagnostics
|
|
||||||
.then(|| stats.frames_shown.to_string())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
frames_dropped: diagnostics
|
|
||||||
.then(|| stats.frames_dropped.to_string())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
loss_percent: diagnostics
|
|
||||||
.then(|| format!("{:.1}%", stats.loss_percent()))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
datagrams: diagnostics
|
|
||||||
.then(|| stats.datagrams.to_string())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
gap_ms: stats
|
|
||||||
.last_interval
|
|
||||||
.filter(|_| diagnostics)
|
|
||||||
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 渲染一边的血量。
|
|
||||||
///
|
|
||||||
/// 现在永远走 `available == false` 的分支,因为还没有数据源。装甲板接上后
|
|
||||||
/// [`ArmorSource::health`] 返回 `Some`,界面自动显示真实数值,这里不用改。
|
|
||||||
///
|
|
||||||
/// 没有数据时用占位符而不是 0:0 看起来像真值,现场看到会以为对方已经没血了。
|
|
||||||
fn render_health(team: u8, armor: &dyn ArmorSource) -> TeamHealth {
|
|
||||||
let name = match team {
|
|
||||||
0 => "队伍一",
|
|
||||||
_ => "队伍二",
|
|
||||||
};
|
|
||||||
|
|
||||||
match armor.health(team) {
|
|
||||||
Some(hps) => TeamHealth {
|
|
||||||
team_name: format!("{name} · 装甲板").into(),
|
|
||||||
available: true,
|
|
||||||
placeholder: String::new().into(),
|
|
||||||
hps: std::rc::Rc::new(slint::VecModel::from(
|
|
||||||
hps.iter().map(|hp| *hp as i32).collect::<Vec<i32>>(),
|
|
||||||
))
|
|
||||||
.into(),
|
|
||||||
},
|
|
||||||
None => TeamHealth {
|
|
||||||
team_name: name.into(),
|
|
||||||
available: false,
|
|
||||||
placeholder:
|
|
||||||
"装甲板未接入:血量接口已预留(protocol::ArmorHealth),当前无数据源".into(),
|
|
||||||
// 依然给出 4 个格子:界面用 `available` 决定颜色,所以空数组会让占位
|
|
||||||
// 框整个消失,看起来像布局出错,而不是像"还没有数据"。
|
|
||||||
hps: std::rc::Rc::new(slint::VecModel::from(vec![0i32; ARMOR_BOARDS])).into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
//! UDP 接收、发现、多路帧重组。
|
|
||||||
//!
|
|
||||||
//! # 多路
|
|
||||||
//!
|
|
||||||
//! 裁判终端同时收两块图传板,队伍终端也会收到两块(只是界面上只显示一块)。
|
|
||||||
//! 所以重组状态**必须按发送方分开**——把两块板的帧塞进同一个重组器,会让它们
|
|
||||||
//! 的 `frame_id` 互相顶掉,两路都出不来。
|
|
||||||
//!
|
|
||||||
//! # 发现与订阅
|
|
||||||
//!
|
|
||||||
//! 两个方向各司其职:
|
|
||||||
//!
|
|
||||||
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
|
|
||||||
//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。
|
|
||||||
//!
|
|
||||||
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在
|
|
||||||
//! 任何超时淘汰的竞态。
|
|
||||||
//!
|
|
||||||
//! # 丢帧策略
|
|
||||||
//!
|
|
||||||
//! 缺了分片的帧整个丢弃、下一帧顶上,不重传也不尝试补洞。对实时画面这是对的:
|
|
||||||
//! 等重传补齐时,那一帧早就不新鲜了。这个取舍的代价是丢帧率必须摆在界面上,否则
|
|
||||||
//! "画面卡"没法区分是链路问题还是传感器问题。
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::ErrorKind;
|
|
||||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use protocol::{Fragment, FrameAssembler, Subscribe, SubscribeRole, DEFAULT_PORT, HEADER_LEN};
|
|
||||||
|
|
||||||
/// 接受的最大数据报。高于协议的理论上限(1427),留出余量,这样一个被截断的读只可能
|
|
||||||
/// 来自网络,而不是来自过小的缓冲。
|
|
||||||
const RECV_BUFFER: usize = 2048;
|
|
||||||
|
|
||||||
/// 重新广播订阅的间隔。
|
|
||||||
///
|
|
||||||
/// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。
|
|
||||||
const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800);
|
|
||||||
|
|
||||||
/// 一张帧没收齐就放弃的时限。
|
|
||||||
///
|
|
||||||
/// 一帧在 22fps 下约 45ms 到,所以几百毫秒还没齐的永远不会齐了。
|
|
||||||
const FRAME_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
|
|
||||||
/// 一块已知的图传板。
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CameraInfo {
|
|
||||||
pub id: u32,
|
|
||||||
pub addr: SocketAddrV4,
|
|
||||||
pub firmware: u16,
|
|
||||||
/// 最近一次收到它广播的时刻。
|
|
||||||
pub last_seen: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CameraInfo {
|
|
||||||
/// 多久没有广播了。
|
|
||||||
pub fn silence(&self) -> Duration {
|
|
||||||
self.last_seen.elapsed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 板子在线吗?
|
|
||||||
///
|
|
||||||
/// 阈值是广播间隔的数倍,所以偶发丢包不会让界面上的在线状态闪��。
|
|
||||||
pub fn is_online(&self) -> bool {
|
|
||||||
self.silence() < Duration::from_secs(7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一块板的运行时统计。
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
pub struct SourceStats {
|
|
||||||
pub frames_shown: u64,
|
|
||||||
/// 因分片缺失被放弃的帧。
|
|
||||||
pub frames_dropped: u64,
|
|
||||||
pub datagrams: u64,
|
|
||||||
pub last_frame_len: usize,
|
|
||||||
pub width: u16,
|
|
||||||
pub height: u16,
|
|
||||||
/// 上一帧到这一帧之间的间隔。
|
|
||||||
pub last_interval: Option<Duration>,
|
|
||||||
last_shown_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SourceStats {
|
|
||||||
/// 丢弃的帧占已收到帧的比例。
|
|
||||||
pub fn loss_percent(&self) -> f32 {
|
|
||||||
let total = self.frames_shown + self.frames_dropped;
|
|
||||||
if total == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
self.frames_dropped as f32 * 100.0 / total as f32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_frame(&mut self, width: u16, height: u16, len: usize) {
|
|
||||||
self.last_interval = self.last_shown_at.map(|t| t.elapsed());
|
|
||||||
self.last_shown_at = Some(Instant::now());
|
|
||||||
self.frames_shown += 1;
|
|
||||||
self.width = width;
|
|
||||||
self.height = height;
|
|
||||||
self.last_frame_len = len;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 每一路的最新帧,等着被解码。
|
|
||||||
///
|
|
||||||
/// 每路只保留最新的一张:在 22fps 下把每一帧都排队意味着内存里压着几十张 JPEG,
|
|
||||||
/// 然后解码那些几毫秒后就被下一帧顶掉的画面——而这正是这条链路要消除的延迟。
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct LatestFrames {
|
|
||||||
inner: Mutex<HashMap<u32, protocol::Frame>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LatestFrames {
|
|
||||||
pub fn put(&self, frame: protocol::Frame) {
|
|
||||||
self.inner
|
|
||||||
.lock()
|
|
||||||
.expect("latest-frames mutex poisoned")
|
|
||||||
.insert(frame.sender_id, frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 取走某一路的待处理帧。
|
|
||||||
pub fn take(&self, sender_id: u32) -> Option<protocol::Frame> {
|
|
||||||
self.inner
|
|
||||||
.lock()
|
|
||||||
.expect("latest-frames mutex poisoned")
|
|
||||||
.remove(&sender_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出所有有待处理帧的板子 id。
|
|
||||||
pub fn pending_senders(&self) -> Vec<u32> {
|
|
||||||
self.inner
|
|
||||||
.lock()
|
|
||||||
.expect("latest-frames mutex poisoned")
|
|
||||||
.keys()
|
|
||||||
.copied()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 所有板子的共享状态,跨线程只读。
|
|
||||||
pub struct Discovery {
|
|
||||||
cameras: Mutex<HashMap<u32, CameraInfo>>,
|
|
||||||
stats: Mutex<HashMap<u32, SourceStats>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Discovery {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
cameras: Mutex::new(HashMap::new()),
|
|
||||||
stats: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前已知的板子,按 id 排序,让界面上���列表顺序稳定。
|
|
||||||
pub fn cameras(&self) -> Vec<CameraInfo> {
|
|
||||||
let mut list: Vec<CameraInfo> = self
|
|
||||||
.cameras
|
|
||||||
.lock()
|
|
||||||
.expect("cameras mutex poisoned")
|
|
||||||
.values()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
list.sort_by_key(|c| c.id);
|
|
||||||
list
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在线且刚刚广播过的板子。
|
|
||||||
pub fn online_cameras(&self) -> Vec<CameraInfo> {
|
|
||||||
self.cameras().into_iter().filter(CameraInfo::is_online).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 某一路的统计。
|
|
||||||
pub fn stats(&self, sender_id: u32) -> SourceStats {
|
|
||||||
self.stats
|
|
||||||
.lock()
|
|
||||||
.expect("stats mutex poisoned")
|
|
||||||
.get(&sender_id)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 只刷新存活时间戳,不改地址或固件版本。
|
|
||||||
///
|
|
||||||
/// 收到视频帧时调用:正在推流本身就是最可靠的存活证据。
|
|
||||||
fn touch(&self, id: u32) {
|
|
||||||
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
|
||||||
|
|
||||||
if let Some(info) = cameras.get_mut(&id) {
|
|
||||||
info.last_seen = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) {
|
|
||||||
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
|
||||||
|
|
||||||
cameras
|
|
||||||
.entry(id)
|
|
||||||
.and_modify(|c| {
|
|
||||||
c.addr = addr;
|
|
||||||
c.last_seen = Instant::now();
|
|
||||||
c.firmware = firmware;
|
|
||||||
})
|
|
||||||
.or_insert(CameraInfo {
|
|
||||||
id,
|
|
||||||
addr,
|
|
||||||
firmware,
|
|
||||||
last_seen: Instant::now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn note_frame(&self, id: u32, width: u16, height: u16, len: usize) {
|
|
||||||
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
|
||||||
stats.entry(id).or_default().on_frame(width, height, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count(&self, id: u32, f: impl FnOnce(&mut SourceStats)) {
|
|
||||||
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
|
||||||
f(stats.entry(id).or_default());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 超过 `max_idle` 没有广播的板子从列表里移除。
|
|
||||||
///
|
|
||||||
/// 界面上不会留下一堆永远离线的板子。
|
|
||||||
pub fn forget_silent_cameras(&self, max_idle: Duration) {
|
|
||||||
self.cameras
|
|
||||||
.lock()
|
|
||||||
.expect("cameras mutex poisoned")
|
|
||||||
.retain(|_, c| c.silence() < max_idle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 客户端身份。
|
|
||||||
///
|
|
||||||
/// 常量而非 MAC 派生:队伍客户端和裁判终端各自唯一,进程内也不需要区分自己,
|
|
||||||
/// 而一个确定的常量让日志更好读。
|
|
||||||
pub const CLIENT_ID: u32 = 0x0C11_0001;
|
|
||||||
|
|
||||||
/// 接收器。
|
|
||||||
///
|
|
||||||
/// socket 在这里创建而不是在接收线程里,这样"端口被占用"会立刻报错,而不是被线程
|
|
||||||
/// 吞掉、表现为一个永远不出画面的空窗口。
|
|
||||||
pub struct Receiver {
|
|
||||||
discovery: Arc<Discovery>,
|
|
||||||
frames: Arc<LatestFrames>,
|
|
||||||
role: SubscribeRole,
|
|
||||||
camera: Ipv4Addr,
|
|
||||||
/// 只处理这一块板子的数据,`None` 表示全都要(裁判端)。
|
|
||||||
///
|
|
||||||
/// 队伍端在 release 下设成 `Some(bound)`,这是一道**接收端**的闸门而不只是界面
|
|
||||||
/// 的:对手的板子即使把流推到了本机端口,也不会被重组、不会进解码队列、不会
|
|
||||||
/// 出现在任何统计里。
|
|
||||||
///
|
|
||||||
/// 为什么必须在这一层而不是靠界面隐藏:`.slint` 只控制显示,而数据已经收下并
|
|
||||||
/// 解码了。隐藏一行文字不阻止任何事。
|
|
||||||
///
|
|
||||||
/// 用 `Mutex` 而不是 `Cell`/`AtomicU32`:接收线程要读它,而绑定回调在主线程上
|
|
||||||
/// 写它。订阅循环本身已经是串行的,所以锁的持有时间极短。
|
|
||||||
only_sender: Mutex<Option<u32>>,
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Receiver {
|
|
||||||
/// 绑定视频端口并准备好发现。
|
|
||||||
///
|
|
||||||
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
|
|
||||||
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
|
|
||||||
///
|
|
||||||
/// `only_sender` 为 `Some` 时只接受那一块板子的数据,见 [`Receiver`] 的字段说明。
|
|
||||||
pub fn start(
|
|
||||||
camera: Ipv4Addr,
|
|
||||||
role: SubscribeRole,
|
|
||||||
only_sender: Option<u32>,
|
|
||||||
) -> std::io::Result<Self> {
|
|
||||||
let socket =
|
|
||||||
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
|
|
||||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
|
||||||
|
|
||||||
let broadcast = socket.set_broadcast(true).is_ok();
|
|
||||||
if !broadcast {
|
|
||||||
log::warn!("could not enable broadcast on the socket");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
discovery: Arc::new(Discovery::new()),
|
|
||||||
frames: Arc::new(LatestFrames::default()),
|
|
||||||
role,
|
|
||||||
camera,
|
|
||||||
only_sender: Mutex::new(only_sender),
|
|
||||||
stop: Arc::new(AtomicBool::new(false)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 绑定之后切换限制目标,或解除限制。
|
|
||||||
///
|
|
||||||
/// 队伍端在选择界面里点定一块板子时调用:此前要能看到所有候选(否则无从选
|
|
||||||
/// 起),此后只看这一块。
|
|
||||||
pub fn restrict_to(&self, only_sender: Option<u32>) {
|
|
||||||
*self.only_sender.lock().expect("only_sender mutex poisoned") = only_sender;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前限制到哪一块板子。
|
|
||||||
fn sender_limit(&self) -> Option<u32> {
|
|
||||||
*self.only_sender.lock().expect("only_sender mutex poisoned")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn discovery(&self) -> Arc<Discovery> {
|
|
||||||
Arc::clone(&self.discovery)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn frames(&self) -> Arc<LatestFrames> {
|
|
||||||
Arc::clone(&self.frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 运行接收循环,直到 [`Receiver::shutdown`]。
|
|
||||||
///
|
|
||||||
/// 阻塞,所以放在单独的线程上。
|
|
||||||
pub fn run(&self, socket: UdpSocket) {
|
|
||||||
let target = SocketAddrV4::new(self.camera, DEFAULT_PORT);
|
|
||||||
let subscribe = Subscribe {
|
|
||||||
role: self.role,
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
};
|
|
||||||
let subscribe_bytes = subscribe.encode();
|
|
||||||
|
|
||||||
// 每个发送方一个重组器:两块板的 frame_id 各自独立,混在一起会互相顶掉。
|
|
||||||
let mut assemblers: HashMap<u32, FrameAssembler> = HashMap::new();
|
|
||||||
let mut buf = [0u8; RECV_BUFFER];
|
|
||||||
let mut last_subscribe = Instant::now() - SUBSCRIBE_INTERVAL;
|
|
||||||
let mut last_sweep = Instant::now();
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"listening on UDP {DEFAULT_PORT}, announcing {:?} to {target}",
|
|
||||||
self.role
|
|
||||||
);
|
|
||||||
|
|
||||||
while !self.stop.load(Ordering::Relaxed) {
|
|
||||||
// 周期性续订就是保活:板子在 6 秒收不到就认为我们走了。
|
|
||||||
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
|
|
||||||
if let Err(err) = socket.send_to(&subscribe_bytes, target) {
|
|
||||||
log::warn!("could not subscribe at {target}: {err}");
|
|
||||||
}
|
|
||||||
last_subscribe = Instant::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
match socket.recv_from(&mut buf) {
|
|
||||||
Ok((n, from)) => {
|
|
||||||
self.handle_datagram(&buf[..n], from, &mut assemblers);
|
|
||||||
}
|
|
||||||
Err(err)
|
|
||||||
if err.kind() == ErrorKind::WouldBlock
|
|
||||||
|| err.kind() == ErrorKind::TimedOut =>
|
|
||||||
{
|
|
||||||
// 帧之间的常态。读超时正是让退出能及时响应的原因。
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!("recv failed: {err}");
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 放弃等太久的半截帧。否则板子在帧中途死掉时,重组器会一直留着它。
|
|
||||||
for (id, asm) in assemblers.iter_mut() {
|
|
||||||
if asm.expire().is_some() {
|
|
||||||
self.discovery.count(*id, |s| s.frames_dropped += 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if last_sweep.elapsed() >= Duration::from_secs(5) {
|
|
||||||
self.discovery.forget_silent_cameras(Duration::from_secs(60));
|
|
||||||
last_sweep = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("receive loop stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理一个数据报。
|
|
||||||
fn handle_datagram(
|
|
||||||
&self,
|
|
||||||
bytes: &[u8],
|
|
||||||
from: std::net::SocketAddr,
|
|
||||||
assemblers: &mut HashMap<u32, FrameAssembler>,
|
|
||||||
) {
|
|
||||||
if bytes.len() < HEADER_LEN {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let limit = self.sender_limit();
|
|
||||||
|
|
||||||
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由
|
|
||||||
// 头里的 sender_id 决定。
|
|
||||||
if let Some(hello) = protocol::Hello::decode(bytes) {
|
|
||||||
// 限制生效时不记录其他板子。理由不只是"不显示":如果把它们记进
|
|
||||||
// discovery,`--list` 式的输出、槽位排序、界面上任何一处遍历 cameras()
|
|
||||||
// 的地方都会间接泄露场上还有谁。
|
|
||||||
if let (Some(only), SocketAddr::V4(v4)) = (limit, from) {
|
|
||||||
if hello.sender_id != only {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let SocketAddr::V4(v4) = from {
|
|
||||||
let is_new = !self.discovery.cameras().iter().any(|c| c.id == hello.sender_id);
|
|
||||||
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
|
|
||||||
|
|
||||||
if is_new {
|
|
||||||
log::info!(
|
|
||||||
"found camera 0x{:08X} at {v4}",
|
|
||||||
hello.sender_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(frag) = Fragment::decode(bytes) else {
|
|
||||||
// 既不是 Hello 也不是合法分片:将来的其它消息,或一次损坏。
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let id = frag.header.sender_id;
|
|
||||||
|
|
||||||
// 视频同样要过闸门。对手的板子一旦把流推到这个端口(IP 已被广播泄露,
|
|
||||||
// 端口是公开常量),它的数据不会进任何重组器。
|
|
||||||
if let Some(only) = limit {
|
|
||||||
if id != only {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 视频帧也算存活证据。
|
|
||||||
//
|
|
||||||
// `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是出现一个
|
|
||||||
// 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。真板子每 2
|
|
||||||
// 秒广播一次所以不易撞上,但它本身就是设计错误——正在推流的板子比广播更
|
|
||||||
// 可靠地证明自己活着。
|
|
||||||
self.discovery.touch(id);
|
|
||||||
|
|
||||||
match assemblers
|
|
||||||
.entry(id)
|
|
||||||
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT))
|
|
||||||
.push(frag)
|
|
||||||
{
|
|
||||||
Ok(frame) => {
|
|
||||||
self.discovery.count(id, |s| s.datagrams += 1);
|
|
||||||
self.discovery
|
|
||||||
.note_frame(id, frame.width, frame.height, frame.data.len());
|
|
||||||
self.frames.put(frame);
|
|
||||||
}
|
|
||||||
Err(protocol::DropReason::Superseded) => {
|
|
||||||
// 被更新的帧追上了。这不是丢帧:半截的那张只是被超越,在健康链路上
|
|
||||||
// 这是帧被放弃的**正常**方式。
|
|
||||||
self.discovery.count(id, |s| s.datagrams += 1);
|
|
||||||
}
|
|
||||||
Err(protocol::DropReason::TimedOut) => {
|
|
||||||
self.discovery.count(id, |s| {
|
|
||||||
s.datagrams += 1;
|
|
||||||
s.frames_dropped += 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shutdown(&self) {
|
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解码后的画面。
|
|
||||||
pub struct DecodedFrame {
|
|
||||||
pub rgba: Vec<u8>,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解码一张 JPEG。
|
|
||||||
///
|
|
||||||
/// 返回 `None` 表示字节不是可解码的图像——协议层挡不住这种:它只检查分片结构,
|
|
||||||
/// 不检查 JPEG 本身是否有效。
|
|
||||||
pub fn decode_frame(jpeg: &[u8]) -> Option<DecodedFrame> {
|
|
||||||
let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).ok()?;
|
|
||||||
let rgba = img.to_rgba8();
|
|
||||||
|
|
||||||
Some(DecodedFrame {
|
|
||||||
width: rgba.width(),
|
|
||||||
height: rgba.height(),
|
|
||||||
rgba: rgba.into_raw(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 装甲板血量的来源。
|
|
||||||
///
|
|
||||||
/// **预留,当前没有实现也没有数据源。** 血量显示为占位符。
|
|
||||||
///
|
|
||||||
/// 接口和端口都已就位([`protocol::ARMOR_PORT`]、[`protocol::ArmorHealth`]):装甲板
|
|
||||||
/// 接上以后往那个端口发消息即可,裁判界面与其它代码都不用改。
|
|
||||||
pub trait ArmorSource: Send + Sync {
|
|
||||||
/// 某一边的血量。
|
|
||||||
///
|
|
||||||
/// 返回 4 个装甲板的当前血量,按 index 排列。没有数据时返回 `None`,界面据此
|
|
||||||
/// 显示占位符而不是 0——0 是一个看起来像真值的假值。
|
|
||||||
fn health(&self, team: u8) -> Option<[u16; 4]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一边的装甲板数量。
|
|
||||||
///
|
|
||||||
/// RoboMaster 规则是每队 4 块。界面按这个数量画格子,所以数据还没接上时也能看到
|
|
||||||
/// 位置——空着比画 0 个格子更让人以为布局坏了。
|
|
||||||
pub const ARMOR_BOARDS: usize = 4;
|
|
||||||
|
|
||||||
/// 目前唯一的实现:什么都不提供。
|
|
||||||
///
|
|
||||||
/// 存在的意义是让界面代码从一开始就对着一个真实接口开发,而不是等装甲板就绪后再改
|
|
||||||
/// 一次结构。
|
|
||||||
pub struct NoArmorYet;
|
|
||||||
|
|
||||||
impl ArmorSource for NoArmorYet {
|
|
||||||
fn health(&self, _team: u8) -> Option<[u16; 4]> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
|
|
||||||
|
|
||||||
/* 一块画面的读数。
|
|
||||||
*
|
|
||||||
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
|
|
||||||
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
|
|
||||||
* 界面也不显示它们。裁判端始终显示。 */
|
|
||||||
export struct CameraStats {
|
|
||||||
// 在线状态
|
|
||||||
online: bool,
|
|
||||||
status-text: string,
|
|
||||||
|
|
||||||
// 实时测量
|
|
||||||
fps: string,
|
|
||||||
resolution: string,
|
|
||||||
frame-bytes: string,
|
|
||||||
|
|
||||||
// 累计
|
|
||||||
frames-shown: string,
|
|
||||||
frames-dropped: string,
|
|
||||||
loss-percent: string,
|
|
||||||
datagrams: string,
|
|
||||||
|
|
||||||
// 帧间隔,用于把"卡"和"帧率低"区分开
|
|
||||||
gap-ms: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 一边的血量(4 块装甲板)。
|
|
||||||
*
|
|
||||||
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
|
|
||||||
* 现场看到 0 会以为对方已经没血了。 */
|
|
||||||
export struct TeamHealth {
|
|
||||||
team-name: string,
|
|
||||||
available: bool,
|
|
||||||
// 占位符文案
|
|
||||||
placeholder: string,
|
|
||||||
// 每块装甲板的血量;available 时有效
|
|
||||||
hps: [int],
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 首次运行时的摄像头选择。
|
|
||||||
*
|
|
||||||
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
|
|
||||||
export struct CameraChoice {
|
|
||||||
id-text: string,
|
|
||||||
addr-text: string,
|
|
||||||
online: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 视频窗格
|
|
||||||
*
|
|
||||||
* 一路画面 + 滚轮缩放 + 拖动平移。两种身份共用这个组件。
|
|
||||||
*
|
|
||||||
* 缩放的实现方式:把 `Image` 的 `width`/`height` 按 `zoom` 放大后放进一个
|
|
||||||
* `clip: true` 的容器里,靠 `offset-x`/`offset-y` 平移。Slint 的 `Image` 没有
|
|
||||||
* 缩放因子属性,所以只能用这个办法——它也顺带让"放大到超出窗口"成为可能。
|
|
||||||
*
|
|
||||||
* `image-fit: contain` 让缩放到 1.0 时画面完整可见且不变形;放大后
|
|
||||||
* `image-fit: fill` 才会跟着缩放,否则画面尺寸固定在 contain 算出的尺寸上,
|
|
||||||
* 放大看起来毫无反应。
|
|
||||||
*/
|
|
||||||
component VideoPane inherits Rectangle {
|
|
||||||
// 画面
|
|
||||||
in property <image> frame;
|
|
||||||
// 没有画面时显示的提示(例如槽位空着)
|
|
||||||
in property <string> empty-text;
|
|
||||||
|
|
||||||
background: #000000;
|
|
||||||
// 关键:不 clip 的话放大后的画面会溢出到界面其它地方去。
|
|
||||||
clip: true;
|
|
||||||
|
|
||||||
// 缩放与平移。zoom = 1.0 是"完整可见"。
|
|
||||||
in-out property <float> zoom: 1.0;
|
|
||||||
in-out property <length> offset-x: 0px;
|
|
||||||
in-out property <length> offset-y: 0px;
|
|
||||||
|
|
||||||
// 放大到几倍为止。再多也看不清了,只是把像素拉大。
|
|
||||||
in property <float> max-zoom: 8.0;
|
|
||||||
in property <float> step: 1.25;
|
|
||||||
|
|
||||||
// 拖动时的起点,由 TouchArea 记录。
|
|
||||||
private property <length> drag-start-x: 0px;
|
|
||||||
private property <length> drag-start-y: 0px;
|
|
||||||
private property <length> drag-origin-x: 0px;
|
|
||||||
private property <length> drag-origin-y: 0px;
|
|
||||||
|
|
||||||
public function reset-view() {
|
|
||||||
root.zoom = 1.0;
|
|
||||||
root.offset-x = 0px;
|
|
||||||
root.offset-y = 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
Image {
|
|
||||||
source: root.frame;
|
|
||||||
// contain:zoom=1 时完整可见且不变形。
|
|
||||||
// fill:放大后跟着容器缩放——只用 contain 的话放大不会有任何视觉变化。
|
|
||||||
image-fit: root.zoom <= 1.0 ? contain : fill;
|
|
||||||
width: parent.width * root.zoom;
|
|
||||||
height: parent.height * root.zoom;
|
|
||||||
x: (parent.width - self.width) / 2 + root.offset-x;
|
|
||||||
y: (parent.height - self.height) / 2 + root.offset-y;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没有画面时的占位。裁判端两块板子通常只到一块,空槽位要能说清原因,
|
|
||||||
// 而不是留一块黑。
|
|
||||||
//
|
|
||||||
// `absolute` 布局:它不参与父级的尺寸需求计算。之前它不是,于是"已连接,
|
|
||||||
// 等待画面…"这行字的长度决定了这一格要多少宽度,裁判端两格就永远不等宽
|
|
||||||
// (实测 55% / 45%)——文字长短本不该影响画面大小。
|
|
||||||
if root.empty-text != "": Text {
|
|
||||||
text: root.empty-text;
|
|
||||||
color: #5a5a64;
|
|
||||||
font-size: 14px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
// 占满整个窗格,文字居中,但不参与布局。
|
|
||||||
x: 0;
|
|
||||||
y: parent.height / 2 - self.height / 2;
|
|
||||||
width: parent.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缩放指示:只在放大时出现,否则一直占着位置。
|
|
||||||
//
|
|
||||||
// 同样是绝对定位(给出 x/y),否则它一出现就会改变这一格的尺寸需求,把两路
|
|
||||||
// 不等宽的问题重新引进来——而且是在用户放大之后才出现,最难排查。
|
|
||||||
if root.zoom > 1.05: Rectangle {
|
|
||||||
background: #000000aa;
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 92px;
|
|
||||||
height: 26px;
|
|
||||||
x: parent.width - self.width - 10px;
|
|
||||||
y: 10px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "x" + root.zoom;
|
|
||||||
color: #d0d0d8;
|
|
||||||
font-size: 13px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset-view 按钮:只在放大/平移后出现,给一个回到全景的出口。
|
|
||||||
if root.zoom > 1.05 || root.offset-x != 0px || root.offset-y != 0px: Rectangle {
|
|
||||||
background: #000000aa;
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 76px;
|
|
||||||
height: 26px;
|
|
||||||
x: 10px;
|
|
||||||
y: 10px;
|
|
||||||
|
|
||||||
TouchArea {
|
|
||||||
clicked => {
|
|
||||||
root.reset-view();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "复位";
|
|
||||||
color: #d0d0d8;
|
|
||||||
font-size: 13px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 滚轮与拖动都在最上层。
|
|
||||||
TouchArea {
|
|
||||||
scroll-event(event) => {
|
|
||||||
// delta-y 为正表示向上滚(放大)。
|
|
||||||
if event.delta-y > 0px {
|
|
||||||
root.zoom = Math.min(root.zoom * root.step, root.max-zoom);
|
|
||||||
} else if event.delta-y < 0px {
|
|
||||||
root.zoom = Math.max(root.zoom / root.step, 1.0);
|
|
||||||
}
|
|
||||||
// 缩回 1.0 时顺便归位:此时画面完整可见,平移没有意义,
|
|
||||||
// 留着会让下一次放大从偏移位置开始,看起来像"跳了一下"。
|
|
||||||
if root.zoom <= 1.0 {
|
|
||||||
root.offset-x = 0px;
|
|
||||||
root.offset-y = 0px;
|
|
||||||
}
|
|
||||||
return EventResult.accept;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拖动平移。
|
|
||||||
//
|
|
||||||
// 用 `pointer-event` 而不是 `pressed` / `moved`:TouchArea 的 `pressed` 是
|
|
||||||
// **属性**(可以读 `self.pressed`)而不是回调,写成 `pressed => {...}` 编译
|
|
||||||
// 不过。按下(Down)时记起点和当前偏移,移动(Move)时按增量更新。
|
|
||||||
pointer-event(event) => {
|
|
||||||
if event.kind == PointerEventKind.down {
|
|
||||||
root.drag-start-x = self.mouse-x;
|
|
||||||
root.drag-start-y = self.mouse-y;
|
|
||||||
root.drag-origin-x = root.offset-x;
|
|
||||||
root.drag-origin-y = root.offset-y;
|
|
||||||
} else if event.kind == PointerEventKind.move && self.pressed {
|
|
||||||
// 只在放大后生效——zoom=1 时画面完整可见,没有可移动的空间。
|
|
||||||
if root.zoom > 1.0 {
|
|
||||||
root.offset-x = root.drag-origin-x
|
|
||||||
+ (self.mouse-x - root.drag-start-x);
|
|
||||||
root.offset-y = root.drag-origin-y
|
|
||||||
+ (self.mouse-y - root.drag-start-y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return EventResult.accept;
|
|
||||||
}
|
|
||||||
|
|
||||||
double-clicked => {
|
|
||||||
root.reset-view();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 放大后用抓取光标提示可拖动。
|
|
||||||
mouse-cursor: root.zoom > 1.0 ? move : default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 一路画面的下方信息条。两种身份共用。
|
|
||||||
*
|
|
||||||
* 技术读数(fps / shown / dropped)在队伍端 release 下为空字符串,`visible`
|
|
||||||
* 让整块消失而不是留一条空行。 */
|
|
||||||
component StatusBar inherits Rectangle {
|
|
||||||
in property <CameraStats> stats;
|
|
||||||
in property <TeamHealth> health;
|
|
||||||
// 血格子比状态栏里的字宽,裁判端并排时用
|
|
||||||
in property <length> cell-width: 34px;
|
|
||||||
in property <length> cell-height: 12px;
|
|
||||||
// 队伍端那一条更高一些,因为它独占一行血量
|
|
||||||
in property <bool> wide: false;
|
|
||||||
|
|
||||||
background: #1a1a20;
|
|
||||||
height: wide ? 72px : 78px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 12px;
|
|
||||||
spacing: 16px;
|
|
||||||
|
|
||||||
// 左:队伍名 + 血量格子
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 5px;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health.team-name;
|
|
||||||
color: #e8e8ec;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 5px;
|
|
||||||
|
|
||||||
for hp[index] in root.health.hps: Rectangle {
|
|
||||||
width: root.cell-width;
|
|
||||||
height: root.cell-height;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: root.health.available ? #4a4a55 : #2f2f38;
|
|
||||||
background: root.health.available ? #6cc96c : #17171c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 右:状态 + 技术读数
|
|
||||||
//
|
|
||||||
// VerticalLayout 没有 horizontal-alignment 属性(右对齐要落在里面的 Text
|
|
||||||
// 上)。给它 preferred-width 而不是 stretch,让它按内容宽度占位。
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 3px;
|
|
||||||
preferred-width: root.wide ? 340px : 300px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.stats.status-text
|
|
||||||
+ (root.stats.fps != "" ? " " + root.stats.fps + " fps" : "")
|
|
||||||
+ (root.stats.resolution != "" ? " " + root.stats.resolution : "");
|
|
||||||
color: root.stats.online ? #e8e8ec : #e0a060;
|
|
||||||
font-size: 13px;
|
|
||||||
horizontal-alignment: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 技术读数整行在 release 下消失,不留空行。
|
|
||||||
if root.stats.frames-shown != "": Text {
|
|
||||||
text: "shown " + root.stats.frames-shown
|
|
||||||
+ (root.stats.frames-dropped != ""
|
|
||||||
? " dropped " + root.stats.frames-dropped
|
|
||||||
+ " (" + root.stats.loss-percent + ")"
|
|
||||||
: "")
|
|
||||||
+ (root.stats.gap-ms != "" ? " gap " + root.stats.gap-ms : "")
|
|
||||||
+ (root.stats.frame-bytes != "" ? " " + root.stats.frame-bytes : "");
|
|
||||||
color: root.stats.frames-dropped == "0" ? #7e7e89 : #e0a060;
|
|
||||||
font-size: 12px;
|
|
||||||
horizontal-alignment: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 队伍端
|
|
||||||
*
|
|
||||||
* 队伍终端:一块画面 + 自己那一边的血量。
|
|
||||||
*
|
|
||||||
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
|
|
||||||
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
|
|
||||||
* 情况。 */
|
|
||||||
export component TeamApp inherits Window {
|
|
||||||
title: "cam-client — 队伍";
|
|
||||||
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
|
|
||||||
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
|
|
||||||
preferred-width: 1280px;
|
|
||||||
preferred-height: 800px;
|
|
||||||
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
|
|
||||||
min-width: 900px;
|
|
||||||
min-height: 600px;
|
|
||||||
|
|
||||||
in property <image> frame;
|
|
||||||
in property <CameraStats> stats;
|
|
||||||
in property <TeamHealth> health;
|
|
||||||
|
|
||||||
// 选择绑定时显示,其余时候隐藏
|
|
||||||
in property <[CameraChoice]> choices;
|
|
||||||
in property <bool> choosing;
|
|
||||||
|
|
||||||
callback pick(int);
|
|
||||||
|
|
||||||
background: #101014;
|
|
||||||
|
|
||||||
if !root.choosing: VerticalLayout {
|
|
||||||
spacing: 0;
|
|
||||||
|
|
||||||
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
|
|
||||||
// 视频终端里每一像素都可能是有用信息。
|
|
||||||
VideoPane {
|
|
||||||
frame: root.frame;
|
|
||||||
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
|
|
||||||
vertical-stretch: 1;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusBar {
|
|
||||||
stats: root.stats;
|
|
||||||
health: root.health;
|
|
||||||
wide: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
|
|
||||||
// release 下这行也保留——它不泄露任何对手信息。
|
|
||||||
Rectangle {
|
|
||||||
background: #14141a;
|
|
||||||
height: 24px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health.placeholder;
|
|
||||||
color: #6a6a75;
|
|
||||||
font-size: 12px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择界面
|
|
||||||
if root.choosing: VerticalLayout {
|
|
||||||
padding: 32px;
|
|
||||||
spacing: 14px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "选择要观看的摄像头";
|
|
||||||
color: #e8e8ec;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
|
|
||||||
color: #8a8a95;
|
|
||||||
font-size: 13px;
|
|
||||||
wrap: word-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle { height: 6px; background: transparent; }
|
|
||||||
|
|
||||||
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
|
|
||||||
// 点击回调;TouchArea 才是带交互的元素。
|
|
||||||
for choice[index] in root.choices: Rectangle {
|
|
||||||
background: choice.online ? #1c1c22 : #16161b;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: #2a2a33;
|
|
||||||
height: 56px;
|
|
||||||
|
|
||||||
TouchArea {
|
|
||||||
clicked => {
|
|
||||||
root.pick(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 14px;
|
|
||||||
spacing: 16px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: choice.id-text;
|
|
||||||
color: #e8e8ec;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: left;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: choice.addr-text;
|
|
||||||
color: #8a8a95;
|
|
||||||
font-size: 12px;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: left;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: choice.online ? "在线" : "离线";
|
|
||||||
color: choice.online ? #7ddc7d : #8a8a95;
|
|
||||||
font-size: 12px;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一块都没发现时给出可操作的提示,而不是一个空列表。
|
|
||||||
if root.choices.length == 0: Rectangle {
|
|
||||||
background: #1a1418;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: #4a2a35;
|
|
||||||
height: 64px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
|
|
||||||
color: #e0a060;
|
|
||||||
font-size: 13px;
|
|
||||||
wrap: word-wrap;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 裁判端
|
|
||||||
*
|
|
||||||
* 裁判终端:两块画面左右并排 + 双方血量。
|
|
||||||
*
|
|
||||||
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
|
|
||||||
* 并排对称。 */
|
|
||||||
export component RefereeApp inherits Window {
|
|
||||||
title: "cam-client — 裁判系统";
|
|
||||||
preferred-width: 1600px;
|
|
||||||
preferred-height: 900px;
|
|
||||||
min-width: 1000px;
|
|
||||||
min-height: 640px;
|
|
||||||
|
|
||||||
in property <image> frame-a;
|
|
||||||
in property <image> frame-b;
|
|
||||||
in property <CameraStats> stats-a;
|
|
||||||
in property <CameraStats> stats-b;
|
|
||||||
in property <TeamHealth> health-a;
|
|
||||||
in property <TeamHealth> health-b;
|
|
||||||
|
|
||||||
// 两路的缩放互相独立:滚轮只作用于鼠标所在的一路(见各自窗格上的 TouchArea)。
|
|
||||||
// 裁判要并排比出两边,所以默认都是 1.0,且不提供联动。
|
|
||||||
|
|
||||||
background: #101014;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: 0;
|
|
||||||
|
|
||||||
// 两路画面等宽并排。
|
|
||||||
//
|
|
||||||
// 之前只写 `horizontal-stretch: 1`,于是**内容多的一格会占更多宽度**:
|
|
||||||
// HorizontalLayout 先按内容的最大需求分配,剩下的才按 stretch 分。队伍一
|
|
||||||
// 那格写着"已连接,等待画面…",队伍二只有"未收到画面",于是左边多要了
|
|
||||||
// 十几个像素,右边就少十几个——实测 55% / 45%。
|
|
||||||
//
|
|
||||||
// 空的那一侧被压成一条缝是更极端的同一类问题:没有图 ⇒ 最小宽度接近 0。
|
|
||||||
//
|
|
||||||
// 解法是让两格的需求**只由画面决定**,与文字无关:
|
|
||||||
// preferred-width: 50% 声明"我想要一半",配合 stretch 1 精确平分
|
|
||||||
// min-width: 320px 兜底,保证任何一侧至少有一个能看画的宽度
|
|
||||||
// 文字的溢出由 VideoPane 的 clip 裁掉,不参与布局。
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
|
|
||||||
VideoPane {
|
|
||||||
frame: root.frame-a;
|
|
||||||
empty-text: root.stats-a.online ? "已连接,等待画面…" : "等待队伍一的图传";
|
|
||||||
vertical-stretch: 1;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
preferred-width: 480px;
|
|
||||||
min-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
VideoPane {
|
|
||||||
frame: root.frame-b;
|
|
||||||
// 槽位对应队伍,所以空槽要说清在等谁,而不是笼统的"未收到画面"。
|
|
||||||
empty-text: root.stats-b.online ? "已连接,等待画面…" : "等待队伍二的图传";
|
|
||||||
vertical-stretch: 1;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
preferred-width: 480px;
|
|
||||||
min-width: 320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每路画面下方的队伍条:名字、血量、统计。
|
|
||||||
//
|
|
||||||
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
|
|
||||||
StatusBar {
|
|
||||||
stats: root.stats-a;
|
|
||||||
health: root.health-a;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
preferred-width: 480px;
|
|
||||||
min-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusBar {
|
|
||||||
stats: root.stats-b;
|
|
||||||
health: root.health-b;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
preferred-width: 480px;
|
|
||||||
min-width: 320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
|
|
||||||
Rectangle {
|
|
||||||
background: #14141a;
|
|
||||||
height: 24px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health-a.placeholder;
|
|
||||||
color: #6a6a75;
|
|
||||||
font-size: 12px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,3 +10,7 @@ dist/
|
|||||||
|
|
||||||
# Tauri 生成的权限 schema(构建产物,随 tauri-build 版本变化)。
|
# Tauri 生成的权限 schema(构建产物,随 tauri-build 版本变化)。
|
||||||
/src-tauri/gen/schemas/
|
/src-tauri/gen/schemas/
|
||||||
|
|
||||||
|
# 本机开发用的状态:dev 配置(里面有密码哈希)与演示/录制的输出、界面截图。
|
||||||
|
/.dev/
|
||||||
|
/.shots/
|
||||||
|
|||||||
@@ -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 没改成宿主三元组 |
|
||||||
@@ -122,6 +122,8 @@ struct Inner {
|
|||||||
boards: HashMap<u32, ArmorBoard>,
|
boards: HashMap<u32, ArmorBoard>,
|
||||||
/// 已经打过"这块板子在线"的板子 id,避免刷屏。
|
/// 已经打过"这块板子在线"的板子 id,避免刷屏。
|
||||||
announced: Vec<u32>,
|
announced: Vec<u32>,
|
||||||
|
/// 已经警告过"同一队有多块板子"的队伍,避免刷屏。
|
||||||
|
warned_multi_board: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相邻两次读数之差还原出来的掉血事件。
|
/// 相邻两次读数之差还原出来的掉血事件。
|
||||||
@@ -304,8 +306,12 @@ impl ArmorHealthStore {
|
|||||||
|
|
||||||
// 血量变化也要能看见:窗口是给别人看的,抓日志的人得知道掉血没有。整车一条
|
// 血量变化也要能看见:窗口是给别人看的,抓日志的人得知道掉血没有。整车一条
|
||||||
// 血、四条同值,所以只对 index 0 报一次。
|
// 血、四条同值,所以只对 index 0 报一次。
|
||||||
|
//
|
||||||
|
// 上一次的读数取自**这块板子自己**的记录,而不是 `readings[team][0]`:那个
|
||||||
|
// 槽位是"这一队"的,同队有两块板子在广播时会被互相覆盖,于是日志会把另一块
|
||||||
|
// 板子的血量当成"这块板子之前的血量"——一条读起来像真事的假线索。
|
||||||
if index == 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 {
|
if previous != health.hp {
|
||||||
log::info!(
|
log::info!(
|
||||||
"armor: board 0x{board:08X} team {team} hp {previous} -> {}",
|
"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 {
|
inner.readings[team][index] = Some(Reading {
|
||||||
hp: health.hp,
|
hp: health.hp,
|
||||||
at: Instant::now(),
|
at: Instant::now(),
|
||||||
@@ -498,6 +519,28 @@ mod tests {
|
|||||||
assert_eq!(seen.lock().unwrap().len(), ARMOR_BOARDS);
|
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]
|
#[test]
|
||||||
fn a_falling_hp_is_exactly_one_hit() {
|
fn a_falling_hp_is_exactly_one_hit() {
|
||||||
let mut tracker = HealthTracker::new();
|
let mut tracker = HealthTracker::new();
|
||||||
|
|||||||
@@ -691,7 +691,18 @@ pub fn parse_id(text: &str) -> Option<u32> {
|
|||||||
/// 返回值里的 `Vec<String>` 是要显示给用户/写进日志的说明。旧文件**不删**:现场出问题
|
/// 返回值里的 `Vec<String>` 是要显示给用户/写进日志的说明。旧文件**不删**:现场出问题
|
||||||
/// 时,"旧的那份到底写了什么"是第一个要看的东西。
|
/// 时,"旧的那份到底写了什么"是第一个要看的东西。
|
||||||
pub fn load_with_migration(path: &Path) -> Result<(Config, 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`] 相同,但旧配置的路径由调用方给。
|
/// 与 [`load_with_migration`] 相同,但旧配置的路径由调用方给。
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ mod tests {
|
|||||||
assert!(phc.contains("$v=19$"), "got {phc}");
|
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]
|
#[test]
|
||||||
fn a_corrupt_stored_hash_is_an_error_not_a_wrong_password() {
|
fn a_corrupt_stored_hash_is_an_error_not_a_wrong_password() {
|
||||||
// 这两种情况必须能分开:一个是输错了,一个是配置文件坏了。
|
// 这两种情况必须能分开:一个是输错了,一个是配置文件坏了。
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ pub fn set_recording_options(
|
|||||||
// 截图与录制
|
// 截图与录制
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 把某一路最新的一帧存成 PNG。
|
/// 把某一路最新的一帧存成 PNG(存在录制目录的 `shots/` 下)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
|
pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
|
||||||
let frame = state
|
let frame = state
|
||||||
@@ -273,19 +273,9 @@ pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
|
|||||||
|
|
||||||
let config = state.config();
|
let config = state.config();
|
||||||
let dir = config.recordings_dir().join("shots");
|
let dir = config.recordings_dir().join("shots");
|
||||||
std::fs::create_dir_all(&dir).map_err(|err| format!("建不了截图目录 {dir:?}:{err}"))?;
|
|
||||||
|
|
||||||
let path = dir.join(format!(
|
let path = crate::recording::save_screenshot(&frame.data, &dir, camera)
|
||||||
"shot-{camera:08X}-{}.png",
|
.map_err(|err| format!("{err:#}"))?;
|
||||||
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()))?;
|
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"screenshot of 0x{camera:08X} (frame {}) -> {}",
|
"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}"))
|
.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` 里面吗。
|
/// `child` 在 `parent` 里面吗。
|
||||||
///
|
///
|
||||||
/// 逐段比较而不是字符串前缀:`/a/bc` 以 `/a/b` 为前缀,但它不在 `/a/b` 里面。
|
/// 逐段比较而不是字符串前缀:`/a/bc` 以 `/a/b` 为前缀,但它不在 `/a/b` 里面。
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ const USAGE: &str = "\
|
|||||||
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
|
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
|
||||||
用户端的绑定改动需要密码;裁判端不需要。
|
用户端的绑定改动需要密码;裁判端不需要。
|
||||||
|
|
||||||
环境变量 CAM_DEMO=1 与 --demo 等价。
|
环境变量:
|
||||||
|
CAM_DEMO=1 等价于 --demo
|
||||||
|
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||||||
|
|
||||||
|
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||||||
|
(`tauri dev -- -- --demo`),而写在一行环境变量里不会有人记错。
|
||||||
";
|
";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -82,6 +87,7 @@ impl Args {
|
|||||||
fn parse(argv: impl Iterator<Item = String>) -> Self {
|
fn parse(argv: impl Iterator<Item = String>) -> Self {
|
||||||
let mut args = Args {
|
let mut args = Args {
|
||||||
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
|
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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,6 +168,13 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
|
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
|
||||||
state.install_sinks();
|
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 {
|
if args.demo {
|
||||||
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
|
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
|
||||||
demo::spawn(Arc::clone(&state));
|
demo::spawn(Arc::clone(&state));
|
||||||
@@ -228,6 +241,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
commands::start_recording,
|
commands::start_recording,
|
||||||
commands::stop_recording,
|
commands::stop_recording,
|
||||||
commands::reveal,
|
commands::reveal,
|
||||||
|
commands::open_file,
|
||||||
commands::clear_config,
|
commands::clear_config,
|
||||||
commands::quit_app,
|
commands::quit_app,
|
||||||
commands::refresh,
|
commands::refresh,
|
||||||
|
|||||||
@@ -229,18 +229,54 @@ fn unique_take_path(dir: &Path, camera: u32) -> PathBuf {
|
|||||||
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
|
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 {
|
for n in 2..1000 {
|
||||||
if !candidate.exists() {
|
if !candidate.exists() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
candidate = dir.join(format!("{base}-{n}.mp4"));
|
candidate = dir.join(format!("{base}-{n}.{ext}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate
|
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,队列关闭后收尾。
|
/// 写入线程的主体:把队列里的帧写进 ffmpeg 的 stdin,队列关闭后收尾。
|
||||||
fn pump(
|
fn pump(
|
||||||
rx: &std::sync::mpsc::Receiver<Arc<Frame>>,
|
rx: &std::sync::mpsc::Receiver<Arc<Frame>>,
|
||||||
@@ -660,6 +696,52 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
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]
|
#[test]
|
||||||
fn media_listing_ignores_anything_that_is_not_a_take() {
|
fn media_listing_ignores_anything_that_is_not_a_take() {
|
||||||
let dir = temp_dir("listing");
|
let dir = temp_dir("listing");
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": [
|
"scope": [
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,8 +48,15 @@ export function HealthBar({ health, config, compact = false }: Props) {
|
|||||||
const total = 100;
|
const total = 100;
|
||||||
const ratio = health.available ? Math.max(0, Math.min(1, (health.hp ?? 0) / total)) : 0;
|
const ratio = health.available ? Math.max(0, Math.min(1, (health.hp ?? 0) / total)) : 0;
|
||||||
|
|
||||||
|
// 宽度按配置收窄并居中。它挂在画面**下面**,所以不需要通栏——通栏反而像一条分隔线,
|
||||||
|
// 而不是一个可以一眼扫到的仪表。
|
||||||
|
const width = `${Math.round(config.width * 100)}%`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`healthbar${compact ? " healthbar--compact" : ""}`}>
|
<div
|
||||||
|
className={`healthbar${compact ? " healthbar--compact" : ""}`}
|
||||||
|
style={compact ? undefined : { width, margin: "0 auto" }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="healthbar__track"
|
className="healthbar__track"
|
||||||
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 命中反馈的整屏层:闪红 + 中央提示。
|
* 命中反馈的整屏层:闪红 + 顶部一条文字。
|
||||||
*
|
*
|
||||||
* 抖动与伤害数字由 `VideoSurface` 画在 canvas 上(它们要跟着瞄准点走),这里只负责
|
* # 大号数字刻意不放在画面正中
|
||||||
* 那一层全屏的、不需要对准任何东西的视觉反馈。
|
*
|
||||||
|
* 第一版把 `-7 / 100 → 93` 放在正中,结果它正好压在瞄准点上——被打中的那一下恰恰是最
|
||||||
|
* 需要看着准星的时候。现在数字由 `VideoSurface` 画在**准星旁边**(跟着瞄准点走,不挡
|
||||||
|
* 目标),这里只留闪红与顶部一条信息。
|
||||||
*
|
*
|
||||||
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
|
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
|
||||||
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
|
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
|
||||||
@@ -26,9 +29,9 @@ export function HitFlash({ hit, config, relevant, label }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
|
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
|
||||||
{label && <div className="hit-flash__label">{label}</div>}
|
<div className="hit-flash__banner">
|
||||||
<div className="hit-flash__value">
|
{label && <span className="hit-flash__label">{label}</span>}
|
||||||
-{hit.damage}
|
<span className="hit-flash__value">-{hit.damage}</span>
|
||||||
<span className="hit-flash__sub">
|
<span className="hit-flash__sub">
|
||||||
{hit.from} → {hit.to}
|
{hit.from} → {hit.to}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -23,8 +23,18 @@ import type {
|
|||||||
StreamInfo,
|
StreamInfo,
|
||||||
} from "./types";
|
} 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> {
|
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 {
|
try {
|
||||||
return await invoke<T>(command, args);
|
return await invoke<T>(command, args);
|
||||||
} catch (raw) {
|
} catch (raw) {
|
||||||
@@ -77,6 +87,7 @@ export const api = {
|
|||||||
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
|
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
|
||||||
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
|
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
|
||||||
reveal: (path: string) => call<void>("reveal", { path }),
|
reveal: (path: string) => call<void>("reveal", { path }),
|
||||||
|
openFile: (path: string) => call<void>("open_file", { path }),
|
||||||
|
|
||||||
// ---- 退出与重置 ----
|
// ---- 退出与重置 ----
|
||||||
clearConfig: (password?: string) =>
|
clearConfig: (password?: string) =>
|
||||||
|
|||||||
@@ -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);
|
client.connect(url);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,11 +117,6 @@ export class StreamClient {
|
|||||||
|
|
||||||
this.counters.frames += 1;
|
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);
|
const jpeg = new Uint8Array(buffer, FRAME_HEADER_LEN, length);
|
||||||
let bitmap: ImageBitmap;
|
let bitmap: ImageBitmap;
|
||||||
|
|
||||||
@@ -132,8 +127,28 @@ export class StreamClient {
|
|||||||
return;
|
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);
|
const current = this.frames.get(camera);
|
||||||
|
|
||||||
|
// 带符号差处理 u32 回绕。
|
||||||
if (current && ((frameId - current.frameId) | 0) <= 0) {
|
if (current && ((frameId - current.frameId) | 0) <= 0) {
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
return;
|
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() {
|
private scheduleReconnect() {
|
||||||
if (this.closed || this.retryTimer !== null) return;
|
if (this.closed || this.retryTimer !== null) return;
|
||||||
|
|
||||||
|
|||||||
@@ -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 />);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
// 被引用的工程(project reference)不能关掉 emit:tsc 需要它来产出 .tsbuildinfo,
|
||||||
|
// 否则会报 TS6310。构建产物落到 node_modules/.tmp,不进仓库。
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true
|
"emitDeclarationOnly": true,
|
||||||
|
"outDir": "node_modules/.tmp/tsconfig.node"
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user