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:
@@ -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
|
||||
|
||||
@@ -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 ? "我方" : "对手";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user