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,136 @@
|
||||
/**
|
||||
* 首次启动的两道门:先选身份,再设密码。
|
||||
*
|
||||
* 身份必须问一次,因为 `role` 的默认值(裁判端)既可能是人选了裁判端,也可能只是还没人
|
||||
* 碰过这个文件——后者让用户端的人第一次打开会直接进裁判端,然后以为这个程序只有那一种
|
||||
* 界面。所以后端单独记了一个 `roleChosen`。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "../lib/ipc";
|
||||
import { pushNote } from "../lib/store";
|
||||
import type { Role } from "../lib/types";
|
||||
|
||||
export function RolePicker() {
|
||||
const [busy, setBusy] = useState<Role | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const choose = async (role: Role) => {
|
||||
setBusy(role);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.setRole(role);
|
||||
await api.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gate">
|
||||
<h1 className="gate__title">这台机器是哪一端?</h1>
|
||||
<p className="gate__hint">选定之后记进配置,下次启动直接进这一端。之后可以在设置里改。</p>
|
||||
|
||||
<div className="gate__choices">
|
||||
<button className="choice" disabled={busy !== null} onClick={() => void choose("team")}>
|
||||
<span className="choice__name">用户端</span>
|
||||
<span className="choice__desc">
|
||||
一路画面 + 血条 + 瞄准器。需要绑定一块图传板与一块装甲板,绑定改动要密码。
|
||||
</span>
|
||||
{busy === "team" && <span className="choice__busy">正在切换…</span>}
|
||||
</button>
|
||||
|
||||
<button className="choice" disabled={busy !== null} onClick={() => void choose("referee")}>
|
||||
<span className="choice__name">裁判端</span>
|
||||
<span className="choice__desc">
|
||||
两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。不需要绑定,也不需要密码。
|
||||
</span>
|
||||
{busy === "referee" && <span className="choice__busy">正在切换…</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="gate__error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupPassword() {
|
||||
const [first, setFirst] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
|
||||
if (first !== again) {
|
||||
setError("两次输入不一致");
|
||||
setAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.setPassword(first);
|
||||
await api.refresh();
|
||||
pushNote("密码已设置。改摄像头/装甲板绑定、切换队伍号、退出都需要它。");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gate">
|
||||
<h1 className="gate__title">先设一个密码</h1>
|
||||
<p className="gate__hint">
|
||||
用户端改摄像头与装甲板绑定、切换队伍号、退出都要用它。至少 4 位,本机存的是
|
||||
Argon2id 哈希,不是明文。
|
||||
</p>
|
||||
|
||||
<div className="gate__form">
|
||||
<input
|
||||
ref={input}
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
autoComplete="new-password"
|
||||
value={first}
|
||||
onChange={(event) => setFirst(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="再输一次"
|
||||
autoComplete="new-password"
|
||||
value={again}
|
||||
onChange={(event) => setAgain(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void submit();
|
||||
}}
|
||||
/>
|
||||
{error && <p className="gate__error">{error}</p>}
|
||||
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "保存中…" : "设置密码并继续"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="gate__note">
|
||||
忘了密码的处理:删掉配置文件里的 password 一行,或者删掉整个配置文件。旧客户端
|
||||
(Slint 版)的配置会在首次启动时自动导入并备份成 .bak。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 解锁对话框。
|
||||
*
|
||||
* 它只是**入口**,不是闸门:真正拦住改绑的是后端的 `require_unlocked`。所以这里即使
|
||||
* 被人绕过、甚至被改成永远返回成功,配置也改不动。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "../lib/ipc";
|
||||
|
||||
interface Props {
|
||||
/** 为什么需要密码:显示在标题下。 */
|
||||
reason: string;
|
||||
onUnlocked: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ok = await api.unlock(password);
|
||||
if (ok) {
|
||||
onUnlocked();
|
||||
} else {
|
||||
setError("密码不对");
|
||||
setPassword("");
|
||||
input.current?.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2 className="dialog__title">需要密码</h2>
|
||||
<p className="dialog__hint">{reason}</p>
|
||||
|
||||
<input
|
||||
ref={input}
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
placeholder="密码"
|
||||
autoComplete="off"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void submit();
|
||||
if (event.key === "Escape") onCancel();
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className="dialog__error">{error}</p>}
|
||||
|
||||
<p className="dialog__note">
|
||||
密码只挡现场随手改绑,不是安全边界:配置文件与程序都在本机,愿意改的人绕得过去。
|
||||
忘了密码就删掉配置里的 password 一行。
|
||||
</p>
|
||||
|
||||
<div className="dialog__actions">
|
||||
<button className="button button--ghost" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "校验中…" : "解锁"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* 一路画面:canvas 绘制 + 缩放/平移 + 网格 + 瞄准器 + 命中反馈。
|
||||
*
|
||||
* # 为什么画在 canvas 上而不是用 CSS 变换 `<img>`
|
||||
*
|
||||
* 瞄准器必须跟着**画面**走:它标的是相机光轴上的一个点。用 DOM 摆一个 `<img>` 再叠一层
|
||||
* `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而两份实现迟早会不一致——表现
|
||||
* 是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。
|
||||
*
|
||||
* 所以变换只算一次(`layout()`),图像、网格、瞄准器、伤害数字全都用同一组数画出来。
|
||||
*
|
||||
* # 尺寸与缩放的关系
|
||||
*
|
||||
* - 图像按 `contain` 放进窗格,再乘 `zoom`。
|
||||
* - **网格**画在图像矩形里,跟着缩放走:它标的是画面里的比例,用来估距离。
|
||||
* - **瞄准器**的**位置**跟着图像走,**大小**不跟着:像真瞄具一样,放大之后准星不会
|
||||
* 跟着变粗,而是覆盖画面里更小的一块——那正是放大有用武之地的原因。
|
||||
* - 镜像靠 `scale(-1, 1)`,同时把拖动方向也翻过来,否则向右拖画面会往左走。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { streamClient } from "../lib/store";
|
||||
import { withAlpha } from "../lib/format";
|
||||
import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types";
|
||||
|
||||
export interface ViewTransform {
|
||||
zoom: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, x: 0, y: 0 };
|
||||
|
||||
/** 缩放上下限。再多只是把像素拉大。 */
|
||||
export const MIN_ZOOM = 1;
|
||||
export const MAX_ZOOM = 8;
|
||||
export const ZOOM_STEP = 1.25;
|
||||
|
||||
export interface HitPulse {
|
||||
/** 每次命中都不同,用来重启动画。 */
|
||||
seq: number;
|
||||
damage: number;
|
||||
/** `performance.now()` 时刻。 */
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
camera: number | null;
|
||||
emptyText: string;
|
||||
mirrored: boolean;
|
||||
crosshair: CrosshairConfig | null;
|
||||
grid: GridConfig | null;
|
||||
view: ViewTransform;
|
||||
onView: (view: ViewTransform) => void;
|
||||
/** 校准模式:拖动画面来挪瞄准点。 */
|
||||
calibrating?: boolean;
|
||||
onCalibrate?: (dxFraction: number, dyFraction: number) => void;
|
||||
/** 命中脉冲。只有属于这一路的命中才该传进来。 */
|
||||
hit?: HitPulse | null;
|
||||
hitFeedback: HitFeedbackConfig;
|
||||
showZoom: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function VideoSurface({
|
||||
camera,
|
||||
emptyText,
|
||||
mirrored,
|
||||
crosshair,
|
||||
grid,
|
||||
view,
|
||||
onView,
|
||||
calibrating = false,
|
||||
onCalibrate,
|
||||
hit = null,
|
||||
hitFeedback,
|
||||
showZoom,
|
||||
children,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
// 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。
|
||||
const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback });
|
||||
props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback };
|
||||
|
||||
const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
|
||||
const dirty = useRef(true);
|
||||
const lastFrameId = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dirty.current = true;
|
||||
}, [camera, mirrored, crosshair, grid, view, hit, hitFeedback]);
|
||||
|
||||
// canvas 的像素尺寸跟着 CSS 尺寸与 DPR 走。不这么做的话在 Retina 上会糊。
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
|
||||
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
|
||||
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
dirty.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
resize();
|
||||
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const loop = (timestamp: number) => {
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
const current = props.current;
|
||||
const frame = streamClient().latest(current.camera);
|
||||
|
||||
if (frame && frame.frameId !== lastFrameId.current) {
|
||||
lastFrameId.current = frame.frameId;
|
||||
dirty.current = true;
|
||||
}
|
||||
|
||||
// 命中动画期间必须持续重绘,否则抖动只画了一帧就停住。
|
||||
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
|
||||
const animating =
|
||||
pulseAge < 700 &&
|
||||
(current.hitFeedback.shake || current.hitFeedback.marker || current.hitFeedback.flash);
|
||||
|
||||
if (!dirty.current && !animating) return;
|
||||
dirty.current = false;
|
||||
|
||||
draw(canvas, current, frame, timestamp);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
// ---- 输入 ----
|
||||
|
||||
const onWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
const factor = event.deltaY > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
|
||||
const zoom = clamp(view.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
// 缩回 1 时顺便归位:此时画面完整可见,平移没有意义,留着会让下一次放大从偏移位置
|
||||
// 开始,看起来像"跳了一下"。
|
||||
onView(zoom <= MIN_ZOOM ? IDENTITY_VIEW : { ...view, zoom });
|
||||
};
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drag.current = { x: event.clientX, y: event.clientY, originX: view.x, originY: view.y };
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const start = drag.current;
|
||||
if (!start) return;
|
||||
|
||||
const dx = event.clientX - start.x;
|
||||
const dy = event.clientY - start.y;
|
||||
|
||||
if (calibrating) {
|
||||
// 校准:把屏幕上的位移换算成画面里的比例位移。用当前显示出来的图像尺寸做分母,
|
||||
// 所以放大之后校准更精细——这正是需要的。
|
||||
const box = canvasRef.current;
|
||||
if (!box || !onCalibrate) return;
|
||||
|
||||
const frame = streamClient().latest(camera);
|
||||
if (!frame) return;
|
||||
|
||||
const { drawWidth, drawHeight } = layout(box, frame.width, frame.height, view);
|
||||
if (drawWidth <= 0 || drawHeight <= 0) return;
|
||||
|
||||
// 一次移动一小步,然后把起点挪过来。
|
||||
drag.current = { ...start, x: event.clientX, y: event.clientY };
|
||||
const sign = mirrored ? -1 : 1;
|
||||
onCalibrate((sign * dx) / drawWidth, dy / drawHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
if (view.zoom <= MIN_ZOOM) return;
|
||||
|
||||
// 镜像时把横向拖动也翻过来:否则向右拖,画面往左走,看起来像拖动坏了。
|
||||
const sign = mirrored ? -1 : 1;
|
||||
onView({ ...view, x: start.originX + sign * dx, y: start.originY + dy });
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
drag.current = null;
|
||||
};
|
||||
|
||||
const cursor = calibrating ? "crosshair" : view.zoom > MIN_ZOOM ? "grab" : "default";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="video-surface"
|
||||
style={{ cursor }}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onDoubleClick={() => onView(IDENTITY_VIEW)}
|
||||
>
|
||||
<canvas ref={canvasRef} className="video-canvas" />
|
||||
|
||||
{emptyText !== "" && <div className="video-empty">{emptyText}</div>}
|
||||
|
||||
{showZoom && view.zoom > MIN_ZOOM + 0.05 && (
|
||||
<div className="video-badge video-badge--zoom">×{view.zoom.toFixed(2)}</div>
|
||||
)}
|
||||
|
||||
{(view.zoom > MIN_ZOOM + 0.05 || view.x !== 0 || view.y !== 0) && (
|
||||
<button className="video-badge video-badge--reset" onClick={() => onView(IDENTITY_VIEW)}>
|
||||
复位
|
||||
</button>
|
||||
)}
|
||||
|
||||
{calibrating && <div className="video-calibrating">校准中:拖动画面挪动瞄准点</div>}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 绘制
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CanvasProps {
|
||||
camera: number | null;
|
||||
mirrored: boolean;
|
||||
crosshair: CrosshairConfig | null;
|
||||
grid: GridConfig | null;
|
||||
view: ViewTransform;
|
||||
hit: HitPulse | null;
|
||||
hitFeedback: HitFeedbackConfig;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
/** 图像在窗格里的落点与大小(CSS 像素)。 */
|
||||
function layout(box: Box, frameWidth: number, frameHeight: number, view: ViewTransform) {
|
||||
const width = box.clientWidth;
|
||||
const height = box.clientHeight;
|
||||
|
||||
if (frameWidth <= 0 || frameHeight <= 0) {
|
||||
return { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
|
||||
}
|
||||
|
||||
const fit = Math.min(width / frameWidth, height / frameHeight);
|
||||
const drawWidth = frameWidth * fit * view.zoom;
|
||||
const drawHeight = frameHeight * fit * view.zoom;
|
||||
|
||||
return {
|
||||
left: (width - drawWidth) / 2 + view.x,
|
||||
top: (height - drawHeight) / 2 + view.y,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function draw(
|
||||
canvas: HTMLCanvasElement,
|
||||
current: CanvasProps,
|
||||
frame: { bitmap: ImageBitmap; width: number; height: number } | undefined,
|
||||
timestamp: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const box = { clientWidth: width, clientHeight: height };
|
||||
|
||||
// 没有画面时把瞄准器留在窗格中心:录像断了,准星不该跟着消失。
|
||||
const fallback = { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
|
||||
const geometry = frame
|
||||
? 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);
|
||||
ctx.scale(-1, 1);
|
||||
}
|
||||
|
||||
// 放大超过 3 倍时关掉插值:这时候要看的是像素本身,双线性插值只会把它糊掉。
|
||||
ctx.imageSmoothingEnabled = current.view.zoom < 3;
|
||||
ctx.drawImage(frame.bitmap, geometry.left, geometry.top, geometry.drawWidth, geometry.drawHeight);
|
||||
ctx.restore();
|
||||
|
||||
if (current.grid?.visible) {
|
||||
drawGrid(ctx, geometry, current.grid, shake);
|
||||
}
|
||||
}
|
||||
|
||||
// 瞄准点的屏幕位置:图像坐标系里的 (0.5 + offsetX, 0.5 + offsetY)。
|
||||
const crosshair = current.crosshair;
|
||||
let aimX = width / 2;
|
||||
let aimY = height / 2;
|
||||
|
||||
if (crosshair && frame && geometry.drawWidth > 0) {
|
||||
const rawX = geometry.left + (0.5 + crosshair.offsetX) * geometry.drawWidth;
|
||||
aimX = current.mirrored ? width - rawX : rawX;
|
||||
aimY = geometry.top + (0.5 + crosshair.offsetY) * geometry.drawHeight;
|
||||
}
|
||||
|
||||
if (crosshair?.visible) {
|
||||
drawCrosshair(ctx, aimX + shake.x, aimY + shake.y, Math.min(width, height), crosshair);
|
||||
}
|
||||
|
||||
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
|
||||
if (current.hit && current.hitFeedback.marker) {
|
||||
const age = timestamp - current.hit.at;
|
||||
if (age >= 0 && age < 700) {
|
||||
const progress = age / 700;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1 - progress;
|
||||
ctx.fillStyle = "#ff8a8a";
|
||||
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.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;
|
||||
|
||||
const x0 = geometry.left;
|
||||
const y0 = geometry.top;
|
||||
const x1 = x0 + geometry.drawWidth;
|
||||
const y1 = y0 + geometry.drawHeight;
|
||||
|
||||
if (grid.style === "thirds") {
|
||||
for (const fraction of [1 / 3, 2 / 3]) {
|
||||
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
|
||||
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
|
||||
}
|
||||
} else if (grid.style === "squares") {
|
||||
const divisions = Math.max(2, grid.divisions);
|
||||
for (let i = 1; i < divisions; i += 1) {
|
||||
const fraction = i / divisions;
|
||||
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
|
||||
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
|
||||
}
|
||||
} else {
|
||||
// 中心十字。
|
||||
line(ctx, x0 + geometry.drawWidth / 2, y0, x0 + geometry.drawWidth / 2, y1);
|
||||
line(ctx, x0, y0 + geometry.drawHeight / 2, x1, y0 + geometry.drawHeight / 2);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function line(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) {
|
||||
// 半像素偏移,让 1px 的线落在像素中心而不是跨两个像素发虚。
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.round(x0) + 0.5, Math.round(y0) + 0.5);
|
||||
ctx.lineTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawCrosshair(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
shortSide: number,
|
||||
config: CrosshairConfig,
|
||||
) {
|
||||
const arm = Math.max(3, config.size * shortSide);
|
||||
const gap = config.gap * shortSide;
|
||||
const thickness = Math.max(1, config.thickness * shortSide);
|
||||
|
||||
// 外描一圈深色:浅色背景下亮绿准星会消失,这一圈让它始终看得见。
|
||||
const halo = withAlpha("#000000", 0.55);
|
||||
|
||||
const stroke = (color: string, width: number, radius: number) => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
|
||||
if (config.style === "circle-dot") {
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
} else {
|
||||
// 四臂,中心留 gap 的空隙。
|
||||
ctx.moveTo(cx - radius, cy);
|
||||
ctx.lineTo(cx - gap, cy);
|
||||
ctx.moveTo(cx + gap, cy);
|
||||
ctx.lineTo(cx + radius, cy);
|
||||
ctx.moveTo(cx, cy - radius);
|
||||
ctx.lineTo(cx, cy - gap);
|
||||
ctx.moveTo(cx, cy + gap);
|
||||
ctx.lineTo(cx, cy + radius);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
stroke(halo, thickness + 2, arm + 1);
|
||||
stroke(config.color, thickness, arm);
|
||||
|
||||
// 中心点:`cross` 之外都画。
|
||||
if (config.style !== "cross") {
|
||||
const dot = Math.max(1.2, thickness * 0.9);
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dot + 1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = config.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dot, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/** 设置面板里复用的几个小控件。分成单独文件只是为了不把面板本身撑成一千行。 */
|
||||
|
||||
import type { ChangeEvent, ReactNode } from "react";
|
||||
|
||||
export function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<h3 className="section__title">{title}</h3>
|
||||
{hint && <p className="section__hint">{hint}</p>}
|
||||
<div className="section__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<span className="field__control">{children}</span>
|
||||
{hint && <span className="field__hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className={`switch${disabled ? " switch--disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className="switch__track">
|
||||
<span className="switch__knob" />
|
||||
</span>
|
||||
<span className="switch__label">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Slider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
format,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
onChange: (value: number) => void;
|
||||
format?: (value: number) => string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="slider">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
<span className="slider__value">{format ? format(value) : value.toFixed(2)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select<T extends string | number>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: T | null;
|
||||
options: { value: T; label: string; hint?: string }[];
|
||||
onChange: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="select"
|
||||
value={value === null ? "" : String(value)}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
const match = options.find((option) => String(option.value) === raw);
|
||||
if (match) onChange(match.value);
|
||||
}}
|
||||
>
|
||||
{value === null && <option value="">(未选择)</option>}
|
||||
{options.map((option) => (
|
||||
<option key={String(option.value)} value={String(option.value)}>
|
||||
{option.hint ? `${option.label} — ${option.hint}` : option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
onClick,
|
||||
variant = "default",
|
||||
disabled = false,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "primary" | "danger" | "ghost";
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button className={`button button--${variant}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行只读信息:左边标题、右边值。 */
|
||||
export function Readout({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="readout">
|
||||
<span className="readout__label">{label}</span>
|
||||
<span className="readout__value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 纯格式化函数。界面里所有"给人看的数字"都从这里过一遍。 */
|
||||
|
||||
/** `0x3D4D8A9A`。与 Rust 侧的 `snapshot::id_text` 输出一致。 */
|
||||
export function idText(id: number | null | undefined): string {
|
||||
if (id === null || id === undefined) return "—";
|
||||
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
/** 22000 → `21.5 kB`。 */
|
||||
export function bytes(value: number): string {
|
||||
if (value <= 0) return "—";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} kB`;
|
||||
return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** 毫秒 → 人话。 */
|
||||
export function elapsed(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined) return "—";
|
||||
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes < 60) return `${minutes} 分 ${seconds} 秒`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours} 小时 ${minutes % 60} 分`;
|
||||
}
|
||||
|
||||
/** 录制时长:从开始到现在的墙钟。 */
|
||||
export function since(ms: number | null | undefined, now = Date.now()): string {
|
||||
if (!ms) return "—";
|
||||
return elapsed(now - ms);
|
||||
}
|
||||
|
||||
/** Unix 毫秒 → `14:03:22`。 */
|
||||
export function clockTime(ms: number | null | undefined): string {
|
||||
if (!ms) return "—";
|
||||
|
||||
const date = new Date(ms);
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 小数位固定的帧率。0 表示"还没读数",显示成 `—` 而不是 `0.0`。 */
|
||||
export function fps(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "—";
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
/** 队伍名。与 Rust 侧的 `team_name` 一致。 */
|
||||
export function teamName(team: number): string {
|
||||
return team === 0 ? "队伍一" : "队伍二";
|
||||
}
|
||||
|
||||
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
|
||||
export function teamColor(team: number): string {
|
||||
return team === 0 ? "#ff5f5f" : "#5aa9ff";
|
||||
}
|
||||
|
||||
/** 把 `#RRGGBB` 变成 `rgba(...)`。瞄准器颜色要带透明度时用。 */
|
||||
export function withAlpha(hex: string, alpha: number): string {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) return hex;
|
||||
|
||||
const value = parseInt(match[1], 16);
|
||||
const r = (value >> 16) & 0xff;
|
||||
const g = (value >> 8) & 0xff;
|
||||
const b = value & 0xff;
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 后端命令的封装。
|
||||
*
|
||||
* 每个函数对应 `desktop/src-tauri/src/commands.rs` 里的一个 `#[tauri::command]`。
|
||||
* Tauri 会把 JS 侧的 camelCase 参数名转成 Rust 侧的 snake_case,所以这里的参数名写成
|
||||
* 前端习惯的样子即可。
|
||||
*
|
||||
* # 错误
|
||||
*
|
||||
* 后端的 `Err(String)` 会以 rejected promise 的形式回来,字符串就是给现场的人看的那句
|
||||
* 话。`call()` 把 `unknown` 归一成 `Error`,调用方只管 `catch (err) { err.message }`。
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
ConfigView,
|
||||
HudView,
|
||||
MediaFileDto,
|
||||
RecordingDto,
|
||||
Role,
|
||||
Snapshot,
|
||||
StreamInfo,
|
||||
} from "./types";
|
||||
|
||||
/** 把后端的字符串错误变成真正的 `Error`。 */
|
||||
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
return await invoke<T>(command, args);
|
||||
} catch (raw) {
|
||||
throw new Error(typeof raw === "string" ? raw : String(raw));
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ---- 只读 ----
|
||||
streamInfo: () => call<StreamInfo>("stream_info"),
|
||||
snapshot: () => call<Snapshot>("get_snapshot"),
|
||||
config: () => call<ConfigView>("get_config"),
|
||||
mediaFiles: () => call<MediaFileDto[]>("list_media_files"),
|
||||
configPath: () => call<string>("config_path"),
|
||||
|
||||
// ---- 密码 ----
|
||||
unlock: (password: string) => call<boolean>("unlock", { password }),
|
||||
lock: () => call<void>("lock"),
|
||||
setPassword: (fresh: string, current?: string) =>
|
||||
call<ConfigView>("set_password", { current: current ?? null, fresh }),
|
||||
setDiscoveryOpen: (open: boolean) => call<void>("set_discovery_open", { open }),
|
||||
|
||||
// ---- 绑定与身份 ----
|
||||
bindCamera: (cameraId: number | null, password?: string) =>
|
||||
call<ConfigView>("bind_camera", { cameraId, password: password ?? null }),
|
||||
bindArmor: (armorId: number | null, password?: string) =>
|
||||
call<ConfigView>("bind_armor", { armorId, password: password ?? null }),
|
||||
setTeam: (team: number, password?: string) =>
|
||||
call<ConfigView>("set_team", { team, password: password ?? null }),
|
||||
setRole: (role: Role, password?: string) =>
|
||||
call<ConfigView>("set_role", { role, password: password ?? null }),
|
||||
setMirror: (mirror: boolean) => call<ConfigView>("set_mirror", { mirror }),
|
||||
saveHud: (hud: HudView) => call<ConfigView>("save_hud", { hud }),
|
||||
setRefereeSlots: (slots: {
|
||||
cameraA: number | null;
|
||||
cameraB: number | null;
|
||||
armorA: number | null;
|
||||
armorB: number | null;
|
||||
manual: boolean;
|
||||
}) => call<ConfigView>("set_referee_slots", slots),
|
||||
setRecordingOptions: (options: {
|
||||
dir: string | null;
|
||||
ffmpeg: string | null;
|
||||
crf: number | null;
|
||||
preset: string | null;
|
||||
}) => call<ConfigView>("set_recording_options", options),
|
||||
|
||||
// ---- 截图与录制 ----
|
||||
screenshot: (camera: number) => call<string>("screenshot", { camera }),
|
||||
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
|
||||
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
|
||||
reveal: (path: string) => call<void>("reveal", { path }),
|
||||
|
||||
// ---- 退出与重置 ----
|
||||
clearConfig: (password?: string) =>
|
||||
call<string>("clear_config", { password: password ?? null }),
|
||||
quit: (password?: string) => call<void>("quit_app", { password: password ?? null }),
|
||||
|
||||
refresh: () => call<void>("refresh"),
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 后端状态的订阅式存储。
|
||||
*
|
||||
* 后端每 200 ms 推一份完整快照,前端只做渲染——没有轮询、没有"各处分别拉数据"带来的
|
||||
* 两种时刻的状态混在一起。实现用一个模块级单例 + `useSyncExternalStore`。
|
||||
*
|
||||
* 快照对象**每次整体替换**:`useSyncExternalStore` 靠引用比较判断要不要重渲染,就地
|
||||
* 修改字段不会触发更新。
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { StreamClient } from "./stream";
|
||||
import type { HitEvent, Snapshot, WireMsg } from "./types";
|
||||
|
||||
/**
|
||||
* 一次掉血在界面上的记录。
|
||||
*
|
||||
* `receivedAt` 用 `performance.now()` 而不是后端给的 `atMs`(Unix 毫秒):动画的时钟必须
|
||||
* 与 `requestAnimationFrame` 的时间戳同源,把两个时钟相减会得到一个荒唐的年龄,动画也
|
||||
* 就不会出现。
|
||||
*/
|
||||
export interface HitPulseRecord extends HitEvent {
|
||||
seq: number;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
export interface AppStore {
|
||||
/** 还没收到第一份快照时为 null。 */
|
||||
snapshot: Snapshot | null;
|
||||
/** 与后端的视频通道是否连着。 */
|
||||
connected: boolean;
|
||||
/** 最近一次掉血,界面用它做闪红/抖动。 */
|
||||
lastHit: HitPulseRecord | null;
|
||||
/** 后端要求弹出解锁框(用户端点关闭窗口时)。 */
|
||||
confirmQuit: number;
|
||||
/** 一条条要显示的说明。 */
|
||||
notes: string[];
|
||||
/** 通道本身的问题(连不上、丢帧)。 */
|
||||
channelError: string | null;
|
||||
}
|
||||
|
||||
const initial: AppStore = {
|
||||
snapshot: null,
|
||||
connected: false,
|
||||
lastHit: null,
|
||||
confirmQuit: 0,
|
||||
notes: [],
|
||||
channelError: null,
|
||||
};
|
||||
|
||||
let store: AppStore = initial;
|
||||
let hitSeq = 0;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(next: Partial<AppStore>) {
|
||||
store = { ...store, ...next };
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** 组件里读状态。 */
|
||||
export function useAppStore(): AppStore {
|
||||
return useSyncExternalStore(subscribe, () => store);
|
||||
}
|
||||
|
||||
/** 组件外读状态(事件处理、命令回调里用)。 */
|
||||
export function currentStore(): AppStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
function handleMessage(msg: WireMsg) {
|
||||
switch (msg.t) {
|
||||
case "snapshot": {
|
||||
const { t: _tag, ...snapshot } = msg;
|
||||
emit({ snapshot: snapshot as Snapshot });
|
||||
return;
|
||||
}
|
||||
case "hit":
|
||||
hitSeq += 1;
|
||||
emit({ lastHit: { ...msg, seq: hitSeq, receivedAt: performance.now() } });
|
||||
return;
|
||||
case "note":
|
||||
emit({ notes: [...store.notes, msg.message].slice(-6) });
|
||||
return;
|
||||
case "confirmQuit":
|
||||
emit({ confirmQuit: store.confirmQuit + 1 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let client: StreamClient | null = null;
|
||||
|
||||
/**
|
||||
* 建立与后端的通道。整个界面只调一次(在 `App` 里)。
|
||||
*/
|
||||
export function startStream(url: string): StreamClient {
|
||||
client?.close();
|
||||
|
||||
client = new StreamClient({
|
||||
onMessage: handleMessage,
|
||||
onConnection: (connected) =>
|
||||
emit({
|
||||
connected,
|
||||
channelError: connected ? null : "与后端的视频通道断开,正在重连…",
|
||||
}),
|
||||
});
|
||||
|
||||
client.connect(url);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function streamClient(): StreamClient {
|
||||
if (!client) throw new Error("startStream 还没有被调用");
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 手动塞一条说明(本地校验失败之类)。 */
|
||||
export function pushNote(message: string) {
|
||||
emit({ notes: [...store.notes, message].slice(-6) });
|
||||
}
|
||||
|
||||
/** 关掉一条说明。 */
|
||||
export function dismissNote(index: number) {
|
||||
emit({ notes: store.notes.filter((_, i) => i !== index) });
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 视频与状态通道的客户端。
|
||||
*
|
||||
* # 为什么是 WebSocket 而不是 invoke
|
||||
*
|
||||
* 两路 22fps、每帧约 22 kB,也就是每秒约 1 MB 的裸 JPEG。走 IPC 的话它要变成 base64
|
||||
* 再涨 1/3,然后在 JS 里解回来——CPU 花在没人需要的编解码上。二进制 WebSocket 帧把
|
||||
* 原始 JPEG 字节直接交给我们,交给 `createImageBitmap` 解码。
|
||||
*
|
||||
* # 解码是异步的,所以帧号要看着用
|
||||
*
|
||||
* `createImageBitmap` 返回 Promise,两个相邻帧的解码**可能**乱序完成。所以每路记住最后
|
||||
* 画过的帧号,只有更新的那帧才会被采用。没有这一步,画面会偶发地往回跳一帧——看起来
|
||||
* 像"卡了一下",极难归因。
|
||||
*
|
||||
* # `ImageBitmap` 必须显式 close
|
||||
*
|
||||
* 它持有解码后的像素(不吃 GC 的账)。每收到新帧就替换旧的那张并不够,必须把旧的那张
|
||||
* `close()` 掉,否则两路 22fps 跑上几分钟就是几百 MB 的显存/内存。
|
||||
*/
|
||||
|
||||
import { FRAME_HEADER_LEN, type WireMsg } from "./types";
|
||||
|
||||
/** 每路最新一张画面。 */
|
||||
export interface FrameInfo {
|
||||
camera: number;
|
||||
frameId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bitmap: ImageBitmap;
|
||||
/** 收到的时刻(`performance.now()`),界面用来显示端到端延迟。 */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
interface Handlers {
|
||||
onMessage: (msg: WireMsg) => void;
|
||||
onConnection: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
/** 断线重连的退避上限。 */
|
||||
const MAX_BACKOFF_MS = 3000;
|
||||
|
||||
export class StreamClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string | null = null;
|
||||
private readonly frames = new Map<number, FrameInfo>();
|
||||
private readonly handlers: Handlers;
|
||||
private retryTimer: number | null = null;
|
||||
private backoff = 250;
|
||||
private closed = false;
|
||||
/** 统计:收到多少帧、解码失败多少张。界面用它区分"链路没数据"和"数据坏了"。 */
|
||||
readonly counters = { frames: 0, decodeFailures: 0, reconnects: 0 };
|
||||
|
||||
constructor(handlers: Handlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
connect(url: string) {
|
||||
this.url = url;
|
||||
this.closed = false;
|
||||
this.open();
|
||||
}
|
||||
|
||||
private open() {
|
||||
if (!this.url || this.closed) return;
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(this.url);
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.binaryType = "arraybuffer";
|
||||
this.ws = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
this.backoff = 250;
|
||||
this.handlers.onConnection(true);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.handlers.onConnection(false);
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// onerror 之后一定会有 onclose,重连交给那一条路径。
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
try {
|
||||
this.handlers.onMessage(JSON.parse(event.data) as WireMsg);
|
||||
} catch {
|
||||
// 前端解析不了的消息只可能是版本不匹配,忽略比崩掉好。
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void this.onBinary(event.data as ArrayBuffer);
|
||||
};
|
||||
}
|
||||
|
||||
private async onBinary(buffer: ArrayBuffer) {
|
||||
if (buffer.byteLength < FRAME_HEADER_LEN) return;
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const camera = view.getUint32(0, true);
|
||||
const frameId = view.getUint32(4, true);
|
||||
const width = view.getUint16(8, true);
|
||||
const height = view.getUint16(10, true);
|
||||
const length = view.getUint32(12, true);
|
||||
|
||||
if (buffer.byteLength < FRAME_HEADER_LEN + length) return;
|
||||
|
||||
this.counters.frames += 1;
|
||||
|
||||
const previous = this.frames.get(camera);
|
||||
|
||||
// 乱序完成时丢弃更旧的那一帧。用带符号差处理 u32 回绕。
|
||||
if (previous && ((frameId - previous.frameId) | 0) <= 0) return;
|
||||
|
||||
const jpeg = new Uint8Array(buffer, FRAME_HEADER_LEN, length);
|
||||
let bitmap: ImageBitmap;
|
||||
|
||||
try {
|
||||
bitmap = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" }));
|
||||
} catch {
|
||||
this.counters.decodeFailures += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 解码期间可能已经来了更新的帧:那就把刚解出来的这张丢掉。
|
||||
const current = this.frames.get(camera);
|
||||
if (current && ((frameId - current.frameId) | 0) <= 0) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
|
||||
current?.bitmap.close();
|
||||
|
||||
this.frames.set(camera, {
|
||||
camera,
|
||||
frameId,
|
||||
width,
|
||||
height,
|
||||
bitmap,
|
||||
receivedAt: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed || this.retryTimer !== null) return;
|
||||
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.counters.reconnects += 1;
|
||||
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
||||
this.open();
|
||||
}, this.backoff);
|
||||
}
|
||||
|
||||
/** 某一路最新的一帧。 */
|
||||
latest(camera: number | null | undefined): FrameInfo | undefined {
|
||||
if (camera === null || camera === undefined) return undefined;
|
||||
return this.frames.get(camera);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
|
||||
if (this.retryTimer !== null) {
|
||||
window.clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
|
||||
for (const frame of this.frames.values()) frame.bitmap.close();
|
||||
this.frames.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 与 Rust 侧 DTO 一一对应的类型。
|
||||
*
|
||||
* 这些名字不是随手起的:Rust 那边的结构体带 `#[serde(rename_all = "camelCase")]`,
|
||||
* 字段名就是这里写的这样。改任何一边都要同时改另一边——**编译器不会帮你发现这件事**,
|
||||
* 所以两边都留了指向对方的注释。
|
||||
*
|
||||
* 对应的 Rust 定义在 `desktop/core/src/snapshot.rs` 与 `desktop/src-tauri/src/recording.rs`。
|
||||
*/
|
||||
|
||||
export type Role = "team" | "referee";
|
||||
|
||||
/** `cam_core::config::CrosshairStyle` */
|
||||
export type CrosshairStyle = "cross" | "cross-dot" | "circle-dot" | "dot";
|
||||
|
||||
/** `cam_core::config::GridStyle` */
|
||||
export type GridStyle = "thirds" | "squares" | "cross";
|
||||
|
||||
/** `cam_core::config::HealthConfig` */
|
||||
export interface HealthConfig {
|
||||
visible: boolean;
|
||||
showNumbers: boolean;
|
||||
showTeamName: boolean;
|
||||
lowThreshold: number;
|
||||
pulse: boolean;
|
||||
/** 血条宽度占画面宽度的比例。 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** `cam_core::config::CrosshairConfig` */
|
||||
export interface CrosshairConfig {
|
||||
visible: boolean;
|
||||
style: CrosshairStyle;
|
||||
/** 臂长 / 半径,画面短边的比例。 */
|
||||
size: number;
|
||||
/** 线宽,画面短边的比例。 */
|
||||
thickness: number;
|
||||
/** 中心空隙,画面短边的比例。 */
|
||||
gap: number;
|
||||
/** `#RRGGBB` */
|
||||
color: string;
|
||||
/** 校准偏移,画面宽度的比例,正数向右。 */
|
||||
offsetX: number;
|
||||
/** 校准偏移,画面高度的比例,正数向下。 */
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
/** `cam_core::config::GridConfig` */
|
||||
export interface GridConfig {
|
||||
visible: boolean;
|
||||
style: GridStyle;
|
||||
divisions: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/** `cam_core::config::HitFeedbackConfig` */
|
||||
export interface HitFeedbackConfig {
|
||||
flash: boolean;
|
||||
shake: boolean;
|
||||
marker: boolean;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::HudView` */
|
||||
export interface HudView {
|
||||
health: HealthConfig;
|
||||
crosshair: CrosshairConfig;
|
||||
grid: GridConfig;
|
||||
hitFeedback: HitFeedbackConfig;
|
||||
showZoom: boolean;
|
||||
showStats: boolean;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::ConfigView`(不含密码哈希) */
|
||||
export interface ConfigView {
|
||||
role: Role;
|
||||
roleChosen: boolean;
|
||||
team: number;
|
||||
cameraId: number | null;
|
||||
cameraIdText: string | null;
|
||||
armorId: number | null;
|
||||
armorIdText: string | null;
|
||||
mirror: boolean;
|
||||
passwordSet: boolean;
|
||||
hud: HudView;
|
||||
recordingDir: string;
|
||||
ffmpeg: string;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::CameraDto` */
|
||||
export interface CameraDto {
|
||||
id: number;
|
||||
idText: string;
|
||||
addr: string;
|
||||
online: boolean;
|
||||
silenceMs: number;
|
||||
firmware: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::ArmorBoardDto` */
|
||||
export interface ArmorBoardDto {
|
||||
id: number;
|
||||
idText: string;
|
||||
addr: string;
|
||||
online: boolean;
|
||||
team: number;
|
||||
hp: number;
|
||||
platesSeen: number;
|
||||
reportsAllPlates: boolean;
|
||||
silenceMs: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::CameraStatsDto` */
|
||||
export interface CameraStatsDto {
|
||||
camera: number;
|
||||
cameraText: string;
|
||||
online: boolean;
|
||||
status: string;
|
||||
hasFrames: boolean;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
framesShown: number;
|
||||
framesDropped: number;
|
||||
lossPercent: number;
|
||||
datagrams: number;
|
||||
lastFrameBytes: number;
|
||||
gapMs: number | null;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::SlotsDto` */
|
||||
export interface SlotsDto {
|
||||
cameraA: number | null;
|
||||
cameraB: number | null;
|
||||
armorA: number | null;
|
||||
armorB: number | null;
|
||||
manual: boolean;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::TeamHealthDto` */
|
||||
export interface TeamHealthDto {
|
||||
team: number;
|
||||
name: string;
|
||||
available: boolean;
|
||||
placeholder: string;
|
||||
hp: number | null;
|
||||
hps: number[];
|
||||
board: number | null;
|
||||
boardText: string | null;
|
||||
critical: boolean;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::RecordingDto` */
|
||||
export interface RecordingDto {
|
||||
camera: number;
|
||||
cameraText: string;
|
||||
active: boolean;
|
||||
path: string | null;
|
||||
inputFps: number;
|
||||
framesWritten: number;
|
||||
framesDropped: number;
|
||||
startedMs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** `desktop/src-tauri/src/recording.rs` 的 `MediaFileDto` */
|
||||
export interface MediaFileDto {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "video" | "image";
|
||||
bytes: number;
|
||||
modifiedMs: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::Snapshot` */
|
||||
export interface Snapshot {
|
||||
role: Role;
|
||||
team: number;
|
||||
mirrored: boolean;
|
||||
demo: boolean;
|
||||
diagnostics: boolean;
|
||||
unlocked: boolean;
|
||||
discoveryOpen: boolean;
|
||||
uptimeMs: number;
|
||||
clients: number;
|
||||
config: ConfigView;
|
||||
cameras: CameraDto[];
|
||||
armorBoards: ArmorBoardDto[];
|
||||
stats: CameraStatsDto[];
|
||||
slots: SlotsDto;
|
||||
health: TeamHealthDto[];
|
||||
recordings: RecordingDto[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** `desktop/src-tauri/src/server.rs` 的 `StreamInfo` */
|
||||
export interface StreamInfo {
|
||||
url: string;
|
||||
port: number;
|
||||
clients: number;
|
||||
}
|
||||
|
||||
/** `server::WireMsg` 的 `Hit`。 */
|
||||
export interface HitEvent {
|
||||
board: number;
|
||||
team: number;
|
||||
index: number;
|
||||
from: number;
|
||||
to: number;
|
||||
damage: number;
|
||||
atMs: number;
|
||||
}
|
||||
|
||||
/** `server::WireMsg` 的文本信封。`t` 是 serde 的内部标签。 */
|
||||
export type WireMsg =
|
||||
| ({ t: "snapshot" } & Snapshot)
|
||||
| ({ t: "hit" } & HitEvent)
|
||||
| { t: "note"; message: string }
|
||||
| { t: "confirmQuit" };
|
||||
|
||||
/** 二进制视频帧的头部长度,与 `server::FRAME_HEADER_LEN` 一致。 */
|
||||
export const FRAME_HEADER_LEN = 16;
|
||||
Reference in New Issue
Block a user