Files
ESP32S3_vt/desktop/src/components/HealthBar.tsx
T
Horldsence a93ae065cf 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)通过。
2026-10-14 20:48:12 +08:00

162 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画面角上的分段血条(平行四边形,一条分 5 格)。
*
* # 为什么改到角上、改成斜的
*
* 原来它在画面**下面**一条窄带里。改成画面左上角/右上角之后:
*
* - 画面不再是"被一条横带压住的一块",整块视频区域给到取景;
* - 两边各占一角,**同时**看到自己与对手的血量,视线不用离开画面;
* - 斜切的形状不会在第一时间被误认成字幕条或进度条,扫一眼就知道是血量。
*
* 左上角给自己、右上角给对手(见 `TeamPage`)。裁判端两格各自一个角,与用户端同一套
* 视觉语言。
*
* # 一条分 5 格 = 一格 20 点
*
* 整条是一条,里面分成 **5 格**:格子少而大,隔着几米扫一眼就能数出"还剩三格",
* 比读一条连续的长度或一个百分比快。血量归零就是 0 格,那时图传中央会给出胜负。
*
* 血少了,亮着的格子从**中心那一侧**开始灭(左条从右往左灭,右条从左往右灭),所以两条
* 血一起减少时,视觉上是往两侧退——不会看成"条变长了"。
*
* # 未接入时不显示 0
*
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到 0
* 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
*/
import { useEffect, useState } from "react";
import { teamColor, withAlpha } from "../lib/format";
import type { HealthConfig, TeamHealthDto } from "../lib/types";
interface Props {
health: TeamHealthDto;
config: HealthConfig;
/** 挂在画面哪一角。`right` 会把条整个镜像过来(含缺血的退向)。 */
align: "left" | "right";
/** 这一格是谁:用户端用「我方 / 对手」把两条同色的血区分开。 */
tag?: string;
}
/** 整条分成几格。 */
const CELLS = 5;
/** 一格多少血。整车一条血 100 点。 */
const CELL_HP = 100 / CELLS;
/** 脉动的周期。与 CSS 动画保持一致。 */
const PULSE_MS = 900;
interface Cell {
/** 这一格是不是亮着(这一格的血还在)。 */
on: boolean;
/** 落在低血阈值以内:这是"危险区",即使还亮着也提前给个警示色。 */
danger: boolean;
}
export function HealthBar({ health, config, align, tag }: Props) {
const [pulseOn, setPulseOn] = useState(true);
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
// 每秒重渲染一次。
const pulsing = config.pulse && health.available && health.critical;
useEffect(() => {
if (!pulsing) {
setPulseOn(true);
return;
}
const timer = window.setInterval(() => setPulseOn((on) => !on), PULSE_MS / 2);
return () => window.clearInterval(timer);
}, [pulsing]);
const color = health.critical ? "#ff5f5f" : teamColor(health.team);
const hp = health.available ? Math.max(0, Math.min(100, health.hp ?? 0)) : 0;
// 向上取整:还剩 1 点血的时候必须还剩一格亮着。向下取整会让"还有 10 点血"显示成空条,
// 而那正是最需要它准确的时候。
const lit = Math.ceil(hp / CELL_HP);
const dangerCells =
config.lowThreshold > 0 && config.lowThreshold < 100
? Math.ceil(config.lowThreshold / CELL_HP)
: 0;
// 逻辑顺序:0 号格是"满血时最靠外的那一格"。
//
// **不要在这里按 `align` 反转数组**:右角那条的翻转让 CSS 的 `scaleX(-1)` 做
// (`segbars--right .segbars__track`),两边都做等于翻两次、又翻回来了——表现是右条
// 从外侧开始灭,与左条相反。
const cells: Cell[] = Array.from({ length: CELLS }, (_, index) => ({
on: index < lit,
danger: index >= CELLS - dangerCells,
}));
// 宽度按配置收窄。它挂在画面角上,太宽会盖住画面本身。
const width = `${Math.round(config.width * 100)}%`;
return (
<div
className={`segbars segbars--${align}`}
style={{ width }}
title={health.available ? undefined : health.placeholder}
>
<div className="segbars__labels">
{tag && <span className="segbars__tag">{tag}</span>}
{config.showTeamName && (
<span className="segbars__team" style={{ color }}>
{health.name}
</span>
)}
{health.available ? (
config.showNumbers && (
<span className="segbars__value" style={{ color }}>
{hp}
</span>
)
) : (
<span className="segbars__missing">装甲板未接入</span>
)}
</div>
<div
className="segbars__track"
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
aria-hidden="true"
>
{cells.map((cell, index) => (
<span
key={index}
className={[
"segbars__cell",
cell.on ? "segbars__cell--on" : "",
cell.danger ? "segbars__cell--danger" : "",
]
.filter(Boolean)
.join(" ")}
style={
cell.on
? {
background: color,
boxShadow: `0 0 8px ${withAlpha(color, 0.45)}`,
}
: undefined
}
/>
))}
</div>
</div>
);
}
/**
* 这一格是谁。
*
* 用户端两条血分别属于两边,而形状是对称的——没有这个标签,"左边这条是我的"要靠位置猜。
*/
export function healthTag(health: TeamHealthDto, myTeam: number): string {
return health.team === myTeam ? "我方" : "对手";
}