Files
simba-rmic-2026/docs/desktop-ui-redesign.md
T
sloray e3acb37d32
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
docs: 按 humanizer 重写项目文档并同步 RMIC::2026 现状
2026-10-19 10:47:00 +08:00

72 KiB
Raw Blame History

cam-desktop 重构设计:The Division 风格 UI/UX · 比赛控制 · 直播包装 · .zip 固件包

版本:草案 v0.1 | 日期:2026-10-19 范围:desktop/(React + Tauri v2)、desktop/core、desktop/src-tauri、protocol/、scripts/、docs/firmware-maintenance.md 约束:保留视频、血量、装甲板、录制、固件维护;Tauri v2 + React + 现有 Rust 后端;中文界面;跨平台。 规则依据:SIMBA ΣDOCS · 2026 校内赛参赛手册、对抗赛与奖项、裁判系统

实施状态(2026-10-19):.zip 打包/读取、F11/Alt+Enter 全屏、SHD 视觉 token 已落地; 比赛状态机/判负/技术暂停/直播页按 §7.1 的 M2–M4 分阶段推进,尚待 §7.4 Q1–Q4 确认。


0. 现状基线(读代码后的结论)

层 现状 对本次重构的意义
页面 App.tsx 单一分支树:MaintenancePage → 致命错误 → 等待快照 → 身份选择 → 设密码 → TeamPage / RefereePage 没有路由、没有 Shell、没有直播视图;需先抽出 AppShell 与视图枚举
样式 styles/global.css 单文件 1687 行,:root 20 个 token,深底 + 荧光绿强调 token 体系要替换,且中文排版规则已踩过坑(见 1.3),不能推倒重来
状态 后端 5 Hz 推整份 Snapshot,前端 useSyncExternalStore 单例 比赛状态机应作为快照的一部分下发,前端不做第二份真相
视频 WebSocket 二进制 JPEG + canvas 绘制,缩放/平移/网格/瞄准器/命中反馈全在 VideoSurface 动效不得侵入 canvas 绘制路径(否则抢帧)
HUD 5 格斜切血条,左上自己/右上对手,数字默认显示 5 格 = 20 点/格,而规则是每发扣 3 点 → 数字必须保留(见 6.2)
裁判端 双路等宽 + 队名编辑 + 装甲板表 + 录制/回放 + 底部 250px 固定高度 无计时/比分/暂停/判负;底部 Dock 要重排
后端 UDP 5600 视频 / 5601 装甲板;命令集中在 commands.rs;密码闸门在 state.rs 新命令同样要过闸门;比赛状态需要 role == referee 的限制
协议 MsgType: Video=1, Subscribe=2, Hello=3, Heartbeat=4, ArmorHealth=5,只有上行/广播,没有下行控制 客户端判负/暂停无法改变硬件血量(见 3.2、R1)
固件包 bundle.json + esp32s3-app.bin + partition-table.bin(+bootloader.bin),客户端选目录,format: 1,逐项 SHA256 改成 .zip 只需改 maintenance::load_bundle 的取数入口,校验逻辑可原样复用
产物 target/firmware/ 已有 esp32s3-app.bin(1,143,504 B)、partition-table.bin(3072=0xC00)、bootloader.bin、BUILD-INFO.json、bundle.json、SHA256SUMS.txt 打包环节有现成输入,缺的是"构建后自动产出 zip"
现场 适应性训练 10-25,决赛 11-01 距决赛 13 天 → 优先级必须围绕这一天排(见 7.1)

1. 设计系统(可落地)

1.1 六条设计原则(附取舍)

# 原则 具体约束 被否决的做法
P1 画面优先 视频窗格内除 HUD 外零装饰;HUD 只贴四角与上沿;中心半径 18% 区域永不放常驻元素 半透明斜纹背景铺满、常驻扫描线
P2 三秒可读 血量/时间/比分/状态四类信息位置固定、字号 ≥ 24px@1080p、对比 ≥ 4.5:1;3 m 外单眼可读 用颜色/图标单独表达状态
P3 状态永不含糊 无数据不显示 0;「离线/演示/暂停/已判定」必须全屏可见;同一时刻只有一个主结论 用 toast 表达比赛状态
P4 误操作成本 > 操作成本 破坏性动作(判负、清空配置、烧录)一律 长按 ≥ 1000ms + 理由 + 可撤销/可审计;高频只读动作零确认 用 confirm() 敷衍
P5 同源渲染 用户端 HUD / 裁判端 / 直播三层共用同一 Snapshot + MatchView 与同一套安全区 给直播另写一套数据拼装
P6 无障碍与光敏安全 闪烁 ≤ 3 Hz 且面积 < 屏幕 25%;prefers-reduced-motion 全局降级;色不单独承载信息 无限 glitch、随机闪白

The Division 能迁移过来的视觉语言只有四样:近黑底 + 单一强调色 + 细线切角 + 等宽数字。它的地图、装备详情、粒子特效属于"非实时、可暂停"的场景,搬进 3 分钟一局的比赛界面会直接损害 P1/P2。

1.2 色彩

色板(可直接替换 global.css 的 :root):

:root {
  /* 底色:三层就够,第四层用"提亮 4%"而不是再加一个变量 */
  --bg-void: #07080a;   /* 窗口最底 / 直播黑场 */
  --bg-base: #0b0d10;   /* 页面底 */
  --bg-panel: #11151a;  /* 面板 / 顶栏 / 状态行 */
  --bg-raised: #171c22; /* 卡片 / 行 */
  --bg-input: #090b0e;  /* 输入框 / 日志 */
  --bg-scrim: rgba(6, 8, 10, 0.78); /* 遮罩 */

  /* 线:Division 的"1px 亮边"是主要体积感来源,阴影只做兜底 */
  --line: #232a33;
  --line-strong: #333c48;
  --line-accent: rgba(255, 122, 26, 0.45);

  /* 文本 */
  --text: #e9ecf1;       /* 15.2:1 on --bg-base */
  --text-dim: #98a2b0;   /* 6.4:1 */
  --text-faint: #5e6875; /* 3.2:1 —— 只用于非信息性文字 */
  --text-invert: #07080a;

  /* 强调:SHD 橙。唯一强调色,用于焦点/当前项/CTA/直播描边 */
  --accent: #ff7a1a;
  --accent-hot: #ff9a4d;
  --accent-deep: #b44e08;
  --accent-veil: rgba(255, 122, 26, 0.12);

  /* 语义 */
  --danger: #ff3b30; /* 判负 / 录制点 / 错误 */
  --warn: #f5b13d;   /* 低血 / 链路抖动 / 未上锁 */
  --ok: #3ddc84;     /* 通过 / 在线 */
  --info: #35d6e6;   /* 技术暂停 / 扫描 / 遥测 */

  /* 队伍:规则"编号小=红方、编号大=蓝方" → 队号 0 固定红、1 固定蓝 */
  --team-0: #ff4d4f;
  --team-1: #3e9bff;
  --team-0-veil: rgba(255, 77, 79, 0.14);
  --team-1-veil: rgba(62, 155, 255, 0.14);
}

用色规则

  1. 强调色不用于血量——血量只用队伍色,低血时切 --danger(现有 HealthBar 行为,保留)。
  2. 每个屏幕最多 1 个 --accent 元素做主 CTA;第二个强调改用 --line-accent 细边。
  3. 红色只出现在三处:判负、录制中、错误;不要用它做"取消"。
  4. 直播层禁止使用 --accent-veil 以上的大面积橙(压缩后发脏),装饰靠线不靠面。
  5. 纯黑 #000 只用于视频信箱区;面板底色不低于 #0b0d10,避免 OLED/编码 banding。
  6. 对比度自检清单:所有正文 ≥ 4.5:1、所有 ≥18px 的数字 ≥ 3:1、队伍色文字必须叠 text-shadow: 0 1px 3px rgba(0,0,0,.9)(现有做法保留)。

1.3 字体与排版

:root {
  --font-ui: "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
    "Source Han Sans SC", ui-sans-serif, system-ui, sans-serif;
  --font-mono: ui-monospace, "JetBrains Mono", "Cascadia Mono",
    "SF Mono", Consolas, monospace;

  --fs-micro: 10px;  --fs-small: 11px; --fs-body: 13px;
  --fs-title: 15px;  --fs-h2: 17px;    --fs-h1: 24px;
  --fs-hud:    clamp(18px, 1.8vmin, 28px);  /* 血量数字 */
  --fs-clock:  clamp(28px, 3.4vmin, 44px);  /* 比赛计时 */
  --fs-score:  clamp(40px, 5vmin, 64px);    /* 比分 */
  --fs-display:clamp(44px, 8vmin, 96px);    /* 判定大字(仅直播/结果卡) */
}

