界面改动都在浏览器预览里逐条核对过(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)通过。
162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
/**
|
||
* 画面角上的分段血条(平行四边形,一条分 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 ? "我方" : "对手";
|
||
}
|