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:
@@ -6,13 +6,13 @@ UDP 图传。
|
|||||||
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 **Tauri v2 +
|
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 **Tauri v2 +
|
||||||
React**,用户端与裁判端两套页面(见 [`desktop/README.md`](desktop/README.md))。同一套
|
React**,用户端与裁判端两套页面(见 [`desktop/README.md`](desktop/README.md))。同一套
|
||||||
系统里还有一队一块的**装甲板**(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量,
|
系统里还有一队一块的**装甲板**(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量,
|
||||||
并把血量广播给裁判终端。
|
并把血量广播给局域网里的两端。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
protocol/ 线路格式 + 帧重组 三端共用,无 ESP-IDF 依赖,14 个单元测试
|
protocol/ 线路格式 + 帧重组 三端共用,无 ESP-IDF 依赖,14 个单元测试
|
||||||
. 图传固件 -> ESP32-S3 交叉编译,Xtensa
|
. 图传固件 -> ESP32-S3 交叉编译,Xtensa
|
||||||
armor/ 装甲板固件 -> ESP32-C3 独立 workspace,RISC-V;规则层 22 个单元测试
|
armor/ 装甲板固件 -> ESP32-C3 独立 workspace,RISC-V;规则层 22 个单元测试
|
||||||
desktop/ 桌面端 Tauri v2 + React;用户端(血条 + 瞄准器)/ 裁判端
|
desktop/ 桌面端 Tauri v2 + React;用户端(双方血条 + 瞄准器)/ 裁判端(可改队名)
|
||||||
core/ 不含界面与 Tauri 的网络层、配置、密码(可单测)
|
core/ 不含界面与 Tauri 的网络层、配置、密码(可单测)
|
||||||
src-tauri/ Tauri 后端:推流、命令、录制
|
src-tauri/ Tauri 后端:推流、命令、录制
|
||||||
src/ React 前端:两套页面 + HUD
|
src/ React 前端:两套页面 + HUD
|
||||||
@@ -43,7 +43,9 @@ desktop/ 桌面端 Tauri v2 + React;用户端(血条 + 瞄
|
|||||||
上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。
|
上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。
|
||||||
|
|
||||||
裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收
|
裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收
|
||||||
全部板子的视频,然后在**界面层**过滤到绑定的那一块。
|
全部板子的视频,然后在**界面层**过滤到绑定的那一块;**血量相反,两边都显示**——用户端的
|
||||||
|
画面左上角是自己、右上角是对手。血量本来就是明文广播的(裁判端与用户端收的是同一份),而
|
||||||
|
"自己的血与对手的血一起看"是这项运动里最基本的对照,藏起来只会让操作手看不见对手的血。
|
||||||
|
|
||||||
## 实测性能
|
## 实测性能
|
||||||
|
|
||||||
@@ -384,8 +386,9 @@ python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么
|
|||||||
|
|
||||||
桌面端侧:`desktop/core/src/armor.rs` 的 `ArmorHealthStore` 监听 5601,按 `(team, index)`
|
桌面端侧:`desktop/core/src/armor.rs` 的 `ArmorHealthStore` 监听 5601,按 `(team, index)`
|
||||||
存最后一次读数,四条都在 1.5 s 内收到才显示——**没有数据时显示占位符而不是 0**,
|
存最后一次读数,四条都在 1.5 s 内收到才显示——**没有数据时显示占位符而不是 0**,
|
||||||
0 看起来像真值,现场看到会以为对方已经没血了。界面上直接显示血量数字(整车一条血,
|
0 看起来像真值,现场看到会以为对方已经没血了。界面上的血量画在画面角上(一条分 5 格的
|
||||||
归零显示 `0 · 已击毁` 并转红);数字变化也会打进客户端日志。
|
平行四边形,20 点血一格),归零不写"已击毁/损毁",而是由图传中央的「胜利 / 击败」横幅表达
|
||||||
|
(见 [`desktop/README.md`](desktop/README.md));数字变化也会打进客户端日志。
|
||||||
|
|
||||||
同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板"
|
同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板"
|
||||||
并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用
|
并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用
|
||||||
|
|||||||
+4
-3
@@ -99,7 +99,8 @@ magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index(
|
|||||||
**裁判端界面原本看不到血量。** 协议与 `ArmorSource` 早已就位,但 Slint 里那 4 格只是
|
**裁判端界面原本看不到血量。** 协议与 `ArmorSource` 早已就位,但 Slint 里那 4 格只是
|
||||||
绿/灰方块 —— `hps` 的数值从没被渲染,于是"板子被打到 0"在界面上完全看不出来(现场
|
绿/灰方块 —— `hps` 的数值从没被渲染,于是"板子被打到 0"在界面上完全看不出来(现场
|
||||||
表现就是"敲死了裁判系统没掉血")。现在 `TeamHealth` 多了一个 `hp-text` 与 `critical`:
|
表现就是"敲死了裁判系统没掉血")。现在 `TeamHealth` 多了一个 `hp-text` 与 `critical`:
|
||||||
队伍名旁边直接显示血量数字,归零显示 `0 · 已击毁`,低于 30 或归零时数字与四格转红。
|
队伍名旁边直接显示血量数字,低于 30 或归零时数字与血条转红。归零**不写"已击毁"**——那一刻
|
||||||
|
图传中央会给出「胜利 / 击败」的结论(见 [`desktop/README.md`](../desktop/README.md))。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
@@ -219,8 +220,8 @@ W (306504) armor: BOOT long press: hp reset to 100 (new round)
|
|||||||
|
|
||||||
协议、`ArmorSource`、`ArmorHealthStore` 都对,但改之前 Slint 里那 4 格只是绿/灰方块,
|
协议、`ArmorSource`、`ArmorHealthStore` 都对,但改之前 Slint 里那 4 格只是绿/灰方块,
|
||||||
`hps` 的**数值从不渲染**——板子被打到 0,界面四格还是绿的,看起来像"没掉血"。现在
|
`hps` 的**数值从不渲染**——板子被打到 0,界面四格还是绿的,看起来像"没掉血"。现在
|
||||||
队伍名旁边直接显示数字(归零显示 `0 · 已击毁`,低于 30 转红),并且客户端日志会打
|
队伍名旁边直接显示数字(低于 30 转红,归零不写"已击毁",改由图传中央的胜负横幅表达),
|
||||||
`armor: team 0 hp 100 -> 97`。
|
并且客户端日志会打 `armor: team 0 hp 100 -> 97`。
|
||||||
|
|
||||||
## 实现要点(都是踩过的坑)
|
## 实现要点(都是踩过的坑)
|
||||||
|
|
||||||
|
|||||||
+74
-10
@@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 用户端(`team`) | 一路 | 自己那边 + 血条 | 自己那块 | **要,改动需密码** |
|
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
||||||
| 裁判端(`referee`) | 两路并排 | 双方 | 发现列表 + 槽位 | 不需要 |
|
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
||||||
|
|
||||||
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进
|
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进
|
||||||
一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。
|
一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
desktop/
|
desktop/
|
||||||
core/ cam-core:不含任何界面与 Tauri 的东西(69 个测试)
|
core/ cam-core:不含任何界面与 Tauri 的东西(80 个测试)
|
||||||
src-tauri/ Tauri 后端:推流、命令、录制(30 个测试)
|
src-tauri/ Tauri 后端:推流、命令、录制(30 个测试)
|
||||||
src/ React 前端:两套页面 + HUD
|
src/ React 前端:两套页面 + HUD
|
||||||
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
|
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
|
||||||
src/components/ 视频窗格、血条、瞄准器、装甲板表、录制面板、设置抽屉
|
src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉
|
||||||
src/pages/ 用户端 / 裁判端
|
src/pages/ 用户端 / 裁判端
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ pnpm tauri build # 出 .app 与 .dmg
|
|||||||
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd desktop/core && cargo test # 69
|
cd desktop/core && cargo test # 80
|
||||||
cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg
|
cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -195,13 +195,39 @@ IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/
|
|||||||
|
|
||||||
## 用户端 HUD
|
## 用户端 HUD
|
||||||
|
|
||||||
### 血条
|
### 血条:两个上角,平行四边形,一条分 5 格
|
||||||
|
|
||||||
画面**下面**一条窄带里的胶囊条,不挡画面。宽度、是否显示数字/队名、低血阈值与脉动都可配。
|
**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数,
|
||||||
|
放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格
|
||||||
|
20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。
|
||||||
|
少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。
|
||||||
|
|
||||||
|
(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、
|
||||||
|
指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。)
|
||||||
|
|
||||||
|
宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区
|
||||||
|
在哪"是一直看得见的,而不是等真的低血了才第一次出现。
|
||||||
|
|
||||||
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
|
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
|
||||||
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||||
|
|
||||||
|
血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留
|
||||||
|
一个 0 与灭掉的格子。
|
||||||
|
|
||||||
|
### 胜负横幅
|
||||||
|
|
||||||
|
一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是
|
||||||
|
「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接
|
||||||
|
看出是哪一边赢了。
|
||||||
|
|
||||||
|
- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是
|
||||||
|
输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的
|
||||||
|
自家主板会让人"凭空胜利"。
|
||||||
|
- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有
|
||||||
|
"这局结束了"的痕迹,截图、回放与现场的争议都无从对照。
|
||||||
|
- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**:
|
||||||
|
它是一局的结束,此刻不再需要瞄准。
|
||||||
|
|
||||||
### 瞄准器
|
### 瞄准器
|
||||||
|
|
||||||
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
|
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
|
||||||
@@ -235,11 +261,42 @@ IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/
|
|||||||
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
|
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 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`,两格的宽度只由容器决定。
|
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
|
||||||
@@ -297,6 +354,7 @@ AP、板子,而不是二十行分配代码。现在它有一个直接对着这
|
|||||||
role = "team"
|
role = "team"
|
||||||
roleChosen = true
|
roleChosen = true
|
||||||
team = 0
|
team = 0
|
||||||
|
teamNames = ["队伍一", "队伍二"]
|
||||||
cameraId = "0x0DE00001"
|
cameraId = "0x0DE00001"
|
||||||
armorId = "0x0A200001"
|
armorId = "0x0A200001"
|
||||||
mirror = false
|
mirror = false
|
||||||
@@ -326,6 +384,8 @@ offsetX = 0.0
|
|||||||
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
|
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
|
||||||
文件会让"解锁"永久失败。
|
文件会让"解锁"永久失败。
|
||||||
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
|
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
|
||||||
|
- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队
|
||||||
|
叫什么"是比赛本身的属性,不是裁判端这个身份的属性。
|
||||||
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
|
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
|
||||||
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
|
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
|
||||||
|
|
||||||
@@ -350,7 +410,7 @@ open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
|
|||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd desktop/core && cargo test # 69:重组、发现、两道闸门、帧率窗口、槽位、配置、密码
|
cd desktop/core && cargo test # 80:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
||||||
cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行
|
cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行
|
||||||
cd protocol && cargo test # 15
|
cd protocol && cargo test # 15
|
||||||
```
|
```
|
||||||
@@ -410,6 +470,10 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
|||||||
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
|
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
|
||||||
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
|
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
|
||||||
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
|
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
|
||||||
|
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
|
||||||
|
| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug |
|
||||||
|
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
|
||||||
|
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
|
||||||
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
|
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
|
||||||
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
|
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
|
||||||
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
|
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
|
||||||
|
|||||||
+119
-3
@@ -46,6 +46,18 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// 用处是将来真需要迁移时有个明确的判据,以及现场排查时能一眼看出文件是哪一版写的。
|
/// 用处是将来真需要迁移时有个明确的判据,以及现场排查时能一眼看出文件是哪一版写的。
|
||||||
pub const CONFIG_VERSION: u32 = 1;
|
pub const CONFIG_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// 队伍名的兜底值,下标就是队伍号。
|
||||||
|
///
|
||||||
|
/// 界面上到处都在用这两个名字(血条、装甲板表、占位文字),所以它们是**默认值**而不是
|
||||||
|
/// "没配置":配置里留空时回落到这里,而不是显示一个空白。
|
||||||
|
pub const DEFAULT_TEAM_NAMES: [&str; 2] = ["队伍一", "队伍二"];
|
||||||
|
|
||||||
|
/// 队伍名的最大长度(字符,不是字节)。
|
||||||
|
///
|
||||||
|
/// 名字会画在血条左上角/右上角那一小块地方,超长的名字会把血条挤走。按**字符**截断而不是
|
||||||
|
/// 字节:中文按字节截断会切出半个字,界面上显示成一个方块。
|
||||||
|
pub const TEAM_NAME_MAX_CHARS: usize = 12;
|
||||||
|
|
||||||
/// 客户端身份。
|
/// 客户端身份。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -245,11 +257,19 @@ impl HealthConfig {
|
|||||||
pub struct HitFeedbackConfig {
|
pub struct HitFeedbackConfig {
|
||||||
/// 整屏闪一下红。
|
/// 整屏闪一下红。
|
||||||
pub flash: bool,
|
pub flash: bool,
|
||||||
/// 画面抖一下。
|
/// **已废弃,保留只是为了兼容旧配置文件。**
|
||||||
|
///
|
||||||
|
/// 命中时抖动画面的做法去掉了:抖动恰恰发生在最需要看清画面的那一下,而且它让**整幅
|
||||||
|
/// 图**(包括瞄准器)跟着动,操作手会误以为车在晃。现在这个字段读进来就丢掉,界面不再
|
||||||
|
/// 画抖动、设置面板里也没有这个开关。
|
||||||
|
///
|
||||||
|
/// 不删掉字段的理由很实际:这个文件是 `deny_unknown_fields` 的,删字段会让所有已经写
|
||||||
|
/// 过 `shake = true` 的配置**直接解析失败**,而配置解析失败等于程序起不来。
|
||||||
|
#[serde(default)]
|
||||||
pub shake: bool,
|
pub shake: bool,
|
||||||
/// 在瞄点旁标出伤害数字。
|
/// 在瞄点旁标出伤害数字。
|
||||||
pub marker: bool,
|
pub marker: bool,
|
||||||
/// 闪红与抖动的强度(0.2–2.0)。
|
/// 闪红与伤害数字的强度(0.2–2.0)。
|
||||||
pub intensity: f32,
|
pub intensity: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +277,8 @@ impl Default for HitFeedbackConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
flash: true,
|
flash: true,
|
||||||
shake: true,
|
// 见字段文档:不再抖动。默认值也就是"读旧配置时会被解析成什么"。
|
||||||
|
shake: false,
|
||||||
marker: true,
|
marker: true,
|
||||||
intensity: 1.0,
|
intensity: 1.0,
|
||||||
}
|
}
|
||||||
@@ -401,6 +422,14 @@ pub struct Config {
|
|||||||
pub role_chosen: bool,
|
pub role_chosen: bool,
|
||||||
/// 用户端属于哪一边:0 = 队伍一,1 = 队伍二。
|
/// 用户端属于哪一边:0 = 队伍一,1 = 队伍二。
|
||||||
pub team: u8,
|
pub team: u8,
|
||||||
|
/// 两边在界面上显示的名字,下标就是队伍号(裁判端可以直接改)。
|
||||||
|
///
|
||||||
|
/// 放在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的队伍叫什么"
|
||||||
|
/// 是**比赛**的属性,不是裁判端这个身份的属性。
|
||||||
|
///
|
||||||
|
/// 空字符串会回落到 [`DEFAULT_TEAM_NAMES`](见 [`Config::normalized`])——名字是画在
|
||||||
|
/// 血条角上的,留空会在现场显示成一个没有名字的血条,而不是"没配置"。
|
||||||
|
pub team_names: [String; 2],
|
||||||
/// 绑定的图传板(MAC 派生,重启不变)。
|
/// 绑定的图传板(MAC 派生,重启不变)。
|
||||||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||||||
pub camera_id: Option<u32>,
|
pub camera_id: Option<u32>,
|
||||||
@@ -427,6 +456,10 @@ impl Default for Config {
|
|||||||
role: Role::Referee,
|
role: Role::Referee,
|
||||||
role_chosen: false,
|
role_chosen: false,
|
||||||
team: 0,
|
team: 0,
|
||||||
|
team_names: [
|
||||||
|
DEFAULT_TEAM_NAMES[0].to_string(),
|
||||||
|
DEFAULT_TEAM_NAMES[1].to_string(),
|
||||||
|
],
|
||||||
camera_id: None,
|
camera_id: None,
|
||||||
armor_id: None,
|
armor_id: None,
|
||||||
mirror: true,
|
mirror: true,
|
||||||
@@ -509,6 +542,10 @@ impl Config {
|
|||||||
pub fn normalized(mut self) -> Self {
|
pub fn normalized(mut self) -> Self {
|
||||||
self.hud = self.hud.clamped();
|
self.hud = self.hud.clamped();
|
||||||
self.recording = self.recording.clamped();
|
self.recording = self.recording.clamped();
|
||||||
|
self.team_names = [
|
||||||
|
sanitize_team_name(&self.team_names[0], 0),
|
||||||
|
sanitize_team_name(&self.team_names[1], 1),
|
||||||
|
];
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +576,20 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 队伍名规范化:合并空白、按字符截断、空则回落到默认名。
|
||||||
|
///
|
||||||
|
/// 名字画在血条左上角/右上角那一小块地方,所以这三件事都是为了"不把布局弄坏":换行会在角上
|
||||||
|
/// 多顶出一行、超长会把血条挤走、空串会显示成一个没有名字的血条(而不是"没配置")。
|
||||||
|
pub fn sanitize_team_name(name: &str, team: usize) -> String {
|
||||||
|
let fallback = DEFAULT_TEAM_NAMES[team.min(1)];
|
||||||
|
|
||||||
|
// 合并内部空白(含换行与制表符):`"队伍 一"` 保留单个空格,`"a\nb"` 变成 `"a b"`。
|
||||||
|
let collapsed = name.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let text = if collapsed.is_empty() { fallback } else { collapsed.as_str() };
|
||||||
|
|
||||||
|
text.chars().take(TEAM_NAME_MAX_CHARS).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// 配置文件开头那段自我说明。
|
/// 配置文件开头那段自我说明。
|
||||||
const HEADER: &str = "\
|
const HEADER: &str = "\
|
||||||
# cam-desktop 配置。
|
# cam-desktop 配置。
|
||||||
@@ -551,6 +602,8 @@ const HEADER: &str = "\
|
|||||||
# team = 用户端:一块画面 + 自己的血条与瞄准器
|
# team = 用户端:一块画面 + 自己的血条与瞄准器
|
||||||
# referee = 裁判端:两块画面 + 双方血量 + 装甲板列表 + 录制
|
# referee = 裁判端:两块画面 + 双方血量 + 装甲板列表 + 录制
|
||||||
# team : 0 = 队伍一, 1 = 队伍二(仅 team 端用)
|
# team : 0 = 队伍一, 1 = 队伍二(仅 team 端用)
|
||||||
|
# teamNames: 两边在界面上显示的名字,下标就是队伍号;裁判端可以直接改。
|
||||||
|
# 超过 12 个字会被截断,留空则回落到「队伍一 / 队伍二」。
|
||||||
# cameraId : 图传板 id,由 MAC 派生,重启不变,所以绑定是稳的
|
# cameraId : 图传板 id,由 MAC 派生,重启不变,所以绑定是稳的
|
||||||
# armorId : 装甲板主板 id,同上
|
# armorId : 装甲板主板 id,同上
|
||||||
# mirror : true = 左右镜像(摄像头装反了,默认)
|
# mirror : true = 左右镜像(摄像头装反了,默认)
|
||||||
@@ -816,6 +869,7 @@ mod tests {
|
|||||||
config.role = Role::Team;
|
config.role = Role::Team;
|
||||||
config.role_chosen = true;
|
config.role_chosen = true;
|
||||||
config.team = 1;
|
config.team = 1;
|
||||||
|
config.team_names = ["红队".to_string(), "蓝队".to_string()];
|
||||||
config.camera_id = Some(0x3D4D_8A9A);
|
config.camera_id = Some(0x3D4D_8A9A);
|
||||||
config.armor_id = Some(0x00AB_CDEF);
|
config.armor_id = Some(0x00AB_CDEF);
|
||||||
config.mirror = false;
|
config.mirror = false;
|
||||||
@@ -832,6 +886,7 @@ mod tests {
|
|||||||
assert_eq!(back.role, Role::Team);
|
assert_eq!(back.role, Role::Team);
|
||||||
assert!(back.role_chosen);
|
assert!(back.role_chosen);
|
||||||
assert_eq!(back.team, 1);
|
assert_eq!(back.team, 1);
|
||||||
|
assert_eq!(back.team_names, config.team_names);
|
||||||
assert_eq!(back.camera_id, Some(0x3D4D_8A9A));
|
assert_eq!(back.camera_id, Some(0x3D4D_8A9A));
|
||||||
assert_eq!(back.armor_id, Some(0x00AB_CDEF));
|
assert_eq!(back.armor_id, Some(0x00AB_CDEF));
|
||||||
assert!(!back.mirror);
|
assert!(!back.mirror);
|
||||||
@@ -933,6 +988,67 @@ roleChosen = true"#)
|
|||||||
assert!(hud.crosshair.offset_x <= 0.5);
|
assert!(hud.crosshair.offset_x <= 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_names_are_sanitized_before_they_reach_the_disk() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
// 前后空白 + 14 个字(超过上限)+ 换行 + 一个纯空白的名字。
|
||||||
|
config.team_names = [
|
||||||
|
" 很长的队伍名字超过了十二个字 ".to_string(),
|
||||||
|
" \n\t ".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let config = config.normalized();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.team_names[0].chars().count(),
|
||||||
|
TEAM_NAME_MAX_CHARS,
|
||||||
|
"按字符截断:按字节截断会切出半个字"
|
||||||
|
);
|
||||||
|
assert_eq!(config.team_names[0], "很长的队伍名字超过了十二");
|
||||||
|
assert_eq!(
|
||||||
|
config.team_names[1], DEFAULT_TEAM_NAMES[1],
|
||||||
|
"留空回落到默认名,而不是显示一个没有名字的血条"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_names_survive_the_round_trip() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.team_names = ["红队".to_string(), "Blue Team".to_string()];
|
||||||
|
|
||||||
|
let text = config.render().expect("renders");
|
||||||
|
assert!(text.contains("teamNames"), "key name matters:\n{text}");
|
||||||
|
|
||||||
|
let back = Config::parse(&text).expect("parses");
|
||||||
|
assert_eq!(back.team_names, config.team_names);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_config_without_team_names_gets_the_defaults() {
|
||||||
|
let config = Config::parse("role = \"team\"\nteam = 1").expect("parses");
|
||||||
|
assert_eq!(
|
||||||
|
config.team_names,
|
||||||
|
[
|
||||||
|
DEFAULT_TEAM_NAMES[0].to_string(),
|
||||||
|
DEFAULT_TEAM_NAMES[1].to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_config_with_shake_true_still_loads() {
|
||||||
|
// 抖动去掉了,但字段必须还能读:这个文件是 deny_unknown_fields 的,删字段会让
|
||||||
|
// 所有写过 shake = true 的配置解析失败,而配置解析失败等于程序起不来。
|
||||||
|
let config = Config::parse("[hud.hitFeedback]\nshake = true")
|
||||||
|
.expect("旧配置必须还能读进来");
|
||||||
|
|
||||||
|
assert!(config.hud.hit_feedback.shake, "字段留着,只是不再生效");
|
||||||
|
assert!(
|
||||||
|
!HitFeedbackConfig::default().shake,
|
||||||
|
"默认值是不抖动"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_and_load_do_not_lose_the_password_hash() {
|
fn save_and_load_do_not_lose_the_password_hash() {
|
||||||
let dir = std::env::temp_dir().join(format!("cam-desktop-cfg-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("cam-desktop-cfg-{}", std::process::id()));
|
||||||
|
|||||||
+205
-15
@@ -194,14 +194,31 @@ pub struct TeamHealthDto {
|
|||||||
pub board_text: Option<String>,
|
pub board_text: Option<String>,
|
||||||
/// 血量偏低或已归零。
|
/// 血量偏低或已归零。
|
||||||
pub critical: bool,
|
pub critical: bool,
|
||||||
|
/// 这一边的胜负结论,由 [`resolve_outcomes`] 从**双方**的血量算出来。
|
||||||
|
///
|
||||||
|
/// 放在每一边的 DTO 上而不是单独一个全局字段:界面上一格画面就是一边,一个格子里
|
||||||
|
/// 要放的就是"从我这边看,现在是什么结果"。裁判端两格因此各看各的那份。
|
||||||
|
pub outcome: Option<MatchOutcome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一边看到的比赛结果。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MatchOutcome {
|
||||||
|
/// 对手归零,自己还活着。
|
||||||
|
Victory,
|
||||||
|
/// 自己归零,对手还活着。
|
||||||
|
Defeat,
|
||||||
|
/// 两边同时归零。不写成"平局"以外的任何一个字:同时打空时两边都看到"胜利"才是错的。
|
||||||
|
Draw,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TeamHealthDto {
|
impl TeamHealthDto {
|
||||||
/// 还没有数据源。
|
/// 还没有数据源。
|
||||||
pub fn absent(team: u8, board: Option<u32>) -> Self {
|
pub fn absent(team: u8, board: Option<u32>, names: &[String; 2]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
team,
|
team,
|
||||||
name: team_name(team),
|
name: team_name(team, names),
|
||||||
available: false,
|
available: false,
|
||||||
placeholder: format!(
|
placeholder: format!(
|
||||||
"装甲板未接入:等主板在 UDP {} 上广播血量",
|
"装甲板未接入:等主板在 UDP {} 上广播血量",
|
||||||
@@ -214,16 +231,23 @@ impl TeamHealthDto {
|
|||||||
board,
|
board,
|
||||||
board_text: board.map(id_text),
|
board_text: board.map(id_text),
|
||||||
critical: false,
|
critical: false,
|
||||||
|
outcome: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有数据。
|
/// 有数据。
|
||||||
pub fn present(team: u8, board: Option<u32>, hps: [u16; crate::receiver::ARMOR_BOARDS], low: u16) -> Self {
|
pub fn present(
|
||||||
|
team: u8,
|
||||||
|
board: Option<u32>,
|
||||||
|
hps: [u16; crate::receiver::ARMOR_BOARDS],
|
||||||
|
low: u16,
|
||||||
|
names: &[String; 2],
|
||||||
|
) -> Self {
|
||||||
let hp = hps.first().copied().unwrap_or(0);
|
let hp = hps.first().copied().unwrap_or(0);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
team,
|
team,
|
||||||
name: team_name(team),
|
name: team_name(team, names),
|
||||||
available: true,
|
available: true,
|
||||||
placeholder: String::new(),
|
placeholder: String::new(),
|
||||||
hp: Some(hp),
|
hp: Some(hp),
|
||||||
@@ -231,15 +255,70 @@ impl TeamHealthDto {
|
|||||||
board,
|
board,
|
||||||
board_text: board.map(id_text),
|
board_text: board.map(id_text),
|
||||||
critical: hp == 0 || hp <= low,
|
critical: hp == 0 || hp <= low,
|
||||||
|
outcome: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 队伍名。
|
/// 把双方的血量合起来算成各自看到的胜负结论,写回 `outcome`。
|
||||||
pub fn team_name(team: u8) -> String {
|
///
|
||||||
match team {
|
/// # 规则是有意不对称的
|
||||||
0 => "队伍一".to_string(),
|
///
|
||||||
_ => "队伍二".to_string(),
|
/// - **输只需要看自己**:自己归零就是输,哪怕对手那块板子的数据没收到。这一条要成立,
|
||||||
|
/// 否则"我被打死了但对手的主板掉线"会显示成什么都没发生。
|
||||||
|
/// - **赢要看两边**:必须看到对手归零**且**自己还有血。少了后一半,一块从没上报过血量的
|
||||||
|
/// 自己方主板会让人"凭空胜利"。
|
||||||
|
/// - 两边同时归零是平局:那种情况下任何一边显示"胜利"都是错的。
|
||||||
|
///
|
||||||
|
/// 变量不足(对不上的队伍、只有一份 DTO)时什么都不写。
|
||||||
|
pub fn resolve_outcomes(health: &mut [TeamHealthDto]) {
|
||||||
|
if health.len() != 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dead = |index: usize| health[index].available && health[index].hp == Some(0);
|
||||||
|
let alive = |index: usize| matches!(health[index].hp, Some(hp) if hp > 0) && health[index].available;
|
||||||
|
|
||||||
|
let (a_dead, b_dead) = (dead(0), dead(1));
|
||||||
|
let (a_alive, b_alive) = (alive(0), alive(1));
|
||||||
|
|
||||||
|
health[0].outcome = if a_dead && b_dead {
|
||||||
|
Some(MatchOutcome::Draw)
|
||||||
|
} else if b_dead && a_alive {
|
||||||
|
Some(MatchOutcome::Victory)
|
||||||
|
} else if a_dead {
|
||||||
|
Some(MatchOutcome::Defeat)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
health[1].outcome = if a_dead && b_dead {
|
||||||
|
Some(MatchOutcome::Draw)
|
||||||
|
} else if a_dead && b_alive {
|
||||||
|
Some(MatchOutcome::Victory)
|
||||||
|
} else if b_dead {
|
||||||
|
Some(MatchOutcome::Defeat)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 队伍名的兜底值(与 `Config::default().team_names` 一致)。
|
||||||
|
pub fn default_team_name(team: u8) -> String {
|
||||||
|
crate::config::DEFAULT_TEAM_NAMES[usize::from(team.min(1))].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一边在界面上叫什么。
|
||||||
|
///
|
||||||
|
/// 配置里的名字是裁判端可以改的,所以名字必须**从这里来**,不能让界面各处自己拼
|
||||||
|
/// "队伍一"/"队伍二"——那种拼法会在改了名字之后留下一半旧名字。
|
||||||
|
pub fn team_name(team: u8, names: &[String; 2]) -> String {
|
||||||
|
let name = names[usize::from(team.min(1))].trim();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
default_team_name(team)
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +332,7 @@ pub struct HitDto {
|
|||||||
pub from: u16,
|
pub from: u16,
|
||||||
pub to: u16,
|
pub to: u16,
|
||||||
pub damage: u16,
|
pub damage: u16,
|
||||||
/// Unix 毫秒。前端用它驱动闪红/抖动的动画,不需要自己计时。
|
/// Unix 毫秒。前端用它驱动闪红的动画,不需要自己计时。
|
||||||
pub at_ms: u64,
|
pub at_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +397,9 @@ pub struct Snapshot {
|
|||||||
pub stats: Vec<CameraStatsDto>,
|
pub stats: Vec<CameraStatsDto>,
|
||||||
/// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。
|
/// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。
|
||||||
pub slots: SlotsDto,
|
pub slots: SlotsDto,
|
||||||
/// 用户端一条(自己那边),裁判端两条。
|
/// **两端都是两条,顺序是队伍号 0、1**:用户端也要显示对手的血量(画面右上角)。
|
||||||
|
///
|
||||||
|
/// 界面按 `team` 取自己那一格,不靠数组下标——用户端绑的是队伍二时 `health[0]` 是别人。
|
||||||
pub health: Vec<TeamHealthDto>,
|
pub health: Vec<TeamHealthDto>,
|
||||||
pub recordings: Vec<RecordingDto>,
|
pub recordings: Vec<RecordingDto>,
|
||||||
/// 启动时的说明(旧配置迁移、端口占用等)。
|
/// 启动时的说明(旧配置迁移、端口占用等)。
|
||||||
@@ -336,6 +417,9 @@ pub struct ConfigView {
|
|||||||
/// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。
|
/// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。
|
||||||
pub role_chosen: bool,
|
pub role_chosen: bool,
|
||||||
pub team: u8,
|
pub team: u8,
|
||||||
|
/// 两边在界面上显示的名字(裁判端可以改)。界面各处显示队伍名都要用它,
|
||||||
|
/// 包括血条之外的地方(装甲板表、等待画面的占位文字)。
|
||||||
|
pub team_names: [String; 2],
|
||||||
pub camera_id: Option<u32>,
|
pub camera_id: Option<u32>,
|
||||||
pub camera_id_text: Option<String>,
|
pub camera_id_text: Option<String>,
|
||||||
pub armor_id: Option<u32>,
|
pub armor_id: Option<u32>,
|
||||||
@@ -364,6 +448,7 @@ impl From<&Config> for ConfigView {
|
|||||||
role: config.role,
|
role: config.role,
|
||||||
role_chosen: config.role_chosen,
|
role_chosen: config.role_chosen,
|
||||||
team: config.team,
|
team: config.team,
|
||||||
|
team_names: config.team_names.clone(),
|
||||||
camera_id: config.camera_id,
|
camera_id: config.camera_id,
|
||||||
camera_id_text: config.camera_id.map(id_text),
|
camera_id_text: config.camera_id.map(id_text),
|
||||||
armor_id: config.armor_id,
|
armor_id: config.armor_id,
|
||||||
@@ -445,29 +530,134 @@ mod tests {
|
|||||||
assert_eq!(dto.status, "离线");
|
assert_eq!(dto.status, "离线");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 默认队名,测试里到处用。
|
||||||
|
fn names() -> [String; 2] {
|
||||||
|
["队伍一".to_string(), "队伍二".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn absent_health_is_a_placeholder_with_four_cells() {
|
fn absent_health_is_a_placeholder_with_four_cells() {
|
||||||
let dto = TeamHealthDto::absent(0, None);
|
let dto = TeamHealthDto::absent(0, None, &names());
|
||||||
|
|
||||||
assert!(!dto.available);
|
assert!(!dto.available);
|
||||||
assert_eq!(dto.hp, None);
|
assert_eq!(dto.hp, None);
|
||||||
assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS);
|
assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS);
|
||||||
assert!(!dto.placeholder.is_empty());
|
assert!(!dto.placeholder.is_empty());
|
||||||
|
assert_eq!(dto.outcome, None, "没有数据时不下结论");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn critical_health_is_flagged_at_zero_and_at_the_threshold() {
|
fn critical_health_is_flagged_at_zero_and_at_the_threshold() {
|
||||||
let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30);
|
let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30, &names());
|
||||||
assert!(low.critical, "at the threshold");
|
assert!(low.critical, "at the threshold");
|
||||||
|
|
||||||
let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30);
|
let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30, &names());
|
||||||
assert!(!ok.critical);
|
assert!(!ok.critical);
|
||||||
|
|
||||||
let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30);
|
let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30, &names());
|
||||||
assert!(dead.critical);
|
assert!(dead.critical);
|
||||||
assert_eq!(dead.hp, Some(0));
|
assert_eq!(dead.hp, Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_team_name_comes_from_the_config_not_from_a_hard_coded_string() {
|
||||||
|
let custom = ["红队".to_string(), "蓝队".to_string()];
|
||||||
|
|
||||||
|
assert_eq!(TeamHealthDto::absent(1, None, &custom).name, "蓝队");
|
||||||
|
assert_eq!(
|
||||||
|
TeamHealthDto::present(0, Some(1), [50; 4], 30, &custom).name,
|
||||||
|
"红队"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 空名字回落默认值,而不是显示一个没有名字的血条。
|
||||||
|
let blank = ["".to_string(), " ".to_string()];
|
||||||
|
assert_eq!(team_name(0, &blank), "队伍一");
|
||||||
|
assert_eq!(team_name(1, &blank), "队伍二");
|
||||||
|
assert_eq!(team_name(9, &blank), "队伍二", "越界的队伍号按第二队处理");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn killing_the_opponent_reads_as_a_victory_for_the_other_side() {
|
||||||
|
let mut health = vec![
|
||||||
|
TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()),
|
||||||
|
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
|
||||||
|
];
|
||||||
|
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
|
assert_eq!(health[0].outcome, Some(MatchOutcome::Victory));
|
||||||
|
assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_is_announced_while_both_sides_are_alive() {
|
||||||
|
let mut health = vec![
|
||||||
|
TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()),
|
||||||
|
TeamHealthDto::present(1, Some(2), [40; 4], 30, &names()),
|
||||||
|
];
|
||||||
|
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
|
assert_eq!(health[0].outcome, None);
|
||||||
|
assert_eq!(health[1].outcome, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_sides_at_zero_is_a_draw_rather_than_two_victories() {
|
||||||
|
let mut health = vec![
|
||||||
|
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
|
||||||
|
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
|
||||||
|
];
|
||||||
|
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
|
assert_eq!(health[0].outcome, Some(MatchOutcome::Draw));
|
||||||
|
assert_eq!(health[1].outcome, Some(MatchOutcome::Draw));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dead_team_loses_even_when_the_other_board_is_missing() {
|
||||||
|
// 输只看自己:对手的主板掉线不该让"我被击毁"变成什么都没发生。
|
||||||
|
let mut health = vec![
|
||||||
|
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
|
||||||
|
TeamHealthDto::absent(1, None, &names()),
|
||||||
|
];
|
||||||
|
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
|
assert_eq!(health[0].outcome, Some(MatchOutcome::Defeat));
|
||||||
|
assert_eq!(health[1].outcome, None, "没有自己数据的这边不下结论");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_own_board_does_not_produce_a_victory() {
|
||||||
|
// 赢要看两边:只看到对手归零、不知道自己还有没有血时不能宣布胜利。
|
||||||
|
let mut health = vec![
|
||||||
|
TeamHealthDto::absent(0, None, &names()),
|
||||||
|
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
|
||||||
|
];
|
||||||
|
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
|
assert_eq!(health[0].outcome, None);
|
||||||
|
assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_snapshot_serialises_the_outcome_as_a_plain_word() {
|
||||||
|
let mut health = vec![
|
||||||
|
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
|
||||||
|
TeamHealthDto::present(1, Some(2), [50; 4], 30, &names()),
|
||||||
|
];
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&health[0]).unwrap();
|
||||||
|
assert!(json.contains(r#""outcome":"defeat""#), "got {json}");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&health[1]).unwrap();
|
||||||
|
assert!(json.contains(r#""outcome":"victory""#), "got {json}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_recording_reports_the_framerate_it_told_ffmpeg() {
|
fn a_recording_reports_the_framerate_it_told_ffmpeg() {
|
||||||
// 回放速度不对时,这是唯一能解释原因的数字。
|
// 回放速度不对时,这是唯一能解释原因的数字。
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ pub fn bind_armor(
|
|||||||
Ok(ConfigView::from(&state.config()))
|
Ok(ConfigView::from(&state.config()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 自己属于哪一边(0/1)。改错队号会让血条显示对手的血量,所以也要密码。
|
/// 自己属于哪一边(0/1)。改错队号会把对手的血条放在左上角(当成自己的),所以也要密码。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_team(
|
pub fn set_team(
|
||||||
team: u8,
|
team: u8,
|
||||||
@@ -232,6 +232,42 @@ pub fn set_referee_slots(
|
|||||||
Ok(ConfigView::from(&state.config()))
|
Ok(ConfigView::from(&state.config()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 改一边在界面上显示的名字(0 = 队伍一,1 = 队伍二)。
|
||||||
|
///
|
||||||
|
/// # 为什么一次只改一边
|
||||||
|
///
|
||||||
|
/// 界面本来可以传一整个 `[String; 2]`,但那意味着每次改名都是"读两条、改一条、写两条",
|
||||||
|
/// 而读来的那两条来自最多 200 ms 前的快照:紧接着改另一边就会把上一次的改动覆盖回去。
|
||||||
|
/// 一次只改一边,这个窗口不存在。
|
||||||
|
///
|
||||||
|
/// # 为什么这个命令也过密码闸门
|
||||||
|
///
|
||||||
|
/// 名字本身不是判罚数据,但它写在**配置文件**里。给一个不设闸门的"改配置"入口,等于在
|
||||||
|
/// 密码闸门上开一条旁路——今天改的是名字,明天有人顺手把绑定也挪进来。裁判端本来就没有
|
||||||
|
/// 密码(`Role::needs_password` 为假),所以对裁判端这个闸门是空的,界面上的入口也在裁判端。
|
||||||
|
///
|
||||||
|
/// 规范化(去空白、截断、留空回落默认名)由 [`cam_core::config::Config::normalized`] 统一
|
||||||
|
/// 做,这里只管队伍号合不合法。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_team_name(
|
||||||
|
team: u8,
|
||||||
|
name: String,
|
||||||
|
password: Option<String>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
|
if team > 1 {
|
||||||
|
return Err("队伍号只能是 0(队伍一)或 1(队伍二)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.update_config(move |config| config.team_names[usize::from(team)] = name)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
/// 录制目录与 ffmpeg 路径。
|
/// 录制目录与 ffmpeg 路径。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_recording_options(
|
pub fn set_recording_options(
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ fn spawn_armor(state: Arc<AppState>) {
|
|||||||
ticks[slot] += 1;
|
ticks[slot] += 1;
|
||||||
|
|
||||||
if hp[slot] == 0 {
|
if hp[slot] == 0 {
|
||||||
// 打空之后停一会儿再重置:让界面上"已击毁"的状态真的能被看到。
|
// 打空之后停一会儿再重置:让界面上的胜负横幅真的能被看到。
|
||||||
dead_for[slot] += 1;
|
dead_for[slot] += 1;
|
||||||
if dead_for[slot] >= 10 {
|
if dead_for[slot] >= 10 {
|
||||||
hp[slot] = 100;
|
hp[slot] = 100;
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
commands::set_role,
|
commands::set_role,
|
||||||
commands::set_mirror,
|
commands::set_mirror,
|
||||||
commands::save_hud,
|
commands::save_hud,
|
||||||
|
commands::set_team_name,
|
||||||
commands::set_referee_slots,
|
commands::set_referee_slots,
|
||||||
commands::set_recording_options,
|
commands::set_recording_options,
|
||||||
commands::screenshot,
|
commands::screenshot,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ use cam_core::receiver::{Discovery, LatestFrames, Receiver};
|
|||||||
use cam_core::ArmorSource;
|
use cam_core::ArmorSource;
|
||||||
use cam_core::slots::SlotAllocator;
|
use cam_core::slots::SlotAllocator;
|
||||||
use cam_core::snapshot::{
|
use cam_core::snapshot::{
|
||||||
ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot, SlotsDto, TeamHealthDto,
|
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot,
|
||||||
|
SlotsDto, TeamHealthDto,
|
||||||
};
|
};
|
||||||
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -127,7 +128,7 @@ impl AppState {
|
|||||||
let discovery = receiver.discovery();
|
let discovery = receiver.discovery();
|
||||||
let latest = receiver.latest_frames();
|
let latest = receiver.latest_frames();
|
||||||
|
|
||||||
// 装甲板:两端都要收。用户端要显示自己的血条,裁判端要显示双方。
|
// 装甲板:两端都要收。用户端要显示**双方**的血条(左上自己、右上对手),裁判端也是双方。
|
||||||
let (armor, armor_socket) = match ArmorHealthStore::start() {
|
let (armor, armor_socket) = match ArmorHealthStore::start() {
|
||||||
Ok((store, socket)) => (store, Some(socket)),
|
Ok((store, socket)) => (store, Some(socket)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -543,11 +544,14 @@ impl AppState {
|
|||||||
|
|
||||||
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
||||||
|
|
||||||
let health = match config.role {
|
// 两边都给:用户端的画面右上角要显示对手的血量,裁判端本来就要双方。
|
||||||
// 用户端只显示自己那一边。
|
//
|
||||||
Role::Team => vec![self.health_for(config.team)],
|
// 顺序固定为队伍号 0、1,界面按 `team` 取那一格——**不靠数组下标**判断"哪个是我",
|
||||||
Role::Referee => vec![self.health_for(0), self.health_for(1)],
|
// 因为下标在"用户端绑的是队伍二"时就不再等于自己的队伍号了。
|
||||||
};
|
let mut health = vec![self.health_for(0), self.health_for(1)];
|
||||||
|
|
||||||
|
// 胜负是**两边合起来**才能算出来的东西,所以在这里补一步。
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
Snapshot {
|
Snapshot {
|
||||||
role: config.role,
|
role: config.role,
|
||||||
@@ -577,14 +581,21 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。
|
/// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。
|
||||||
|
///
|
||||||
|
/// 槽位指定只有**裁判端**有:`referee.armor_a/b` 是裁判端界面上的"左格/右格",用户端
|
||||||
|
/// 拿它当自己的板子会让绑定被一份不相干的配置盖掉。
|
||||||
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
|
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
|
||||||
let explicit = if team == 0 {
|
let explicit = if config.role == Role::Referee {
|
||||||
config.referee.armor_a
|
if team == 0 {
|
||||||
|
config.referee.armor_a
|
||||||
|
} else {
|
||||||
|
config.referee.armor_b
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
config.referee.armor_b
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if config.referee.manual_slots {
|
if config.role == Role::Referee && config.referee.manual_slots {
|
||||||
return explicit;
|
return explicit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,27 +612,29 @@ impl AppState {
|
|||||||
///
|
///
|
||||||
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
|
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
|
||||||
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
|
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
|
||||||
|
///
|
||||||
|
/// # 对手那一块绝不能用 `armor_id`
|
||||||
|
///
|
||||||
|
/// `armor_id` 是**我自己**绑定的主板。拿它去填对手那一格,右上角显示的就是自己的血——
|
||||||
|
/// 比不显示更糟:现场会照着一个假的对手血量做决策。
|
||||||
fn health_for(&self, team: u8) -> TeamHealthDto {
|
fn health_for(&self, team: u8) -> TeamHealthDto {
|
||||||
let config = self.config();
|
let config = self.config();
|
||||||
let low = config.hud.health.low_threshold;
|
let low = config.hud.health.low_threshold;
|
||||||
|
|
||||||
let board = match config.role {
|
let own = config.role == Role::Team && team == config.team;
|
||||||
Role::Team => config
|
let bound = if own { config.armor_id } else { None };
|
||||||
.armor_id
|
let board = bound.or_else(|| self.assigned_armor(&config, team));
|
||||||
.or_else(|| self.assigned_armor(&config, team)),
|
|
||||||
Role::Referee => self.assigned_armor(&config, team),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(board) = board {
|
if let Some(board) = board {
|
||||||
return match self.armor.health_of_board(board) {
|
return match self.armor.health_of_board(board) {
|
||||||
Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low),
|
Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names),
|
||||||
None => TeamHealthDto::absent(team, Some(board)),
|
None => TeamHealthDto::absent(team, Some(board), &config.team_names),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
match self.armor.health(team) {
|
match self.armor.health(team) {
|
||||||
Some(hps) => TeamHealthDto::present(team, None, hps, low),
|
Some(hps) => TeamHealthDto::present(team, None, hps, low, &config.team_names),
|
||||||
None => TeamHealthDto::absent(team, None),
|
None => TeamHealthDto::absent(team, None, &config.team_names),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import type { ArmorBoardDto, SlotsDto } from "../lib/types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
boards: ArmorBoardDto[];
|
boards: ArmorBoardDto[];
|
||||||
slots: SlotsDto;
|
slots: SlotsDto;
|
||||||
|
/** 两边显示的名字(裁判端可改)。缺省时回落到「队伍一 / 队伍二」。 */
|
||||||
|
names?: [string, string];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArmorTable({ boards, slots }: Props) {
|
export function ArmorTable({ boards, slots, names }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="armor-table">
|
<div className="armor-table">
|
||||||
<div className="armor-table__head">
|
<div className="armor-table__head">
|
||||||
@@ -36,7 +38,11 @@ export function ArmorTable({ boards, slots }: Props) {
|
|||||||
|
|
||||||
{boards.map((board) => {
|
{boards.map((board) => {
|
||||||
const assigned =
|
const assigned =
|
||||||
slots.armorA === board.id ? "左(队伍一)" : slots.armorB === board.id ? "右(队伍二)" : "—";
|
slots.armorA === board.id
|
||||||
|
? `左(${teamName(0, names)})`
|
||||||
|
: slots.armorB === board.id
|
||||||
|
? `右(${teamName(1, names)})`
|
||||||
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -45,7 +51,7 @@ export function ArmorTable({ boards, slots }: Props) {
|
|||||||
>
|
>
|
||||||
<span className="mono">{board.idText}</span>
|
<span className="mono">{board.idText}</span>
|
||||||
<span className="mono dim">{board.addr}</span>
|
<span className="mono dim">{board.addr}</span>
|
||||||
<span style={{ color: teamColor(board.team) }}>{teamName(board.team)}</span>
|
<span style={{ color: teamColor(board.team) }}>{teamName(board.team, names)}</span>
|
||||||
<span className={`mono${board.hp === 0 ? " danger" : ""}`}>{board.hp}</span>
|
<span className={`mono${board.hp === 0 ? " danger" : ""}`}>{board.hp}</span>
|
||||||
<span className={board.reportsAllPlates ? "dim" : "warn"}>
|
<span className={board.reportsAllPlates ? "dim" : "warn"}>
|
||||||
{board.platesSeen}/4
|
{board.platesSeen}/4
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* 底部胶囊血条。
|
* 画面角上的分段血条(平行四边形,一条分 5 格)。
|
||||||
*
|
*
|
||||||
* # 为什么它在画面**下面**而不是叠在画面上
|
* # 为什么改到角上、改成斜的
|
||||||
*
|
*
|
||||||
* 用户端要的是"一眼看到还剩多少血",而不是把画面挡住。放在画面下沿一条窄带里,
|
* 原来它在画面**下面**一条窄带里。改成画面左上角/右上角之后:
|
||||||
* 视频区域一个像素都不占;裁判端并排两路时也正好对称。
|
*
|
||||||
|
* - 画面不再是"被一条横带压住的一块",整块视频区域给到取景;
|
||||||
|
* - 两边各占一角,**同时**看到自己与对手的血量,视线不用离开画面;
|
||||||
|
* - 斜切的形状不会在第一时间被误认成字幕条或进度条,扫一眼就知道是血量。
|
||||||
|
*
|
||||||
|
* 左上角给自己、右上角给对手(见 `TeamPage`)。裁判端两格各自一个角,与用户端同一套
|
||||||
|
* 视觉语言。
|
||||||
|
*
|
||||||
|
* # 一条分 5 格 = 一格 20 点
|
||||||
|
*
|
||||||
|
* 整条是一条,里面分成 **5 格**:格子少而大,隔着几米扫一眼就能数出"还剩三格",
|
||||||
|
* 比读一条连续的长度或一个百分比快。血量归零就是 0 格,那时图传中央会给出胜负。
|
||||||
|
*
|
||||||
|
* 血少了,亮着的格子从**中心那一侧**开始灭(左条从右往左灭,右条从左往右灭),所以两条
|
||||||
|
* 血一起减少时,视觉上是往两侧退——不会看成"条变长了"。
|
||||||
*
|
*
|
||||||
* # 未接入时不显示 0
|
* # 未接入时不显示 0
|
||||||
*
|
*
|
||||||
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到
|
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到 0
|
||||||
* 两个 0 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
* 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -20,14 +34,28 @@ import type { HealthConfig, TeamHealthDto } from "../lib/types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
health: TeamHealthDto;
|
health: TeamHealthDto;
|
||||||
config: HealthConfig;
|
config: HealthConfig;
|
||||||
/** 紧凑模式:裁判端两路并排时用。 */
|
/** 挂在画面哪一角。`right` 会把条整个镜像过来(含缺血的退向)。 */
|
||||||
compact?: boolean;
|
align: "left" | "right";
|
||||||
|
/** 这一格是谁:用户端用「我方 / 对手」把两条同色的血区分开。 */
|
||||||
|
tag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 整条分成几格。 */
|
||||||
|
const CELLS = 5;
|
||||||
|
/** 一格多少血。整车一条血 100 点。 */
|
||||||
|
const CELL_HP = 100 / CELLS;
|
||||||
|
|
||||||
/** 脉动的周期。与 CSS 动画保持一致。 */
|
/** 脉动的周期。与 CSS 动画保持一致。 */
|
||||||
const PULSE_MS = 900;
|
const PULSE_MS = 900;
|
||||||
|
|
||||||
export function HealthBar({ health, config, compact = false }: Props) {
|
interface Cell {
|
||||||
|
/** 这一格是不是亮着(这一格的血还在)。 */
|
||||||
|
on: boolean;
|
||||||
|
/** 落在低血阈值以内:这是"危险区",即使还亮着也提前给个警示色。 */
|
||||||
|
danger: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthBar({ health, config, align, tag }: Props) {
|
||||||
const [pulseOn, setPulseOn] = useState(true);
|
const [pulseOn, setPulseOn] = useState(true);
|
||||||
|
|
||||||
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
|
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
|
||||||
@@ -45,62 +73,89 @@ export function HealthBar({ health, config, compact = false }: Props) {
|
|||||||
}, [pulsing]);
|
}, [pulsing]);
|
||||||
|
|
||||||
const color = health.critical ? "#ff5f5f" : teamColor(health.team);
|
const color = health.critical ? "#ff5f5f" : teamColor(health.team);
|
||||||
const total = 100;
|
|
||||||
const ratio = health.available ? Math.max(0, Math.min(1, (health.hp ?? 0) / total)) : 0;
|
|
||||||
|
|
||||||
// 宽度按配置收窄并居中。它挂在画面**下面**,所以不需要通栏——通栏反而像一条分隔线,
|
const hp = health.available ? Math.max(0, Math.min(100, health.hp ?? 0)) : 0;
|
||||||
// 而不是一个可以一眼扫到的仪表。
|
// 向上取整:还剩 1 点血的时候必须还剩一格亮着。向下取整会让"还有 10 点血"显示成空条,
|
||||||
|
// 而那正是最需要它准确的时候。
|
||||||
|
const lit = Math.ceil(hp / CELL_HP);
|
||||||
|
const dangerCells =
|
||||||
|
config.lowThreshold > 0 && config.lowThreshold < 100
|
||||||
|
? Math.ceil(config.lowThreshold / CELL_HP)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 逻辑顺序:0 号格是"满血时最靠外的那一格"。
|
||||||
|
//
|
||||||
|
// **不要在这里按 `align` 反转数组**:右角那条的翻转让 CSS 的 `scaleX(-1)` 做
|
||||||
|
// (`segbars--right .segbars__track`),两边都做等于翻两次、又翻回来了——表现是右条
|
||||||
|
// 从外侧开始灭,与左条相反。
|
||||||
|
const cells: Cell[] = Array.from({ length: CELLS }, (_, index) => ({
|
||||||
|
on: index < lit,
|
||||||
|
danger: index >= CELLS - dangerCells,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 宽度按配置收窄。它挂在画面角上,太宽会盖住画面本身。
|
||||||
const width = `${Math.round(config.width * 100)}%`;
|
const width = `${Math.round(config.width * 100)}%`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`healthbar${compact ? " healthbar--compact" : ""}`}
|
className={`segbars segbars--${align}`}
|
||||||
style={compact ? undefined : { width, margin: "0 auto" }}
|
style={{ width }}
|
||||||
|
title={health.available ? undefined : health.placeholder}
|
||||||
>
|
>
|
||||||
<div
|
<div className="segbars__labels">
|
||||||
className="healthbar__track"
|
{tag && <span className="segbars__tag">{tag}</span>}
|
||||||
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
{config.showTeamName && (
|
||||||
title={health.available ? undefined : health.placeholder}
|
<span className="segbars__team" style={{ color }}>
|
||||||
>
|
{health.name}
|
||||||
<div
|
</span>
|
||||||
className="healthbar__fill"
|
|
||||||
style={{
|
|
||||||
width: `${ratio * 100}%`,
|
|
||||||
background: `linear-gradient(90deg, ${withAlpha(color, 0.75)}, ${color})`,
|
|
||||||
boxShadow: `0 0 12px ${withAlpha(color, 0.45)}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 低血刻度:一根竖线标出阈值,让人知道"还有多久开始报警"。 */}
|
|
||||||
{config.lowThreshold > 0 && config.lowThreshold < total && (
|
|
||||||
<div
|
|
||||||
className="healthbar__threshold"
|
|
||||||
style={{ left: `${(config.lowThreshold / total) * 100}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="healthbar__labels">
|
|
||||||
{config.showTeamName && <span className="healthbar__team">{health.name}</span>}
|
|
||||||
|
|
||||||
{health.available ? (
|
{health.available ? (
|
||||||
config.showNumbers && (
|
config.showNumbers && (
|
||||||
<span className="healthbar__value" style={{ color }}>
|
<span className="segbars__value" style={{ color }}>
|
||||||
{health.hp}
|
{hp}
|
||||||
{health.critical && <span className="healthbar__dead"> · 已击毁</span>}
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="healthbar__missing">{health.placeholder}</span>
|
<span className="segbars__missing">装甲板未接入</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{health.available && health.boardText && (
|
<div
|
||||||
<span className="healthbar__board" title="绑定的装甲板主板">
|
className="segbars__track"
|
||||||
{compact ? "" : "装甲板 "}
|
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
||||||
{health.boardText}
|
aria-hidden="true"
|
||||||
</span>
|
>
|
||||||
)}
|
{cells.map((cell, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={[
|
||||||
|
"segbars__cell",
|
||||||
|
cell.on ? "segbars__cell--on" : "",
|
||||||
|
cell.danger ? "segbars__cell--danger" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
style={
|
||||||
|
cell.on
|
||||||
|
? {
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 8px ${withAlpha(color, 0.45)}`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这一格是谁。
|
||||||
|
*
|
||||||
|
* 用户端两条血分别属于两边,而形状是对称的——没有这个标签,"左边这条是我的"要靠位置猜。
|
||||||
|
*/
|
||||||
|
export function healthTag(health: TeamHealthDto, myTeam: number): string {
|
||||||
|
return health.team === myTeam ? "我方" : "对手";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 图传中央的胜负横幅:一边归零之后,画面正中显示「胜利」或「击败」。
|
||||||
|
*
|
||||||
|
* # 为什么这次放在正中
|
||||||
|
*
|
||||||
|
* 命中反馈那一套刻意避开正中(伤害数字放在准星旁),因为被打中的那一下恰恰最需要看着
|
||||||
|
* 准星。**胜负不是那种信息**:它是一局的结束,此刻不再需要瞄准;反过来说,它是这一屏
|
||||||
|
* 上唯一还重要的事,放在角上会让人错过整场比赛的结论。
|
||||||
|
*
|
||||||
|
* # 它一直显示到血量被重置
|
||||||
|
*
|
||||||
|
* 不做 3 秒淡出:淡出之后屏幕上就再没有"这局结束了"的痕迹,截图、回放、现场的争议都
|
||||||
|
* 无从对照。血量被重置(长按 BOOT / 下一局开始)时它自己消失——那个动作本来就是
|
||||||
|
* "这局翻篇了"。
|
||||||
|
*
|
||||||
|
* 用词是两边各说各的:赢的一边看到「胜利」,输的一边看到「击败」。同时归零显示「平局」,
|
||||||
|
* 因为那时候任何一边显示"胜利"都是错的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MatchOutcome } from "../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 从这一边看的结论。两边都活着时是 null,什么都不显示。 */
|
||||||
|
outcome: MatchOutcome | null;
|
||||||
|
/** 我这一边的名字。 */
|
||||||
|
mine: string;
|
||||||
|
/** 对手的名字。 */
|
||||||
|
theirs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORD: Record<MatchOutcome, string> = {
|
||||||
|
victory: "胜利",
|
||||||
|
defeat: "击败",
|
||||||
|
draw: "平局",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MatchBanner({ outcome, mine, theirs }: Props) {
|
||||||
|
if (!outcome) return null;
|
||||||
|
|
||||||
|
const caption =
|
||||||
|
outcome === "victory"
|
||||||
|
? `击败 ${theirs}`
|
||||||
|
: outcome === "defeat"
|
||||||
|
? `被 ${theirs} 击败`
|
||||||
|
: "双方装甲板同时归零";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`match-banner match-banner--${outcome}`} role="status">
|
||||||
|
<div className="match-banner__card">
|
||||||
|
<span className="match-banner__team">{mine}</span>
|
||||||
|
<span className="match-banner__word">{WORD[outcome]}</span>
|
||||||
|
<span className="match-banner__caption">{caption}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -128,11 +128,11 @@ export function SettingsPanel({
|
|||||||
snapshot.armorBoards.map((board) => ({
|
snapshot.armorBoards.map((board) => ({
|
||||||
value: board.id,
|
value: board.id,
|
||||||
label: board.idText,
|
label: board.idText,
|
||||||
hint: `${board.addr} · ${teamName(board.team)} · ${board.hp} 血 · ${
|
hint: `${board.addr} · ${teamName(board.team, snapshot.config.teamNames)} · ${board.hp} 血 · ${
|
||||||
board.online ? "在线" : "离线"
|
board.online ? "在线" : "离线"
|
||||||
}`,
|
}`,
|
||||||
})),
|
})),
|
||||||
[snapshot.armorBoards],
|
[snapshot.armorBoards, snapshot.config.teamNames],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -204,12 +204,15 @@ export function SettingsPanel({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="自己属于哪一边" hint="改错队号会让血条显示对手的血量。">
|
<Field
|
||||||
|
label="自己属于哪一边"
|
||||||
|
hint="改错队号会把对手的血条当成自己的:画面左上角那一条就会是对手的血。"
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={config.team}
|
value={config.team}
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, label: "队伍一(红)" },
|
{ value: 0, label: `${teamName(0, config.teamNames)}(红)` },
|
||||||
{ value: 1, label: "队伍二(蓝)" },
|
{ value: 1, label: `${teamName(1, config.teamNames)}(蓝)` },
|
||||||
]}
|
]}
|
||||||
onChange={(team) => void run(() => api.setTeam(team))}
|
onChange={(team) => void run(() => api.setTeam(team))}
|
||||||
/>
|
/>
|
||||||
@@ -411,16 +414,6 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
|
||||||
checked={hud.hitFeedback.shake}
|
|
||||||
label="被打中时画面抖动"
|
|
||||||
onChange={(shake) =>
|
|
||||||
update((draft) => {
|
|
||||||
draft.hitFeedback.shake = shake;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={hud.hitFeedback.marker}
|
checked={hud.hitFeedback.marker}
|
||||||
label="在瞄点旁显示伤害数字"
|
label="在瞄点旁显示伤害数字"
|
||||||
@@ -431,7 +424,7 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Field label="反馈强度">
|
<Field label="闪红强度">
|
||||||
<Slider
|
<Slider
|
||||||
value={hud.hitFeedback.intensity}
|
value={hud.hitFeedback.intensity}
|
||||||
min={0.2}
|
min={0.2}
|
||||||
@@ -447,7 +440,10 @@ export function SettingsPanel({
|
|||||||
</Field>
|
</Field>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="血条">
|
<Section
|
||||||
|
title="血条"
|
||||||
|
hint="条挂在画面两个上角:左上角是自己、右上角是对手。一条分 5 格,一格 20 点血。"
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={hud.health.visible}
|
checked={hud.health.visible}
|
||||||
label="显示血条"
|
label="显示血条"
|
||||||
@@ -539,7 +535,7 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
{snapshot.slots.manual && (
|
{snapshot.slots.manual && (
|
||||||
<>
|
<>
|
||||||
<Field label="左格:队伍一摄像头">
|
<Field label={`左格:${teamName(0, config.teamNames)}摄像头`}>
|
||||||
<Select
|
<Select
|
||||||
value={snapshot.slots.cameraA}
|
value={snapshot.slots.cameraA}
|
||||||
options={cameraOptions}
|
options={cameraOptions}
|
||||||
@@ -550,7 +546,7 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="左格:队伍一装甲板">
|
<Field label={`左格:${teamName(0, config.teamNames)}装甲板`}>
|
||||||
<Select
|
<Select
|
||||||
value={snapshot.slots.armorA}
|
value={snapshot.slots.armorA}
|
||||||
options={armorOptions}
|
options={armorOptions}
|
||||||
@@ -561,7 +557,7 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="右格:队伍二摄像头">
|
<Field label={`右格:${teamName(1, config.teamNames)}摄像头`}>
|
||||||
<Select
|
<Select
|
||||||
value={snapshot.slots.cameraB}
|
value={snapshot.slots.cameraB}
|
||||||
options={cameraOptions}
|
options={cameraOptions}
|
||||||
@@ -572,7 +568,7 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="右格:队伍二装甲板">
|
<Field label={`右格:${teamName(1, config.teamNames)}装甲板`}>
|
||||||
<Select
|
<Select
|
||||||
value={snapshot.slots.armorB}
|
value={snapshot.slots.armorB}
|
||||||
options={armorOptions}
|
options={armorOptions}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 裁判端就地改队伍名。
|
||||||
|
*
|
||||||
|
* # 为什么是输入框而不是设置面板里的两项
|
||||||
|
*
|
||||||
|
* 现场改名字的时机是"比赛开始前,两队报上名来"。那时候裁判盯着的是两格画面,让他在
|
||||||
|
* 设置面板的某一节里找到对应项、改完再关掉,是一次没有必要的往返。名字就放在两格画面
|
||||||
|
* 各自下面,改完直接看到血条上的名字变了。
|
||||||
|
*
|
||||||
|
* # 回车/失焦才提交
|
||||||
|
*
|
||||||
|
* 逐字提交会在每次按键后写一次配置文件(还带一次 Argon2 无关的原子替换),而且中间态
|
||||||
|
* ("红"、"红队")会被后端截断/回落规则改写一次,光标跟着跳。失焦提交是唯一不会跟
|
||||||
|
* 打字打架的时机。
|
||||||
|
*
|
||||||
|
* 超过上限的字数由后端统一截断(`TEAM_NAME_MAX_CHARS`),这里也限一下长度——输入框
|
||||||
|
* 里就能看到"打不进去了",比提交后被悄悄截掉诚实。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { DEFAULT_TEAM_NAMES } from "../lib/format";
|
||||||
|
|
||||||
|
/** 与 Rust 侧的 `config::TEAM_NAME_MAX_CHARS` 一致。 */
|
||||||
|
const MAX_CHARS = 12;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
team: number;
|
||||||
|
/** 当前生效的名字(来自快照,可能已经被后端规范化过)。 */
|
||||||
|
name: string;
|
||||||
|
/** 提交。后端会规范化,成功之后快照会带新名字回来。 */
|
||||||
|
onSave: (name: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
|
||||||
|
const [draft, setDraft] = useState(name);
|
||||||
|
const focused = useRef(false);
|
||||||
|
/** Esc 已经改回去了,紧接着的那次失焦不该再提交。 */
|
||||||
|
const reverted = useRef(false);
|
||||||
|
|
||||||
|
// 焦点不在输入框里时跟着快照走:改完之后后端返回的规范名要能显示出来,而正在打字时
|
||||||
|
// 不能被一份恰好到达的快照顶掉。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused.current) setDraft(name);
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
focused.current = false;
|
||||||
|
|
||||||
|
if (reverted.current) {
|
||||||
|
reverted.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft === name) return;
|
||||||
|
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
setDraft(trimmed);
|
||||||
|
onSave(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="team-name">
|
||||||
|
<span className="team-name__label" style={{ color: team === 0 ? "#ff5f5f" : "#5aa9ff" }}>
|
||||||
|
队名
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input team-name__input"
|
||||||
|
value={draft}
|
||||||
|
maxLength={MAX_CHARS}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={DEFAULT_TEAM_NAMES[team] ?? "队伍"}
|
||||||
|
title="改完按回车或点到别处生效;留空回落到默认名"
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onFocus={() => {
|
||||||
|
focused.current = true;
|
||||||
|
}}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
// 只走失焦这一条提交路径,避免回车与失焦各提交一次。
|
||||||
|
event.currentTarget.blur();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
// 注意:不能只 `setDraft(name)` 再 blur——blur 是同一次事件里同步发生的,
|
||||||
|
// `commit` 读到的还是这一帧的旧 draft,于是"取消"反而把改动存了下去。
|
||||||
|
reverted.current = true;
|
||||||
|
setDraft(name);
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -133,11 +133,13 @@ export function VideoSurface({
|
|||||||
dirty.current = true;
|
dirty.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 命中动画期间必须持续重绘,否则抖动只画了一帧就停住。
|
// 命中动画期间必须持续重绘,否则闪红/伤害数字只画了一帧就停住。
|
||||||
|
//
|
||||||
|
// **抖动没有了**(见 `HitFeedbackConfig::shake` 的文档):不再有"整幅图跟着晃"的
|
||||||
|
// 那一路,所以这里也不再需要因为抖动而复绘。
|
||||||
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
|
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
|
||||||
const animating =
|
const animating =
|
||||||
pulseAge < 700 &&
|
pulseAge < 700 && (current.hitFeedback.marker || current.hitFeedback.flash);
|
||||||
(current.hitFeedback.shake || current.hitFeedback.marker || current.hitFeedback.flash);
|
|
||||||
|
|
||||||
if (!dirty.current && !animating) return;
|
if (!dirty.current && !animating) return;
|
||||||
dirty.current = false;
|
dirty.current = false;
|
||||||
@@ -300,11 +302,8 @@ function draw(
|
|||||||
? layout(box, frame.width, frame.height, current.view)
|
? layout(box, frame.width, frame.height, current.view)
|
||||||
: fallback;
|
: fallback;
|
||||||
|
|
||||||
const shake = current.hit ? shakeOffset(current.hit, timestamp, current.hitFeedback) : { x: 0, y: 0 };
|
|
||||||
|
|
||||||
if (frame) {
|
if (frame) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(shake.x, shake.y);
|
|
||||||
|
|
||||||
if (current.mirrored) {
|
if (current.mirrored) {
|
||||||
ctx.translate(width, 0);
|
ctx.translate(width, 0);
|
||||||
@@ -317,7 +316,7 @@ function draw(
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
if (current.grid?.visible) {
|
if (current.grid?.visible) {
|
||||||
drawGrid(ctx, geometry, current.grid, shake);
|
drawGrid(ctx, geometry, current.grid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +332,7 @@ function draw(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (crosshair?.visible) {
|
if (crosshair?.visible) {
|
||||||
drawCrosshair(ctx, aimX + shake.x, aimY + shake.y, Math.min(width, height), crosshair);
|
drawCrosshair(ctx, aimX, aimY, Math.min(width, height), crosshair);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
|
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
|
||||||
@@ -348,39 +347,20 @@ function draw(
|
|||||||
ctx.font = `600 ${Math.round(Math.max(14, Math.min(width, height) * 0.05))}px ui-sans-serif, system-ui, sans-serif`;
|
ctx.font = `600 ${Math.round(Math.max(14, Math.min(width, height) * 0.05))}px ui-sans-serif, system-ui, sans-serif`;
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText(
|
ctx.fillText(`-${current.hit.damage}`, aimX + 24, aimY - 26 - progress * 26);
|
||||||
`-${current.hit.damage}`,
|
|
||||||
aimX + shake.x + 24,
|
|
||||||
aimY + shake.y - 26 - progress * 26,
|
|
||||||
);
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shakeOffset(hit: HitPulse, timestamp: number, feedback: HitFeedbackConfig) {
|
|
||||||
if (!feedback.shake) return { x: 0, y: 0 };
|
|
||||||
|
|
||||||
const age = timestamp - hit.at;
|
|
||||||
if (age < 0 || age > 400) return { x: 0, y: 0 };
|
|
||||||
|
|
||||||
const amplitude = 7 * feedback.intensity * (1 - age / 400);
|
|
||||||
return {
|
|
||||||
x: Math.sin(age * 0.09) * amplitude,
|
|
||||||
y: Math.cos(age * 0.11) * amplitude,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGrid(
|
function drawGrid(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
geometry: { left: number; top: number; drawWidth: number; drawHeight: number },
|
geometry: { left: number; top: number; drawWidth: number; drawHeight: number },
|
||||||
grid: GridConfig,
|
grid: GridConfig,
|
||||||
shake: { x: number; y: number },
|
|
||||||
) {
|
) {
|
||||||
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
|
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(shake.x, shake.y);
|
|
||||||
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
|
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_TEAM_NAMES } from "./format";
|
||||||
import type { StreamClient } from "./stream";
|
import type { StreamClient } from "./stream";
|
||||||
import type {
|
import type {
|
||||||
ConfigView,
|
ConfigView,
|
||||||
@@ -71,7 +72,7 @@ const DEFAULT_HUD: HudView = {
|
|||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
},
|
},
|
||||||
grid: { visible: false, style: "thirds", divisions: 6, opacity: 0.22 },
|
grid: { visible: false, style: "thirds", divisions: 6, opacity: 0.22 },
|
||||||
hitFeedback: { flash: true, shake: true, marker: true, intensity: 1 },
|
hitFeedback: { flash: true, shake: false, marker: true, intensity: 1 },
|
||||||
showZoom: true,
|
showZoom: true,
|
||||||
showStats: true,
|
showStats: true,
|
||||||
};
|
};
|
||||||
@@ -87,6 +88,8 @@ interface PreviewState {
|
|||||||
cameraId: number | null;
|
cameraId: number | null;
|
||||||
armorId: number | null;
|
armorId: number | null;
|
||||||
team: number;
|
team: number;
|
||||||
|
/** 两边显示的名字,与后端配置里的 `teamNames` 对应。 */
|
||||||
|
teamNames: [string, string];
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
password: string | null;
|
password: string | null;
|
||||||
@@ -101,6 +104,7 @@ const state: PreviewState = {
|
|||||||
cameraId: startAsReferee ? null : CAMERA_A,
|
cameraId: startAsReferee ? null : CAMERA_A,
|
||||||
armorId: startAsReferee ? null : ARMOR_A,
|
armorId: startAsReferee ? null : ARMOR_A,
|
||||||
team: 0,
|
team: 0,
|
||||||
|
teamNames: ["队伍一", "队伍二"],
|
||||||
mirror: true,
|
mirror: true,
|
||||||
// 预览里直接是已解锁状态,免得每次刷新都要输密码。
|
// 预览里直接是已解锁状态,免得每次刷新都要输密码。
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
@@ -156,6 +160,14 @@ function handle(command: string, args: Record<string, unknown>): unknown {
|
|||||||
guard();
|
guard();
|
||||||
state.team = Number(args.team);
|
state.team = Number(args.team);
|
||||||
return configView();
|
return configView();
|
||||||
|
case "set_team_name": {
|
||||||
|
guard();
|
||||||
|
const team = Number(args.team);
|
||||||
|
if (team !== 0 && team !== 1) throw new Error("队伍号只能是 0 或 1");
|
||||||
|
// 与后端一样:合并空白、按字符截断、留空回落默认名。
|
||||||
|
state.teamNames[team] = sanitizeName(String(args.name ?? ""), team);
|
||||||
|
return configView();
|
||||||
|
}
|
||||||
case "set_role":
|
case "set_role":
|
||||||
state.role = args.role as "team" | "referee";
|
state.role = args.role as "team" | "referee";
|
||||||
return configView();
|
return configView();
|
||||||
@@ -213,6 +225,7 @@ function configView(): ConfigView {
|
|||||||
role: state.role,
|
role: state.role,
|
||||||
roleChosen: true,
|
roleChosen: true,
|
||||||
team: state.team,
|
team: state.team,
|
||||||
|
teamNames: [...state.teamNames] as [string, string],
|
||||||
cameraId: state.cameraId,
|
cameraId: state.cameraId,
|
||||||
cameraIdText: state.cameraId === null ? null : hex(state.cameraId),
|
cameraIdText: state.cameraId === null ? null : hex(state.cameraId),
|
||||||
armorId: state.armorId,
|
armorId: state.armorId,
|
||||||
@@ -239,6 +252,13 @@ function recordings() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与后端 `config::sanitize_team_name` 一致:合并空白、按字符截断、留空回落默认名。 */
|
||||||
|
function sanitizeName(name: string | undefined, team: number): string {
|
||||||
|
const collapsed = (name ?? "").split(/\s+/).filter(Boolean).join(" ");
|
||||||
|
const text = collapsed.length > 0 ? collapsed : DEFAULT_TEAM_NAMES[team];
|
||||||
|
return [...text].slice(0, 12).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function hex(id: number): string {
|
function hex(id: number): string {
|
||||||
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
|
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
|
||||||
}
|
}
|
||||||
@@ -288,13 +308,25 @@ function snapshot(): Snapshot {
|
|||||||
gapMs: 50,
|
gapMs: 50,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 与后端 `cam_core::snapshot::resolve_outcomes` 同一套规则,包括那条不对称:
|
||||||
|
// 输只看自己,赢要看对手归零**且**自己还活着。
|
||||||
|
const outcomeFor = (team: number) => {
|
||||||
|
const mine = state.hp[team];
|
||||||
|
const theirs = state.hp[team === 0 ? 1 : 0];
|
||||||
|
|
||||||
|
if (mine === 0 && theirs === 0) return "draw" as const;
|
||||||
|
if (theirs === 0 && mine > 0) return "victory" as const;
|
||||||
|
if (mine === 0) return "defeat" as const;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const healthFor = (team: number) => {
|
const healthFor = (team: number) => {
|
||||||
const hp = state.hp[team];
|
const hp = state.hp[team];
|
||||||
const board = team === 0 ? ARMOR_A : ARMOR_B;
|
const board = team === 0 ? ARMOR_A : ARMOR_B;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
team,
|
team,
|
||||||
name: team === 0 ? "队伍一" : "队伍二",
|
name: state.teamNames[team],
|
||||||
available: true,
|
available: true,
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
hp,
|
hp,
|
||||||
@@ -302,6 +334,7 @@ function snapshot(): Snapshot {
|
|||||||
board,
|
board,
|
||||||
boardText: hex(board),
|
boardText: hex(board),
|
||||||
critical: hp === 0 || hp <= state.hud.health.lowThreshold,
|
critical: hp === 0 || hp <= state.hud.health.lowThreshold,
|
||||||
|
outcome: outcomeFor(team),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,7 +359,8 @@ function snapshot(): Snapshot {
|
|||||||
armorB: state.role === "referee" ? ARMOR_B : null,
|
armorB: state.role === "referee" ? ARMOR_B : null,
|
||||||
manual: false,
|
manual: false,
|
||||||
},
|
},
|
||||||
health: state.role === "referee" ? [healthFor(0), healthFor(1)] : [healthFor(state.team)],
|
// 两边都给:用户端也要显示对手的血量(画面右上角那一条)。
|
||||||
|
health: [healthFor(0), healthFor(1)],
|
||||||
recordings: recordings(),
|
recordings: recordings(),
|
||||||
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
|
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
|
||||||
};
|
};
|
||||||
@@ -377,7 +411,7 @@ export function startPreview(client: StreamClient, emit: (msg: WireMsg) => void)
|
|||||||
|
|
||||||
// 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。
|
// 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。
|
||||||
//
|
//
|
||||||
// 打空的那一拍只做重置、不出命中("已击毁"的状态要真的能看到),其余每一拍都出一次
|
// 打空的那一拍只做重置、不出命中(胜负横幅要真的能看到),其余每一拍都出一次
|
||||||
// 命中。早先的写法是 `to = from === 0 ? 100 : ...` 然后按 `to >= from` 判断——那样一旦
|
// 命中。早先的写法是 `to = from === 0 ? 100 : ...` 然后按 `to >= from` 判断——那样一旦
|
||||||
// 两块都打空,之后每次都是重置,命中就再也不会出现,盯着界面等闪红会等到超时。
|
// 两块都打空,之后每次都是重置,命中就再也不会出现,盯着界面等闪红会等到超时。
|
||||||
let tick = 0;
|
let tick = 0;
|
||||||
|
|||||||
@@ -52,9 +52,21 @@ export function fps(value: number): string {
|
|||||||
return value.toFixed(1);
|
return value.toFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 队伍名。与 Rust 侧的 `team_name` 一致。 */
|
/** 队伍名的兜底值。与 Rust 侧的 `config::DEFAULT_TEAM_NAMES` 一致。 */
|
||||||
export function teamName(team: number): string {
|
export const DEFAULT_TEAM_NAMES = ["队伍一", "队伍二"];
|
||||||
return team === 0 ? "队伍一" : "队伍二";
|
|
||||||
|
/**
|
||||||
|
* 队伍名。
|
||||||
|
*
|
||||||
|
* 名字是**配置里可以改的**(裁判端能直接编辑),所以显示的地方一律从这里取:散在各处的
|
||||||
|
* `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,而且不会报错。
|
||||||
|
*
|
||||||
|
* `names` 传配置里的两条;缺省或留空时回落到默认名。
|
||||||
|
*/
|
||||||
|
export function teamName(team: number, names?: readonly string[] | null): string {
|
||||||
|
const custom = names?.[team]?.trim();
|
||||||
|
if (custom) return custom;
|
||||||
|
return DEFAULT_TEAM_NAMES[team] ?? "队伍";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
|
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export const api = {
|
|||||||
call<ConfigView>("set_role", { role, password: password ?? null }),
|
call<ConfigView>("set_role", { role, password: password ?? null }),
|
||||||
setMirror: (mirror: boolean) => call<ConfigView>("set_mirror", { mirror }),
|
setMirror: (mirror: boolean) => call<ConfigView>("set_mirror", { mirror }),
|
||||||
saveHud: (hud: HudView) => call<ConfigView>("save_hud", { hud }),
|
saveHud: (hud: HudView) => call<ConfigView>("save_hud", { hud }),
|
||||||
|
/** 改一边的队名(0 = 队伍一,1 = 队伍二)。一次只改一边,避免覆盖另一边的改动。 */
|
||||||
|
setTeamName: (team: number, name: string, password?: string) =>
|
||||||
|
call<ConfigView>("set_team_name", { team, name, password: password ?? null }),
|
||||||
setRefereeSlots: (slots: {
|
setRefereeSlots: (slots: {
|
||||||
cameraA: number | null;
|
cameraA: number | null;
|
||||||
cameraB: number | null;
|
cameraB: number | null;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface AppStore {
|
|||||||
snapshot: Snapshot | null;
|
snapshot: Snapshot | null;
|
||||||
/** 与后端的视频通道是否连着。 */
|
/** 与后端的视频通道是否连着。 */
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
/** 最近一次掉血,界面用它做闪红/抖动。 */
|
/** 最近一次掉血,界面用它做闪红与伤害数字。 */
|
||||||
lastHit: HitPulseRecord | null;
|
lastHit: HitPulseRecord | null;
|
||||||
/** 后端要求弹出解锁框(用户端点关闭窗口时)。 */
|
/** 后端要求弹出解锁框(用户端点关闭窗口时)。 */
|
||||||
confirmQuit: number;
|
confirmQuit: number;
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ export interface GridConfig {
|
|||||||
/** `cam_core::config::HitFeedbackConfig` */
|
/** `cam_core::config::HitFeedbackConfig` */
|
||||||
export interface HitFeedbackConfig {
|
export interface HitFeedbackConfig {
|
||||||
flash: boolean;
|
flash: boolean;
|
||||||
|
/**
|
||||||
|
* 已废弃:命中时不再抖动画面。
|
||||||
|
*
|
||||||
|
* 字段留着只是为了读得进旧配置文件(Rust 侧 `deny_unknown_fields`,删字段会让写过
|
||||||
|
* `shake = true` 的配置直接解析失败)。界面不读它。
|
||||||
|
*/
|
||||||
shake: boolean;
|
shake: boolean;
|
||||||
marker: boolean;
|
marker: boolean;
|
||||||
intensity: number;
|
intensity: number;
|
||||||
@@ -76,6 +82,8 @@ export interface ConfigView {
|
|||||||
role: Role;
|
role: Role;
|
||||||
roleChosen: boolean;
|
roleChosen: boolean;
|
||||||
team: number;
|
team: number;
|
||||||
|
/** 两边在界面上显示的名字,下标就是队伍号。裁判端可以直接改。 */
|
||||||
|
teamNames: [string, string];
|
||||||
cameraId: number | null;
|
cameraId: number | null;
|
||||||
cameraIdText: string | null;
|
cameraIdText: string | null;
|
||||||
armorId: number | null;
|
armorId: number | null;
|
||||||
@@ -137,6 +145,13 @@ export interface SlotsDto {
|
|||||||
manual: boolean;
|
manual: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `cam_core::snapshot::MatchOutcome`。
|
||||||
|
*
|
||||||
|
* 是**从这一边看**的结论,不是"谁赢了"这个全局判断:同一场对局里两边的 DTO 各带一份。
|
||||||
|
*/
|
||||||
|
export type MatchOutcome = "victory" | "defeat" | "draw";
|
||||||
|
|
||||||
/** `cam_core::snapshot::TeamHealthDto` */
|
/** `cam_core::snapshot::TeamHealthDto` */
|
||||||
export interface TeamHealthDto {
|
export interface TeamHealthDto {
|
||||||
team: number;
|
team: number;
|
||||||
@@ -148,6 +163,8 @@ export interface TeamHealthDto {
|
|||||||
board: number | null;
|
board: number | null;
|
||||||
boardText: string | null;
|
boardText: string | null;
|
||||||
critical: boolean;
|
critical: boolean;
|
||||||
|
/** 有结论时(有人归零)才有值;两边都活着时是 null。 */
|
||||||
|
outcome: MatchOutcome | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `cam_core::snapshot::RecordingDto` */
|
/** `cam_core::snapshot::RecordingDto` */
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
|
* 裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
|
||||||
*
|
*
|
||||||
* 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量并排对称、
|
* 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量各自贴在画面的
|
||||||
* 缩放置互相独立(滚轮只作用于鼠标所在的那一路)。
|
* 上角(左格左上、右格右上,与用户端的"自己在左上、对手在右上"是同一套读法)、缩放置互相
|
||||||
|
* 独立(滚轮只作用于鼠标所在的那一路)。
|
||||||
*
|
*
|
||||||
* 两格等宽这件事在旧客户端上是踩过坑的:`HorizontalLayout` 先按内容的**最大**需求分配,
|
* 两格等宽这件事在旧客户端上是踩过坑的:`HorizontalLayout` 先按内容的**最大**需求分配,
|
||||||
* 剩下的才按 stretch 分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"
|
* 剩下的才按 stretch 分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"
|
||||||
* (5 字),于是左边多要十几个像素——实测 55% / 45%。解法是让两格的需求只由画面决定:
|
* (5 字),于是左边多要十几个像素——实测 55% / 45%。解法是让两格的需求只由画面决定:
|
||||||
* 统一的 `preferred-width` 加 `min-width`,文字用绝对定位不参与布局。CSS 的 flex 里
|
* 统一的 `preferred-width` 加 `min-width`,文字用绝对定位不参与布局。CSS 的 flex 里
|
||||||
* 用 `flex: 1 1 0` + `min-width: 0` 达到同一件事。
|
* 用 `flex: 1 1 0` + `min-width: 0` 达到同一件事。
|
||||||
|
*
|
||||||
|
* # 队名可以直接改
|
||||||
|
*
|
||||||
|
* 名字属于这一场比赛,所以它放在两格画面各自下面(见 `TeamNameEditor`),改完立刻反映到
|
||||||
|
* 血条、装甲板表与等待画面的文字上。命令在 Rust 侧,规范化也在那边统一做。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -16,7 +22,9 @@ import { useMemo, useState } from "react";
|
|||||||
import { ArmorTable, StatsLine } from "../components/ArmorTable";
|
import { ArmorTable, StatsLine } from "../components/ArmorTable";
|
||||||
import { HealthBar } from "../components/HealthBar";
|
import { HealthBar } from "../components/HealthBar";
|
||||||
import { HitFlash } from "../components/HitFlash";
|
import { HitFlash } from "../components/HitFlash";
|
||||||
|
import { MatchBanner } from "../components/MatchBanner";
|
||||||
import { MediaPanel } from "../components/MediaPanel";
|
import { MediaPanel } from "../components/MediaPanel";
|
||||||
|
import { TeamNameEditor } from "../components/TeamNameEditor";
|
||||||
import { TopBar } from "../components/TopBar";
|
import { TopBar } from "../components/TopBar";
|
||||||
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
|
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
|
||||||
import { Button } from "../components/controls";
|
import { Button } from "../components/controls";
|
||||||
@@ -44,6 +52,9 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
const [viewA, setViewA] = useState<ViewTransform>(IDENTITY_VIEW);
|
const [viewA, setViewA] = useState<ViewTransform>(IDENTITY_VIEW);
|
||||||
const [viewB, setViewB] = useState<ViewTransform>(IDENTITY_VIEW);
|
const [viewB, setViewB] = useState<ViewTransform>(IDENTITY_VIEW);
|
||||||
|
|
||||||
|
// 队名以快照为准(后端会规范化)。
|
||||||
|
const names = snapshot.config.teamNames;
|
||||||
|
|
||||||
const panes = useMemo(
|
const panes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
@@ -52,6 +63,8 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
camera: snapshot.slots.cameraA,
|
camera: snapshot.slots.cameraA,
|
||||||
armor: snapshot.slots.armorA,
|
armor: snapshot.slots.armorA,
|
||||||
team: 0,
|
team: 0,
|
||||||
|
// 左格的血条贴左上角,右格贴右上角:与用户端"自己在左上、对手在右上"一致。
|
||||||
|
align: "left" as const,
|
||||||
view: viewA,
|
view: viewA,
|
||||||
onView: setViewA,
|
onView: setViewA,
|
||||||
},
|
},
|
||||||
@@ -60,6 +73,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
camera: snapshot.slots.cameraB,
|
camera: snapshot.slots.cameraB,
|
||||||
armor: snapshot.slots.armorB,
|
armor: snapshot.slots.armorB,
|
||||||
team: 1,
|
team: 1,
|
||||||
|
align: "right" as const,
|
||||||
view: viewB,
|
view: viewB,
|
||||||
onView: setViewB,
|
onView: setViewB,
|
||||||
},
|
},
|
||||||
@@ -67,10 +81,23 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
...pane,
|
...pane,
|
||||||
stats: snapshot.stats.find((entry) => entry.camera === pane.camera),
|
stats: snapshot.stats.find((entry) => entry.camera === pane.camera),
|
||||||
health: snapshot.health.find((entry) => entry.team === pane.team) ?? snapshot.health[0],
|
health: snapshot.health.find((entry) => entry.team === pane.team) ?? snapshot.health[0],
|
||||||
|
opponent: snapshot.health.find((entry) => entry.team !== pane.team) ?? null,
|
||||||
})),
|
})),
|
||||||
[snapshot.slots, snapshot.stats, snapshot.health, viewA, viewB],
|
[snapshot.slots, snapshot.stats, snapshot.health, viewA, viewB],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改一边的队名。
|
||||||
|
*
|
||||||
|
* 命令一次只改一边,所以这里**不**把另一边的名字一起带上:那是"读两条、写两条",
|
||||||
|
* 而读来的值来自最多 200 ms 前的快照——紧接着改另一边就会把它覆盖回去。
|
||||||
|
*/
|
||||||
|
const saveName = (team: number, value: string) => {
|
||||||
|
void api.setTeamName(team, value).catch((err) => {
|
||||||
|
onError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
|
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
|
||||||
@@ -83,6 +110,8 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
(pane.armor !== null ? lastHit.board === pane.armor : lastHit.team === pane.team),
|
(pane.armor !== null ? lastHit.board === pane.armor : lastHit.team === pane.team),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const label = teamName(pane.team, names);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={pane.key} className="pane">
|
<section key={pane.key} className="pane">
|
||||||
<VideoSurface
|
<VideoSurface
|
||||||
@@ -92,7 +121,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
? ""
|
? ""
|
||||||
: pane.stats?.online
|
: pane.stats?.online
|
||||||
? "已连接,等待画面…"
|
? "已连接,等待画面…"
|
||||||
: `等待${teamName(pane.team)}的图传`
|
: `等待${label}的图传`
|
||||||
}
|
}
|
||||||
mirrored={snapshot.mirrored}
|
mirrored={snapshot.mirrored}
|
||||||
// 裁判端不给瞄准器:这里没有人在瞄。网格留着,用来比对两路的取景。
|
// 裁判端不给瞄准器:这里没有人在瞄。网格留着,用来比对两路的取景。
|
||||||
@@ -108,16 +137,34 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
hitFeedback={hud.hitFeedback}
|
hitFeedback={hud.hitFeedback}
|
||||||
showZoom
|
showZoom
|
||||||
>
|
>
|
||||||
|
{hud.health.visible && (
|
||||||
|
<div className={`video-hud video-hud--${pane.align}`}>
|
||||||
|
<HealthBar health={pane.health} config={hud.health} align={pane.align} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MatchBanner
|
||||||
|
outcome={pane.health.outcome}
|
||||||
|
mine={pane.health.name}
|
||||||
|
theirs={
|
||||||
|
pane.opponent?.name ?? teamName(pane.team === 0 ? 1 : 0, names)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<HitFlash
|
<HitFlash
|
||||||
hit={lastHit}
|
hit={lastHit}
|
||||||
config={hud.hitFeedback}
|
config={hud.hitFeedback}
|
||||||
relevant={hitRelevant}
|
relevant={hitRelevant}
|
||||||
label={`${teamName(pane.team)}被打中`}
|
label={`${label}被打中`}
|
||||||
/>
|
/>
|
||||||
</VideoSurface>
|
</VideoSurface>
|
||||||
|
|
||||||
<div className="pane__foot">
|
<div className="pane__foot">
|
||||||
<HealthBar health={pane.health} config={hud.health} compact />
|
<TeamNameEditor
|
||||||
|
team={pane.team}
|
||||||
|
name={pane.health.name}
|
||||||
|
onSave={(value) => saveName(pane.team, value)}
|
||||||
|
/>
|
||||||
<StatsLine stats={pane.stats} diagnostics={snapshot.diagnostics} />
|
<StatsLine stats={pane.stats} diagnostics={snapshot.diagnostics} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -153,7 +200,11 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
|||||||
|
|
||||||
<div className="referee-bottom__body">
|
<div className="referee-bottom__body">
|
||||||
{tab === "armor" ? (
|
{tab === "armor" ? (
|
||||||
<ArmorTable boards={snapshot.armorBoards} slots={snapshot.slots} />
|
<ArmorTable
|
||||||
|
boards={snapshot.armorBoards}
|
||||||
|
slots={snapshot.slots}
|
||||||
|
names={names}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MediaPanel snapshot={snapshot} onError={onError} onInfo={onInfo} />
|
<MediaPanel snapshot={snapshot} onError={onError} onInfo={onInfo} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* 用户端:一路画面 + 血条 + 瞄准器。
|
* 用户端:一路画面 + 双方血条 + 瞄准器 + 胜负横幅。
|
||||||
*
|
*
|
||||||
* 界面上的东西按"操作手需要什么"排:
|
* 界面上的东西按"操作手需要什么"排:
|
||||||
*
|
*
|
||||||
* - 画面占满除底部一条窄带以外的全部空间(一个像素都不浪费在装饰上);
|
* - 画面占满除底部状态行以外的全部空间(一个像素都不浪费在装饰上);
|
||||||
* - 血条在画面**下方**,不挡画面;
|
* - **血条叠在画面两个上角**:左上角是自己,右上角是对手。血量是这一屏上最需要"同时看到"
|
||||||
|
* 的两个数,放在画面之外意味着视线要离开取景;
|
||||||
* - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走;
|
* - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走;
|
||||||
* - 被打中时整屏闪一下红、画面抖一下、瞄点旁跳一个伤害数字。
|
* - 被打中时整屏闪一下红、瞄点旁跳一个伤害数字——**画面不抖**(见 `HitFeedbackConfig`);
|
||||||
|
* - 一方归零之后,画面正中给出「胜利 / 击败」。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
|
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
|
||||||
import { HealthBar } from "../components/HealthBar";
|
import { HealthBar, healthTag } from "../components/HealthBar";
|
||||||
import { HitFlash } from "../components/HitFlash";
|
import { HitFlash } from "../components/HitFlash";
|
||||||
|
import { MatchBanner } from "../components/MatchBanner";
|
||||||
import { StatsLine } from "../components/ArmorTable";
|
import { StatsLine } from "../components/ArmorTable";
|
||||||
import { TopBar } from "../components/TopBar";
|
import { TopBar } from "../components/TopBar";
|
||||||
import { Button } from "../components/controls";
|
import { Button } from "../components/controls";
|
||||||
|
import { teamName } from "../lib/format";
|
||||||
import { useAppStore } from "../lib/store";
|
import { useAppStore } from "../lib/store";
|
||||||
import type { HudView, Snapshot } from "../lib/types";
|
import type { HudView, Snapshot, TeamHealthDto } from "../lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
@@ -45,12 +49,21 @@ export function TeamPage({
|
|||||||
() => snapshot.stats.find((entry) => entry.camera === camera),
|
() => snapshot.stats.find((entry) => entry.camera === camera),
|
||||||
[snapshot.stats, camera],
|
[snapshot.stats, camera],
|
||||||
);
|
);
|
||||||
const health = snapshot.health[0];
|
|
||||||
|
// 快照里两边都有一份,**按队伍号取**而不是按下标:绑的是队伍二时 `health[0]` 是别人。
|
||||||
|
const mine: TeamHealthDto =
|
||||||
|
snapshot.health.find((entry) => entry.team === snapshot.team) ?? snapshot.health[0];
|
||||||
|
const theirs: TeamHealthDto | null =
|
||||||
|
snapshot.health.find((entry) => entry.team !== snapshot.team) ?? null;
|
||||||
|
|
||||||
|
const theirTeam = snapshot.team === 0 ? 1 : 0;
|
||||||
|
const myName = mine?.name ?? teamName(snapshot.team, snapshot.config.teamNames);
|
||||||
|
const theirName = theirs?.name ?? teamName(theirTeam, snapshot.config.teamNames);
|
||||||
|
|
||||||
// 这一下是不是打在我身上:绑定了装甲板就比板子 id,没绑定就退回比队伍号。
|
// 这一下是不是打在我身上:绑定了装甲板就比板子 id,没绑定就退回比队伍号。
|
||||||
const hitIsMine = Boolean(
|
const hitIsMine = Boolean(
|
||||||
lastHit &&
|
lastHit &&
|
||||||
(health?.board != null ? lastHit.board === health.board : lastHit.team === snapshot.team),
|
(mine?.board != null ? lastHit.board === mine.board : lastHit.team === snapshot.team),
|
||||||
);
|
);
|
||||||
|
|
||||||
const pulse = hitIsMine && lastHit ? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt } : null;
|
const pulse = hitIsMine && lastHit ? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt } : null;
|
||||||
@@ -92,16 +105,36 @@ export function TeamPage({
|
|||||||
hitFeedback={hud.hitFeedback}
|
hitFeedback={hud.hitFeedback}
|
||||||
showZoom={hud.showZoom}
|
showZoom={hud.showZoom}
|
||||||
>
|
>
|
||||||
|
{hud.health.visible && (
|
||||||
|
<>
|
||||||
|
<div className="video-hud video-hud--left">
|
||||||
|
<HealthBar
|
||||||
|
health={mine}
|
||||||
|
config={hud.health}
|
||||||
|
align="left"
|
||||||
|
tag={healthTag(mine, snapshot.team)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="video-hud video-hud--right">
|
||||||
|
{theirs && (
|
||||||
|
<HealthBar
|
||||||
|
health={theirs}
|
||||||
|
config={hud.health}
|
||||||
|
align="right"
|
||||||
|
tag={healthTag(theirs, snapshot.team)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 结论只看自己这一份:后端的 outcome 就是"从这一边看"的。 */}
|
||||||
|
<MatchBanner outcome={mine?.outcome ?? null} mine={myName} theirs={theirName} />
|
||||||
|
|
||||||
<HitFlash hit={lastHit} config={hud.hitFeedback} relevant={hitIsMine} />
|
<HitFlash hit={lastHit} config={hud.hitFeedback} relevant={hitIsMine} />
|
||||||
</VideoSurface>
|
</VideoSurface>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hud.health.visible && (
|
|
||||||
<div className="hud-bottom">
|
|
||||||
<HealthBar health={health} config={hud.health} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="statusbar">
|
<footer className="statusbar">
|
||||||
<StatsLine stats={stats} diagnostics={snapshot.diagnostics} />
|
<StatsLine stats={stats} diagnostics={snapshot.diagnostics} />
|
||||||
<span className="dim">
|
<span className="dim">
|
||||||
|
|||||||
+194
-51
@@ -266,9 +266,10 @@ code {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 倍率与复位挪到画面**下沿**:上沿两个角现在归血条。 */
|
||||||
.video-badge {
|
.video-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
bottom: 10px;
|
||||||
background: rgba(0, 0, 0, 0.68);
|
background: rgba(0, 0, 0, 0.68);
|
||||||
color: #d0d0d8;
|
color: #d0d0d8;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
@@ -338,10 +339,12 @@ code {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 提示条在顶部,不压瞄准点。 */
|
/* 提示条在上方但**让开两个角**:上沿现在归血条(左上角自己、右上角对手)。
|
||||||
|
*
|
||||||
|
* 也不放在正中:被打中的那一下恰恰是最需要看着准星的时候,而这里正对着瞄准点。 */
|
||||||
.hit-flash__banner {
|
.hit-flash__banner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 56px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -372,81 +375,221 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------- 血条 */
|
/* -------------------------------------------------------------- 血条 */
|
||||||
|
/*
|
||||||
|
* 条挂在画面左上角/右上角,所以它是**绝对定位在视频窗格里的一层**,不是页面里的一行。
|
||||||
|
* 视频窗格自己是 `position: relative`,孩子按角贴上去即可。
|
||||||
|
*
|
||||||
|
* `pointer-events: none` 是必须的:这一层盖在画面上,而画面要能滚轮缩放、指针拖动。
|
||||||
|
*/
|
||||||
|
|
||||||
.hud-bottom {
|
.video-hud {
|
||||||
flex: none;
|
position: absolute;
|
||||||
padding: 0 8px 6px;
|
top: 10px;
|
||||||
|
/* 宽度必须是**确定**的:绝对定位元素不给宽度时会收缩到内容宽度,而里面那条血的宽度是
|
||||||
|
* 它的百分比——两者互相依赖,结局是两条血被压成几十像素、数字竖着排。 */
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar {
|
.video-hud--left {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-hud--right {
|
||||||
|
right: 12px;
|
||||||
|
/* 把里面那条血推到右边:`.segbars--right` 管的是文字/格子在**条内部**靠哪边,
|
||||||
|
* 管不到"条本身在容器里的位置"。 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segbars {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar--compact {
|
/* 右角那一条整体靠右:文字与血条用同一个边对齐,不然条在角上、字在中间。 */
|
||||||
gap: 3px;
|
.segbars--right {
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar__track {
|
.segbars__labels {
|
||||||
position: relative;
|
display: flex;
|
||||||
height: 14px;
|
flex-wrap: nowrap;
|
||||||
border-radius: 999px;
|
align-items: baseline;
|
||||||
background: #17171d;
|
gap: 8px;
|
||||||
|
min-height: 15px;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
/* 画面亮的时候白字会糊掉,一层阴影比加背景条便宜且不挡画面。 */
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segbars__tag {
|
||||||
|
flex: none;
|
||||||
|
padding: 1px 6px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #d7d7de;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segbars__team {
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segbars__value {
|
||||||
|
flex: none;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segbars__missing {
|
||||||
|
color: #ffc9a0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平行四边形:整条统一斜切,右角那条同时镜像,于是两条的斜边朝外对称。
|
||||||
|
* 斜切放在整条上而不是每个格子上——格子各自斜切会在缝里露出锯齿。 */
|
||||||
|
.segbars__track {
|
||||||
|
align-self: stretch;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
/* 一条分 5 格:格子之间的缝就是那 5 块的界线。 */
|
||||||
|
background: rgba(8, 8, 12, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
transform: skewX(-15deg);
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.55));
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar--compact .healthbar__track {
|
.segbars--right .segbars__track {
|
||||||
height: 12px;
|
transform: skewX(-15deg) scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar__fill {
|
.segbars__cell {
|
||||||
height: 100%;
|
flex: 1 1 0;
|
||||||
border-radius: 999px;
|
min-width: 0;
|
||||||
transition: width 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
transition: background 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar__threshold {
|
/* 低血阈值以内那些格子:还亮着的时候也提前给一点警示色,于是"危险区在哪"是一直看得见的,
|
||||||
|
* 而不是等真的低血了才第一次出现。 */
|
||||||
|
.segbars__cell--danger {
|
||||||
|
background: rgba(255, 90, 90, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------ 胜负横幅 */
|
||||||
|
|
||||||
|
/* 归零之后画面正中的结论:胜利 / 击败 / 平局。 */
|
||||||
|
.match-banner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
inset: 0;
|
||||||
bottom: 0;
|
z-index: 4;
|
||||||
width: 1px;
|
|
||||||
background: rgba(255, 255, 255, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthbar__labels {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
font-size: 12px;
|
pointer-events: none;
|
||||||
min-height: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar__team {
|
.match-banner__card {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 16px 34px 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 14px;
|
||||||
|
/* 半透明而不是实心:输了之后画面还在,回放与截图也要能看出当时是什么场面。 */
|
||||||
|
background: rgba(8, 8, 12, 0.66);
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
|
||||||
|
animation: match-in 260ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar__value {
|
@keyframes match-in {
|
||||||
font-family: var(--mono);
|
from {
|
||||||
font-size: 15px;
|
opacity: 0;
|
||||||
font-weight: 700;
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.healthbar__dead {
|
.match-banner__team {
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthbar__board {
|
|
||||||
color: var(--fg-faint);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.healthbar__missing {
|
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-banner__word {
|
||||||
|
font-size: clamp(30px, 8vmin, 68px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-banner__caption {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-banner--victory .match-banner__word {
|
||||||
|
color: #ffd45a;
|
||||||
|
text-shadow: 0 0 26px rgba(255, 212, 90, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-banner--defeat .match-banner__word {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-shadow: 0 0 26px rgba(255, 107, 107, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-banner--draw .match-banner__word {
|
||||||
|
color: #d8d8e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- 队名编辑 */
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name__label {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name__input {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 状态行 */
|
/* ------------------------------------------------------------ 状态行 */
|
||||||
|
|||||||
Reference in New Issue
Block a user