desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)

把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层:

- desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按
  发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。
- desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、
  每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。
- protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来——
  装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选"
  不需要改固件。

几个改对了的旧问题:

- 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位
  会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。
- 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出
  几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。
- 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了"
  会被读成"密码错了"。现在要求盐与摘要都在。
- 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。
  现在先收尾上一段再开新的,并且重名往后排号。
- 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面**
  不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。

测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用
ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
2026-10-14 19:14:16 +08:00
parent eafa112959
commit a89d7ae5fb
50 changed files with 15615 additions and 11 deletions
+38
View File
@@ -0,0 +1,38 @@
/**
* 命中反馈的整屏层:闪红 + 中央提示。
*
* 抖动与伤害数字由 `VideoSurface` 画在 canvas 上(它们要跟着瞄准点走),这里只负责
* 那一层全屏的、不需要对准任何东西的视觉反馈。
*
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
*/
import type { HitPulseRecord } from "../lib/store";
import type { HitFeedbackConfig } from "../lib/types";
interface Props {
hit: HitPulseRecord | null;
config: HitFeedbackConfig;
/** 这一下是不是打在我这一边(裁判端两边都显示,但只有相关那一路闪)。 */
relevant: boolean;
label?: string;
}
export function HitFlash({ hit, config, relevant, label }: Props) {
if (!hit || !relevant || !config.flash) return null;
const strength = Math.min(0.55, 0.16 * config.intensity);
return (
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
{label && <div className="hit-flash__label">{label}</div>}
<div className="hit-flash__value">
-{hit.damage}
<span className="hit-flash__sub">
{hit.from} → {hit.to}
</span>
</div>
</div>
);
}