规则:

  • 中文不加 letter-spacing。global.css 已注明"表头是中文,加字距只会让它看起来散架"——这条继续执行,只对拉丁文(LIVE、TECH PAUSE、SERIES)用 letter-spacing: .14em + text-transform: uppercase。
  • 所有数字(血量、时间、比分、ID、帧率)走 --font-mono + font-variant-numeric: tabular-nums,避免数字跳动时宽度变化引起布局抖动。
  • 字号阶梯只有 6 档正文 + 4 档显示级;禁止在组件里写 font-size: 12.5px 这种一次性值。
  • 字体不打包(现场离线,中文字体体积又大)。若要 Division 味的拉丁字形,可后续单独引入 OFL 的 Barlow Condensed SemiBold(约 40 KB),仅用于数字与拉丁标签,并保留系统字体作为 fallback。
  • 行高:正文 1.7、标题 1.3、数字 1.0。

1.4 层级(z-index / elevation / 几何)

:root {
  --z-stage: 0;     /* 视频 canvas */
  --z-hud: 10;      /* 画面内 HUD */
  --z-overlay: 20;  /* 暂停/判定覆盖层 */
  --z-chrome: 30;   /* 顶栏/状态行/悬浮徽标 */
  --z-drawer: 40;   /* 设置抽屉 */
  --z-dialog: 50;   /* 模态框 */
  --z-toast: 60;    /* 提示 */
  --z-critical: 70; /* 链路断开/致命错误 */
}
  • 面板体积感 = 1px var(--line) + inset 0 1px 0 rgba(255,255,255,.05),不用大阴影;阴影只用于浮动层(抽屉/对话框/toast)。
  • 切角:--cut: 8px。只在按钮、徽标、比分板、判定卡上用 clip-path;面板不要用(clip-path 会吃掉 border 和 box-shadow,改完还得补 SVG 边框,收益不抵成本)。
    .cut { clip-path: polygon(var(--cut) 0, 100% 0, 100% calc(100% - var(--cut)),
          calc(100% - var(--cut)) 100%, 0 100%, 0 var(--cut)); }
    
  • 圆角:--r-xs 2 / --r-sm 4 / --r-md 8 / --r-lg 12。Division 几乎全直角,只在小控件上保留 2–4px;10px 的大圆角(现有 --radius)改到 4px。
  • 角标(bracket):面板四角用 ::before/::after 画 8×1px 亮线,只在"当前焦点面板"上出现一个角,用来替代发光描边。

1.5 HUD 规格

元素 位置 尺寸 规则
我方血条 画面左上,距边 3.5% 宽 宽 36%(现 42%)、高 16px、5 格、skewX(-15deg) 数字必显;available=false 时显示"装甲板未接入",不显示 0
对手血条 画面右上,镜像 同上 同上;标签"我方/对手"保留
瞄准器 跟随图像 大小不随缩放 现有 drawCrosshair 不动,仅颜色默认值改为 #ff7a1a(可配置)
倍率 画面右下角 11px mono 仅在 zoom > 1 时出现
命中提示条 画面顶部 top: 56px 居中 高 26px 避开准星(已踩坑,保留)
比赛计时(新) 画面顶部居中 top: 12px 32px mono 只在 Running/Paused/Ended 出现;< 30s 变 --danger 且每秒脉冲一次(不闪)
暂停/判负遮罩(新) 整画面 --z-overlay — 半透明 + 居中文字;不遮血条与计时(保留可读性)
网格 画面内 现有 默认关;opacity ≤ .22
技术读数 底部状态行 11px mono 用户端 release 下不显示(TEAM_DIAGNOSTICS 已有门控)

HUD 熄灯规则(新增):非关键 HUD(网格、倍率、帧率、界面数)在 3 s 无指针输入后降到 opacity: .35;prefers-reduced-motion: reduce 时直接隐藏而不做透明度过渡(避免持续重绘)。

1.6 动效原则

:root {
  --t-fast: 90ms;
  --t-base: 140ms;
  --t-slow: 220ms;
  --t-emphatic: 320ms;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-in: cubic-bezier(0.7, 0, 1, 0.6);
  --motion-scale: 1; /* 0 = 降级 */
}
@media (prefers-reduced-motion: reduce) {
  :root { --motion-scale: 0; --t-fast: 1ms; --t-base: 1ms;
          --t-slow: 1ms; --t-emphatic: 1ms; }
}

动效只分三类,不另设表外时长

类别 时长 用途 属性
状态确认 90–140 ms 按钮按下、开关、页签、hover transform/opacity/background-color
状态迁移 220–320 ms 抽屉、对话框、覆盖层、全屏切换、比分变化 transform/opacity
连续数据 ≤ 200 ms 插值 血量格、数字滚动、计时 只改文本与宽度,不改布局

动效清单(实现时逐条对表)

组件 触发 动效 时长/曲线
Button press scale(.98) 90ms / standard
Button hover hover 边框转 --line-accent + 背景 +4% 140ms
页签 切换 底线 2px 从左滑到新页签 220ms / out
HealthBar 格 掉血 该格 background 140ms 淡出;数字 160ms 计数 140/160ms
命中闪红 hit 620ms 全屏径向渐变淡出(现有 hit-fade) 保留
暂停遮罩 进入/退出 opacity 0→1 + 文字 translateY(6px)→0 220ms / out
判定卡 结束 scale(.94)→1 + opacity 320ms / out
计时最后 30 s 每秒 数字 scale(1→1.06→1) 一次 300ms
键盘提示 Tab 按住 底部面板 translateY(8px)→0 220ms
全屏切换 F11 无自定义动画(由窗口管理器负责) —

禁止清单

  1. 全屏抖动(HitFeedbackConfig.shake 已废弃,代码里仍然不要复活)。
  2. 任何 > 3 Hz 的闪烁;任何占屏 > 25% 的红色闪烁(判负的 320 ms 一次性淡入除外)。
  3. 常驻循环动画(扫描线、呼吸背景、粒子)。
  4. 遮住准星中心 18% 半径的动画。
  5. 会触发 layout 的动画(width/height/top/left 的 transition)——血条格子用 background,进度用 transform: scaleX。
  6. 依赖 requestAnimationFrame 的新增长循环(现有 VideoSurface 的绘制循环是唯一允许的一个)。

降级开关:设置里加"低动效模式"(ui.reduceMotion),等价于强制 prefers-reduced-motion,给低配裁判机与"现场怕闪"的场合用。


2. 页面与组件重构方案

2.1 应用外壳(新增 AppShell)

┌──────────────────────────────────────────────────────────────────────┐
│ TopBar 44px   cam-desktop · 裁判端 · 链路●   │ 比赛条(裁判/直播) │ 工具 │
├──────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   Stage:视频 + 画面内 HUD + 覆盖层(暂停/判定/校准)                  │
│                                                                      │
├──────────────────────────────────────────────────────────────────────┤
│ StatusBar 28px  链路 / 帧率 / 丢帧 / 提示                             │
└──────────────────────────────────────────────────────────────────────┘

三种外壳状态(data-shell):

状态 触发 行为
normal 默认 全部 chrome 可见
immersive F11 chrome 隐藏;指针移到上沿 8px 时 TopBar 150ms 淡入,3 s 后淡出;指针静止 2 s 隐藏光标
broadcast 视图切换 Ctrl+Shift+B 只渲染 BroadcastStage;禁用设置/维护/发现列表;不渲染任何 IP/token

结构落点:App.tsx 里现有的门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码)不动,只在最后一段把 TeamPage | RefereePage 包进 AppShell,并新增 BroadcastPage 分支。

2.2 用户端 TeamPage(操作手视角)

目标:布帘后、单手、不看键盘、只有 3 分钟。

┌──────────────────────────────────────────────┐
│              02:41                  (计时)  │  ← 仅比赛进行中出现
│ ┌────────────┐                  ┌────────────┐│
│ │我方 ████ 76│                  │100 ████ 对手││  ← 血条贴角,宽 36%
│ └────────────┘                  └────────────┘│
│                                              │
│                    +                    (准星)│
│                                              │
│  (命中时:上中"命中 -3" + 全屏淡红 620ms)    │
└──────────────────────────────────────────────┘
│ 链路 · 28.6 fps · 丢 0.4%                     │ ← release 下只留"链路"

改动点:

  1. 顶部加计时条(仅 match.phase ∈ {running, paused} 且本队已绑定);不显示比分细节,局间才显示"第 2 局 · 1:1"。
  2. 底部状态行从"滚轮缩放 · 拖动平移…"改成首次提示:进入页面 5 s 后自动淡出,鼠标移动再出现(现在常驻,占屏且无用)。
  3. 新增 PausedOverlay:技术暂停时整屏 86% 遮罩 + "技术暂停 03:12 · 由裁判发起",血条/计时保留可见。
  4. 新增 JudgedOverlay:判负/结束时显示"本局判负(设备故障)"或"胜利",与 MatchBanner 合并成一个组件(避免两套结论 UI 同时出现)。
  5. 设置入口、录制入口不进主界面(现在 TopBar 里有"设置/固件维护",用户端误触风险高):改为 Ctrl+K 命令面板或长按品牌名 2 s。
  6. 校准模式(现有 calibrating)保留,但提示从底部横幅改到画面内角标,避免遮挡。

