5.1 输入事件模型与坐标换算
前四章我们把《方块冲刺》做到了「会动」:固定步长让方块匀速右移,相机跟着它滚动。但方块还不会跳,因为它听不见玩家的声音。输入的第一个难点不是「收到事件」,而是事件给我们的那个坐标,和游戏世界里那个坐标,根本不是同一套数。
本节把《方块冲刺》推进到「能收到点击」这一步:用 Pointer Events 接住
pointerdown/move/up,用getBoundingClientRect把浏览器坐标搬进画布,处理 DPR 与 CSS 缩放带来的偏差,再用相机的逆矩阵把屏幕坐标还原成世界坐标。
5.1.1 五种坐标空间
玩家点在屏幕上的一点,要经过四次换算才变成游戏逻辑里的一个世界坐标。任何一次错了,都会出现「点偏了」的诡异现象。先把这五套坐标摊开:
| 坐标空间 | 单位 | 原点 | 由谁产生 |
|---|---|---|---|
| client | CSS 像素 | 浏览器视口左上 | PointerEvent.clientX/Y |
| canvas-local | CSS 像素 | 画布元素左上 | client - rect.left/top |
| device | 物理像素 | 位图左上 | canvas-local × dpr |
| screen | 设备像素 | 视口中心,y 向上 | 由相机约定定义 |
| world | 世界单位 | 世界原点 | 相机逆矩阵的输出 |
最容易混的是中间三套:client 与 canvas-local 之间差的是画布在页面里的位置,canvas-local 与 device 之间差的是 DPR,device 与 world 之间差的是相机。
5.1.2 Pointer Events:一套事件收所有指针
浏览器历史上有三套输入事件:mousedown 系、touchstart 系、以及 2019 年后的 pointerdown 系。Pointer Events 的价值是用一套事件统一鼠标、手指与触控笔,不必为移动端和桌面端各写一份逻辑。
// src/input/input-system.ts
export type PointerPhase = "down" | "move" | "up" | "cancel";
export interface RawPointer {
id: number;
phase: PointerPhase;
clientX: number;
clientY: number;
time: number;
}
const PHASE_BY_TYPE: Record<string, PointerPhase> = {
pointerdown: "down",
pointermove: "move",
pointerup: "up",
pointercancel: "cancel",
};
export class InputSystem {
private readonly canvas: HTMLCanvasElement;
private readonly listeners = new Set<(p: RawPointer) => void>();
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
for (const type of Object.keys(PHASE_BY_TYPE)) {
canvas.addEventListener(type, this.handle, { passive: false });
}
}
private handle = (ev: PointerEvent): void => {
const phase = PHASE_BY_TYPE[ev.type];
if (!phase) return;
const raw: RawPointer = {
id: ev.pointerId,
phase,
clientX: ev.clientX,
clientY: ev.clientY,
time: ev.timeStamp,
};
for (const fn of this.listeners) fn(raw);
};
onPointer(fn: (p: RawPointer) => void): () => void {
this.listeners.add(fn);
return () => {
this.listeners.delete(fn);
};
}
dispose(): void {
for (const type of Object.keys(PHASE_BY_TYPE)) {
this.canvas.removeEventListener(type, this.handle);
}
this.listeners.clear();
}
}
三个设计决定值得说明。其一,pointerId 是跟踪多指的唯一依据——第 5.2 节的手势全靠它区分「同一根手指在动」还是「新手指按下」。其二,passive: false:浏览器默认把触摸监听当被动,preventDefault() 会失效;做手势时要阻止页面滚动,必须显式关掉。其三,返回注销函数,方便切场景时精确解绑,避免监听器泄漏——这与 Cocos 的 input.off 是同一思路。
5.1.3 getBoundingClientRect 与 DPR
clientX/clientY 相对浏览器视口,而我们要的是相对画布。差值就是画布位置,由 getBoundingClientRect() 给出。注意 rect 返回的已是 CSS 像素,且已吃掉页面滚动与 CSS 变换——这正是我们想要的。
// src/input/viewport.ts
export interface ViewportMetrics {
left: number; top: number;
cssWidth: number; cssHeight: number;
dpr: number;
}
export interface CanvasPoint {
cssX: number; cssY: number;
deviceX: number; deviceY: number;
}
export function measure(canvas: HTMLCanvasElement): ViewportMetrics {
const rect = canvas.getBoundingClientRect();
return {
left: rect.left, top: rect.top,
cssWidth: rect.width, cssHeight: rect.height,
dpr: window.devicePixelRatio || 1,
};
}
export function clientToCanvas(
clientX: number, clientY: number, m: ViewportMetrics,
): CanvasPoint {
const cssX = clientX - m.left;
const cssY = clientY - m.top;
return { cssX, cssY, deviceX: cssX * m.dpr, deviceY: cssY * m.dpr };
}
DPR 是这里最容易埋雷的地方。Canvas2D 有两个尺寸:canvas.width/height 是位图尺寸(物理像素),canvas.style.width/height 是显示尺寸(CSS 像素)。在 2 倍屏上如果两者都设成 800,位图会被拉伸到 1600 物理像素,画面发虚。正确做法是让位图 = 显示 × DPR:canvas.width = cssWidth * dpr,canvas.style.width = cssWidth + 'px'。
一个常被忽略的情况:画布若被 CSS transform: scale() 缩放过,rect.width 会等于 cssWidth × 缩放比,而 canvas.width 只反映位图,此时 clientToCanvas 的结果还要再除以这个缩放比。本书的 mini2d 不让画布做 CSS 缩放,但你要知道这个坑存在。
5.1.4 屏幕到世界:相机的逆矩阵
现在拿到的是画布内部的 device 坐标(左上为原点、y 向下),而游戏世界原点在中心、y 向上。两者之间隔着相机。先备好一个 3×3 矩阵与它的逆:
// src/math/mat3.ts
export type Mat3 = readonly [
number, number, number, number, number, number, number, number, number,
];
export function apply(m: Mat3, x: number, y: number): { x: number; y: number } {
return { x: m[0] * x + m[1] * y + m[2], y: m[3] * x + m[4] * y + m[5] };
}
export function invert(m: Mat3): Mat3 {
const a = m[0], b = m[1], c = m[2];
const d = m[3], e = m[4], f = m[5];
const g = m[6], h = m[7], i = m[8];
const A = e * i - f * h;
const B = f * g - d * i;
const C = d * h - e * g;
const det = a * A + b * B + c * C;
if (det === 0) throw new Error("mat3 不可逆:相机缩放是不是 0?");
const s = 1 / det;
return [
A * s, (c * h - b * i) * s, (b * f - c * e) * s,
B * s, (a * i - c * g) * s, (c * d - a * f) * s,
C * s, (b * g - a * h) * s, (a * e - b * d) * s,
];
}
相机的正向矩阵把世界点送到屏幕。第二行那个 -zoom 把世界坐标 y 向上翻成屏幕 y 向下,再叠加相机位置:
// src/camera/camera2d.ts
import { Mat3, apply, invert } from "../math/mat3";
export function worldToScreenMatrix(
camX: number, camY: number, zoom: number, viewW: number, viewH: number,
): Mat3 {
return [
zoom, 0, viewW / 2 - camX * zoom,
0, -zoom, viewH / 2 + camY * zoom,
0, 0, 1,
];
}
export function screenToWorld(
m: Mat3, screenX: number, screenY: number,
): { x: number; y: number } {
return apply(invert(m), screenX, screenY);
}
用一组数字验算:相机在原点、缩放 2、视口 800×600,世界点 (10, 20) 经正向矩阵变成屏幕 (420, 260);把 (420, 260) 丢进 screenToWorld,逆矩阵精确还原出 (10, 20)。这个「往返一致」正是最该被单测锁住的属性。
5.1.5 接起来:一次点击的完整旅程
把上面几块拼起来,就是一个能用的指针路由器:
// src/input/pointer-router.ts
import { Camera2D, screenToWorld } from "../camera/camera2d";
import { ViewportMetrics, measure, clientToCanvas } from "./viewport";
import { InputSystem, RawPointer } from "./input-system";
export class PointerRouter {
private readonly system: InputSystem;
private readonly camera: Camera2D;
private metrics: ViewportMetrics;
constructor(canvas: HTMLCanvasElement, camera: Camera2D) {
this.camera = camera;
this.metrics = measure(canvas);
this.system = new InputSystem(canvas);
this.system.onPointer((p) => this.route(p));
}
/** 页面滚动、缩放或窗口尺寸变化后必须重新测量 */
remeasure(canvas: HTMLCanvasElement): void {
this.metrics = measure(canvas);
}
private route(p: RawPointer): void {
const c = clientToCanvas(p.clientX, p.clientY, this.metrics);
const w = screenToWorld(this.camera.matrix(), c.deviceX, c.deviceY);
this.camera.onWorldPointer({ id: p.id, phase: p.phase, worldX: w.x, worldY: w.y, time: p.time });
}
dispose(): void {
this.system.dispose();
}
}
一次点击的旅程因此是:PointerEvent → client → canvas-local → device → world → 玩法逻辑。每一段都可单独测试,这正是我们把它拆成 viewport.ts、mat3.ts、camera2d.ts、pointer-router.ts 四个文件的原因。
5.1.6 用 vitest 锁住换算
坐标换算最怕「看起来对」。用假数据把边界情况固定下来:
// test/input-coords.test.ts
import { describe, it, expect } from "vitest";
import { clientToCanvas } from "../src/input/viewport";
import { apply } from "../src/math/mat3";
import { worldToScreenMatrix, screenToWorld } from "../src/camera/camera2d";
describe("坐标换算", () => {
it("把 client 坐标搬进画布并乘上 DPR", () => {
const m = { left: 100, top: 50, cssWidth: 800, cssHeight: 600, dpr: 2 };
const p = clientToCanvas(300, 250, m);
expect([p.cssX, p.cssY, p.deviceX, p.deviceY]).toEqual([200, 200, 400, 400]);
});
it("world -> screen -> world 往返一致", () => {
const m = worldToScreenMatrix(120, -30, 1.5, 1280, 720);
const forward = apply(m, 33, 77);
const back = screenToWorld(m, forward.x, forward.y);
expect(back.x).toBeCloseTo(33, 6);
expect(back.y).toBeCloseTo(77, 6);
});
});
第二条是这类代码的黄金法则:不关心中间值是多少,只关心「去而复返」是否回到原点。它在你换掉相机实现后依然有效。
5.1.7 四个常见坑
- rect 会被缓存欺骗:页面滚动、窗口缩放、软键盘弹出都会让
rect变化。别只在启动时测一次,在resize、scroll、orientationchange时重测。 passive与preventDefault:想阻止页面跟着手指滚动,监听必须{ passive: false }。pointercancel不能漏:来电、系统手势会打断触摸,不收这个事件,游戏会卡在「手指按着」的状态。- DPR 会变:窗口拖到不同缩放的外接屏时
devicePixelRatio会变,用matchMedia监听resolution变化并重建位图。
5.1.8 Cocos 对照:input 与 Camera.screenToWorld
同一件事在 Cocos Creator 3.8 里怎么做?
| 要做的事 | mini2d | Cocos 3.8 |
|---|---|---|
| 注册触摸 | canvas.addEventListener | input.on(Input.EventType.TOUCH_START, cb, this) |
| 拿触点坐标 | clientToCanvas | event.getUILocation(vec2) |
| 屏幕→世界 | 相机逆矩阵 | camera.screenToWorld(screenVec3, out) |
| 世界→节点局部 | 手动减去节点坐标 | uiTransform.convertToNodeSpaceAR(worldVec3, out) |
| 系统事件 | 无对应 | systemEvent(3.4 起废弃,改用 input) |
Cocos 把这些换算封装好了,但分层逻辑与 mini2d 一模一样:getUILocation 对应 client→canvas 段,screenToWorld 对应逆矩阵段,convertToNodeSpaceAR 对应世界→局部的最后一段。
import {
_decorator, Component, Node, input, Input, EventTouch,
Vec2, Vec3, Camera, UITransform,
} from 'cc';
const { ccclass, property } = _decorator;
@ccclass('BlockTapController')
export class BlockTapController extends Component {
@property(Camera)
camera: Camera | null = null;
private readonly _uiPos = new Vec2();
onEnable(): void {
input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);
}
onDisable(): void {
input.off(Input.EventType.TOUCH_START, this.onTouchStart, this);
}
private onTouchStart(event: EventTouch): void {
event.getUILocation(this._uiPos); // 屏幕 / UI 坐标
const world = new Vec3(this._uiPos.x, this._uiPos.y, 0);
if (this.camera) this.camera.screenToWorld(world, world);
const ui = this.node.getComponent(UITransform);
if (ui) console.log('命中局部坐标', ui.convertToNodeSpaceAR(world).x);
}
}
注意 Cocos 的 y 轴方向:getUILocation 返回的是 y 向上的 UI 坐标,与我们的 world 约定一致,所以到 screenToWorld 之间不需要额外翻转——这和原生 Canvas 的 y 向下正好相反,是从网页迁移过来最容易翻车的一处。
下一节我们会用这套坐标链路去识别手势:一次点击和一次滑动怎么区分、双指捏合怎么算缩放、多根手指的轨迹怎么各自跟踪。
阅读导航:上一节:目录 · 下一节:5.2 触摸手势与多点触控 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。