Files
ESP32S3_vt/desktop/src/components/VideoSurface.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

458 lines
14 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.
/**
* 一路画面:canvas 绘制 + 缩放/平移 + 网格 + 瞄准器 + 命中反馈。
*
* # 为什么画在 canvas 上而不是用 CSS 变换 `<img>`
*
* 瞄准器必须跟着**画面**走:它标的是相机光轴上的一个点。用 DOM 摆一个 `<img>` 再叠一层
* `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而两份实现迟早会不一致——表现
* 是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。
*
* 所以变换只算一次(`layout()`),图像、网格、瞄准器、伤害数字全都用同一组数画出来。
*
* # 尺寸与缩放的关系
*
* - 图像按 `contain` 放进窗格,再乘 `zoom`。
* - **网格**画在图像矩形里,跟着缩放走:它标的是画面里的比例,用来估距离。
* - **瞄准器**的**位置**跟着图像走,**大小**不跟着:像真瞄具一样,放大之后准星不会
* 跟着变粗,而是覆盖画面里更小的一块——那正是放大有用武之地的原因。
* - 镜像靠 `scale(-1, 1)`,同时把拖动方向也翻过来,否则向右拖画面会往左走。
*/
import { useEffect, useRef, type ReactNode } from "react";
import { streamClient } from "../lib/store";
import { withAlpha } from "../lib/format";
import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types";
export interface ViewTransform {
zoom: number;
x: number;
y: number;
}
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, x: 0, y: 0 };
/** 缩放上下限。再多只是把像素拉大。 */
export const MIN_ZOOM = 1;
export const MAX_ZOOM = 8;
export const ZOOM_STEP = 1.25;
export interface HitPulse {
/** 每次命中都不同,用来重启动画。 */
seq: number;
damage: number;
/** `performance.now()` 时刻。 */
at: number;
}
interface Props {
camera: number | null;
emptyText: string;
mirrored: boolean;
crosshair: CrosshairConfig | null;
grid: GridConfig | null;
view: ViewTransform;
onView: (view: ViewTransform) => void;
/** 校准模式:拖动画面来挪瞄准点。 */
calibrating?: boolean;
onCalibrate?: (dxFraction: number, dyFraction: number) => void;
/** 命中脉冲。只有属于这一路的命中才该传进来。 */
hit?: HitPulse | null;
hitFeedback: HitFeedbackConfig;
showZoom: boolean;
children?: ReactNode;
}
export function VideoSurface({
camera,
emptyText,
mirrored,
crosshair,
grid,
view,
onView,
calibrating = false,
onCalibrate,
hit = null,
hitFeedback,
showZoom,
children,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。
const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback });
props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback };
const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
const dirty = useRef(true);
const lastFrameId = useRef<number | null>(null);
useEffect(() => {
dirty.current = true;
}, [camera, mirrored, crosshair, grid, view, hit, hitFeedback]);
// canvas 的像素尺寸跟着 CSS 尺寸与 DPR 走。不这么做的话在 Retina 上会糊。
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
dirty.current = true;
}
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
return () => observer.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let raf = 0;
const loop = (timestamp: number) => {
raf = requestAnimationFrame(loop);
const current = props.current;
const frame = streamClient().latest(current.camera);
if (frame && frame.frameId !== lastFrameId.current) {
lastFrameId.current = frame.frameId;
dirty.current = true;
}
// 命中动画期间必须持续重绘,否则闪红/伤害数字只画了一帧就停住。
//
// **抖动没有了**(见 `HitFeedbackConfig::shake` 的文档):不再有"整幅图跟着晃"的
// 那一路,所以这里也不再需要因为抖动而复绘。
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
const animating =
pulseAge < 700 && (current.hitFeedback.marker || current.hitFeedback.flash);
if (!dirty.current && !animating) return;
dirty.current = false;
draw(canvas, current, frame, timestamp);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
// ---- 输入 ----
const onWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const factor = event.deltaY > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
const zoom = clamp(view.zoom * factor, MIN_ZOOM, MAX_ZOOM);
// 缩回 1 时顺便归位:此时画面完整可见,平移没有意义,留着会让下一次放大从偏移位置
// 开始,看起来像"跳了一下"。
onView(zoom <= MIN_ZOOM ? IDENTITY_VIEW : { ...view, zoom });
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { x: event.clientX, y: event.clientY, originX: view.x, originY: view.y };
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const start = drag.current;
if (!start) return;
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
if (calibrating) {
// 校准:把屏幕上的位移换算成画面里的比例位移。用当前显示出来的图像尺寸做分母,
// 所以放大之后校准更精细——这正是需要的。
const box = canvasRef.current;
if (!box || !onCalibrate) return;
const frame = streamClient().latest(camera);
if (!frame) return;
const { drawWidth, drawHeight } = layout(box, frame.width, frame.height, view);
if (drawWidth <= 0 || drawHeight <= 0) return;
// 一次移动一小步,然后把起点挪过来。
drag.current = { ...start, x: event.clientX, y: event.clientY };
const sign = mirrored ? -1 : 1;
onCalibrate((sign * dx) / drawWidth, dy / drawHeight);
return;
}
if (view.zoom <= MIN_ZOOM) return;
// 镜像时把横向拖动也翻过来:否则向右拖,画面往左走,看起来像拖动坏了。
const sign = mirrored ? -1 : 1;
onView({ ...view, x: start.originX + sign * dx, y: start.originY + dy });
};
const endDrag = () => {
drag.current = null;
};
const cursor = calibrating ? "crosshair" : view.zoom > MIN_ZOOM ? "grab" : "default";
return (
<div
className="video-surface"
style={{ cursor }}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={() => onView(IDENTITY_VIEW)}
>
<canvas ref={canvasRef} className="video-canvas" />
{emptyText !== "" && <div className="video-empty">{emptyText}</div>}
{showZoom && view.zoom > MIN_ZOOM + 0.05 && (
<div className="video-badge video-badge--zoom">×{view.zoom.toFixed(2)}</div>
)}
{(view.zoom > MIN_ZOOM + 0.05 || view.x !== 0 || view.y !== 0) && (
<button className="video-badge video-badge--reset" onClick={() => onView(IDENTITY_VIEW)}>
复位
</button>
)}
{calibrating && <div className="video-calibrating">校准中:拖动画面挪动瞄准点</div>}
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// 绘制
// ---------------------------------------------------------------------------
interface CanvasProps {
camera: number | null;
mirrored: boolean;
crosshair: CrosshairConfig | null;
grid: GridConfig | null;
view: ViewTransform;
hit: HitPulse | null;
hitFeedback: HitFeedbackConfig;
}
interface Box {
clientWidth: number;
clientHeight: number;
}
/** 图像在窗格里的落点与大小(CSS 像素)。 */
function layout(box: Box, frameWidth: number, frameHeight: number, view: ViewTransform) {
const width = box.clientWidth;
const height = box.clientHeight;
if (frameWidth <= 0 || frameHeight <= 0) {
return { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
}
const fit = Math.min(width / frameWidth, height / frameHeight);
const drawWidth = frameWidth * fit * view.zoom;
const drawHeight = frameHeight * fit * view.zoom;
return {
left: (width - drawWidth) / 2 + view.x,
top: (height - drawHeight) / 2 + view.y,
drawWidth,
drawHeight,
};
}
function draw(
canvas: HTMLCanvasElement,
current: CanvasProps,
frame: { bitmap: ImageBitmap; width: number; height: number } | undefined,
timestamp: number,
) {
const dpr = window.devicePixelRatio || 1;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const box = { clientWidth: width, clientHeight: height };
// 没有画面时把瞄准器留在窗格中心:录像断了,准星不该跟着消失。
const fallback = { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
const geometry = frame
? layout(box, frame.width, frame.height, current.view)
: fallback;
if (frame) {
ctx.save();
if (current.mirrored) {
ctx.translate(width, 0);
ctx.scale(-1, 1);
}
// 放大超过 3 倍时关掉插值:这时候要看的是像素本身,双线性插值只会把它糊掉。
ctx.imageSmoothingEnabled = current.view.zoom < 3;
ctx.drawImage(frame.bitmap, geometry.left, geometry.top, geometry.drawWidth, geometry.drawHeight);
ctx.restore();
if (current.grid?.visible) {
drawGrid(ctx, geometry, current.grid);
}
}
// 瞄准点的屏幕位置:图像坐标系里的 (0.5 + offsetX, 0.5 + offsetY)。
const crosshair = current.crosshair;
let aimX = width / 2;
let aimY = height / 2;
if (crosshair && frame && geometry.drawWidth > 0) {
const rawX = geometry.left + (0.5 + crosshair.offsetX) * geometry.drawWidth;
aimX = current.mirrored ? width - rawX : rawX;
aimY = geometry.top + (0.5 + crosshair.offsetY) * geometry.drawHeight;
}
if (crosshair?.visible) {
drawCrosshair(ctx, aimX, aimY, Math.min(width, height), crosshair);
}
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
if (current.hit && current.hitFeedback.marker) {
const age = timestamp - current.hit.at;
if (age >= 0 && age < 700) {
const progress = age / 700;
ctx.save();
ctx.globalAlpha = 1 - progress;
ctx.fillStyle = "#ff8a8a";
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 + 24, aimY - 26 - progress * 26);
ctx.restore();
}
}
}
function drawGrid(
ctx: CanvasRenderingContext2D,
geometry: { left: number; top: number; drawWidth: number; drawHeight: number },
grid: GridConfig,
) {
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
ctx.save();
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
ctx.lineWidth = 1;
const x0 = geometry.left;
const y0 = geometry.top;
const x1 = x0 + geometry.drawWidth;
const y1 = y0 + geometry.drawHeight;
if (grid.style === "thirds") {
for (const fraction of [1 / 3, 2 / 3]) {
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
}
} else if (grid.style === "squares") {
const divisions = Math.max(2, grid.divisions);
for (let i = 1; i < divisions; i += 1) {
const fraction = i / divisions;
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
}
} else {
// 中心十字。
line(ctx, x0 + geometry.drawWidth / 2, y0, x0 + geometry.drawWidth / 2, y1);
line(ctx, x0, y0 + geometry.drawHeight / 2, x1, y0 + geometry.drawHeight / 2);
}
ctx.restore();
}
function line(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) {
// 半像素偏移,让 1px 的线落在像素中心而不是跨两个像素发虚。
ctx.beginPath();
ctx.moveTo(Math.round(x0) + 0.5, Math.round(y0) + 0.5);
ctx.lineTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
ctx.stroke();
}
function drawCrosshair(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
shortSide: number,
config: CrosshairConfig,
) {
const arm = Math.max(3, config.size * shortSide);
const gap = config.gap * shortSide;
const thickness = Math.max(1, config.thickness * shortSide);
// 外描一圈深色:浅色背景下亮绿准星会消失,这一圈让它始终看得见。
const halo = withAlpha("#000000", 0.55);
const stroke = (color: string, width: number, radius: number) => {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
if (config.style === "circle-dot") {
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
} else {
// 四臂,中心留 gap 的空隙。
ctx.moveTo(cx - radius, cy);
ctx.lineTo(cx - gap, cy);
ctx.moveTo(cx + gap, cy);
ctx.lineTo(cx + radius, cy);
ctx.moveTo(cx, cy - radius);
ctx.lineTo(cx, cy - gap);
ctx.moveTo(cx, cy + gap);
ctx.lineTo(cx, cy + radius);
}
ctx.stroke();
};
stroke(halo, thickness + 2, arm + 1);
stroke(config.color, thickness, arm);
// 中心点:`cross` 之外都画。
if (config.style !== "cross") {
const dot = Math.max(1.2, thickness * 0.9);
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, dot + 1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = config.color;
ctx.beginPath();
ctx.arc(cx, cy, dot, 0, Math.PI * 2);
ctx.fill();
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}