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:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 底部胶囊血条。
|
||||
*
|
||||
* # 为什么它在画面**下面**而不是叠在画面上
|
||||
*
|
||||
* 用户端要的是"一眼看到还剩多少血",而不是把画面挡住。放在画面下沿一条窄带里,
|
||||
* 视频区域一个像素都不占;裁判端并排两路时也正好对称。
|
||||
*
|
||||
* # 未接入时不显示 0
|
||||
*
|
||||
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到
|
||||
* 两个 0 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { teamColor, withAlpha } from "../lib/format";
|
||||
import type { HealthConfig, TeamHealthDto } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
health: TeamHealthDto;
|
||||
config: HealthConfig;
|
||||
/** 紧凑模式:裁判端两路并排时用。 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** 脉动的周期。与 CSS 动画保持一致。 */
|
||||
const PULSE_MS = 900;
|
||||
|
||||
export function HealthBar({ health, config, compact = false }: Props) {
|
||||
const [pulseOn, setPulseOn] = useState(true);
|
||||
|
||||
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
|
||||
// 每秒重渲染一次。
|
||||
const pulsing = config.pulse && health.available && health.critical;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulsing) {
|
||||
setPulseOn(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => setPulseOn((on) => !on), PULSE_MS / 2);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<div className={`healthbar${compact ? " healthbar--compact" : ""}`}>
|
||||
<div
|
||||
className="healthbar__track"
|
||||
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
||||
title={health.available ? undefined : health.placeholder}
|
||||
>
|
||||
<div
|
||||
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 ? (
|
||||
config.showNumbers && (
|
||||
<span className="healthbar__value" style={{ color }}>
|
||||
{health.hp}
|
||||
{health.critical && <span className="healthbar__dead"> · 已击毁</span>}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="healthbar__missing">{health.placeholder}</span>
|
||||
)}
|
||||
|
||||
{health.available && health.boardText && (
|
||||
<span className="healthbar__board" title="绑定的装甲板主板">
|
||||
{compact ? "" : "装甲板 "}
|
||||
{health.boardText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user