2.3 裁判端 RefereePage

三区 + 一条比赛条:

┌ TopBar ────────────────────────────────────────────────────────────────┐
│ cam-desktop · 裁判端 · 链路● · 录制×2                    [直播] [设置]    │
├ MatchBar 52px ─────────────────────────────────────────────────────────┤
│ BO3 第2局 │ 02:41 │ 红 76 ╍╍╍╍ 100 蓝 │ ⏸ 技术暂停 │ ⚑ 判负 │ ⋯ 更多 │
├────────────────────────────────────────────────────────────────────────┤
│  ┌─ 红方(左)──────────────┐   ┌─ 蓝方(右)──────────────┐          │
│  │  video + HUD            │   │  video + HUD             │          │
│  │  [队名 可编辑] [fps]     │   │  [队名 可编辑] [fps]      │          │
│  └─────────────────────────┘   └──────────────────────────┘          │
├ Dock ─────────────────────────────────────────────────────────────────┤
│ [装甲板 8] [录制与回放 3] [事件 27] [连接诊断]              [刷新] [⟳]  │
│  …内容(表格/列表/日志)…                                              │
└────────────────────────────────────────────────────────────────────────┘

改动点:

  1. MatchBar(新,常驻,高 52px):局次、计时、比分、暂停、判负、更多菜单。这是裁判端最重要的新增组件;判定后整条切成"结果态"(红/蓝底 + 理由 + 撤销入口)。
  2. referee-stage 保持 flex: 1,新增的 MatchBar 占固定高度;现有 referee-bottom 的 250px 改成 Dock 可拖拽高度(默认 240px,最小 120,最大 45vh),因为加一行比赛条后 900px 高的窗口会只剩 560px 给画面。
  3. ArmorTable 改为两队并列(左红右蓝),每行左侧加 5 格迷你血条(与 HUD 同源,便于核对);主装甲板加 主 徽标;离线行 opacity .55(保留);reportsAllPlates=false 时显示"仅报 2 块"——规则允许 2 或 4 块装甲板。
  4. 新增 EventPanel(Dock 第 3 个页签):命中/暂停/判负/录制开始/结束的时间轴列表,可导出。
  5. MediaPanel 保留全部功能,新增两个东西:按局分组(matchId 目录)与 "回放本局"(双路同步 + 事件定位)。
  6. 队名编辑保留(现在是内联输入),但改成"点击铅笔 → 行内编辑 → Enter 保存 / Esc 取消",避免一直占一行高。

2.4 直播 BroadcastPage(新)

见第 4 节。要点:一个 1920×1080 虚拟画布 + 4 个模板 + 安全区参考线 + 只读数据。

2.5 维护页 MaintenancePage

现在是一个 2 列网格的长页面(设备卡 / 固件包卡 / 分区表 / 报告),信息密度高但"下一步做什么"不明显。改成 5 步 Stepper:

① 设备  →  ② 固件包(.zip)  →  ③ 烧录计划  →  ④ 烧录  →  ⑤ 结果
──────────────────────────────────────────────────────────────
左:步骤栏(已完成 ✓ / 当前 ▶ / 未开始 ○)
右:当前步骤卡(大字号 + 一个主按钮 + 次级信息折叠)
  • 步骤 ② 接受 .zip 与目录(目录收在"高级 / 兼容旧包"里),支持拖拽。
  • 步骤 ③ 用 diff 表显示"factory: 0x070000 → 0x200000",并把 warnings 提到主区域(现在埋在下面)。
  • 步骤 ④ 全屏进度 + 不可关闭 + 取消(已有 maintenance_cancel)+ 实时日志。
  • 步骤 ⑤ 报告卡:结果、坏字节/坏扇区、备份目录、恢复旧分区表、复制报告。
  • 保留全部诊断细节(分区表、候选槽位、回读不一致列表),只是默认折叠。

2.6 组件清单

新增

文件 职责
components/ui/Panel.tsx 带切角/角标的容器(tone/dense/flush)
components/ui/HoldButton.tsx 长按确认按钮(1000ms 环形进度,松手取消,Esc 取消)
components/ui/KeyHint.tsx 键帽样式(<KeyHint keys="F11" />)
components/ui/Stepper.tsx 维护页步骤栏
components/ui/EmptyState.tsx 统一空状态(图标 + 一句 + 一个动作)
components/match/MatchBar.tsx 裁判端比赛条
components/match/MatchClock.tsx 计时显示(插值 + 最后 30 s 强调)
components/match/ScoreBoard.tsx 比分(局/系列赛)
components/match/ForfeitDialog.tsx 判负对话框(败方 + 理由 + 长按)
components/match/TechPauseDialog.tsx 技术暂停(申请方 + 理由 + 已批准勾选)
components/match/EventPanel.tsx 事件时间轴 + 导出
components/match/PausedOverlay.tsx / JudgedOverlay.tsx 覆盖层
components/replay/ReplayView.tsx 双路同步回放 + 事件定位
components/broadcast/BroadcastStage.tsx 直播画布
components/broadcast/SafeGuides.tsx 安全区参考线(仅预览)
components/broadcast/{TitleBar,ScoreBug,RoundClock,TalentCard,LowerThird,AlertCard,SponsorRail,StandbyCard}.tsx 包装层
components/firmware/FirmwareDropZone.tsx .zip 拖拽/选择
hooks/useFullscreen.ts / useHotkeys.ts / useIdle.ts / useServerClock.ts 全屏 / 快捷键 / 空闲 / 服务器时钟

修改

App.tsx(Shell + 视图)、TopBar(比赛条位、直播入口、工具收纳)、TeamPage、RefereePage、MaintenancePage、MediaPanel(按局分组/回放)、ArmorTable(双队/迷你条)、HealthBar(宽 36%、字号 token)、SettingsPanel(新增"界面/动效/直播"段)、global.css → 拆成 tokens.css + base.css + components.css(或保持单文件但按 1–7 节分区)。

2.7 状态模型(前端)

interface AppStore {
  snapshot: Snapshot | null;
  connected: boolean;
  lastHit: HitPulseRecord | null;
  confirmQuit: number;
  notes: string[];
  channelError: string | null;
  // 新增
  ui: { shell: "normal" | "immersive" | "broadcast"; guides: boolean; idle: boolean };
  events: MatchEventView[];      // 由 WireMsg::MatchEvent 累积(本地最多 500 条)
  clockSkewMs: number;           // serverNow - localNow,用于计时插值
}

事件的传输方式(别塞进快照):比赛事件走独立推送,与现有的 hit 同一条路(server::WireMsg 新增 MatchEvent),前端累积;Snapshot.match 只带最近 12 条用于重连后补齐。理由:快照 5 Hz × 200 条事件会让每份快照涨到几十 KB,纯浪费;而事件本身就是"发生即推"。

计时不许前端自己数:Snapshot 增加 nowMs: u64(后端 Unix 毫秒),前端用 useServerClock 每帧算 serverNow = localNow + clockSkewMs,再用 match.remainingMs - (serverNow - snapshotAt) 插值。理由:setInterval 在窗口失焦时会被节流,而比赛计时绝不能因为切窗口而变慢。


3. 交互逻辑

3.1 F11 全屏

实现

// hooks/useFullscreen.ts
const win = getCurrentWindow();
const [full, setFull] = useState(false);
useEffect(() => { win.isFullscreen().then(setFull); }, []);
const toggle = useCallback(async () => {
  const next = !(await win.isFullscreen());
  await win.setFullscreen(next);
  setFull(next);
}, []);

权限:capabilities/default.json 现在只有 core:default + dialog:default,不含全屏。需要追加(identifier 已在本仓库 gen/schemas/acl-manifests.json 中确认存在):

"permissions": [
  "core:default",
  "dialog:default",
  "core:window:allow-set-fullscreen",
  "core:window:allow-is-fullscreen"
]

按键

平台 按键 备注
Windows / Linux F11 keydown capture + preventDefault()
macOS ⌃⌘F 同时支持 F11(部分外接键盘有)
全平台 Alt+Enter 备用,游戏玩家肌肉记忆
全平台 Esc 退出全屏 / 关闭顶层对话框(按层级,一次只关一个)

细节

  • 切换后 400 ms 内忽略再次触发(部分窗口管理器会发出重复 keydown)。
  • 全屏切到 immersive:chrome 隐藏、broadcast 模式下同时隐藏安全区参考线与光标。
  • 退出通道必须看得见:全屏时右上角保留一个 1px 高热区,指针进入显示"退出全屏 F11",3 s 后淡出。
  • 不做"全屏时禁止关闭窗口"这类硬锁——现场需要能立刻退出。
  • 多显示器:P2 再加 setFullscreenOnMonitor(core:window:allow-set-fullscreen-on-monitor 已存在),让直播输出指定显示器。

