界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/ (该目录不进仓库)。 1. 客户端显示对手的血量 快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠 数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己 绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用 `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。 2. 血条改到画面两个上角,平行四边形,一条分 5 格 左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg), 右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。 条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、 拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。 注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条 从外侧开始灭(代码里写了注释钉住这件事)。 3. 受击画面不抖动 删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但 **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的 配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关, 并有单测钉住"旧配置读得进来、默认不抖"。 4. 血量归零算击败,不显示"损毁" 血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在 Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己** (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血), 两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也 没有"这局结束了"的痕迹。 5. 裁判端可以改队伍名 两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到 「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个 两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照, 紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name` (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。 名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置 面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。 测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
142 lines
4.0 KiB
TypeScript
142 lines
4.0 KiB
TypeScript
/**
|
||
* 后端状态的订阅式存储。
|
||
*
|
||
* 后端每 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 : "与后端的视频通道断开,正在重连…",
|
||
}),
|
||
});
|
||
|
||
// 浏览器预览:没有 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) });
|
||
}
|