《Cocos 游戏开发入门》5.1 输入事件模型与坐标换算

本节用 Pointer Events 统一鼠标与触摸,讲清 client、canvas、device、screen、world 五种坐标空间的换算:getBoundingClientRect 校正画布偏移、DPR 对齐位图与 CSS 尺寸、相机逆矩阵把屏幕坐标还原成世界坐标,并用 vitest 断言往返一致,最后对照 Cocos 3.8 的 input 与 screenToWorld。

5.1 输入事件模型与坐标换算

前四章我们把《方块冲刺》做到了「会动」:固定步长让方块匀速右移,相机跟着它滚动。但方块还不会跳,因为它听不见玩家的声音。输入的第一个难点不是「收到事件」,而是事件给我们的那个坐标,和游戏世界里那个坐标,根本不是同一套数。

本节把《方块冲刺》推进到「能收到点击」这一步:用 Pointer Events 接住 pointerdown/move/up,用 getBoundingClientRect 把浏览器坐标搬进画布,处理 DPR 与 CSS 缩放带来的偏差,再用相机的逆矩阵把屏幕坐标还原成世界坐标。

5.1.1 五种坐标空间

玩家点在屏幕上的一点,要经过四次换算才变成游戏逻辑里的一个世界坐标。任何一次错了,都会出现「点偏了」的诡异现象。先把这五套坐标摊开:

坐标空间单位原点由谁产生
clientCSS 像素浏览器视口左上PointerEvent.clientX/Y
canvas-localCSS 像素画布元素左上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 里怎么做?

要做的事mini2dCocos 3.8
注册触摸canvas.addEventListenerinput.on(Input.EventType.TOUCH_START, cb, this)
拿触点坐标clientToCanvasevent.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 触摸手势与多点触控 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画