3.2 一键判负(最危险的动作)

入口:MatchBar 右侧 ⚑ 判负,或 Ctrl/Cmd+Shift+F。只在裁判端、phase ∈ {running, paused} 时可用。

三步,不可合并

① 点按 → ForfeitDialog
   ┌─────────────────────────────────────────┐
   │ 判定本局结果                             │
   │ 当前:红 76 · 蓝 100 | 剩余 02:41 | 第2局│
   │ 败方: (●) 红方  ( ) 蓝方                 │
   │ 理由: [设备故障 ▾]                       │
   │   设备故障 / 违规 / 弃权 / 未到场 / 其他   │
   │ 备注: [________________________]         │
   │ ⚠ 本操作只改变比赛判定与记录,不改变硬件血量 │
   │            [取消]  [长按 1 秒确认判负]     │
   └─────────────────────────────────────────┘
② 长按 1000ms(HoldButton,松手即取消,Esc 取消)
③ 5s 内 toast 提供「撤销」

执行语义

match_end { reason: Forfeit { loser: 0|1, kind: ForfeitKind, note: Option<String> } }
  • 状态 → Ended { by: Referee, reason, at, actor },比分按规则 +1 给胜方。
  • 用户端若已同步(见 R2):显示 JudgedOverlay「本局判负」;未同步则只影响裁判端与直播。
  • 不改硬件血量:ArmorHealthStore 只接收上报,没有任何写入口(见 R1)。界面必须显式写明这一点,否则现场会以为"判负之后对方血量归零了"。
  • 审计:事件 { at, actor, action:"forfeit", loser, reason, note, hp:[76,100], remainingMs:161000 },追加到 matches/<yyyy-mm-dd>/<matchId>/events.ndjson,同时进 snapshot.match.events。
  • 撤销:match_undo { eventId },5 s 内可直接撤销;之后只能 match_reopen_round { reason },在历史里留下"更正"两条记录(不删除原记录)。

与自动判定共存:resolve_outcomes 已经能在血量归零时给出 Victory/Defeat/Draw。两者必须用同一字段建模:

end: { by: "auto" | "referee"; reason: "knockout" | "timeout" | "forfeit" | "equipment" | "tiebreak"; loser?: 0|1 }

否则"回放/统计"里会分不清这局是怎么结束的。

3.3 技术暂停

规则对齐:手册写"任意一方…可向场边裁判申请发起时长为 5 分钟 的技术暂停并说明理由,待主裁判批准后技术暂停开始。技术暂停期间,双方均可在该段时间内检修机器人。"

四步流程

申请方(红/蓝) → 理由 → ✅ 主裁判已批准 → 开始计时(05:00) → 恢复
  • 入口:MatchBar 的 ⏸ 技术暂停。对话框必填:申请方、理由(枚举+备注)、"主裁判已批准"复选框(不勾不能开始——把规则写进交互里)。
  • 开始后:phase = Paused { kind: Tech, endsAt, applicant },比赛计时冻结,技术暂停独立倒计时 5:00。
  • 用户端/直播:PausedOverlay 显示"技术暂停 · 剩余 04:12",不遮挡血条与计时。
  • 结束:
    • 手动「恢复比赛」→ 3-2-1 倒数 3 s(Resuming 过渡态)→ Running。
    • 05:00 到点 → 状态 PausedTimeout,提示"技术暂停已超时",按钮变成「恢复」/「判负」二选一,不自动判负(规则没写超时即判负)。
  • 录制不停(证据链需要),但写入 bookmark 事件;视频文件里保留暂停期间的画面。
  • 暂停期间的命中:硬件照常上报,客户端如实显示并把事件标红(不隐藏、不改血量)。理由见 R1/Q3——客户端没有写硬件的能力,做"冻结基线"只会制造两份互相矛盾的血量。

3.4 计时 / 比分

两个不同的 3 分钟,界面必须分开写

阶段 时长 文案
赛前准备 3:00 准备 02:41(浅色,不计入比赛)
单局比赛 3:00 比赛 02:41(主色,进入最后 30 s 变红)
技术暂停 5:00 技术暂停 04:12(青色)

状态机

Idle ──start_round──▶ Running ──pause──▶ Paused ──resume──▶ Running
  ▲                      │                  │
  │                      ├──hp=0 / timeout──▶ Ended(by=auto)
  │                      └──forfeit─────────▶ Ended(by=referee)
  └──reset/next_round─────┴──────────────────┘

Ended(timeout 且血量相等) ──▶ AwaitingTiebreak ──set_tiebreak──▶ Ended(by=referee, reason=tiebreak)

到点判定(严格照手册四条):

  1. 血量先减为 0 的一方落败 → 已有 resolve_outcomes,直接复用。
  2. 3 分钟耗尽且无人归零 → 血量多者胜。
  3. 双方血量均等 → 不以"平局"结束,进入 AwaitingTiebreak,由裁判选择"本局最快开始移动的一队"。
    • 客户端没有运动遥测(协议里没有位置/速度),所以只能人工录入:set_tiebreak { winner: 0|1 }。
    • 这条必须显眼提示裁判"需要人工判定",不能默认算平局,否则会静默改变比赛结果。
  4. 两边同时归零 → Draw(现有 MatchOutcome::Draw 逻辑保留,仅用于显示)。

比分:bestOf ∈ {1,3,5}(对应手册 BO1/BO3/BO5),score: [u8;2],history: Vec<RoundResult>。先到 ceil(bestOf/2) 局的一方赢下系列赛,界面显示 2:0 · 系列赛结束。

计时实现(后端为准):

pub struct MatchClock {
    started: Option<Instant>,
    frozen_elapsed: Duration, // 暂停累计
    limit: Duration,
}
impl MatchClock {
    fn remaining(&self) -> Duration;
    fn pause(&mut self); fn resume(&mut self);
}

在 AppState::tick()(5 Hz)里推进;Snapshot.match.remaining_ms 为权威值;Snapshot.now_ms 用于前端插值。不允许前端 setInterval 自己减。

3.5 回放

入口:Ended 后结果卡 →「回放本局」;或 Dock 的「录制与回放 → 按局分组」。

布局

┌ 回放 · 2026-11-01 第2局 红 vs 蓝 ─────────────── [导出] [返回] ┐
│ ┌──────────────────────┐ ┌──────────────────────┐              │
│ │ 红方录屏             │ │ 蓝方录屏             │              │
│ └──────────────────────┘ └──────────────────────┘              │
│ ▶ ━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 01:12/03:00  │
│   ⚑ 00:00 开始 · ● 00:41 命中 · ⏸ 01:12 技术暂停 · ⚑ 02:33 判负 │
└────────────────────────────────────────────────────────────────┘
  • 两个 <video> 共用主时钟(以 A 为主,B 每秒校正一次,偏差 > 120 ms 才 seek,避免抖动)。
  • 事件时间轴可点击跳转:currentTime = (event.atMs - roundStartAtMs) / 1000。
  • 快捷键:空格 播放/暂停、←/→ ±1 s、Shift+←/→ ±5 s、M 打标记。
  • 截图:现有 api.screenshot(camera) 只能截最新一帧,不能截回放帧。需要新增 save_png { data: Vec<u8>, name: String }(限制写入 recordings_dir/shots),前端用 canvas.drawImage(video) + toBlob 取帧。
  • 录制文件命名建议:<date>_<round>_<红队>_vs_<蓝队>_<cameraText>.mp4;若比赛开始时自动开启录制,需要给 Recorder::start 增加一个 label: Option<&str>。
  • 录制起停由 match_start/end 发出事件,是否自动则看用户配置(默认自动开、自动停;退出程序仍走现有 stop_all 收尾)。

4. 直播 UI:安全区与包装层

4.1 基准画布与安全区

基准 1920×1080(16:9)。相机是 640×480(4:3),所以直播层必须自己决定是加信箱还是裁切,不能指望画面填满。

区域 距边距 1080p 像素 内容
画布 — 0 1920×1080
Action safe(93%) 3.5% 左右 67px / 上下 38px 视频、装饰边框
Title safe(90%) 5% 左右 96px / 上下 54px 比分、计时、队名、判定卡
可牺牲区(平台遮挡) — 底部 120px(y ≥ 960)、右侧 60px 只放赞助条/次要字幕;比分、计时、判定卡一律不得进入
视频安全区(画面内 HUD) 3.5% 640×480 上约 22px 横 / 17px 纵 血条与命中提示条

若输出 720p,以上像素值 ×2/3;若必须 4:3 输出(1440×1080),安全区按宽高比各算一次,不要简单套 16:9 的数字。

4.2 分区与坐标(模板 A:双路对称,默认)

