desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名

界面改动都在浏览器预览里逐条核对过(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)通过。
This commit is contained in:
2026-10-14 20:48:12 +08:00
parent fd71b83e3a
commit a93ae065cf
23 changed files with 1144 additions and 239 deletions
+38 -4
View File
@@ -26,6 +26,7 @@
* ```
*/
import { DEFAULT_TEAM_NAMES } from "./format";
import type { StreamClient } from "./stream";
import type {
ConfigView,
@@ -71,7 +72,7 @@ const DEFAULT_HUD: HudView = {
offsetY: 0,
},
grid: { visible: false, style: "thirds", divisions: 6, opacity: 0.22 },
hitFeedback: { flash: true, shake: true, marker: true, intensity: 1 },
hitFeedback: { flash: true, shake: false, marker: true, intensity: 1 },
showZoom: true,
showStats: true,
};
@@ -87,6 +88,8 @@ interface PreviewState {
cameraId: number | null;
armorId: number | null;
team: number;
/** 两边显示的名字,与后端配置里的 `teamNames` 对应。 */
teamNames: [string, string];
mirror: boolean;
unlocked: boolean;
password: string | null;
@@ -101,6 +104,7 @@ const state: PreviewState = {
cameraId: startAsReferee ? null : CAMERA_A,
armorId: startAsReferee ? null : ARMOR_A,
team: 0,
teamNames: ["队伍一", "队伍二"],
mirror: true,
// 预览里直接是已解锁状态,免得每次刷新都要输密码。
unlocked: false,
@@ -156,6 +160,14 @@ function handle(command: string, args: Record<string, unknown>): unknown {
guard();
state.team = Number(args.team);
return configView();
case "set_team_name": {
guard();
const team = Number(args.team);
if (team !== 0 && team !== 1) throw new Error("队伍号只能是 0 或 1");
// 与后端一样:合并空白、按字符截断、留空回落默认名。
state.teamNames[team] = sanitizeName(String(args.name ?? ""), team);
return configView();
}
case "set_role":
state.role = args.role as "team" | "referee";
return configView();
@@ -213,6 +225,7 @@ function configView(): ConfigView {
role: state.role,
roleChosen: true,
team: state.team,
teamNames: [...state.teamNames] as [string, string],
cameraId: state.cameraId,
cameraIdText: state.cameraId === null ? null : hex(state.cameraId),
armorId: state.armorId,
@@ -239,6 +252,13 @@ function recordings() {
}));
}
/** 与后端 `config::sanitize_team_name` 一致:合并空白、按字符截断、留空回落默认名。 */
function sanitizeName(name: string | undefined, team: number): string {
const collapsed = (name ?? "").split(/\s+/).filter(Boolean).join(" ");
const text = collapsed.length > 0 ? collapsed : DEFAULT_TEAM_NAMES[team];
return [...text].slice(0, 12).join("");
}
function hex(id: number): string {
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
}
@@ -288,13 +308,25 @@ function snapshot(): Snapshot {
gapMs: 50,
}));
// 与后端 `cam_core::snapshot::resolve_outcomes` 同一套规则,包括那条不对称:
// 输只看自己,赢要看对手归零**且**自己还活着。
const outcomeFor = (team: number) => {
const mine = state.hp[team];
const theirs = state.hp[team === 0 ? 1 : 0];
if (mine === 0 && theirs === 0) return "draw" as const;
if (theirs === 0 && mine > 0) return "victory" as const;
if (mine === 0) return "defeat" as const;
return null;
};
const healthFor = (team: number) => {
const hp = state.hp[team];
const board = team === 0 ? ARMOR_A : ARMOR_B;
return {
team,
name: team === 0 ? "队伍一" : "队伍二",
name: state.teamNames[team],
available: true,
placeholder: "",
hp,
@@ -302,6 +334,7 @@ function snapshot(): Snapshot {
board,
boardText: hex(board),
critical: hp === 0 || hp <= state.hud.health.lowThreshold,
outcome: outcomeFor(team),
};
};
@@ -326,7 +359,8 @@ function snapshot(): Snapshot {
armorB: state.role === "referee" ? ARMOR_B : null,
manual: false,
},
health: state.role === "referee" ? [healthFor(0), healthFor(1)] : [healthFor(state.team)],
// 两边都给:用户端也要显示对手的血量(画面右上角那一条)。
health: [healthFor(0), healthFor(1)],
recordings: recordings(),
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
};
@@ -377,7 +411,7 @@ export function startPreview(client: StreamClient, emit: (msg: WireMsg) => void)
// 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。
//
// 打空的那一拍只做重置、不出命中("已击毁"的状态要真的能看到),其余每一拍都出一次
// 打空的那一拍只做重置、不出命中(胜负横幅要真的能看到),其余每一拍都出一次
// 命中。早先的写法是 `to = from === 0 ? 100 : ...` 然后按 `to >= from` 判断——那样一旦
// 两块都打空,之后每次都是重置,命中就再也不会出现,盯着界面等闪红会等到超时。
let tick = 0;
+15 -3
View File
@@ -52,9 +52,21 @@ export function fps(value: number): string {
return value.toFixed(1);
}
/** 队伍名。与 Rust 侧的 `team_name` 一致。 */
export function teamName(team: number): string {
return team === 0 ? "队伍一" : "队伍二";
/** 队伍名的兜底值。与 Rust 侧的 `config::DEFAULT_TEAM_NAMES` 一致。 */
export const DEFAULT_TEAM_NAMES = ["队伍一", "队伍二"];
/**
* 队伍名。
*
* 名字是**配置里可以改的**(裁判端能直接编辑),所以显示的地方一律从这里取:散在各处的
* `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,而且不会报错。
*
* `names` 传配置里的两条;缺省或留空时回落到默认名。
*/
export function teamName(team: number, names?: readonly string[] | null): string {
const custom = names?.[team]?.trim();
if (custom) return custom;
return DEFAULT_TEAM_NAMES[team] ?? "队伍";
}
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
+3
View File
@@ -68,6 +68,9 @@ export const api = {
call<ConfigView>("set_role", { role, password: password ?? null }),
setMirror: (mirror: boolean) => call<ConfigView>("set_mirror", { mirror }),
saveHud: (hud: HudView) => call<ConfigView>("save_hud", { hud }),
/** 改一边的队名(0 = 队伍一,1 = 队伍二)。一次只改一边,避免覆盖另一边的改动。 */
setTeamName: (team: number, name: string, password?: string) =>
call<ConfigView>("set_team_name", { team, name, password: password ?? null }),
setRefereeSlots: (slots: {
cameraA: number | null;
cameraB: number | null;
+1 -1
View File
@@ -30,7 +30,7 @@ export interface AppStore {
snapshot: Snapshot | null;
/** 与后端的视频通道是否连着。 */
connected: boolean;
/** 最近一次掉血,界面用它做闪红/抖动。 */
/** 最近一次掉血,界面用它做闪红与伤害数字。 */
lastHit: HitPulseRecord | null;
/** 后端要求弹出解锁框(用户端点关闭窗口时)。 */
confirmQuit: number;
+17
View File
@@ -56,6 +56,12 @@ export interface GridConfig {
/** `cam_core::config::HitFeedbackConfig` */
export interface HitFeedbackConfig {
flash: boolean;
/**
* 已废弃:命中时不再抖动画面。
*
* 字段留着只是为了读得进旧配置文件(Rust 侧 `deny_unknown_fields`,删字段会让写过
* `shake = true` 的配置直接解析失败)。界面不读它。
*/
shake: boolean;
marker: boolean;
intensity: number;
@@ -76,6 +82,8 @@ export interface ConfigView {
role: Role;
roleChosen: boolean;
team: number;
/** 两边在界面上显示的名字,下标就是队伍号。裁判端可以直接改。 */
teamNames: [string, string];
cameraId: number | null;
cameraIdText: string | null;
armorId: number | null;
@@ -137,6 +145,13 @@ export interface SlotsDto {
manual: boolean;
}
/**
* `cam_core::snapshot::MatchOutcome`。
*
* 是**从这一边看**的结论,不是"谁赢了"这个全局判断:同一场对局里两边的 DTO 各带一份。
*/
export type MatchOutcome = "victory" | "defeat" | "draw";
/** `cam_core::snapshot::TeamHealthDto` */
export interface TeamHealthDto {
team: number;
@@ -148,6 +163,8 @@ export interface TeamHealthDto {
board: number | null;
boardText: string | null;
critical: boolean;
/** 有结论时(有人归零)才有值;两边都活着时是 null。 */
outcome: MatchOutcome | null;
}
/** `cam_core::snapshot::RecordingDto` */