desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名

界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/
(该目录不进仓库)。

1. 客户端显示对手的血量
   快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠
   数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己
   绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用
   `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。

2. 血条改到画面两个上角,平行四边形,一条分 5 格
   左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg),
   右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。
   条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、
   拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。
   注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条
   从外侧开始灭(代码里写了注释钉住这件事)。

3. 受击画面不抖动
   删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但
   **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的
   配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关,
   并有单测钉住"旧配置读得进来、默认不抖"。

4. 血量归零算击败,不显示"损毁"
   血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在
   Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己**
   (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血),
   两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也
   没有"这局结束了"的痕迹。

5. 裁判端可以改队伍名
   两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到
   「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个
   两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照,
   紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name`
   (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。
   名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置
   面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。

测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
This commit is contained in:
2026-10-14 20:48:12 +08:00
parent fd71b83e3a
commit a93ae065cf
23 changed files with 1144 additions and 239 deletions
+74 -10
View File
@@ -4,19 +4,19 @@
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|---|---|---|---|---|
| 用户端(`team`) | 一路 | 自己那边 + 血条 | 自己那块 | **要,改动需密码** |
| 裁判端(`referee`) | 两路并排 | 双方 | 发现列表 + 槽位 | 不需要 |
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进
一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。
```text
desktop/
core/ cam-core:不含任何界面与 Tauri 的东西(69 个测试)
core/ cam-core:不含任何界面与 Tauri 的东西(80 个测试)
src-tauri/ Tauri 后端:推流、命令、录制(30 个测试)
src/ React 前端:两套页面 + HUD
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
src/components/ 视频窗格、血条、瞄准器、装甲板表、录制面板、设置抽屉
src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉
src/pages/ 用户端 / 裁判端
```
@@ -33,7 +33,7 @@ pnpm tauri build # 出 .app 与 .dmg
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
```sh
cd desktop/core && cargo test # 69
cd desktop/core && cargo test # 80
cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg
```
@@ -195,13 +195,39 @@ IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/
## 用户端 HUD
### 血条
### 血条:两个上角,平行四边形,一条分 5 格
画面**下面**一条窄带里的胶囊条,不挡画面。宽度、是否显示数字/队名、低血阈值与脉动都可配。
**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数,
放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格
20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。
少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。
(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、
指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。)
宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区
在哪"是一直看得见的,而不是等真的低血了才第一次出现。
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留
一个 0 与灭掉的格子。
### 胜负横幅
一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是
「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接
看出是哪一边赢了。
- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是
输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的
自家主板会让人"凭空胜利"。
- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有
"这局结束了"的痕迹,截图、回放与现场的争议都无从对照。
- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**:
它是一局的结束,此刻不再需要瞄准。
### 瞄准器
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
@@ -235,11 +261,42 @@ IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
反馈有三样:整屏闪红、画面抖动、准星旁的伤害数字。**大号数字不放在画面正中**——第一版
放了,结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。
反馈有**两样**:整屏闪红、准星旁的伤害数字。**大号数字不放在画面正中**——第一版放了,
结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。
**没有"画面抖动"了。** 原来有第三样:被打中时整幅图(连瞄准器一起)晃一下。它去掉了,
理由有两条:
- 抖动恰好发生在最需要看清画面的那一下;
- 它把**画面**动了,而操作手看到画面动时的第一反应是"车在晃",不是"我被打中了"——一个
只有几百毫秒的信号,代价是让人怀疑一个不存在的问题。
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
面板里也没有这个开关了。
## 裁判端
### 队名可以直接改
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
规范化的三件事统一在 Rust 侧做(`config::sanitize_team_name`,有单测):合并空白、按**字符**
截断到 12 个字(按字节截断会切出半个字)、留空回落到「队伍一 / 队伍二」。超过长度在输入框里
就限制了,比提交后被悄悄截掉诚实。
命令是 `set_team_name(team, name)`——**一次只改一边**。界面本来可以传一整个两条的数组,但那样
每次改名都是"读两条、改一条、写两条",而读来的那两条来自最多 200 ms 前的快照,紧接着改另一边
就会把上一次的改动覆盖回去。**同样过密码闸门**:裁判端本来就没有密码(`Role::needs_password`
为假),所以对现场没有影响;而给一个不设闸门的"写配置"入口,等于在闸门上开一条旁路——今天
改的是名字,明天有人顺手把绑定也挪进来。
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
而且不会报错。
### 两格等宽
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
@@ -297,6 +354,7 @@ AP、板子,而不是二十行分配代码。现在它有一个直接对着这
role = "team"
roleChosen = true
team = 0
teamNames = ["队伍一", "队伍二"]
cameraId = "0x0DE00001"
armorId = "0x0A200001"
mirror = false
@@ -326,6 +384,8 @@ offsetX = 0.0
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
文件会让"解锁"永久失败。
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队
叫什么"是比赛本身的属性,不是裁判端这个身份的属性。
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
@@ -350,7 +410,7 @@ open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
## 测试
```sh
cd desktop/core && cargo test # 69:重组、发现、两道闸门、帧率窗口、槽位、配置、密码
cd desktop/core && cargo test # 80:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行
cd protocol && cargo test # 15
```
@@ -410,6 +470,10 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug |
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |