/** * 仅开发用的浏览器预览。 * * # 它解决什么问题 * * 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(command: string, args?: Record): Promise { const value = handle(command, args ?? {}); return value as T; } function handle(command: string, args: Record): 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(); 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); }