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
+9 -3
View File
@@ -12,9 +12,11 @@ import type { ArmorBoardDto, SlotsDto } from "../lib/types";
interface Props {
boards: ArmorBoardDto[];
slots: SlotsDto;
/** 两边显示的名字(裁判端可改)。缺省时回落到「队伍一 / 队伍二」。 */
names?: [string, string];
}
export function ArmorTable({ boards, slots }: Props) {
export function ArmorTable({ boards, slots, names }: Props) {
return (
<div className="armor-table">
<div className="armor-table__head">
@@ -36,7 +38,11 @@ export function ArmorTable({ boards, slots }: Props) {
{boards.map((board) => {
const assigned =
slots.armorA === board.id ? "左(队伍一)" : slots.armorB === board.id ? "右(队伍二)" : "—";
slots.armorA === board.id
? `左(${teamName(0, names)})`
: slots.armorB === board.id
? `右(${teamName(1, names)})`
: "—";
return (
<div
@@ -45,7 +51,7 @@ export function ArmorTable({ boards, slots }: Props) {
>
<span className="mono">{board.idText}</span>
<span className="mono dim">{board.addr}</span>
<span style={{ color: teamColor(board.team) }}>{teamName(board.team)}</span>
<span style={{ color: teamColor(board.team) }}>{teamName(board.team, names)}</span>
<span className={`mono${board.hp === 0 ? " danger" : ""}`}>{board.hp}</span>
<span className={board.reportsAllPlates ? "dim" : "warn"}>
{board.platesSeen}/4
+104 -49
View File
@@ -1,15 +1,29 @@
/**
* 底部胶囊血条。
* 画面角上的分段血条(平行四边形,一条分 5 格)。
*
* # 为什么它在画面**下面**而不是叠在画面上
* # 为什么改到角上、改成斜的
*
* 用户端要的是"一眼看到还剩多少血",而不是把画面挡住。放在画面下沿一条窄带里,
* 视频区域一个像素都不占;裁判端并排两路时也正好对称。
* 原来它在画面**下面**一条窄带里。改成画面左上角/右上角之后:
*
* - 画面不再是"被一条横带压住的一块",整块视频区域给到取景;
* - 两边各占一角,**同时**看到自己与对手的血量,视线不用离开画面;
* - 斜切的形状不会在第一时间被误认成字幕条或进度条,扫一眼就知道是血量。
*
* 左上角给自己、右上角给对手(见 `TeamPage`)。裁判端两格各自一个角,与用户端同一套
* 视觉语言。
*
* # 一条分 5 格 = 一格 20 点
*
* 整条是一条,里面分成 **5 格**:格子少而大,隔着几米扫一眼就能数出"还剩三格",
* 比读一条连续的长度或一个百分比快。血量归零就是 0 格,那时图传中央会给出胜负。
*
* 血少了,亮着的格子从**中心那一侧**开始灭(左条从右往左灭,右条从左往右灭),所以两条
* 血一起减少时,视觉上是往两侧退——不会看成"条变长了"。
*
* # 未接入时不显示 0
*
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到
* 两个 0 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到 0
* 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
*/
import { useEffect, useState } from "react";
@@ -20,14 +34,28 @@ import type { HealthConfig, TeamHealthDto } from "../lib/types";
interface Props {
health: TeamHealthDto;
config: HealthConfig;
/** 紧凑模式:裁判端两路并排时用。 */
compact?: boolean;
/** 挂在画面哪一角。`right` 会把条整个镜像过来(含缺血的退向)。 */
align: "left" | "right";
/** 这一格是谁:用户端用「我方 / 对手」把两条同色的血区分开。 */
tag?: string;
}
/** 整条分成几格。 */
const CELLS = 5;
/** 一格多少血。整车一条血 100 点。 */
const CELL_HP = 100 / CELLS;
/** 脉动的周期。与 CSS 动画保持一致。 */
const PULSE_MS = 900;
export function HealthBar({ health, config, compact = false }: Props) {
interface Cell {
/** 这一格是不是亮着(这一格的血还在)。 */
on: boolean;
/** 落在低血阈值以内:这是"危险区",即使还亮着也提前给个警示色。 */
danger: boolean;
}
export function HealthBar({ health, config, align, tag }: Props) {
const [pulseOn, setPulseOn] = useState(true);
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
@@ -45,62 +73,89 @@ export function HealthBar({ health, config, compact = false }: Props) {
}, [pulsing]);
const color = health.critical ? "#ff5f5f" : teamColor(health.team);
const total = 100;
const ratio = health.available ? Math.max(0, Math.min(1, (health.hp ?? 0) / total)) : 0;
// 宽度按配置收窄并居中。它挂在画面**下面**,所以不需要通栏——通栏反而像一条分隔线,
// 而不是一个可以一眼扫到的仪表。
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={`healthbar${compact ? " healthbar--compact" : ""}`}
style={compact ? undefined : { width, margin: "0 auto" }}
className={`segbars segbars--${align}`}
style={{ width }}
title={health.available ? undefined : health.placeholder}
>
<div
className="healthbar__track"
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
title={health.available ? undefined : health.placeholder}
>
<div
className="healthbar__fill"
style={{
width: `${ratio * 100}%`,
background: `linear-gradient(90deg, ${withAlpha(color, 0.75)}, ${color})`,
boxShadow: `0 0 12px ${withAlpha(color, 0.45)}`,
}}
/>
{/* 低血刻度:一根竖线标出阈值,让人知道"还有多久开始报警"。 */}
{config.lowThreshold > 0 && config.lowThreshold < total && (
<div
className="healthbar__threshold"
style={{ left: `${(config.lowThreshold / total) * 100}%` }}
/>
<div className="segbars__labels">
{tag && <span className="segbars__tag">{tag}</span>}
{config.showTeamName && (
<span className="segbars__team" style={{ color }}>
{health.name}
</span>
)}
</div>
<div className="healthbar__labels">
{config.showTeamName && <span className="healthbar__team">{health.name}</span>}
{health.available ? (
config.showNumbers && (
<span className="healthbar__value" style={{ color }}>
{health.hp}
{health.critical && <span className="healthbar__dead"> · 已击毁</span>}
<span className="segbars__value" style={{ color }}>
{hp}
</span>
)
) : (
<span className="healthbar__missing">{health.placeholder}</span>
<span className="segbars__missing">装甲板未接入</span>
)}
</div>
{health.available && health.boardText && (
<span className="healthbar__board" title="绑定的装甲板主板">
{compact ? "" : "装甲板 "}
{health.boardText}
</span>
)}
<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 ? "我方" : "对手";
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 图传中央的胜负横幅:一边归零之后,画面正中显示「胜利」或「击败」。
*
* # 为什么这次放在正中
*
* 命中反馈那一套刻意避开正中(伤害数字放在准星旁),因为被打中的那一下恰恰最需要看着
* 准星。**胜负不是那种信息**:它是一局的结束,此刻不再需要瞄准;反过来说,它是这一屏
* 上唯一还重要的事,放在角上会让人错过整场比赛的结论。
*
* # 它一直显示到血量被重置
*
* 不做 3 秒淡出:淡出之后屏幕上就再没有"这局结束了"的痕迹,截图、回放、现场的争议都
* 无从对照。血量被重置(长按 BOOT / 下一局开始)时它自己消失——那个动作本来就是
* "这局翻篇了"。
*
* 用词是两边各说各的:赢的一边看到「胜利」,输的一边看到「击败」。同时归零显示「平局」,
* 因为那时候任何一边显示"胜利"都是错的。
*/
import type { MatchOutcome } from "../lib/types";
interface Props {
/** 从这一边看的结论。两边都活着时是 null,什么都不显示。 */
outcome: MatchOutcome | null;
/** 我这一边的名字。 */
mine: string;
/** 对手的名字。 */
theirs: string;
}
const WORD: Record<MatchOutcome, string> = {
victory: "胜利",
defeat: "击败",
draw: "平局",
};
export function MatchBanner({ outcome, mine, theirs }: Props) {
if (!outcome) return null;
const caption =
outcome === "victory"
? `击败 ${theirs}`
: outcome === "defeat"
? `被 ${theirs} 击败`
: "双方装甲板同时归零";
return (
<div className={`match-banner match-banner--${outcome}`} role="status">
<div className="match-banner__card">
<span className="match-banner__team">{mine}</span>
<span className="match-banner__word">{WORD[outcome]}</span>
<span className="match-banner__caption">{caption}</span>
</div>
</div>
);
}
+17 -21
View File
@@ -128,11 +128,11 @@ export function SettingsPanel({
snapshot.armorBoards.map((board) => ({
value: board.id,
label: board.idText,
hint: `${board.addr} · ${teamName(board.team)} · ${board.hp} 血 · ${
hint: `${board.addr} · ${teamName(board.team, snapshot.config.teamNames)} · ${board.hp} 血 · ${
board.online ? "在线" : "离线"
}`,
})),
[snapshot.armorBoards],
[snapshot.armorBoards, snapshot.config.teamNames],
);
return (
@@ -204,12 +204,15 @@ export function SettingsPanel({
/>
</Field>
<Field label="自己属于哪一边" hint="改错队号会让血条显示对手的血量。">
<Field
label="自己属于哪一边"
hint="改错队号会把对手的血条当成自己的:画面左上角那一条就会是对手的血。"
>
<Select
value={config.team}
options={[
{ value: 0, label: "队伍一(红)" },
{ value: 1, label: "队伍二(蓝)" },
{ value: 0, label: `${teamName(0, config.teamNames)}(红)` },
{ value: 1, label: `${teamName(1, config.teamNames)}(蓝)` },
]}
onChange={(team) => void run(() => api.setTeam(team))}
/>
@@ -411,16 +414,6 @@ export function SettingsPanel({
}
/>
<Switch
checked={hud.hitFeedback.shake}
label="被打中时画面抖动"
onChange={(shake) =>
update((draft) => {
draft.hitFeedback.shake = shake;
})
}
/>
<Switch
checked={hud.hitFeedback.marker}
label="在瞄点旁显示伤害数字"
@@ -431,7 +424,7 @@ export function SettingsPanel({
}
/>
<Field label="反馈强度">
<Field label="闪红强度">
<Slider
value={hud.hitFeedback.intensity}
min={0.2}
@@ -447,7 +440,10 @@ export function SettingsPanel({
</Field>
</Section>
<Section title="血条">
<Section
title="血条"
hint="条挂在画面两个上角:左上角是自己、右上角是对手。一条分 5 格,一格 20 点血。"
>
<Switch
checked={hud.health.visible}
label="显示血条"
@@ -539,7 +535,7 @@ export function SettingsPanel({
{snapshot.slots.manual && (
<>
<Field label="左格:队伍一摄像头">
<Field label={`左格:${teamName(0, config.teamNames)}摄像头`}>
<Select
value={snapshot.slots.cameraA}
options={cameraOptions}
@@ -550,7 +546,7 @@ export function SettingsPanel({
}
/>
</Field>
<Field label="左格:队伍一装甲板">
<Field label={`左格:${teamName(0, config.teamNames)}装甲板`}>
<Select
value={snapshot.slots.armorA}
options={armorOptions}
@@ -561,7 +557,7 @@ export function SettingsPanel({
}
/>
</Field>
<Field label="右格:队伍二摄像头">
<Field label={`右格:${teamName(1, config.teamNames)}摄像头`}>
<Select
value={snapshot.slots.cameraB}
options={cameraOptions}
@@ -572,7 +568,7 @@ export function SettingsPanel({
}
/>
</Field>
<Field label="右格:队伍二装甲板">
<Field label={`右格:${teamName(1, config.teamNames)}装甲板`}>
<Select
value={snapshot.slots.armorB}
options={armorOptions}
+95
View File
@@ -0,0 +1,95 @@
/**
* 裁判端就地改队伍名。
*
* # 为什么是输入框而不是设置面板里的两项
*
* 现场改名字的时机是"比赛开始前,两队报上名来"。那时候裁判盯着的是两格画面,让他在
* 设置面板的某一节里找到对应项、改完再关掉,是一次没有必要的往返。名字就放在两格画面
* 各自下面,改完直接看到血条上的名字变了。
*
* # 回车/失焦才提交
*
* 逐字提交会在每次按键后写一次配置文件(还带一次 Argon2 无关的原子替换),而且中间态
* ("红"、"红队")会被后端截断/回落规则改写一次,光标跟着跳。失焦提交是唯一不会跟
* 打字打架的时机。
*
* 超过上限的字数由后端统一截断(`TEAM_NAME_MAX_CHARS`),这里也限一下长度——输入框
* 里就能看到"打不进去了",比提交后被悄悄截掉诚实。
*/
import { useEffect, useRef, useState } from "react";
import { DEFAULT_TEAM_NAMES } from "../lib/format";
/** 与 Rust 侧的 `config::TEAM_NAME_MAX_CHARS` 一致。 */
const MAX_CHARS = 12;
interface Props {
team: number;
/** 当前生效的名字(来自快照,可能已经被后端规范化过)。 */
name: string;
/** 提交。后端会规范化,成功之后快照会带新名字回来。 */
onSave: (name: string) => void;
disabled?: boolean;
}
export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
const [draft, setDraft] = useState(name);
const focused = useRef(false);
/** Esc 已经改回去了,紧接着的那次失焦不该再提交。 */
const reverted = useRef(false);
// 焦点不在输入框里时跟着快照走:改完之后后端返回的规范名要能显示出来,而正在打字时
// 不能被一份恰好到达的快照顶掉。
useEffect(() => {
if (!focused.current) setDraft(name);
}, [name]);
const commit = () => {
focused.current = false;
if (reverted.current) {
reverted.current = false;
return;
}
if (draft === name) return;
const trimmed = draft.trim();
setDraft(trimmed);
onSave(trimmed);
};
return (
<label className="team-name">
<span className="team-name__label" style={{ color: team === 0 ? "#ff5f5f" : "#5aa9ff" }}>
队名
</span>
<input
className="input team-name__input"
value={draft}
maxLength={MAX_CHARS}
disabled={disabled}
placeholder={DEFAULT_TEAM_NAMES[team] ?? "队伍"}
title="改完按回车或点到别处生效;留空回落到默认名"
onChange={(event) => setDraft(event.target.value)}
onFocus={() => {
focused.current = true;
}}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
// 只走失焦这一条提交路径,避免回车与失焦各提交一次。
event.currentTarget.blur();
} else if (event.key === "Escape") {
// 注意:不能只 `setDraft(name)` 再 blur——blur 是同一次事件里同步发生的,
// `commit` 读到的还是这一帧的旧 draft,于是"取消"反而把改动存了下去。
reverted.current = true;
setDraft(name);
event.currentTarget.blur();
}
}}
/>
</label>
);
}
+8 -28
View File
@@ -133,11 +133,13 @@ export function VideoSurface({
dirty.current = true;
}
// 命中动画期间必须持续重绘,否则抖动只画了一帧就停住。
// 命中动画期间必须持续重绘,否则闪红/伤害数字只画了一帧就停住。
//
// **抖动没有了**(见 `HitFeedbackConfig::shake` 的文档):不再有"整幅图跟着晃"的
// 那一路,所以这里也不再需要因为抖动而复绘。
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
const animating =
pulseAge < 700 &&
(current.hitFeedback.shake || current.hitFeedback.marker || current.hitFeedback.flash);
pulseAge < 700 && (current.hitFeedback.marker || current.hitFeedback.flash);
if (!dirty.current && !animating) return;
dirty.current = false;
@@ -300,11 +302,8 @@ function draw(
? layout(box, frame.width, frame.height, current.view)
: fallback;
const shake = current.hit ? shakeOffset(current.hit, timestamp, current.hitFeedback) : { x: 0, y: 0 };
if (frame) {
ctx.save();
ctx.translate(shake.x, shake.y);
if (current.mirrored) {
ctx.translate(width, 0);
@@ -317,7 +316,7 @@ function draw(
ctx.restore();
if (current.grid?.visible) {
drawGrid(ctx, geometry, current.grid, shake);
drawGrid(ctx, geometry, current.grid);
}
}
@@ -333,7 +332,7 @@ function draw(
}
if (crosshair?.visible) {
drawCrosshair(ctx, aimX + shake.x, aimY + shake.y, Math.min(width, height), crosshair);
drawCrosshair(ctx, aimX, aimY, Math.min(width, height), crosshair);
}
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
@@ -348,39 +347,20 @@ function draw(
ctx.font = `600 ${Math.round(Math.max(14, Math.min(width, height) * 0.05))}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(
`-${current.hit.damage}`,
aimX + shake.x + 24,
aimY + shake.y - 26 - progress * 26,
);
ctx.fillText(`-${current.hit.damage}`, aimX + 24, aimY - 26 - progress * 26);
ctx.restore();
}
}
}
function shakeOffset(hit: HitPulse, timestamp: number, feedback: HitFeedbackConfig) {
if (!feedback.shake) return { x: 0, y: 0 };
const age = timestamp - hit.at;
if (age < 0 || age > 400) return { x: 0, y: 0 };
const amplitude = 7 * feedback.intensity * (1 - age / 400);
return {
x: Math.sin(age * 0.09) * amplitude,
y: Math.cos(age * 0.11) * amplitude,
};
}
function drawGrid(
ctx: CanvasRenderingContext2D,
geometry: { left: number; top: number; drawWidth: number; drawHeight: number },
grid: GridConfig,
shake: { x: number; y: number },
) {
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
ctx.save();
ctx.translate(shake.x, shake.y);
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
ctx.lineWidth = 1;
+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` */
+57 -6
View File
@@ -1,14 +1,20 @@
/**
* 裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
*
* 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量并排对称、
* 缩放置互相独立(滚轮只作用于鼠标所在的那一路)。
* 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量各自贴在画面的
* 上角(左格左上、右格右上,与用户端的"自己在左上、对手在右上"是同一套读法)、缩放置互相
* 独立(滚轮只作用于鼠标所在的那一路)。
*
* 两格等宽这件事在旧客户端上是踩过坑的:`HorizontalLayout` 先按内容的**最大**需求分配,
* 剩下的才按 stretch 分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"
* (5 字),于是左边多要十几个像素——实测 55% / 45%。解法是让两格的需求只由画面决定:
* 统一的 `preferred-width` 加 `min-width`,文字用绝对定位不参与布局。CSS 的 flex 里
* 用 `flex: 1 1 0` + `min-width: 0` 达到同一件事。
*
* # 队名可以直接改
*
* 名字属于这一场比赛,所以它放在两格画面各自下面(见 `TeamNameEditor`),改完立刻反映到
* 血条、装甲板表与等待画面的文字上。命令在 Rust 侧,规范化也在那边统一做。
*/
import { useMemo, useState } from "react";
@@ -16,7 +22,9 @@ import { useMemo, useState } from "react";
import { ArmorTable, StatsLine } from "../components/ArmorTable";
import { HealthBar } from "../components/HealthBar";
import { HitFlash } from "../components/HitFlash";
import { MatchBanner } from "../components/MatchBanner";
import { MediaPanel } from "../components/MediaPanel";
import { TeamNameEditor } from "../components/TeamNameEditor";
import { TopBar } from "../components/TopBar";
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
import { Button } from "../components/controls";
@@ -44,6 +52,9 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
const [viewA, setViewA] = useState<ViewTransform>(IDENTITY_VIEW);
const [viewB, setViewB] = useState<ViewTransform>(IDENTITY_VIEW);
// 队名以快照为准(后端会规范化)。
const names = snapshot.config.teamNames;
const panes = useMemo(
() =>
[
@@ -52,6 +63,8 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
camera: snapshot.slots.cameraA,
armor: snapshot.slots.armorA,
team: 0,
// 左格的血条贴左上角,右格贴右上角:与用户端"自己在左上、对手在右上"一致。
align: "left" as const,
view: viewA,
onView: setViewA,
},
@@ -60,6 +73,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
camera: snapshot.slots.cameraB,
armor: snapshot.slots.armorB,
team: 1,
align: "right" as const,
view: viewB,
onView: setViewB,
},
@@ -67,10 +81,23 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
...pane,
stats: snapshot.stats.find((entry) => entry.camera === pane.camera),
health: snapshot.health.find((entry) => entry.team === pane.team) ?? snapshot.health[0],
opponent: snapshot.health.find((entry) => entry.team !== pane.team) ?? null,
})),
[snapshot.slots, snapshot.stats, snapshot.health, viewA, viewB],
);
/**
* 改一边的队名。
*
* 命令一次只改一边,所以这里**不**把另一边的名字一起带上:那是"读两条、写两条",
* 而读来的值来自最多 200 ms 前的快照——紧接着改另一边就会把它覆盖回去。
*/
const saveName = (team: number, value: string) => {
void api.setTeamName(team, value).catch((err) => {
onError(err instanceof Error ? err.message : String(err));
});
};
return (
<div className="page">
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
@@ -83,6 +110,8 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
(pane.armor !== null ? lastHit.board === pane.armor : lastHit.team === pane.team),
);
const label = teamName(pane.team, names);
return (
<section key={pane.key} className="pane">
<VideoSurface
@@ -92,7 +121,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
? ""
: pane.stats?.online
? "已连接,等待画面…"
: `等待${teamName(pane.team)}的图传`
: `等待${label}的图传`
}
mirrored={snapshot.mirrored}
// 裁判端不给瞄准器:这里没有人在瞄。网格留着,用来比对两路的取景。
@@ -108,16 +137,34 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
hitFeedback={hud.hitFeedback}
showZoom
>
{hud.health.visible && (
<div className={`video-hud video-hud--${pane.align}`}>
<HealthBar health={pane.health} config={hud.health} align={pane.align} />
</div>
)}
<MatchBanner
outcome={pane.health.outcome}
mine={pane.health.name}
theirs={
pane.opponent?.name ?? teamName(pane.team === 0 ? 1 : 0, names)
}
/>
<HitFlash
hit={lastHit}
config={hud.hitFeedback}
relevant={hitRelevant}
label={`${teamName(pane.team)}被打中`}
label={`${label}被打中`}
/>
</VideoSurface>
<div className="pane__foot">
<HealthBar health={pane.health} config={hud.health} compact />
<TeamNameEditor
team={pane.team}
name={pane.health.name}
onSave={(value) => saveName(pane.team, value)}
/>
<StatsLine stats={pane.stats} diagnostics={snapshot.diagnostics} />
</div>
</section>
@@ -153,7 +200,11 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
<div className="referee-bottom__body">
{tab === "armor" ? (
<ArmorTable boards={snapshot.armorBoards} slots={snapshot.slots} />
<ArmorTable
boards={snapshot.armorBoards}
slots={snapshot.slots}
names={names}
/>
) : (
<MediaPanel snapshot={snapshot} onError={onError} onInfo={onInfo} />
)}
+47 -14
View File
@@ -1,24 +1,28 @@
/**
* 用户端:一路画面 + 血条 + 瞄准器。
* 用户端:一路画面 + 双方血条 + 瞄准器 + 胜负横幅。
*
* 界面上的东西按"操作手需要什么"排:
*
* - 画面占满除底部一条窄带以外的全部空间(一个像素都不浪费在装饰上);
* - 血条在画面**下方**,不挡画面;
* - 画面占满除底部状态行以外的全部空间(一个像素都不浪费在装饰上);
* - **血条叠在画面两个上角**:左上角是自己,右上角是对手。血量是这一屏上最需要"同时看到"
* 的两个数,放在画面之外意味着视线要离开取景;
* - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走;
* - 被打中时整屏闪一下红、画面抖一下、瞄点旁跳一个伤害数字。
* - 被打中时整屏闪一下红、瞄点旁跳一个伤害数字——**画面不抖**(见 `HitFeedbackConfig`);
* - 一方归零之后,画面正中给出「胜利 / 击败」。
*/
import { useMemo, useState } from "react";
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
import { HealthBar } from "../components/HealthBar";
import { HealthBar, healthTag } from "../components/HealthBar";
import { HitFlash } from "../components/HitFlash";
import { MatchBanner } from "../components/MatchBanner";
import { StatsLine } from "../components/ArmorTable";
import { TopBar } from "../components/TopBar";
import { Button } from "../components/controls";
import { teamName } from "../lib/format";
import { useAppStore } from "../lib/store";
import type { HudView, Snapshot } from "../lib/types";
import type { HudView, Snapshot, TeamHealthDto } from "../lib/types";
interface Props {
snapshot: Snapshot;
@@ -45,12 +49,21 @@ export function TeamPage({
() => snapshot.stats.find((entry) => entry.camera === camera),
[snapshot.stats, camera],
);
const health = snapshot.health[0];
// 快照里两边都有一份,**按队伍号取**而不是按下标:绑的是队伍二时 `health[0]` 是别人。
const mine: TeamHealthDto =
snapshot.health.find((entry) => entry.team === snapshot.team) ?? snapshot.health[0];
const theirs: TeamHealthDto | null =
snapshot.health.find((entry) => entry.team !== snapshot.team) ?? null;
const theirTeam = snapshot.team === 0 ? 1 : 0;
const myName = mine?.name ?? teamName(snapshot.team, snapshot.config.teamNames);
const theirName = theirs?.name ?? teamName(theirTeam, snapshot.config.teamNames);
// 这一下是不是打在我身上:绑定了装甲板就比板子 id,没绑定就退回比队伍号。
const hitIsMine = Boolean(
lastHit &&
(health?.board != null ? lastHit.board === health.board : lastHit.team === snapshot.team),
(mine?.board != null ? lastHit.board === mine.board : lastHit.team === snapshot.team),
);
const pulse = hitIsMine && lastHit ? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt } : null;
@@ -92,16 +105,36 @@ export function TeamPage({
hitFeedback={hud.hitFeedback}
showZoom={hud.showZoom}
>
{hud.health.visible && (
<>
<div className="video-hud video-hud--left">
<HealthBar
health={mine}
config={hud.health}
align="left"
tag={healthTag(mine, snapshot.team)}
/>
</div>
<div className="video-hud video-hud--right">
{theirs && (
<HealthBar
health={theirs}
config={hud.health}
align="right"
tag={healthTag(theirs, snapshot.team)}
/>
)}
</div>
</>
)}
{/* 结论只看自己这一份:后端的 outcome 就是"从这一边看"的。 */}
<MatchBanner outcome={mine?.outcome ?? null} mine={myName} theirs={theirName} />
<HitFlash hit={lastHit} config={hud.hitFeedback} relevant={hitIsMine} />
</VideoSurface>
</div>
{hud.health.visible && (
<div className="hud-bottom">
<HealthBar health={health} config={hud.health} />
</div>
)}
<footer className="statusbar">
<StatsLine stats={stats} diagnostics={snapshot.diagnostics} />
<span className="dim">
+194 -51
View File
@@ -266,9 +266,10 @@ code {
pointer-events: none;
}
/* 倍率与复位挪到画面**下沿**:上沿两个角现在归血条。 */
.video-badge {
position: absolute;
top: 10px;
bottom: 10px;
background: rgba(0, 0, 0, 0.68);
color: #d0d0d8;
border: 1px solid var(--border-strong);
@@ -338,10 +339,12 @@ code {
}
}
/* 提示条在顶部,不压瞄准点。 */
/* 提示条在上方但**让开两个角**:上沿现在归血条(左上角自己、右上角对手)。
*
* 也不放在正中:被打中的那一下恰恰是最需要看着准星的时候,而这里正对着瞄准点。 */
.hit-flash__banner {
position: absolute;
top: 16px;
top: 56px;
left: 50%;
transform: translateX(-50%);
display: flex;
@@ -372,81 +375,221 @@ code {
}
/* -------------------------------------------------------------- 血条 */
/*
* 条挂在画面左上角/右上角,所以它是**绝对定位在视频窗格里的一层**,不是页面里的一行。
* 视频窗格自己是 `position: relative`,孩子按角贴上去即可。
*
* `pointer-events: none` 是必须的:这一层盖在画面上,而画面要能滚轮缩放、指针拖动。
*/
.hud-bottom {
flex: none;
padding: 0 8px 6px;
.video-hud {
position: absolute;
top: 10px;
/* 宽度必须是**确定**的:绝对定位元素不给宽度时会收缩到内容宽度,而里面那条血的宽度是
* 它的百分比——两者互相依赖,结局是两条血被压成几十像素、数字竖着排。 */
width: calc(100% - 24px);
z-index: 2;
pointer-events: none;
}
.healthbar {
.video-hud--left {
left: 12px;
}
.video-hud--right {
right: 12px;
/* 把里面那条血推到右边:`.segbars--right` 管的是文字/格子在**条内部**靠哪边,
* 管不到"条本身在容器里的位置"。 */
display: flex;
justify-content: flex-end;
}
.segbars {
display: flex;
flex-direction: column;
gap: 5px;
gap: 4px;
}
.healthbar--compact {
gap: 3px;
/* 右角那一条整体靠右:文字与血条用同一个边对齐,不然条在角上、字在中间。 */
.segbars--right {
align-items: flex-end;
text-align: right;
}
.healthbar__track {
position: relative;
height: 14px;
border-radius: 999px;
background: #17171d;
.segbars__labels {
display: flex;
flex-wrap: nowrap;
align-items: baseline;
gap: 8px;
min-height: 15px;
max-width: 100%;
font-size: 12px;
/* 画面亮的时候白字会糊掉,一层阴影比加背景条便宜且不挡画面。 */
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
.segbars__tag {
flex: none;
padding: 1px 6px;
border: 1px solid var(--border-strong);
border-radius: 3px;
background: rgba(0, 0, 0, 0.55);
color: #d7d7de;
font-size: 10px;
letter-spacing: 0.08em;
white-space: nowrap;
}
.segbars__team {
min-width: 0;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.segbars__value {
flex: none;
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.segbars__missing {
color: #ffc9a0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 平行四边形:整条统一斜切,右角那条同时镜像,于是两条的斜边朝外对称。
* 斜切放在整条上而不是每个格子上——格子各自斜切会在缝里露出锯齿。 */
.segbars__track {
align-self: stretch;
width: 100%;
display: flex;
gap: 2px;
padding: 2px;
/* 一条分 5 格:格子之间的缝就是那 5 块的界线。 */
background: rgba(8, 8, 12, 0.72);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
transform: skewX(-15deg);
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.55));
transition: opacity 200ms ease;
}
.healthbar--compact .healthbar__track {
height: 12px;
.segbars--right .segbars__track {
transform: skewX(-15deg) scaleX(-1);
}
.healthbar__fill {
height: 100%;
border-radius: 999px;
transition: width 220ms cubic-bezier(0.22, 1, 0.36, 1);
.segbars__cell {
flex: 1 1 0;
min-width: 0;
height: 14px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.07);
transition: background 160ms ease;
}
.healthbar__threshold {
/* 低血阈值以内那些格子:还亮着的时候也提前给一点警示色,于是"危险区在哪"是一直看得见的,
* 而不是等真的低血了才第一次出现。 */
.segbars__cell--danger {
background: rgba(255, 90, 90, 0.18);
}
/* ------------------------------------------------------ 胜负横幅 */
/* 归零之后画面正中的结论:胜利 / 击败 / 平局。 */
.match-banner {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.28);
}
.healthbar__labels {
inset: 0;
z-index: 4;
display: flex;
align-items: baseline;
gap: 10px;
font-size: 12px;
min-height: 16px;
align-items: center;
justify-content: center;
pointer-events: none;
}
.healthbar__team {
font-weight: 600;
.match-banner__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 34px 18px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 14px;
/* 半透明而不是实心:输了之后画面还在,回放与截图也要能看出当时是什么场面。 */
background: rgba(8, 8, 12, 0.66);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
animation: match-in 260ms ease-out;
}
.healthbar__value {
font-family: var(--mono);
font-size: 15px;
font-weight: 700;
@keyframes match-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
.healthbar__dead {
font-size: 12px;
font-weight: 600;
}
.healthbar__board {
color: var(--fg-faint);
font-family: var(--mono);
font-size: 11px;
}
.healthbar__missing {
.match-banner__team {
color: var(--fg-dim);
font-size: 12px;
letter-spacing: 0.18em;
}
.match-banner__word {
font-size: clamp(30px, 8vmin, 68px);
font-weight: 800;
line-height: 1.05;
letter-spacing: 0.1em;
}
.match-banner__caption {
color: var(--fg-dim);
font-size: 12px;
}
.match-banner--victory .match-banner__word {
color: #ffd45a;
text-shadow: 0 0 26px rgba(255, 212, 90, 0.45);
}
.match-banner--defeat .match-banner__word {
color: #ff6b6b;
text-shadow: 0 0 26px rgba(255, 107, 107, 0.4);
}
.match-banner--draw .match-banner__word {
color: #d8d8e0;
}
/* -------------------------------------------------------- 队名编辑 */
.team-name {
display: flex;
align-items: center;
gap: 8px;
}
.team-name__label {
flex: none;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
}
.team-name__input {
flex: 1 1 0;
min-width: 0;
padding: 4px 8px;
font-size: 13px;
}
/* ------------------------------------------------------------ 状态行 */