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:
@@ -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;
|
||||
|
||||
@@ -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] ?? "队伍";
|
||||
}
|
||||
|
||||
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface AppStore {
|
||||
snapshot: Snapshot | null;
|
||||
/** 与后端的视频通道是否连着。 */
|
||||
connected: boolean;
|
||||
/** 最近一次掉血,界面用它做闪红/抖动。 */
|
||||
/** 最近一次掉血,界面用它做闪红与伤害数字。 */
|
||||
lastHit: HitPulseRecord | null;
|
||||
/** 后端要求弹出解锁框(用户端点关闭窗口时)。 */
|
||||
confirmQuit: number;
|
||||
|
||||
@@ -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` */
|
||||
|
||||
Reference in New Issue
Block a user