/** * 后端状态的订阅式存储。 * * 后端每 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) { 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 : "与后端的视频通道断开,正在重连…", }), }); // 浏览器预览:没有 WebSocket,状态与画面由 dev-preview.ts 合成。 // 动态引入让这段代码在正式构建里被完全剔掉。 if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) { emit({ connected: true }); const current = client; void import("./dev-preview").then((preview) => preview.startPreview(current, handleMessage)); return client; } 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) }); }