y=0    ┌──────────────────────────────────────────────────────────┐
       │  [赛事名/校徽]                          ● LIVE   BO3 第2局 │ 0–108     标题区
y=108  ├──────────────────────────────────────────────────────────┤
       │         ┌────────────────┐  ┌────────────────┐            │
y=150  │  红方   │  视频 800×600  │  │  视频 800×600  │   蓝方     │ 150–750   媒体区
       │  队名   │  (640×480 放大)│  │                │   队名     │ x:140–940 / 980–1780
y=750  ├──────────────────────────────────────────────────────────┤
       │            红 76 ━━━━━━ 02:41 ━━━━━━ 100 蓝                │ 770–930   比分/计时
y=930  ├──────────────────────────────────────────────────────────┤
       │  [事件字幕]                                    [赞助条]    │ 940–1080  可牺牲区
y=960  │ - - - - - - - - - 平台遮挡区上沿(不放关键信息) - - - - - │
y=1080 └──────────────────────────────────────────────────────────┘
  • 媒体区左右各留 140px:其中 100px 给队名,剩余给队伍色条(--team-0/1 4px 竖条)。
  • 比分/计时带固定 y 770–930,底边 930 < 960,永远不进平台遮挡区;判定卡必须在 title safe 内(x 96–1824,y 54–1026)。
  • 计时居中,字号 --fs-clock;比分 --fs-score。
  • 4:3 → 800×600 是 1.25× 放大,VGA 源放大后仍可读;不要裁切(裁切会切掉装甲板或场地边线,判罚举证时没有依据)。

模板 B:主视角 + PIP(一方图传丢失时自动建议)

┌──────────────────────────────────────────────┐
│ 标题条                                        │
│  ┌────────────────────────────────────────┐  │
│  │        主画面 1600×900(居中偏上)      │  │
│  └────────────────────────────────────────┘  │
│                    ┌──────────┐               │
│                    │ PIP 480×360│  ← 右下,避开平台遮挡区
│  比分板            └──────────┘               │
└──────────────────────────────────────────────┘

模板 C:待机/准备(Idle/Ready,或信号中断)

  • 中央:赛事名 + 双方队名 + 准备 03:00(大号)+ "信号恢复中"(画面缺失时显示)。
  • 背景:--bg-void + 2% 噪点 + 细网格(background-image: repeating-linear-gradient,静态,不滚)。
  • 永不显示纯黑屏(黑屏会被平台判为离线)。

模板 D:判定(Ended)

  • 中央判定卡:红方胜 / 本局判负(--fs-display)+ 理由(设备故障/超出时间/血量归零)+ 比分。
  • 卡背景 rgba(8,8,12,.72) + 1px 亮边 + 切角,不做全屏红闪。

4.3 包装层结构(z 序)

z0  BackgroundLayer    底色 + 静态网格 + 噪点
z1  MediaLayer         双路 VideoSurface(复用现有组件,crosshair=null)
z2  HudLayer           画面内血条(可选显示,直播默认显示)
z3  LowerLayer         比分板 / 计时 / 队名 / 事件字幕
z4  AlertLayer         暂停 / 判负 / 判定卡
z5  BrandLayer         赛事名、校徽、赞助、LIVE 徽标
z6  GuideLayer         安全区参考线(仅预览,输出时 display:none)

实现要点:

  • BroadcastStage 用 1920×1080 的绝对坐标系 + transform: scale(min(vw/1920, vh/1080)),transform-origin: top left。这样所有设计稿坐标直接写进 CSS,不用 % 心算。
  • 参考线由 GuideLayer 画(outline: 1px dashed),受 props.guides 控制;全屏/输出时自动关闭。
  • 视频源仍用 VideoSurface(ViewTransform 固定 IDENTITY_VIEW),只是容器换成 800×600 的固定框——不要为直播再写一套 canvas 绘制。

4.4 输出方式(推荐路径与坑)

方案 做法 评价
A(推荐) 同一窗口切 broadcast 视图 + F11 全屏;OBS「窗口采集」+ 裁到 16:9 零新权限、零新进程,最稳
B 第二个 WebviewWindow(label: broadcast,无边框、置顶) 需要 core:webview:allow-create-webview-window;双窗口抢 GPU,P2 再做
C NDI / 虚拟摄像头 明确不做(依赖重、跨平台成本高)

坑(按重要性):

  1. WebView 背景:transparent: true 在 Windows WebView2 与 macOS WKWebView 行为不一致,直播场景下透明边缘会出黑边。结论:用不透明底色 --bg-void 铺满,不做透明。
  2. 低帧画面 + 高帧包装:视频是 JPEG 序列,帧率取决于固件配置(commands.rs 的注释里记着"The sensor 实测 22 fps",target/firmware/BUILD-INFO.json 里 vga30 档实测 29.7 fps);无论哪个值都远低于包装层的 60 fps,动效会"飘"在画面上。所以包装层只做状态迁移动画,不做连续动画(与 1.6 的禁止清单一致)。
  3. 纯黑编码:#000 在 H.264 里容易 banding,用 #07080a + 2% 噪点。
  4. OBS 窗口采集会采到鼠标:全屏时 cursor: none(immersive 状态负责)。
  5. 窗口标题:直播窗口标题里不要出现队名/端口等内部信息(setTitle 现在会写身份;broadcast 视图下改成 cam-desktop — 直播)。
  6. 分辨率自适应:transform: scale() 后文字仍清晰(矢量),但如果 OBS 缩放窗口,字号会变成非整数像素 → 建议 OBS 采集时窗口保持 1920×1080 或 1280×720。

