/** * 一路画面:canvas 绘制 + 缩放/平移 + 网格 + 瞄准器 + 命中反馈。 * * # 为什么画在 canvas 上而不是用 CSS 变换 `` * * 瞄准器必须跟着**画面**走:它标的是相机光轴上的一个点。用 DOM 摆一个 `` 再叠一层 * `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(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(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; } // 命中动画期间必须持续重绘,否则抖动只画了一帧就停住。 const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY; const animating = pulseAge < 700 && (current.hitFeedback.shake || 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) => { 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) => { event.currentTarget.setPointerCapture(event.pointerId); drag.current = { x: event.clientX, y: event.clientY, originX: view.x, originY: view.y }; }; const onPointerMove = (event: React.PointerEvent) => { 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 (
onView(IDENTITY_VIEW)} > {emptyText !== "" &&
{emptyText}
} {showZoom && view.zoom > MIN_ZOOM + 0.05 && (
×{view.zoom.toFixed(2)}
)} {(view.zoom > MIN_ZOOM + 0.05 || view.x !== 0 || view.y !== 0) && ( )} {calibrating &&
校准中:拖动画面挪动瞄准点
} {children}
); } // --------------------------------------------------------------------------- // 绘制 // --------------------------------------------------------------------------- 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; 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); 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, shake); } } // 瞄准点的屏幕位置:图像坐标系里的 (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 + shake.x, aimY + shake.y, 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 + shake.x + 24, aimY + shake.y - 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; 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)); }