From a93ae065cfa7e0acf84b068e5861b0ae6b6b1a66 Mon Sep 17 00:00:00 2001 From: Horldsence Date: Wed, 14 Oct 2026 20:48:12 +0800 Subject: [PATCH] =?UTF-8?q?desktop:=20=E5=8F=8C=E6=96=B9=E8=A1=80=E6=9D=A1?= =?UTF-8?q?=EF=BC=88=E8=A7=92=E4=B8=8A=E5=B9=B3=E8=A1=8C=E5=9B=9B=E8=BE=B9?= =?UTF-8?q?=E5=BD=A2=EF=BC=89=E3=80=81=E5=BD=92=E9=9B=B6=E5=8D=B3=E5=87=BB?= =?UTF-8?q?=E8=B4=A5=E3=80=81=E5=8E=BB=E6=8E=89=E5=8F=97=E5=87=BB=E6=8A=96?= =?UTF-8?q?=E5=8A=A8=E3=80=81=E8=A3=81=E5=88=A4=E7=AB=AF=E5=8F=AF=E6=94=B9?= =?UTF-8?q?=E9=98=9F=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 界面改动都在浏览器预览里逐条核对过(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)通过。 --- README.md | 13 +- armor/README.md | 7 +- desktop/README.md | 84 +++++++- desktop/core/src/config.rs | 122 ++++++++++- desktop/core/src/snapshot.rs | 220 +++++++++++++++++-- desktop/src-tauri/src/commands.rs | 38 +++- desktop/src-tauri/src/demo.rs | 2 +- desktop/src-tauri/src/main.rs | 1 + desktop/src-tauri/src/state.rs | 55 +++-- desktop/src/components/ArmorTable.tsx | 12 +- desktop/src/components/HealthBar.tsx | 153 +++++++++----- desktop/src/components/MatchBanner.tsx | 56 +++++ desktop/src/components/SettingsPanel.tsx | 38 ++-- desktop/src/components/TeamNameEditor.tsx | 95 +++++++++ desktop/src/components/VideoSurface.tsx | 36 +--- desktop/src/lib/dev-preview.ts | 42 +++- desktop/src/lib/format.ts | 18 +- desktop/src/lib/ipc.ts | 3 + desktop/src/lib/store.ts | 2 +- desktop/src/lib/types.ts | 17 ++ desktop/src/pages/RefereePage.tsx | 63 +++++- desktop/src/pages/TeamPage.tsx | 61 ++++-- desktop/src/styles/global.css | 245 +++++++++++++++++----- 23 files changed, 1144 insertions(+), 239 deletions(-) create mode 100644 desktop/src/components/MatchBanner.tsx create mode 100644 desktop/src/components/TeamNameEditor.tsx diff --git a/README.md b/README.md index d611c64..4224d18 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ UDP 图传。 固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 **Tauri v2 + React**,用户端与裁判端两套页面(见 [`desktop/README.md`](desktop/README.md))。同一套 系统里还有一队一块的**装甲板**(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量, -并把血量广播给裁判终端。 +并把血量广播给局域网里的两端。 ```text protocol/ 线路格式 + 帧重组 三端共用,无 ESP-IDF 依赖,14 个单元测试 . 图传固件 -> ESP32-S3 交叉编译,Xtensa armor/ 装甲板固件 -> ESP32-C3 独立 workspace,RISC-V;规则层 22 个单元测试 -desktop/ 桌面端 Tauri v2 + React;用户端(血条 + 瞄准器)/ 裁判端 +desktop/ 桌面端 Tauri v2 + React;用户端(双方血条 + 瞄准器)/ 裁判端(可改队名) core/ 不含界面与 Tauri 的网络层、配置、密码(可单测) src-tauri/ Tauri 后端:推流、命令、录制 src/ React 前端:两套页面 + HUD @@ -43,7 +43,9 @@ desktop/ 桌面端 Tauri v2 + React;用户端(血条 + 瞄 上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。 裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收 -全部板子的视频,然后在**界面层**过滤到绑定的那一块。 +全部板子的视频,然后在**界面层**过滤到绑定的那一块;**血量相反,两边都显示**——用户端的 +画面左上角是自己、右上角是对手。血量本来就是明文广播的(裁判端与用户端收的是同一份),而 +"自己的血与对手的血一起看"是这项运动里最基本的对照,藏起来只会让操作手看不见对手的血。 ## 实测性能 @@ -384,8 +386,9 @@ python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么 桌面端侧:`desktop/core/src/armor.rs` 的 `ArmorHealthStore` 监听 5601,按 `(team, index)` 存最后一次读数,四条都在 1.5 s 内收到才显示——**没有数据时显示占位符而不是 0**, -0 看起来像真值,现场看到会以为对方已经没血了。界面上直接显示血量数字(整车一条血, -归零显示 `0 · 已击毁` 并转红);数字变化也会打进客户端日志。 +0 看起来像真值,现场看到会以为对方已经没血了。界面上的血量画在画面角上(一条分 5 格的 +平行四边形,20 点血一格),归零不写"已击毁/损毁",而是由图传中央的「胜利 / 击败」横幅表达 +(见 [`desktop/README.md`](desktop/README.md));数字变化也会打进客户端日志。 同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板" 并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用 diff --git a/armor/README.md b/armor/README.md index 3ad9537..3b1beaa 100644 --- a/armor/README.md +++ b/armor/README.md @@ -99,7 +99,8 @@ magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index( **裁判端界面原本看不到血量。** 协议与 `ArmorSource` 早已就位,但 Slint 里那 4 格只是 绿/灰方块 —— `hps` 的数值从没被渲染,于是"板子被打到 0"在界面上完全看不出来(现场 表现就是"敲死了裁判系统没掉血")。现在 `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 格只是绿/灰方块, `hps` 的**数值从不渲染**——板子被打到 0,界面四格还是绿的,看起来像"没掉血"。现在 -队伍名旁边直接显示数字(归零显示 `0 · 已击毁`,低于 30 转红),并且客户端日志会打 -`armor: team 0 hp 100 -> 97`。 +队伍名旁边直接显示数字(低于 30 转红,归零不写"已击毁",改由图传中央的胜负横幅表达), +并且客户端日志会打 `armor: team 0 hp 100 -> 97`。 ## 实现要点(都是踩过的坑) diff --git a/desktop/README.md b/desktop/README.md index 0ae1d6b..3de1d40 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -4,19 +4,19 @@ | 身份 | 画面 | 血量 | 装甲板 | 绑定 | |---|---|---|---|---| -| 用户端(`team`) | 一路 | 自己那边 + 血条 | 自己那块 | **要,改动需密码** | -| 裁判端(`referee`) | 两路并排 | 双方 | 发现列表 + 槽位 | 不需要 | +| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** | +| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 | 界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进 一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。 ```text desktop/ - core/ cam-core:不含任何界面与 Tauri 的东西(69 个测试) + core/ cam-core:不含任何界面与 Tauri 的东西(80 个测试) src-tauri/ Tauri 后端:推流、命令、录制(30 个测试) src/ React 前端:两套页面 + HUD src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化 - src/components/ 视频窗格、血条、瞄准器、装甲板表、录制面板、设置抽屉 + src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉 src/pages/ 用户端 / 裁判端 ``` @@ -33,7 +33,7 @@ pnpm tauri build # 出 .app 与 .dmg 下面两条是 Rust 侧,用它验证不依赖窗口的部分: ```sh -cd desktop/core && cargo test # 69 +cd desktop/core && cargo test # 80 cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg ``` @@ -195,13 +195,39 @@ IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/ ## 用户端 HUD -### 血条 +### 血条:两个上角,平行四边形,一条分 5 格 -画面**下面**一条窄带里的胶囊条,不挡画面。宽度、是否显示数字/队名、低血阈值与脉动都可配。 +**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数, +放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格 +20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。 +少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。 + +(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、 +指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。) + +宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区 +在哪"是一直看得见的,而不是等真的低血了才第一次出现。 **未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经 没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。 +血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留 +一个 0 与灭掉的格子。 + +### 胜负横幅 + +一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是 +「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接 +看出是哪一边赢了。 + +- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是 + 输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的 + 自家主板会让人"凭空胜利"。 +- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有 + "这局结束了"的痕迹,截图、回放与现场的争议都无从对照。 +- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**: + 它是一局的结束,此刻不再需要瞄准。 + ### 瞄准器 画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。 @@ -235,11 +261,42 @@ IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/ 已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次 打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。 -反馈有三样:整屏闪红、画面抖动、准星旁的伤害数字。**大号数字不放在画面正中**——第一版 -放了,结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。 +反馈有**两样**:整屏闪红、准星旁的伤害数字。**大号数字不放在画面正中**——第一版放了, +结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。 + +**没有"画面抖动"了。** 原来有第三样:被打中时整幅图(连瞄准器一起)晃一下。它去掉了, +理由有两条: + +- 抖动恰好发生在最需要看清画面的那一下; +- 它把**画面**动了,而操作手看到画面动时的第一反应是"车在晃",不是"我被打中了"——一个 + 只有几百毫秒的信号,代价是让人怀疑一个不存在的问题。 + +`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的, +删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置 +面板里也没有这个开关了。 ## 裁判端 +### 队名可以直接改 + +两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。 +名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完 +立刻反映到血条、装甲板表与"等待图传"的占位文字上。 + +规范化的三件事统一在 Rust 侧做(`config::sanitize_team_name`,有单测):合并空白、按**字符** +截断到 12 个字(按字节截断会切出半个字)、留空回落到「队伍一 / 队伍二」。超过长度在输入框里 +就限制了,比提交后被悄悄截掉诚实。 + +命令是 `set_team_name(team, name)`——**一次只改一边**。界面本来可以传一整个两条的数组,但那样 +每次改名都是"读两条、改一条、写两条",而读来的那两条来自最多 200 ms 前的快照,紧接着改另一边 +就会把上一次的改动覆盖回去。**同样过密码闸门**:裁判端本来就没有密码(`Role::needs_password` +为假),所以对现场没有影响;而给一个不设闸门的"写配置"入口,等于在闸门上开一条旁路——今天 +改的是名字,明天有人顺手把绑定也挪进来。 + +名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name` +也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字, +而且不会报错。 + ### 两格等宽 `flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。 @@ -297,6 +354,7 @@ AP、板子,而不是二十行分配代码。现在它有一个直接对着这 role = "team" roleChosen = true team = 0 +teamNames = ["队伍一", "队伍二"] cameraId = "0x0DE00001" armorId = "0x0A200001" mirror = false @@ -326,6 +384,8 @@ offsetX = 0.0 - **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断 文件会让"解锁"永久失败。 - **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。 +- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队 + 叫什么"是比赛本身的属性,不是裁判端这个身份的属性。 - `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判 端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。 @@ -350,7 +410,7 @@ open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234) ## 测试 ```sh -cd desktop/core && cargo test # 69:重组、发现、两道闸门、帧率窗口、槽位、配置、密码 +cd desktop/core && cargo test # 80:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定 cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行 cd protocol && cargo test # 15 ``` @@ -410,6 +470,10 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:' | 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 | | 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) | | 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 | +| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" | +| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug | +| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 | +| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 | | 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 | | 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" | | 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 | diff --git a/desktop/core/src/config.rs b/desktop/core/src/config.rs index 14e1a0d..ccf743d 100644 --- a/desktop/core/src/config.rs +++ b/desktop/core/src/config.rs @@ -46,6 +46,18 @@ use serde::{Deserialize, Serialize}; /// 用处是将来真需要迁移时有个明确的判据,以及现场排查时能一眼看出文件是哪一版写的。 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)] #[serde(rename_all = "lowercase")] @@ -245,11 +257,19 @@ impl HealthConfig { pub struct HitFeedbackConfig { /// 整屏闪一下红。 pub flash: bool, - /// 画面抖一下。 + /// **已废弃,保留只是为了兼容旧配置文件。** + /// + /// 命中时抖动画面的做法去掉了:抖动恰恰发生在最需要看清画面的那一下,而且它让**整幅 + /// 图**(包括瞄准器)跟着动,操作手会误以为车在晃。现在这个字段读进来就丢掉,界面不再 + /// 画抖动、设置面板里也没有这个开关。 + /// + /// 不删掉字段的理由很实际:这个文件是 `deny_unknown_fields` 的,删字段会让所有已经写 + /// 过 `shake = true` 的配置**直接解析失败**,而配置解析失败等于程序起不来。 + #[serde(default)] pub shake: bool, /// 在瞄点旁标出伤害数字。 pub marker: bool, - /// 闪红与抖动的强度(0.2–2.0)。 + /// 闪红与伤害数字的强度(0.2–2.0)。 pub intensity: f32, } @@ -257,7 +277,8 @@ impl Default for HitFeedbackConfig { fn default() -> Self { Self { flash: true, - shake: true, + // 见字段文档:不再抖动。默认值也就是"读旧配置时会被解析成什么"。 + shake: false, marker: true, intensity: 1.0, } @@ -401,6 +422,14 @@ pub struct Config { pub role_chosen: bool, /// 用户端属于哪一边:0 = 队伍一,1 = 队伍二。 pub team: u8, + /// 两边在界面上显示的名字,下标就是队伍号(裁判端可以直接改)。 + /// + /// 放在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的队伍叫什么" + /// 是**比赛**的属性,不是裁判端这个身份的属性。 + /// + /// 空字符串会回落到 [`DEFAULT_TEAM_NAMES`](见 [`Config::normalized`])——名字是画在 + /// 血条角上的,留空会在现场显示成一个没有名字的血条,而不是"没配置"。 + pub team_names: [String; 2], /// 绑定的图传板(MAC 派生,重启不变)。 #[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")] pub camera_id: Option, @@ -427,6 +456,10 @@ impl Default for Config { role: Role::Referee, role_chosen: false, team: 0, + team_names: [ + DEFAULT_TEAM_NAMES[0].to_string(), + DEFAULT_TEAM_NAMES[1].to_string(), + ], camera_id: None, armor_id: None, mirror: true, @@ -509,6 +542,10 @@ impl Config { pub fn normalized(mut self) -> Self { self.hud = self.hud.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 } @@ -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::>().join(" "); + let text = if collapsed.is_empty() { fallback } else { collapsed.as_str() }; + + text.chars().take(TEAM_NAME_MAX_CHARS).collect() +} + /// 配置文件开头那段自我说明。 const HEADER: &str = "\ # cam-desktop 配置。 @@ -551,6 +602,8 @@ const HEADER: &str = "\ # team = 用户端:一块画面 + 自己的血条与瞄准器 # referee = 裁判端:两块画面 + 双方血量 + 装甲板列表 + 录制 # team : 0 = 队伍一, 1 = 队伍二(仅 team 端用) +# teamNames: 两边在界面上显示的名字,下标就是队伍号;裁判端可以直接改。 +# 超过 12 个字会被截断,留空则回落到「队伍一 / 队伍二」。 # cameraId : 图传板 id,由 MAC 派生,重启不变,所以绑定是稳的 # armorId : 装甲板主板 id,同上 # mirror : true = 左右镜像(摄像头装反了,默认) @@ -816,6 +869,7 @@ mod tests { config.role = Role::Team; config.role_chosen = true; config.team = 1; + config.team_names = ["红队".to_string(), "蓝队".to_string()]; config.camera_id = Some(0x3D4D_8A9A); config.armor_id = Some(0x00AB_CDEF); config.mirror = false; @@ -832,6 +886,7 @@ mod tests { assert_eq!(back.role, Role::Team); assert!(back.role_chosen); assert_eq!(back.team, 1); + assert_eq!(back.team_names, config.team_names); assert_eq!(back.camera_id, Some(0x3D4D_8A9A)); assert_eq!(back.armor_id, Some(0x00AB_CDEF)); assert!(!back.mirror); @@ -933,6 +988,67 @@ roleChosen = true"#) 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] fn save_and_load_do_not_lose_the_password_hash() { let dir = std::env::temp_dir().join(format!("cam-desktop-cfg-{}", std::process::id())); diff --git a/desktop/core/src/snapshot.rs b/desktop/core/src/snapshot.rs index 568cc1d..d45cc7a 100644 --- a/desktop/core/src/snapshot.rs +++ b/desktop/core/src/snapshot.rs @@ -194,14 +194,31 @@ pub struct TeamHealthDto { pub board_text: Option, /// 血量偏低或已归零。 pub critical: bool, + /// 这一边的胜负结论,由 [`resolve_outcomes`] 从**双方**的血量算出来。 + /// + /// 放在每一边的 DTO 上而不是单独一个全局字段:界面上一格画面就是一边,一个格子里 + /// 要放的就是"从我这边看,现在是什么结果"。裁判端两格因此各看各的那份。 + pub outcome: Option, +} + +/// 一边看到的比赛结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MatchOutcome { + /// 对手归零,自己还活着。 + Victory, + /// 自己归零,对手还活着。 + Defeat, + /// 两边同时归零。不写成"平局"以外的任何一个字:同时打空时两边都看到"胜利"才是错的。 + Draw, } impl TeamHealthDto { /// 还没有数据源。 - pub fn absent(team: u8, board: Option) -> Self { + pub fn absent(team: u8, board: Option, names: &[String; 2]) -> Self { Self { team, - name: team_name(team), + name: team_name(team, names), available: false, placeholder: format!( "装甲板未接入:等主板在 UDP {} 上广播血量", @@ -214,16 +231,23 @@ impl TeamHealthDto { board, board_text: board.map(id_text), critical: false, + outcome: None, } } /// 有数据。 - pub fn present(team: u8, board: Option, hps: [u16; crate::receiver::ARMOR_BOARDS], low: u16) -> Self { + pub fn present( + team: u8, + board: Option, + hps: [u16; crate::receiver::ARMOR_BOARDS], + low: u16, + names: &[String; 2], + ) -> Self { let hp = hps.first().copied().unwrap_or(0); Self { team, - name: team_name(team), + name: team_name(team, names), available: true, placeholder: String::new(), hp: Some(hp), @@ -231,15 +255,70 @@ impl TeamHealthDto { board, board_text: board.map(id_text), critical: hp == 0 || hp <= low, + outcome: None, } } } -/// 队伍名。 -pub fn team_name(team: u8) -> String { - match team { - 0 => "队伍一".to_string(), - _ => "队伍二".to_string(), +/// 把双方的血量合起来算成各自看到的胜负结论,写回 `outcome`。 +/// +/// # 规则是有意不对称的 +/// +/// - **输只需要看自己**:自己归零就是输,哪怕对手那块板子的数据没收到。这一条要成立, +/// 否则"我被打死了但对手的主板掉线"会显示成什么都没发生。 +/// - **赢要看两边**:必须看到对手归零**且**自己还有血。少了后一半,一块从没上报过血量的 +/// 自己方主板会让人"凭空胜利"。 +/// - 两边同时归零是平局:那种情况下任何一边显示"胜利"都是错的。 +/// +/// 变量不足(对不上的队伍、只有一份 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 to: u16, pub damage: u16, - /// Unix 毫秒。前端用它驱动闪红/抖动的动画,不需要自己计时。 + /// Unix 毫秒。前端用它驱动闪红的动画,不需要自己计时。 pub at_ms: u64, } @@ -318,7 +397,9 @@ pub struct Snapshot { pub stats: Vec, /// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。 pub slots: SlotsDto, - /// 用户端一条(自己那边),裁判端两条。 + /// **两端都是两条,顺序是队伍号 0、1**:用户端也要显示对手的血量(画面右上角)。 + /// + /// 界面按 `team` 取自己那一格,不靠数组下标——用户端绑的是队伍二时 `health[0]` 是别人。 pub health: Vec, pub recordings: Vec, /// 启动时的说明(旧配置迁移、端口占用等)。 @@ -336,6 +417,9 @@ pub struct ConfigView { /// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。 pub role_chosen: bool, pub team: u8, + /// 两边在界面上显示的名字(裁判端可以改)。界面各处显示队伍名都要用它, + /// 包括血条之外的地方(装甲板表、等待画面的占位文字)。 + pub team_names: [String; 2], pub camera_id: Option, pub camera_id_text: Option, pub armor_id: Option, @@ -364,6 +448,7 @@ impl From<&Config> for ConfigView { role: config.role, role_chosen: config.role_chosen, team: config.team, + team_names: config.team_names.clone(), camera_id: config.camera_id, camera_id_text: config.camera_id.map(id_text), armor_id: config.armor_id, @@ -445,29 +530,134 @@ mod tests { assert_eq!(dto.status, "离线"); } + /// 默认队名,测试里到处用。 + fn names() -> [String; 2] { + ["队伍一".to_string(), "队伍二".to_string()] + } + #[test] 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_eq!(dto.hp, None); assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS); assert!(!dto.placeholder.is_empty()); + assert_eq!(dto.outcome, None, "没有数据时不下结论"); } #[test] 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"); - 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); - 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_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] fn a_recording_reports_the_framerate_it_told_ffmpeg() { // 回放速度不对时,这是唯一能解释原因的数字。 diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index 31ca304..417de5b 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -141,7 +141,7 @@ pub fn bind_armor( Ok(ConfigView::from(&state.config())) } -/// 自己属于哪一边(0/1)。改错队号会让血条显示对手的血量,所以也要密码。 +/// 自己属于哪一边(0/1)。改错队号会把对手的血条放在左上角(当成自己的),所以也要密码。 #[tauri::command] pub fn set_team( team: u8, @@ -232,6 +232,42 @@ pub fn set_referee_slots( 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, + state: Shared<'_>, +) -> Result { + 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 路径。 #[tauri::command] pub fn set_recording_options( diff --git a/desktop/src-tauri/src/demo.rs b/desktop/src-tauri/src/demo.rs index 5689039..f6796e8 100644 --- a/desktop/src-tauri/src/demo.rs +++ b/desktop/src-tauri/src/demo.rs @@ -125,7 +125,7 @@ fn spawn_armor(state: Arc) { ticks[slot] += 1; if hp[slot] == 0 { - // 打空之后停一会儿再重置:让界面上"已击毁"的状态真的能被看到。 + // 打空之后停一会儿再重置:让界面上的胜负横幅真的能被看到。 dead_for[slot] += 1; if dead_for[slot] >= 10 { hp[slot] = 100; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 65a392e..27f3739 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -235,6 +235,7 @@ fn run() -> Result<(), Box> { commands::set_role, commands::set_mirror, commands::save_hud, + commands::set_team_name, commands::set_referee_slots, commands::set_recording_options, commands::screenshot, diff --git a/desktop/src-tauri/src/state.rs b/desktop/src-tauri/src/state.rs index a038670..22a209d 100644 --- a/desktop/src-tauri/src/state.rs +++ b/desktop/src-tauri/src/state.rs @@ -27,7 +27,8 @@ use cam_core::receiver::{Discovery, LatestFrames, Receiver}; use cam_core::ArmorSource; use cam_core::slots::SlotAllocator; 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 tokio::sync::broadcast; @@ -127,7 +128,7 @@ impl AppState { let discovery = receiver.discovery(); let latest = receiver.latest_frames(); - // 装甲板:两端都要收。用户端要显示自己的血条,裁判端要显示双方。 + // 装甲板:两端都要收。用户端要显示**双方**的血条(左上自己、右上对手),裁判端也是双方。 let (armor, armor_socket) = match ArmorHealthStore::start() { Ok((store, socket)) => (store, Some(socket)), Err(err) => { @@ -543,11 +544,14 @@ impl AppState { let slots = self.slots.lock().expect("slots mutex poisoned").slots(); - let health = match config.role { - // 用户端只显示自己那一边。 - Role::Team => vec![self.health_for(config.team)], - Role::Referee => vec![self.health_for(0), self.health_for(1)], - }; + // 两边都给:用户端的画面右上角要显示对手的血量,裁判端本来就要双方。 + // + // 顺序固定为队伍号 0、1,界面按 `team` 取那一格——**不靠数组下标**判断"哪个是我", + // 因为下标在"用户端绑的是队伍二"时就不再等于自己的队伍号了。 + let mut health = vec![self.health_for(0), self.health_for(1)]; + + // 胜负是**两边合起来**才能算出来的东西,所以在这里补一步。 + resolve_outcomes(&mut health); Snapshot { role: config.role, @@ -577,14 +581,21 @@ impl AppState { } /// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。 + /// + /// 槽位指定只有**裁判端**有:`referee.armor_a/b` 是裁判端界面上的"左格/右格",用户端 + /// 拿它当自己的板子会让绑定被一份不相干的配置盖掉。 fn assigned_armor(&self, config: &Config, team: u8) -> Option { - let explicit = if team == 0 { - config.referee.armor_a + let explicit = if config.role == Role::Referee { + if team == 0 { + config.referee.armor_a + } else { + config.referee.armor_b + } } else { - config.referee.armor_b + None }; - if config.referee.manual_slots { + if config.role == Role::Referee && config.referee.manual_slots { return explicit; } @@ -601,27 +612,29 @@ impl AppState { /// /// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线 /// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。 + /// + /// # 对手那一块绝不能用 `armor_id` + /// + /// `armor_id` 是**我自己**绑定的主板。拿它去填对手那一格,右上角显示的就是自己的血—— + /// 比不显示更糟:现场会照着一个假的对手血量做决策。 fn health_for(&self, team: u8) -> TeamHealthDto { let config = self.config(); let low = config.hud.health.low_threshold; - let board = match config.role { - Role::Team => config - .armor_id - .or_else(|| self.assigned_armor(&config, team)), - Role::Referee => self.assigned_armor(&config, team), - }; + let own = config.role == Role::Team && team == config.team; + let bound = if own { config.armor_id } else { None }; + let board = bound.or_else(|| self.assigned_armor(&config, team)); if let Some(board) = board { return match self.armor.health_of_board(board) { - Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low), - None => TeamHealthDto::absent(team, Some(board)), + Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names), + None => TeamHealthDto::absent(team, Some(board), &config.team_names), }; } match self.armor.health(team) { - Some(hps) => TeamHealthDto::present(team, None, hps, low), - None => TeamHealthDto::absent(team, None), + Some(hps) => TeamHealthDto::present(team, None, hps, low, &config.team_names), + None => TeamHealthDto::absent(team, None, &config.team_names), } } } diff --git a/desktop/src/components/ArmorTable.tsx b/desktop/src/components/ArmorTable.tsx index 1dd8ab6..e909677 100644 --- a/desktop/src/components/ArmorTable.tsx +++ b/desktop/src/components/ArmorTable.tsx @@ -12,9 +12,11 @@ import type { ArmorBoardDto, SlotsDto } from "../lib/types"; interface Props { boards: ArmorBoardDto[]; slots: SlotsDto; + /** 两边显示的名字(裁判端可改)。缺省时回落到「队伍一 / 队伍二」。 */ + names?: [string, string]; } -export function ArmorTable({ boards, slots }: Props) { +export function ArmorTable({ boards, slots, names }: Props) { return (
@@ -36,7 +38,11 @@ export function ArmorTable({ boards, slots }: Props) { {boards.map((board) => { 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 (
{board.idText} {board.addr} - {teamName(board.team)} + {teamName(board.team, names)} {board.hp} {board.platesSeen}/4 diff --git a/desktop/src/components/HealthBar.tsx b/desktop/src/components/HealthBar.tsx index 1394b8f..0e5e53e 100644 --- a/desktop/src/components/HealthBar.tsx +++ b/desktop/src/components/HealthBar.tsx @@ -1,15 +1,29 @@ /** - * 底部胶囊血条。 + * 画面角上的分段血条(平行四边形,一条分 5 格)。 * - * # 为什么它在画面**下面**而不是叠在画面上 + * # 为什么改到角上、改成斜的 * - * 用户端要的是"一眼看到还剩多少血",而不是把画面挡住。放在画面下沿一条窄带里, - * 视频区域一个像素都不占;裁判端并排两路时也正好对称。 + * 原来它在画面**下面**一条窄带里。改成画面左上角/右上角之后: + * + * - 画面不再是"被一条横带压住的一块",整块视频区域给到取景; + * - 两边各占一角,**同时**看到自己与对手的血量,视线不用离开画面; + * - 斜切的形状不会在第一时间被误认成字幕条或进度条,扫一眼就知道是血量。 + * + * 左上角给自己、右上角给对手(见 `TeamPage`)。裁判端两格各自一个角,与用户端同一套 + * 视觉语言。 + * + * # 一条分 5 格 = 一格 20 点 + * + * 整条是一条,里面分成 **5 格**:格子少而大,隔着几米扫一眼就能数出"还剩三格", + * 比读一条连续的长度或一个百分比快。血量归零就是 0 格,那时图传中央会给出胜负。 + * + * 血少了,亮着的格子从**中心那一侧**开始灭(左条从右往左灭,右条从左往右灭),所以两条 + * 血一起减少时,视觉上是往两侧退——不会看成"条变长了"。 * * # 未接入时不显示 0 * - * `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到 - * 两个 0 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。 + * `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到 0 + * 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。 */ import { useEffect, useState } from "react"; @@ -20,14 +34,28 @@ import type { HealthConfig, TeamHealthDto } from "../lib/types"; interface Props { health: TeamHealthDto; config: HealthConfig; - /** 紧凑模式:裁判端两路并排时用。 */ - compact?: boolean; + /** 挂在画面哪一角。`right` 会把条整个镜像过来(含缺血的退向)。 */ + align: "left" | "right"; + /** 这一格是谁:用户端用「我方 / 对手」把两条同色的血区分开。 */ + tag?: string; } +/** 整条分成几格。 */ +const CELLS = 5; +/** 一格多少血。整车一条血 100 点。 */ +const CELL_HP = 100 / CELLS; + /** 脉动的周期。与 CSS 动画保持一致。 */ 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); // 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面 @@ -45,62 +73,89 @@ export function HealthBar({ health, config, compact = false }: Props) { }, [pulsing]); 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)}%`; return (
-
-
- - {/* 低血刻度:一根竖线标出阈值,让人知道"还有多久开始报警"。 */} - {config.lowThreshold > 0 && config.lowThreshold < total && ( -
+
+ {tag && {tag}} + {config.showTeamName && ( + + {health.name} + )} -
- -
- {config.showTeamName && {health.name}} {health.available ? ( config.showNumbers && ( - - {health.hp} - {health.critical && · 已击毁} + + {hp} ) ) : ( - {health.placeholder} + 装甲板未接入 )} +
- {health.available && health.boardText && ( - - {compact ? "" : "装甲板 "} - {health.boardText} - - )} +
); } + +/** + * 这一格是谁。 + * + * 用户端两条血分别属于两边,而形状是对称的——没有这个标签,"左边这条是我的"要靠位置猜。 + */ +export function healthTag(health: TeamHealthDto, myTeam: number): string { + return health.team === myTeam ? "我方" : "对手"; +} diff --git a/desktop/src/components/MatchBanner.tsx b/desktop/src/components/MatchBanner.tsx new file mode 100644 index 0000000..4d3398e --- /dev/null +++ b/desktop/src/components/MatchBanner.tsx @@ -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 = { + victory: "胜利", + defeat: "击败", + draw: "平局", +}; + +export function MatchBanner({ outcome, mine, theirs }: Props) { + if (!outcome) return null; + + const caption = + outcome === "victory" + ? `击败 ${theirs}` + : outcome === "defeat" + ? `被 ${theirs} 击败` + : "双方装甲板同时归零"; + + return ( +
+
+ {mine} + {WORD[outcome]} + {caption} +
+
+ ); +} diff --git a/desktop/src/components/SettingsPanel.tsx b/desktop/src/components/SettingsPanel.tsx index c89100d..6755c77 100644 --- a/desktop/src/components/SettingsPanel.tsx +++ b/desktop/src/components/SettingsPanel.tsx @@ -128,11 +128,11 @@ export function SettingsPanel({ snapshot.armorBoards.map((board) => ({ value: board.id, label: board.idText, - hint: `${board.addr} · ${teamName(board.team)} · ${board.hp} 血 · ${ + hint: `${board.addr} · ${teamName(board.team, snapshot.config.teamNames)} · ${board.hp} 血 · ${ board.online ? "在线" : "离线" }`, })), - [snapshot.armorBoards], + [snapshot.armorBoards, snapshot.config.teamNames], ); return ( @@ -204,12 +204,15 @@ export function SettingsPanel({ /> - + - + - + 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(); + } + }} + /> + + ); +} diff --git a/desktop/src/components/VideoSurface.tsx b/desktop/src/components/VideoSurface.tsx index d68c64d..13b95bb 100644 --- a/desktop/src/components/VideoSurface.tsx +++ b/desktop/src/components/VideoSurface.tsx @@ -133,11 +133,13 @@ export function VideoSurface({ dirty.current = true; } - // 命中动画期间必须持续重绘,否则抖动只画了一帧就停住。 + // 命中动画期间必须持续重绘,否则闪红/伤害数字只画了一帧就停住。 + // + // **抖动没有了**(见 `HitFeedbackConfig::shake` 的文档):不再有"整幅图跟着晃"的 + // 那一路,所以这里也不再需要因为抖动而复绘。 const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY; const animating = - pulseAge < 700 && - (current.hitFeedback.shake || current.hitFeedback.marker || current.hitFeedback.flash); + pulseAge < 700 && (current.hitFeedback.marker || current.hitFeedback.flash); if (!dirty.current && !animating) return; dirty.current = false; @@ -300,11 +302,8 @@ function draw( ? layout(box, frame.width, frame.height, current.view) : fallback; - const shake = current.hit ? shakeOffset(current.hit, timestamp, current.hitFeedback) : { x: 0, y: 0 }; - if (frame) { ctx.save(); - ctx.translate(shake.x, shake.y); if (current.mirrored) { ctx.translate(width, 0); @@ -317,7 +316,7 @@ function draw( ctx.restore(); if (current.grid?.visible) { - drawGrid(ctx, geometry, current.grid, shake); + drawGrid(ctx, geometry, current.grid); } } @@ -333,7 +332,7 @@ function draw( } 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.textAlign = "left"; ctx.textBaseline = "middle"; - ctx.fillText( - `-${current.hit.damage}`, - aimX + shake.x + 24, - aimY + shake.y - 26 - progress * 26, - ); + ctx.fillText(`-${current.hit.damage}`, aimX + 24, aimY - 26 - progress * 26); 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( ctx: CanvasRenderingContext2D, geometry: { left: number; top: number; drawWidth: number; drawHeight: number }, grid: GridConfig, - shake: { x: number; y: number }, ) { if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return; ctx.save(); - ctx.translate(shake.x, shake.y); ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`; ctx.lineWidth = 1; diff --git a/desktop/src/lib/dev-preview.ts b/desktop/src/lib/dev-preview.ts index 110505d..0dd7433 100644 --- a/desktop/src/lib/dev-preview.ts +++ b/desktop/src/lib/dev-preview.ts @@ -26,6 +26,7 @@ * ``` */ +import { DEFAULT_TEAM_NAMES } from "./format"; import type { StreamClient } from "./stream"; import type { ConfigView, @@ -71,7 +72,7 @@ const DEFAULT_HUD: HudView = { offsetY: 0, }, 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, showStats: true, }; @@ -87,6 +88,8 @@ interface PreviewState { cameraId: number | null; armorId: number | null; team: number; + /** 两边显示的名字,与后端配置里的 `teamNames` 对应。 */ + teamNames: [string, string]; mirror: boolean; unlocked: boolean; password: string | null; @@ -101,6 +104,7 @@ const state: PreviewState = { cameraId: startAsReferee ? null : CAMERA_A, armorId: startAsReferee ? null : ARMOR_A, team: 0, + teamNames: ["队伍一", "队伍二"], mirror: true, // 预览里直接是已解锁状态,免得每次刷新都要输密码。 unlocked: false, @@ -156,6 +160,14 @@ function handle(command: string, args: Record): unknown { guard(); state.team = Number(args.team); 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": state.role = args.role as "team" | "referee"; return configView(); @@ -213,6 +225,7 @@ function configView(): ConfigView { role: state.role, roleChosen: true, team: state.team, + teamNames: [...state.teamNames] as [string, string], cameraId: state.cameraId, cameraIdText: state.cameraId === null ? null : hex(state.cameraId), 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 { return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`; } @@ -288,13 +308,25 @@ function snapshot(): Snapshot { 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 hp = state.hp[team]; const board = team === 0 ? ARMOR_A : ARMOR_B; return { team, - name: team === 0 ? "队伍一" : "队伍二", + name: state.teamNames[team], available: true, placeholder: "", hp, @@ -302,6 +334,7 @@ function snapshot(): Snapshot { board, boardText: hex(board), 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, manual: false, }, - health: state.role === "referee" ? [healthFor(0), healthFor(1)] : [healthFor(state.team)], + // 两边都给:用户端也要显示对手的血量(画面右上角那一条)。 + health: [healthFor(0), healthFor(1)], recordings: recordings(), notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"], }; @@ -377,7 +411,7 @@ export function startPreview(client: StreamClient, emit: (msg: WireMsg) => void) // 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。 // - // 打空的那一拍只做重置、不出命中("已击毁"的状态要真的能看到),其余每一拍都出一次 + // 打空的那一拍只做重置、不出命中(胜负横幅要真的能看到),其余每一拍都出一次 // 命中。早先的写法是 `to = from === 0 ? 100 : ...` 然后按 `to >= from` 判断——那样一旦 // 两块都打空,之后每次都是重置,命中就再也不会出现,盯着界面等闪红会等到超时。 let tick = 0; diff --git a/desktop/src/lib/format.ts b/desktop/src/lib/format.ts index c9e7ed6..d65073b 100644 --- a/desktop/src/lib/format.ts +++ b/desktop/src/lib/format.ts @@ -52,9 +52,21 @@ export function fps(value: number): string { return value.toFixed(1); } -/** 队伍名。与 Rust 侧的 `team_name` 一致。 */ -export function teamName(team: number): string { - return team === 0 ? "队伍一" : "队伍二"; +/** 队伍名的兜底值。与 Rust 侧的 `config::DEFAULT_TEAM_NAMES` 一致。 */ +export const DEFAULT_TEAM_NAMES = ["队伍一", "队伍二"]; + +/** + * 队伍名。 + * + * 名字是**配置里可以改的**(裁判端能直接编辑),所以显示的地方一律从这里取:散在各处的 + * `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] ?? "队伍"; } /** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */ diff --git a/desktop/src/lib/ipc.ts b/desktop/src/lib/ipc.ts index aeb6760..fa90b69 100644 --- a/desktop/src/lib/ipc.ts +++ b/desktop/src/lib/ipc.ts @@ -68,6 +68,9 @@ export const api = { call("set_role", { role, password: password ?? null }), setMirror: (mirror: boolean) => call("set_mirror", { mirror }), saveHud: (hud: HudView) => call("save_hud", { hud }), + /** 改一边的队名(0 = 队伍一,1 = 队伍二)。一次只改一边,避免覆盖另一边的改动。 */ + setTeamName: (team: number, name: string, password?: string) => + call("set_team_name", { team, name, password: password ?? null }), setRefereeSlots: (slots: { cameraA: number | null; cameraB: number | null; diff --git a/desktop/src/lib/store.ts b/desktop/src/lib/store.ts index 66b7db4..393459b 100644 --- a/desktop/src/lib/store.ts +++ b/desktop/src/lib/store.ts @@ -30,7 +30,7 @@ export interface AppStore { snapshot: Snapshot | null; /** 与后端的视频通道是否连着。 */ connected: boolean; - /** 最近一次掉血,界面用它做闪红/抖动。 */ + /** 最近一次掉血,界面用它做闪红与伤害数字。 */ lastHit: HitPulseRecord | null; /** 后端要求弹出解锁框(用户端点关闭窗口时)。 */ confirmQuit: number; diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 8db1e91..79425c4 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -56,6 +56,12 @@ export interface GridConfig { /** `cam_core::config::HitFeedbackConfig` */ export interface HitFeedbackConfig { flash: boolean; + /** + * 已废弃:命中时不再抖动画面。 + * + * 字段留着只是为了读得进旧配置文件(Rust 侧 `deny_unknown_fields`,删字段会让写过 + * `shake = true` 的配置直接解析失败)。界面不读它。 + */ shake: boolean; marker: boolean; intensity: number; @@ -76,6 +82,8 @@ export interface ConfigView { role: Role; roleChosen: boolean; team: number; + /** 两边在界面上显示的名字,下标就是队伍号。裁判端可以直接改。 */ + teamNames: [string, string]; cameraId: number | null; cameraIdText: string | null; armorId: number | null; @@ -137,6 +145,13 @@ export interface SlotsDto { manual: boolean; } +/** + * `cam_core::snapshot::MatchOutcome`。 + * + * 是**从这一边看**的结论,不是"谁赢了"这个全局判断:同一场对局里两边的 DTO 各带一份。 + */ +export type MatchOutcome = "victory" | "defeat" | "draw"; + /** `cam_core::snapshot::TeamHealthDto` */ export interface TeamHealthDto { team: number; @@ -148,6 +163,8 @@ export interface TeamHealthDto { board: number | null; boardText: string | null; critical: boolean; + /** 有结论时(有人归零)才有值;两边都活着时是 null。 */ + outcome: MatchOutcome | null; } /** `cam_core::snapshot::RecordingDto` */ diff --git a/desktop/src/pages/RefereePage.tsx b/desktop/src/pages/RefereePage.tsx index 3269028..cbeb0ca 100644 --- a/desktop/src/pages/RefereePage.tsx +++ b/desktop/src/pages/RefereePage.tsx @@ -1,14 +1,20 @@ /** * 裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。 * - * 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量并排对称、 - * 缩放置互相独立(滚轮只作用于鼠标所在的那一路)。 + * 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量各自贴在画面的 + * 上角(左格左上、右格右上,与用户端的"自己在左上、对手在右上"是同一套读法)、缩放置互相 + * 独立(滚轮只作用于鼠标所在的那一路)。 * * 两格等宽这件事在旧客户端上是踩过坑的:`HorizontalLayout` 先按内容的**最大**需求分配, * 剩下的才按 stretch 分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面" * (5 字),于是左边多要十几个像素——实测 55% / 45%。解法是让两格的需求只由画面决定: * 统一的 `preferred-width` 加 `min-width`,文字用绝对定位不参与布局。CSS 的 flex 里 * 用 `flex: 1 1 0` + `min-width: 0` 达到同一件事。 + * + * # 队名可以直接改 + * + * 名字属于这一场比赛,所以它放在两格画面各自下面(见 `TeamNameEditor`),改完立刻反映到 + * 血条、装甲板表与等待画面的文字上。命令在 Rust 侧,规范化也在那边统一做。 */ import { useMemo, useState } from "react"; @@ -16,7 +22,9 @@ import { useMemo, useState } from "react"; import { ArmorTable, StatsLine } from "../components/ArmorTable"; import { HealthBar } from "../components/HealthBar"; import { HitFlash } from "../components/HitFlash"; +import { MatchBanner } from "../components/MatchBanner"; import { MediaPanel } from "../components/MediaPanel"; +import { TeamNameEditor } from "../components/TeamNameEditor"; import { TopBar } from "../components/TopBar"; import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface"; import { Button } from "../components/controls"; @@ -44,6 +52,9 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }: const [viewA, setViewA] = useState(IDENTITY_VIEW); const [viewB, setViewB] = useState(IDENTITY_VIEW); + // 队名以快照为准(后端会规范化)。 + const names = snapshot.config.teamNames; + const panes = useMemo( () => [ @@ -52,6 +63,8 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }: camera: snapshot.slots.cameraA, armor: snapshot.slots.armorA, team: 0, + // 左格的血条贴左上角,右格贴右上角:与用户端"自己在左上、对手在右上"一致。 + align: "left" as const, view: viewA, onView: setViewA, }, @@ -60,6 +73,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }: camera: snapshot.slots.cameraB, armor: snapshot.slots.armorB, team: 1, + align: "right" as const, view: viewB, onView: setViewB, }, @@ -67,10 +81,23 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }: ...pane, stats: snapshot.stats.find((entry) => entry.camera === pane.camera), 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], ); + /** + * 改一边的队名。 + * + * 命令一次只改一边,所以这里**不**把另一边的名字一起带上:那是"读两条、写两条", + * 而读来的值来自最多 200 ms 前的快照——紧接着改另一边就会把它覆盖回去。 + */ + const saveName = (team: number, value: string) => { + void api.setTeamName(team, value).catch((err) => { + onError(err instanceof Error ? err.message : String(err)); + }); + }; + return (
@@ -83,6 +110,8 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }: (pane.armor !== null ? lastHit.board === pane.armor : lastHit.team === pane.team), ); + const label = teamName(pane.team, names); + return (
+ {hud.health.visible && ( +
+ +
+ )} + + +
- + saveName(pane.team, value)} + />
@@ -153,7 +200,11 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
{tab === "armor" ? ( - + ) : ( )} diff --git a/desktop/src/pages/TeamPage.tsx b/desktop/src/pages/TeamPage.tsx index 4ccc137..a8daabe 100644 --- a/desktop/src/pages/TeamPage.tsx +++ b/desktop/src/pages/TeamPage.tsx @@ -1,24 +1,28 @@ /** - * 用户端:一路画面 + 血条 + 瞄准器。 + * 用户端:一路画面 + 双方血条 + 瞄准器 + 胜负横幅。 * * 界面上的东西按"操作手需要什么"排: * - * - 画面占满除底部一条窄带以外的全部空间(一个像素都不浪费在装饰上); - * - 血条在画面**下方**,不挡画面; + * - 画面占满除底部状态行以外的全部空间(一个像素都不浪费在装饰上); + * - **血条叠在画面两个上角**:左上角是自己,右上角是对手。血量是这一屏上最需要"同时看到" + * 的两个数,放在画面之外意味着视线要离开取景; * - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走; - * - 被打中时整屏闪一下红、画面抖一下、瞄点旁跳一个伤害数字。 + * - 被打中时整屏闪一下红、瞄点旁跳一个伤害数字——**画面不抖**(见 `HitFeedbackConfig`); + * - 一方归零之后,画面正中给出「胜利 / 击败」。 */ import { useMemo, useState } from "react"; 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 { MatchBanner } from "../components/MatchBanner"; import { StatsLine } from "../components/ArmorTable"; import { TopBar } from "../components/TopBar"; import { Button } from "../components/controls"; +import { teamName } from "../lib/format"; import { useAppStore } from "../lib/store"; -import type { HudView, Snapshot } from "../lib/types"; +import type { HudView, Snapshot, TeamHealthDto } from "../lib/types"; interface Props { snapshot: Snapshot; @@ -45,12 +49,21 @@ export function TeamPage({ () => snapshot.stats.find((entry) => entry.camera === 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,没绑定就退回比队伍号。 const hitIsMine = Boolean( 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; @@ -92,16 +105,36 @@ export function TeamPage({ hitFeedback={hud.hitFeedback} showZoom={hud.showZoom} > + {hud.health.visible && ( + <> +
+ +
+
+ {theirs && ( + + )} +
+ + )} + + {/* 结论只看自己这一份:后端的 outcome 就是"从这一边看"的。 */} + +
- {hud.health.visible && ( -
- -
- )} -