4.5 直播视图的安全边界(必须做)

  • broadcast 视图不渲染:设置按钮、维护入口、cameras[].addr(IP)、stream_info.url(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
  • 所有管理命令在广播态禁用(不是隐藏,是 disabled + aria-hidden,防止误触与 Tab 聚焦)。
  • 演示模式(demo)在直播里显示红色角标"演示画面",与现有 tag--demo 同一语义(现在只在 TopBar,直播态必须另有一处)。
  • 断流时 StandbyCard 明确写"信号中断 · 正在重连",不显示上一帧冻结画面冒充直播(冻结画面会被观众当成"画面卡住")。

5. .zip 固件包:格式、打包、读取

5.1 为什么改成 zip

现状(目录) zip
选目录,容易选错上一层/子目录;缺文件要到"生成计划"才报错 单文件,扩展名即契约;打开即校验
分发要压缩后发,现场再解压(解压位置随便) 分发即成品,不用解压
无法整包校验(只校验 app/分区表) 可做整包 SHA256,便于赛后追溯"烧的是哪一版"
无版本标识 文件名 + manifest 双版本

5.2 包格式(保持 format: 1,不要翻版本)

camfw-esp32s3-vga30-fb3-realtime-tx15-4794bed-20261024.zip
├── bundle.json          ← 必需,沿用现有 BundleManifest(format: 1)
├── esp32s3-app.bin      ← 必需,文件名由 bundle.json 的 "app" 指定
├── partition-table.bin  ← 必需,必须 0xC00 字节
├── bootloader.bin       ← 可选(客户端默认不写)
├── BUILD-INFO.json      ← 可选,仅用于展示版本/构建信息
└── SHA256SUMS.txt       ← 可选,人读用;客户端以 bundle.json 里的 sha 为准

理由:format: 1 + 新增可选字段,旧客户端读目录包照旧,新客户端两种都能读;翻成 format: 2 只会让"旧客户端打不开新包",而我们本来就要同时支持两种来源,没有收益。

bundle.json 建议新增一个可选块(deny_unknown_fields 只出现在其他结构上,BundleManifest 没有,可直接加字段):

{
  "package": {
    "kind": "zip",
    "createdAt": "2026-10-24T18:02:11+08:00",
    "file": "camfw-esp32s3-vga30-fb3-realtime-tx15-4794bed-20261024.zip",
    "sha256": "…(zip 整包)",
    "entryCount": 5
  }
}

注意:BundleManifest 目前没有 deny_unknown_fields,加字段不会破坏旧客户端;但不要给它会读的字段改名。

5.3 校验规则(客户端必须在读的时候就挡住)

项 规则 失败文案(面向现场)
容器 目录 / *.zip / bundle.json;zip 需 magic PK\x03\x04 "不是有效的固件包(既不是 .zip,也没有 bundle.json)"
条目路径 拒绝 ..、绝对路径、盘符、\\ 开头、UNC、空名、重复名、符号链接 "固件包内有非法路径:…"
条目数 ≤ 32 "固件包条目过多"
单文件 bundle.json ≤ 64 KiB;app ≤ appPartitionSize;分区表 = 0xC00 "bundle.json 过大 / App 超过分区大小"
解压上限 总解压 ≤ 8 MiB(App 现 1.09 MiB) "固件包解压后过大"
完整性 zip CRC(zip crate 自动校验)+ appSha256 / partitionTableSha256 "App SHA256 不匹配:期望 …,实际 …"
版本 format == 1 "不支持的 bundle 格式版本:N"
设备 chip / flashSize / Secure Boot / Flash Encryption(现有 check_bundle_against_device) 沿用现有文案

5.4 编译后打包环节

现状链路

scripts/build.ps1|build.sh  →  cargo build --release --locked
                              └─ target/xtensa-esp32s3-espidf/release/esp32(ELF)
target/firmware/            →  esp32s3-app.bin / partition-table.bin / bootloader.bin
                              BUILD-INFO.json / bundle.json / SHA256SUMS.txt(人工或脚本产出,未进 build)

新链路(在编译之后加一节,不改编译本身)

build.sh|build.ps1 [--package]
  ├─ cargo build --release --locked
  └─ python scripts/package_firmware.py
       ① 定位 App 镜像
          优先 <esp-idf-sys out/build>/esp32s3-app.bin
          否则 espflash save-image --chip esp32s3 --format esp-idf <elf> <out>/esp32s3-app.bin
          否则回退 target/firmware/esp32s3-app.bin(mtime 早于 ELF 则警告"可能是旧镜像")
       ② 复制 partition-table.bin(必须 0xC00)、可选 bootloader.bin
       ③ 生成 BUILD-INFO.json(git rev / dirty / 时间 / variant / rustc / idf)
       ④ 生成 bundle.json(复用 make_bundle.py 的逻辑)
       ⑤ 打包 zip(deflate, 固定 mtime, 固定条目顺序)
       ⑥ 写 <zip>.sha256 + 打印摘要

产物

target/firmware/
├── camfw-esp32s3-<variant>-<sha7>-<yyyymmdd>.zip
├── camfw-esp32s3-<variant>-<sha7>-<yyyymmdd>.zip.sha256
├── bundle.json            (保留,供旧的"选目录"路径与排障)
└── BUILD-INFO.json

可复现性:固定 zip 条目顺序、固定 mtime(用 SOURCE_DATE_EPOCH 或构建时间)、固定 deflate level(9),保证同一份输入产出同一个 SHA256——这样"两个 U 盘里的包是不是同一版"可以一眼核对。

接入点

  • scripts/build.ps1:加 [switch]$Package,构建成功后调用 python scripts/package_firmware.py(找不到 python 时给出明确提示,不静默跳过)。
  • scripts/build.sh:--package 透传(现有 "$@" 已经把参数传给 cargo,要单独解析 --package 并从 cargo 参数里剔除)。
  • scripts/make_bundle.py:保留(不动它的 CLI),package_firmware.py 以 subprocess 调用它,避免两套 manifest 生成逻辑。
  • CI:先不进。加一个固件构建 job 要装 Xtensa 工具链 + ESP-IDF(体积大、耗时长),而决赛在 11-01;等赛后再评估。若要做,放 workflow_dispatch 手动触发。

5.5 客户端读取流程(maintenance.rs)

命令签名不变(前端只改选文件对话框):

#[tauri::command]
pub fn maintenance_load_bundle(path: String) -> Result<BundleInfo, String>

内部改动

enum BundleSource { Dir(PathBuf), Manifest(PathBuf), Zip(PathBuf) }

fn sniff(path: &Path) -> Result<BundleSource>;

fn load_bundle(path: &Path) -> Result<BundleLoaded> {
    match sniff(path)? {
        BundleSource::Zip(zip) => load_from_zip(&zip),
        dir_or_manifest => load_from_dir(dir_or_manifest),  // 现有逻辑,原样保留
    }
}

fn load_from_zip(path: &Path) -> Result<BundleLoaded> {
    let mut archive = zip::ZipArchive::new(File::open(path)?)?;
    let manifest = read_entry_json::<BundleManifest>(&mut archive, "bundle.json", 64 << 10)?;
    // 路径规范化 + 大小上限 + CRC 由 zip crate 校验
    let app = read_entry_limited(&mut archive, &manifest.app, manifest_parse_app_limit(&manifest))?;
    let table = read_entry_limited(&mut archive, &manifest.partition_table, 0xC00)?;
    // 之后与目录分支完全共用:SHA256 校验、尺寸校验、bootloader 校验
    build_loaded(manifest, app, table, SourceInfo::Zip { path, sha256 })
}

要点:

  1. 不落地解压:BundleLoaded.app 本来就是全内存的 Vec<u8>,直接复用;只在需要备份时把 bundle.json 文本写进备份目录(fs::copy 对 zip 路径不适用,改成写文本)。
  2. BundleInfo 新增字段(#[serde(rename_all = "camelCase")]):
pub source_kind: String,        // "zip" | "dir"
pub source_path: String,        // zip 文件路径(原 path 字段保留:展示用)
pub source_sha256: Option<String>, // zip 整包 sha256
pub entry_count: Option<u32>,

前端 BundleInfo 同步加这三个字段(desktop/src/lib/maintenance.ts)。

  1. 依赖:zip = { version = "2", default-features = false, features = ["deflate"] }(约新增 flate2/miniz_oxide/crc32fast)。必须同时提交更新后的 Cargo.lock,否则 CI 的 cargo test --locked 会失败。

  2. 错误信息统一成"给现场的人看的一句话"(现有风格),并且区分"包坏了"与"包是好的、设备不对"——后者不要让人去重新拷包。

前端改动

  • selectBundleDirectory() → selectBundlePackage():
    open({ multiple: false, filters: [{ name: "固件包", extensions: ["zip"] }] })
    
    目录选择收进"高级 / 兼容旧包(目录)"。
  • 新增 FirmwareDropZone:拖拽 .zip 进来(Tauri v2 需窗口 dragDropEnabled: true,默认即为 true;实现时用 getCurrentWebview().onDragDropEvent 验证一次)。
  • 展示:包名 / 版本 / chip / flash / App 大小 / 分区大小 / 整包 SHA 前 12 位 / 来源(zip/目录)。
  • 维护页步骤 ② 的三态:未选 → 已加载(打勾)→ 校验失败(红框 + 一句话原因 + "重新选择")。

CLI 兼容

--maintenance-plan <串口> <固件包> 现在只接受目录;改为接受 zip 或目录,帮助文本同步更新(main.rs 的 USAGE)。


6. 校内赛规则要点与判罚流程

6.1 规则事实(来自官方手册,不改写)

条目 规定 来源
队伍 每队严格 3 人,每人限一队;16 队取得比赛资格 赛事概览
赛制 BO1 / BO3 / BO5;16→8 双败 BO3,8→4 双败 BO3,总决赛 BO5 对抗赛与奖项
红蓝方 每场编号小者为红方,编号大者为蓝方 同上
检录 尺寸 / 遥控运动 / 发射 / 裁判系统安装 / 裁判系统(含图传)运作;通过后不得再烧录或改硬件直至准备阶段 同上
赛前准备 3 分钟;双方把机器人放到与装甲板颜色相同的部署点;确认裁判系统与操作位图传客户端连接、遥控连接正常 同上
技术暂停 5 分钟;任意一方遇突发问题向场边裁判申请并说明理由,主裁判批准后开始;期间双方均可检修 同上
血量 初始 100;每发有效攻击扣 3 点 同上
单局时长 3 分钟 同上
胜负 ①血量先归零者负 ②时间耗尽血量多者胜 ③双方血量均等则以本局最快开始移动的一队获胜 同上
装甲板 每车 2 或 4 块,其中一块为主装甲板;检测周期 100 ms;任一触发后全车 100 ms 静默期;供电 5 V ± 1 V;UART 可向主控发键鼠指令 裁判系统
图传 1 个模块,回传第一人称画面到裁判系统客户端屏幕;供电 3.3 V(与装甲板 5 V 不同) 同上
日程 10-25 适应性训练;11-01 决赛(9:00–11:00 / 14:00–18:00) 赛事概览

6.2 对客户端的直接影响(不是装饰性结论)

  1. 血量必须同时有数字和格子。100 点 / 5 格 = 20 点/格,而单发只扣 3 点——一格的粒度相当于 6.7 发。只给格子,操作手无法判断"再中两发就死"。→ 保留 showNumbers 默认开启,且数字 ≥ 18px。
  2. 100 点 = 34 发(33 发后剩 1 点)。裁判端可以显示"已中 33 发"这类辅助读数(100 - hp) / 3),但不要把它当成权威值——静默期内的连击会被合并。
  3. 100 ms 静默期决定命中动效下限:同一次攻击可能在 4 块板上各上报一次,HealthTracker 按血量差派生,已经能合并;UI 侧不要为每个 board 事件各弹一次伤害数字(否则一次命中会闪 4 下)。
  4. 装甲板 2 块也要能显示:ArmorBoardDto.reportsAllPlates / platesSeen 已存在,界面在 platesSeen < 4 时显示"仅报 N 块",不要显示 4 个空槽让人以为板子坏了。
  5. "最快开始移动"没有数据源。图传只给画面,协议里没有位移/速度;因此这条规则只能人工判定(见 3.4)。不要试图从画面里推算。
  6. 赛前 3 分钟是"确认客户端连上了"的窗口,所以用户端需要一个准备检查卡:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 Ready 阶段自动出现,Running 后消失。
  7. 红蓝配色固定:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 teamColor 已是这个语义,只改色值)。

