Files
simba-rmic-2026/desktop/src/lib/dev-preview.ts
T
Horldsence 4f7a142a39 desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端
界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。

用户端:
- 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0;
- 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟,
  像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效;
- 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中:
  第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血");
- 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。

裁判端:
- 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放;
- 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位;
- 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。

密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的
解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。

其它:
- 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名,
  原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做);
- 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来;
- 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证);
- CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明
  JS 加载、IPC、WebSocket、React effect 四件事。

删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了
(槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名
同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。

测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
2026-10-14 19:28:49 +08:00

499 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 仅开发用的浏览器预览。
*
* # 它解决什么问题
*
* Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建;而且截图要靠 macOS 的
* 屏幕录制权限。预览模式让同一套 React 组件在浏览器里跑起来:状态由本文件合成,画面用
* `createImageBitmap` 从一块 canvas 上取——`VideoSurface`、血条、瞄准器、命中反馈全都是
* 真代码,只有"后端"是假的。
*
* # 它绝不可能进到正式构建里
*
* 两重门:
*
* 1. `import.meta.env.DEV` —— 只在 `vite dev` 下为真;`vite build` 时是 false,
* 2. 并且页面里没有 Tauri 注入的 `__TAURI_INTERNALS__`。
*
* 调用点还用 `await import(...)`:条件为假时整个模块不会被打进产物,而不是打进去再靠
* 运行时不走。
*
* # 用法
*
* ```sh
* pnpm dev # 用户端
* open 'http://127.0.0.1:1420/?preview=referee' # 裁判端
* ```
*/
import type { StreamClient } from "./stream";
import type {
ConfigView,
HudView,
Snapshot,
WireMsg,
} from "./types";
/** 页面里有没有 Tauri 注入。 */
function hasTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** 现在是不是在预览模式。 */
export function isPreview(): boolean {
return import.meta.env.DEV && !hasTauri();
}
const params = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
const startAsReferee = params.get("preview") === "referee";
// ---------------------------------------------------------------------------
// 可变状态:设置面板里的改动会落到这里,界面因此是可交互的
// ---------------------------------------------------------------------------
const DEFAULT_HUD: HudView = {
health: {
visible: true,
showNumbers: true,
showTeamName: true,
lowThreshold: 30,
pulse: true,
width: 0.42,
},
crosshair: {
visible: true,
style: "cross-dot",
size: 0.045,
thickness: 0.004,
gap: 0.012,
color: "#3dff88",
offsetX: 0,
offsetY: 0,
},
grid: { visible: false, style: "thirds", divisions: 6, opacity: 0.22 },
hitFeedback: { flash: true, shake: true, marker: true, intensity: 1 },
showZoom: true,
showStats: true,
};
const CAMERA_A = 0x0de0_0001;
const CAMERA_B = 0x0de0_0002;
const ARMOR_A = 0x0a20_0001;
const ARMOR_B = 0x0a20_0002;
interface PreviewState {
role: "team" | "referee";
hud: HudView;
cameraId: number | null;
armorId: number | null;
team: number;
mirror: boolean;
unlocked: boolean;
password: string | null;
calibrating: boolean;
recordings: { camera: number; frames: number; startedMs: number }[];
hp: [number, number];
}
const state: PreviewState = {
role: startAsReferee ? "referee" : "team",
hud: structuredClone(DEFAULT_HUD),
cameraId: startAsReferee ? null : CAMERA_A,
armorId: startAsReferee ? null : ARMOR_A,
team: 0,
mirror: true,
// 预览里直接是已解锁状态,免得每次刷新都要输密码。
unlocked: false,
password: "1234",
calibrating: false,
recordings: [],
hp: [100, 100],
};
// ---------------------------------------------------------------------------
// 假的命令实现
// ---------------------------------------------------------------------------
/** 供 `ipc.ts` 在有 Tauri 时不会走到的分支上用。 */
export async function previewInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const value = handle(command, args ?? {});
return value as T;
}
function handle(command: string, args: Record<string, unknown>): unknown {
switch (command) {
case "stream_info":
return { url: "preview://", port: 0, clients: 1 };
case "get_snapshot":
return snapshot();
case "get_config":
return configView();
case "list_media_files":
return [];
case "config_path":
return "/预览模式/没有真实配置文件";
case "unlock":
state.unlocked = String(args.password) === state.password;
return state.unlocked;
case "lock":
state.unlocked = false;
return undefined;
case "set_password":
state.password = String(args.fresh);
state.unlocked = true;
return configView();
case "set_discovery_open":
return undefined;
case "bind_camera":
guard();
state.cameraId = (args.cameraId as number | null) ?? null;
return configView();
case "bind_armor":
guard();
state.armorId = (args.armorId as number | null) ?? null;
return configView();
case "set_team":
guard();
state.team = Number(args.team);
return configView();
case "set_role":
state.role = args.role as "team" | "referee";
return configView();
case "set_mirror":
state.mirror = Boolean(args.mirror);
return configView();
case "save_hud":
state.hud = args.hud as HudView;
return configView();
case "set_referee_slots":
return configView();
case "set_recording_options":
return configView();
case "screenshot":
return "/预览模式/没有截图";
case "start_recording": {
const cameras = (args.cameras as number[]) ?? [];
const startedMs = Date.now();
for (const camera of cameras) {
if (!state.recordings.some((r) => r.camera === camera)) {
state.recordings.push({ camera, frames: 0, startedMs });
}
}
return recordings();
}
case "stop_recording": {
const cameras = (args.cameras as number[]) ?? [];
state.recordings = cameras.length
? state.recordings.filter((r) => !cameras.includes(r.camera))
: [];
return recordings();
}
case "refresh":
case "reveal":
case "open_file":
return undefined;
case "clear_config":
return "预览模式:没有真实配置可清空";
case "quit":
return undefined;
default:
throw new Error(`预览模式没有实现命令 ${command}`);
}
}
/** 预览里模拟后端的密码闸门,好让解锁流程也能被看到。 */
function guard() {
if (state.role === "team" && state.password !== null && !state.unlocked) {
throw new Error("这个改动需要密码。先解锁(预览模式里密码是 1234)。");
}
}
function configView(): ConfigView {
return {
role: state.role,
roleChosen: true,
team: state.team,
cameraId: state.cameraId,
cameraIdText: state.cameraId === null ? null : hex(state.cameraId),
armorId: state.armorId,
armorIdText: state.armorId === null ? null : hex(state.armorId),
mirror: state.mirror,
passwordSet: state.password !== null,
hud: structuredClone(state.hud),
recordingDir: "/预览模式/recordings",
ffmpeg: "ffmpeg",
};
}
function recordings() {
return state.recordings.map((recording) => ({
camera: recording.camera,
cameraText: hex(recording.camera),
active: true,
path: `/预览模式/rec-${recording.camera.toString(16)}.mp4`,
inputFps: 20,
framesWritten: recording.frames,
framesDropped: 0,
startedMs: recording.startedMs,
error: null,
}));
}
function hex(id: number): string {
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
}
function snapshot(): Snapshot {
const cameras = [
{ id: CAMERA_A, team: 0 },
{ id: CAMERA_B, team: 1 },
].map(({ id, team }) => ({
id,
idText: hex(id),
addr: team === 0 ? "192.168.1.41" : "192.168.1.42",
online: true,
silenceMs: 120,
firmware: 3,
}));
const boards = [
{ id: ARMOR_A, team: 0 },
{ id: ARMOR_B, team: 1 },
].map(({ id, team }, index) => ({
id,
idText: hex(id),
addr: team === 0 ? "192.168.1.51" : "192.168.1.52",
online: true,
team,
hp: state.hp[index],
platesSeen: 4,
reportsAllPlates: true,
silenceMs: 90,
}));
const stats = cameras.map((camera) => ({
camera: camera.id,
cameraText: camera.idText,
online: true,
status: camera.idText,
hasFrames: true,
fps: 20,
width: 640,
height: 480,
framesShown: 1000,
framesDropped: 3,
lossPercent: 0.3,
datagrams: 17000,
lastFrameBytes: 22000,
gapMs: 50,
}));
const healthFor = (team: number) => {
const hp = state.hp[team];
const board = team === 0 ? ARMOR_A : ARMOR_B;
return {
team,
name: team === 0 ? "队伍一" : "队伍二",
available: true,
placeholder: "",
hp,
hps: [hp, hp, hp, hp],
board,
boardText: hex(board),
critical: hp === 0 || hp <= state.hud.health.lowThreshold,
};
};
return {
role: state.role,
team: state.team,
mirrored: state.mirror,
demo: true,
diagnostics: true,
unlocked: state.unlocked || state.role === "referee",
discoveryOpen: true,
uptimeMs: performance.now(),
clients: 1,
config: configView(),
cameras,
armorBoards: boards,
stats,
slots: {
cameraA: state.role === "referee" ? CAMERA_A : state.cameraId,
cameraB: state.role === "referee" ? CAMERA_B : null,
armorA: state.role === "referee" ? ARMOR_A : state.armorId,
armorB: state.role === "referee" ? ARMOR_B : null,
manual: false,
},
health: state.role === "referee" ? [healthFor(0), healthFor(1)] : [healthFor(state.team)],
recordings: recordings(),
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
};
}
// ---------------------------------------------------------------------------
// 假的画面与心跳
// ---------------------------------------------------------------------------
/** 启动预览的数据源:每 200ms 一份快照,每 50ms 一帧画面。 */
export function startPreview(client: StreamClient, emit: (msg: WireMsg) => void) {
let frameId = 0;
const started = performance.now();
// 快照。
const snapshotTimer = window.setInterval(() => {
emit({ t: "snapshot", ...snapshot() } as WireMsg);
}, 200);
// 两路画面,各 20fps。
const canvases = new Map<number, HTMLCanvasElement>();
const render = async (camera: number, team: number) => {
const canvas = canvases.get(camera) ?? makeCanvas();
canvases.set(camera, canvas);
draw(canvas, team, frameId);
const bitmap = await createImageBitmap(canvas);
client.injectFrame(camera, frameId, canvas.width, canvas.height, bitmap);
};
const frameTimer = window.setInterval(() => {
frameId += 1;
const cameras =
state.role === "referee"
? [
[CAMERA_A, 0],
[CAMERA_B, 1],
]
: [[state.cameraId ?? CAMERA_A, state.team]];
for (const [camera, team] of cameras as [number, number][]) {
void render(camera, team);
}
for (const recording of state.recordings) recording.frames += 1;
}, 50);
// 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。
let tick = 0;
const hitTimer = window.setInterval(() => {
tick += 1;
const team = tick % 2;
const damage = 3 + ((tick * 7) % 20);
const from = state.hp[team];
const to = from === 0 ? 100 : Math.max(0, from - damage);
// 打空之后重置成满血:界面上"已击毁"的状态要真的能看到。
state.hp[team] = to;
if (to >= from) return;
emit({
t: "hit",
board: team === 0 ? ARMOR_A : ARMOR_B,
team,
index: 0,
from,
to,
damage: from - to,
atMs: Date.now(),
} as WireMsg);
}, 3000);
// 第一帧不必等 200ms。
emit({ t: "snapshot", ...snapshot() } as WireMsg);
return () => {
window.clearInterval(snapshotTimer);
window.clearInterval(frameTimer);
window.clearInterval(hitTimer);
void started;
};
}
function makeCanvas(): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
return canvas;
}
/** 与 `src-tauri/src/demo.rs` 画的是同一类东西:底色区分两路、中心靶心、二进制帧号。 */
function draw(canvas: HTMLCanvasElement, team: number, frameId: number) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { width, height } = canvas;
const tint = team === 0 ? [0, 46, 40] : [58, 30, 0];
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, `rgb(${18 + tint[0] / 3}, ${18 + tint[1] / 3}, ${22 + tint[2] / 3})`);
gradient.addColorStop(1, `rgb(${78 + tint[0] / 3}, ${78 + tint[1] / 3}, ${82 + tint[2] / 3})`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = "rgba(255,255,255,0.07)";
ctx.lineWidth = 1;
for (let x = 0; x < width; x += 40) {
ctx.beginPath();
ctx.moveTo(x + 0.5, 0);
ctx.lineTo(x + 0.5, height);
ctx.stroke();
}
for (let y = 0; y < height; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y + 0.5);
ctx.lineTo(width, y + 0.5);
ctx.stroke();
}
const cx = width / 2;
const cy = height / 2;
ctx.strokeStyle = "#c8c8d2";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, 60, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#96969f";
ctx.beginPath();
ctx.arc(cx, cy, 28, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#78788a";
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
ctx.beginPath();
ctx.moveTo(cx + dx * 40, cy + dy * 40);
ctx.lineTo(cx + dx * 90, cy + dy * 90);
ctx.stroke();
}
for (let bit = 0; bit < 16; bit += 1) {
const on = ((frameId >> bit) & 1) === 1;
ctx.fillStyle = on ? "#78ffa0" : "#34343c";
ctx.fillRect(12 + bit * 14, 12, 10, 10);
}
const t = frameId / 20;
const phase = team === 0 ? 0 : 1.7;
ctx.fillStyle = "#ffd25a";
ctx.beginPath();
ctx.arc(cx + 250 * Math.sin(t * 0.9 + phase), cy + 150 * Math.cos(t * 1.3 + phase), 26, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#5ac8ff";
ctx.fillRect(((t * 60) % (width - 80)) | 0, height - 24, 60, 8);
ctx.font = "600 20px ui-monospace, monospace";
ctx.fillStyle = "#e8e8ec";
ctx.fillText(team === 0 ? "PREVIEW · TEAM 1" : "PREVIEW · TEAM 2", 14, height - 40);
ctx.font = "500 16px ui-monospace, monospace";
ctx.fillStyle = "#8a8a95";
ctx.fillText(`frame ${frameId}`, 14, height - 16);
}