界面改动都在浏览器预览里逐条核对过(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)通过。
458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
/**
|
||
* 一路画面: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));
|
||
}
|