6.3 判罚流程(客户端可执行的部分)

                     ┌──────────────────────────────┐
                     │ 单局结束条件                  │
                     └──────────────┬───────────────┘
        ┌───────────────────────────┼───────────────────────────┐
        │                           │                           │
   血量归零                     时间耗尽                    人工裁定
   (自动,双方一致)           (自动比血量)              (裁判操作)
        │                           │                           │
        │                    ┌──────┴──────┐          ┌─────────┼─────────┐
        │                 血量不等      血量相等      设备故障   违规/弃权  平血仲裁
        │                    │              │             │         │         │
        ▼                    ▼              ▼             ▼         ▼         ▼
   Ended(by=auto,      Ended(by=auto,  AwaitingTiebreak  ForfeitDialog(长按 1s + 理由)
   reason=knockout)    reason=timeout) → set_tiebreak      → Ended(by=referee)
                                            │
                                            ▼
                                   Ended(by=referee, reason=tiebreak)

每一步都必须留痕

{
  "id": "evt-0007",
  "atMs": 1761998400000,
  "actor": "referee",
  "action": "forfeit",
  "loser": 0,
  "reason": "equipment",
  "note": "图传模块断电",
  "hp": [76, 100],
  "remainingMs": 161000,
  "round": 2,
  "score": [1, 0]
}
  • 落盘:<app_data>/matches/<yyyy-mm-dd>/<matchId>/events.ndjson(追加写,不覆盖),另有 match.json 存最终结果。
  • 导出:match_export 把 result.csv 与 events.ndjson 放到录制目录,供组委会归档。
  • 更正:match_undo(5 s 内)或 match_reopen_round(带理由);任何更正都新增记录,不删除原记录。

规则缺口(手册未定义,客户端不臆造)

缺口 客户端默认行为 需要谁定
警告/红黄牌累计 不实现累计逻辑,只有"理由 + 备注" 组委会
判负后是否重赛 只记录 reason,不自动重开 主裁判
平血"最快开始移动"的判定依据 人工选择,必填备注 主裁判
设备故障(图传中断)导致无法比赛 人工裁定:技术暂停 / 判负 / 重赛 主裁判
技术暂停超时(>5 分钟) 提示超时,不自动判负 主裁判

手册 1.0.0 的更新日志提到"添加了详细的扣血、判罚规则与单局时间",但当前公开页面只给出了 6.1 表列出的内容(扣血 3 点、技术暂停 5 分钟、四条胜负条件)。如果组委会另有一份判罚细则(红黄牌、犯规等级),需要提供原文,否则界面不写死任何罚则。


7. 实施优先级、风险与待确认问题

7.1 时间线(关键约束:11-01 决赛)

日期 里程碑 交付物
10-19 ~ 10-21 M0 + M1 视觉基线与外壳 tokens.css、AppShell、F11、TopBar/StatusBar 换肤、用户端/裁判端布局不回归
10-21 ~ 10-23 M2 比赛状态机 + 比赛条 core/match.rs、match_* 命令、MatchBar/Clock/ScoreBoard、事件落盘
10-23 ~ 10-24 M3 判负 / 技术暂停 ForfeitDialog、TechPauseDialog、覆盖层、撤销与审计
10-24 ~ 10-25 M4-lite 直播最小可用 BroadcastPage(模板 A + D)、安全区、F11 输出、StandbyCard
10-25 适应性训练(冻结线) 只允许修 bug,不再进新功能
10-26 ~ 10-27 M6 .zip 固件包 package_firmware.py、maintenance_load_bundle 支持 zip、维护页步骤 ②
10-27 ~ 10-29 M5 回放(双路同步)+ M7 维护页 Stepper 按局分组、事件时间轴、5 步流程
10-29 ~ 10-31 M8 无障碍/性能/回归/演示彩排 prefers-reduced-motion、低动效开关、跨平台验证、演练脚本
11-01 决赛 —

现实是:M1 的"整个界面重构"和"11-01 前必须能用"是冲突的。建议把视觉重构限定在"token 替换 + 布局调整",不动 VideoSurface/stream.ts/recorder 这些已稳定的链路代码。

7.2 优先级矩阵

优先级 事项 理由 依赖
P0 F11 全屏 + 沉浸模式 + 快捷键表 现场投影/操作位必需,成本低 capabilities 权限
P0 比赛条(计时/比分/局次)+ 状态机 没有它就没有"比赛",只有"两个画面" core/match.rs
P0 一键判负(长按 + 理由 + 审计 + 撤销) 用户明确要求;现场一定会用到 M2
P0 技术暂停(申请/批准/5 分钟/恢复) 规则明文;复现手册流程 M2
P0 视觉基线(token/顶栏/血条/按钮/表格) "按 Division 重构"的主诉求;纯 CSS,风险低 —
P0 直播安全区 + 模板 A/D + 待机卡 直播包装的最小闭环 状态机
P1 .zip 固件包读取 + 打包脚本 用户明确要求;影响赛前烧录效率 zip 依赖
P1 维护页 Stepper 化 与 zip 同批,避免两次改同一页 M6
P1 装甲板表双队化 + 迷你血条 裁判现场核对效率 —
P1 事件面板 + 导出 判罚留痕,赛后申诉依据 M2
P1 回放双路同步 + 事件定位 判定后复盘 录制 label
P2 直播模板 B(PIP)/ C 细化 / 赞助位 观感提升,非比赛必需 —
P2 跨端同步(裁判判定 → 用户端) 需要协议 + 信任模型(见 R2) 协议扩展
P2 多显示器全屏、第二窗口输出 现场单屏也能用 —
P2 系列赛完整状态(BO5 编排/对阵表) 赛程由人排,客户端只记比分 —
P3 低动效开关、字体包、深色/浅色 可选 —

7.3 风险

# 风险 概率/影响 缓解
R1 客户端判负 ≠ 硬件血量:协议只有上行(MsgType 1–5),没有下行控制,判负后对方装甲板仍报 100 高 / 高 界面明确写"只改变判定与记录,不改变硬件血量";不伪造 HP;若要硬件生效必须另做固件+协议(Q2)
R2 跨端不同步:裁判端判负/暂停,操作位用户端看不到 高 / 高 v1 只作用于裁判端与直播;v2 用 5601 广播新 MsgType(见下)并加共享码签名;现场先用对讲机兜底
R3 Tauri 全屏权限/快捷键冲突(macOS ⌃⌘F 与系统全屏叠加) 中 / 中 显式加两个 permission;preventDefault;按 400 ms 去抖;三平台各验一遍
R4 zip 恶意/畸形输入(zip slip、zip bomb) 中 / 高 路径规范化白名单 + 条目数/大小上限 + 只读内存 + CRC
R5 新增 zip 依赖打断离线/锁文件构建 中 / 中 提交 Cargo.lock;CI 用 --locked 会立刻暴露;备选:只支持 store(不压缩)条目 + 自研 150 行 reader
R6 动效拖慢低配裁判机(双路 canvas + CSS 动画) 中 / 中 只用 transform/opacity;禁连续动画;低动效开关;实测 30 min 不掉帧
R7 录制与比赛状态机耦合(自动起停误伤手动录制) 中 / 中 状态机只发事件;"随比赛自动录制"做成开关,默认开
R8 直播泄密(IP/token/密码入口出现在画面里) 中 / 高 broadcast 视图白名单渲染;不渲染 addr/url;只读态;截图核对清单
R9 规则变更/规则缺口(平血、超时、罚则) 中 / 中 规则参数可配置(MatchConfig);缺省行为全部"人工裁定 + 记录"
R10 中文排版回归(字距、字重、字号) 中 / 低 沿用"中文不加字距";只换 token 不动排版规则;逐页截图对比
R11 光敏/无障碍(红闪、脉动) 低 / 中 ≤ 3 Hz、面积 < 25%、prefers-reduced-motion 降级、低动效开关
R12 1690 行 CSS 重构引入视觉回归 高 / 中 分两步:先"加 token + 保留旧类名"(零风险),再逐组件替换;每步截图对比

R2 的可选技术方案(若用户要跨端同步)

// protocol/src/lib.rs 新增(不改动已有枚举值)
pub enum MsgType { Video = 1, Subscribe = 2, Hello = 3, Heartbeat = 4, ArmorHealth = 5, MatchControl = 6 }

// 载荷:version(1) op(1) team(1) seq(4) issued_unix_ms(8) mac(16)
// op: 1=pause 2=resume 3=forfeit 4=reset 5=state-sync
  • 裁判端以 REFEREE_ID 广播到 5601;用户端在装甲板监听里增加一个 MsgType::MatchControl 分支(不改相机订阅逻辑)。
  • 安全:局域网里任何人都能发 UDP,所以必须带共享码派生 MAC(检录时裁判端生成 6 位码,双方在客户端输入),否则队伍可以伪造"对方判负"。
  • 固件不受影响:装甲板固件只认 MsgType::ArmorHealth,未知类型按现有 decode_header 逻辑返回 None 被忽略;但上线前必须在实机上验证一次"未知 MsgType 不会让板子异常"。

7.4 需要用户确认的问题

比赛语义(最关键,答案决定方向)

  1. Q1 客户端"一键判负"要不要让用户端(操作位)也看到?如果需要,是否接受"局域网广播 + 共享码"的方案(R2)?
  2. Q2 判负/技术暂停是否需要改变装甲板硬件血量(例如判负后立即把对方 HP 打到 0)?这需要改固件 + 协议,属于另一个工程。
  3. Q3 技术暂停期间的命中:如实显示并标红(当前建议),还是冻结/忽略?谁来确定"暂停开始"的时间点——客户端按钮,还是场边裁判口头通知后由裁判端按下?
  4. Q4 平血时"本局最快开始移动的一队"由谁录入?是否接受"裁判端手动点选一方 + 必填备注"?
  5. Q5 是否需要 BO3/BO5 的系列赛比分与"第 N 局"编排?队伍名/编号从哪来(手工输入 vs 导入名单)?

直播

  1. Q6 输出分辨率与帧率(1080p30 / 720p60?)、推流平台(B 站 / 抖音 / 校内大屏 / 录播)?安全区按哪个标准?
  2. Q7 是否用 OBS 窗口采集(推荐,方案 A)?还是需要独立输出窗口 / 透明背景(方案 B)?
  3. Q8 包装层需要哪些固定素材:赛事名、校徽、赞助商 LOGO、选手照片/队徽?由谁提供、什么格式?

固件包

  1. Q9 .zip 是否需要签名(Ed25519)?如果需要,私钥谁保管、公钥怎么随客户端发布?
  2. Q10 是否必须同时兼容旧的目录包(检录现场可能只发了目录)?我建议兼容(成本几乎为零)。
  3. Q11 打包环节只做本地脚本,还是要进 CI?(进 CI 需要 Xtensa 工具链,赛前不建议)
  4. Q12 zip 里的 partition-table.bin 仍只是"模板 + 校验"(客户端实际写的是设备当前分区的 patch 版本)——这个语义要不要在维护页显式写出来?

交互与现场

  1. Q13 用户端是否允许 F11 全屏?是否记忆"上次是否全屏"?(操作手误触退出全屏会很慌,建议:允许,但全屏状态落盘,且退出时有 1.5 s 提示条)
  2. Q14 录制是否随比赛状态自动起停?文件名要不要带"红队 vs 蓝队 + 第 N 局"?
  3. Q15 目标平台优先级:现场是 Windows(NSIS)?macOS 只用于开发?(desktop/.cargo/config.toml 的默认 target 是 aarch64-apple-darwin,Windows 构建要显式 --target)

附录 A:热键总表

键 范围 动作 备注
F11 / ⌃⌘F / Alt+Enter 全局 全屏切换 400 ms 去抖
Esc 全局 关最上层(对话框 → 抽屉 → 全屏) 逐层关闭
Ctrl/Cmd+K 全局 命令面板(设置/维护/直播/回放) 替代主界面按钮
Ctrl/Cmd+Shift+B 全局 切换直播视图 仅裁判端
Ctrl/Cmd+Enter 裁判端 开始本局 phase ∈ {Idle, Ready}
Ctrl/Cmd+Shift+P 裁判端 技术暂停 弹对话框
Ctrl/Cmd+Shift+F 裁判端 判负 仍需长按确认
Ctrl/Cmd+1 / +2 裁判端 选中红/蓝(对话框内) —
Space 仅回放 播放/暂停 比赛中禁止
←/→、Shift+←/→ 仅回放 ±1 s / ±5 s —
M 仅回放 打标记 写入事件
Tab(按住) 全局 显示当前视图快捷键 半透明面板
Ctrl/Cmd+R 全局 刷新快照(现有 refresh) —

附录 B:变更文件清单

后端(Rust)

文件 改动
desktop/core/src/match.rs(新) MatchState / MatchPhase / MatchEvent / MatchConfig / 状态迁移 + 单测
desktop/core/src/lib.rs pub mod match;
desktop/core/src/snapshot.rs Snapshot 增加 now_ms、match: MatchView;MatchView 等 DTO
desktop/src-tauri/src/server.rs WireMsg 增加 MatchEvent(与 Hit 同一条推送路径)
desktop/src-tauri/src/state.rs 持有 Mutex<MatchState>;tick() 推进;snapshot() 填充;match_* 方法;审计写盘
desktop/src-tauri/src/commands.rs match_configure/start/pause/resume/end/undo/next_round/set_tiebreak/export;require_referee();save_png
desktop/src-tauri/src/main.rs 注册新命令;USAGE 更新(zip 包路径)
desktop/src-tauri/src/maintenance.rs BundleSource / load_from_zip / 路径与大小校验 / BundleInfo 新字段 / 备份写文本
desktop/src-tauri/src/recording.rs start(..., label: Option<&str>),文件名带局次
desktop/src-tauri/capabilities/default.json 加 core:window:allow-set-fullscreen、core:window:allow-is-fullscreen(直播第二窗口再加 core:webview:allow-create-webview-window)
desktop/src-tauri/Cargo.toml zip 依赖;同时提交根目录更新后的 Cargo.lock

前端(React/CSS)

文件 改动
styles/tokens.css(新) 1.2–1.6 的全部 token
styles/global.css 保留结构,按节替换为 token;拆分可选
App.tsx AppShell + view 状态 + 快捷键注册 + 全屏 hook
components/TopBar.tsx 工具收纳、直播入口、状态徽标
components/match/*(新) 比赛条、计时、比分、判负/暂停对话框、覆盖层、事件面板
components/broadcast/*(新) 直播画布与包装层
components/replay/ReplayView.tsx(新) 双路同步回放
pages/TeamPage.tsx 计时条、覆盖层、准备检查卡、底部提示淡出
pages/RefereePage.tsx MatchBar + 可拖拽 Dock + 双队装甲板 + 事件页签
pages/MaintenancePage.tsx Stepper 重构 + zip/拖拽
pages/BroadcastPage.tsx(新) 直播视图
components/{HealthBar,ArmorTable,MediaPanel,SettingsPanel}.tsx 尺寸/布局/新段
lib/types.ts MatchView、nowMs、WireMsg.matchEvent、BundleInfo 新字段
lib/ipc.ts match* 命令、savePng
lib/maintenance.ts selectBundlePackage、新字段
lib/store.ts ui、events、clockSkewMs
lib/dev-preview.ts 合成 match 状态(否则浏览器预览会白屏)

脚本/文档

文件 改动
scripts/package_firmware.py(新) 编译后打包(5.4)
scripts/build.ps1 / build.sh --package 开关
docs/firmware-maintenance.md zip 格式、打包命令、读取流程
docs/desktop-ui-redesign.md 本文

附录 C:验收标准(可测量)

项 标准 验证方法
全屏 F11 切换 < 350 ms;切 10 次不卡死、不丢键 手工
计时 3 分钟误差 < 200 ms;切窗口/最小化不失速 与手机秒表对比 3 次
判负 必须长按 ≥ 1000 ms;< 1000 ms 不生效;撤销窗口 5 s 自动化 + 手工
技术暂停 申请表单不勾"已批准"无法开始;5:00 倒计时准确 手工
直播 安全区内无越界元素;不出现 IP/token/设置入口 截图核对清单
.zip 5 MiB 包加载 < 300 ms;篡改 1 字节被拒且文案指出是哪个文件 单测 + 手工
动效 30 min 双路运行,主线程不掉帧(≥ 25 fps) 性能面板
无障碍 关键文字对比 ≥ 4.5:1;prefers-reduced-motion 下无位移动画 对比度工具 + 开关
回归 视频/血量/装甲板/录制/维护五项原功能全部可用 回归清单

附录 D:不做的事(明确边界)

  1. 不做网络 OTA / 设备自升级(与现有 docs/firmware-maintenance.md 的边界一致)。
  2. 不做修改固件源码;打包只组装已有产物。
  3. 不做从画面推算"移动先后"、命中位置、弹道等规则数据。
  4. 不做直播的透明背景/虚拟摄像头/NDI。
  5. 不做多语言(中文界面为唯一语言,符合约束)。
  6. 不做用户账号/云端同步。