1.2 游戏的心智模型:帧、时间、坐标系
环境搭好之后,很多人会直接去写「让方块动起来」的代码。但经验告诉我们,在写第一行动画之前,先把三个约定想清楚,能省掉后面每一章的返工。这三个约定是:时间怎么走、坐标从哪里量、y 轴朝哪边。
它们看起来像常识,实际上每一种都至少有两条互相冲突的「业界惯例」,而且浏览器、数学课本、引擎各选各的。你如果不在开头定死,就会在第 5 章处理点击偏移、第 7 章处理碰撞法线、第 11 章处理 UI 布局时,反复被同一类「方向反了」的 bug 咬。
本节是纯概念 + 少量可跑的验证代码,不给《方块冲刺》加玩法,但它决定了后面所有玩法代码长什么样。读完你应该能回答:为什么游戏里的时间是离散的、dt 为什么以秒为单位、屏幕坐标和世界坐标差在哪几步换算。
1.2.1 一帧就是一次采样
游戏画面不是连续的。你看到的「平滑动画」,其实是每秒几十张静止画面快速切换——这是电影留下的把戏,每秒 24 张就足以骗过人眼。
所以游戏世界运行在一串离散的时刻上,每个时刻叫一帧(frame)。引擎在每一帧做同一套事:读输入 → 更新状态 → 绘制画面。第 N 帧的世界状态,是从第 N-1 帧的状态推算出来的。用一句话概括:
世界不是「一直在动」,而是「每隔一小段时间被重算一次」。
这个视角一旦建立,很多问题就有了答案。比如「为什么快速移动的子弹会穿过墙」——因为碰撞只在每一帧检查一次,子弹在相邻两帧的位置之间可能已经越过了墙,两帧都检测不到。第 7 章会用连续碰撞检测处理它,但根因就在「一帧是一次采样」。
帧率(fps)就是每秒的采样次数。它同时决定了「每帧有多少毫秒可以花」,这个额度叫帧预算(frame budget):
| 目标帧率 | 每帧预算 | 典型设备 |
|---|---|---|
| 30fps | 33.3ms | 低端手机、省电模式 |
| 60fps | 16.7ms | 主流手机与显示器 |
| 120fps | 8.3ms | 高刷屏、ProMotion |
| 144fps | 6.9ms | 电竞显示器 |
这张表的读法是:如果你这一帧的更新 + 绘制超过了预算,就会掉到下一档帧率。 60fps 掉到 30fps 的瞬间,帧时间从 16.7ms 跳到 33.3ms——如果代码把「每帧」当成固定时间单位,速度就会跟着抖一下。
但真实帧率从不恒定:后台标签页被降频、一次垃圾回收、一次纹理上传,都会让某一帧变长。把「每帧」当成固定时间单位,是新手第一个系统性错误。
1.2.2 dt:从时间戳到「这一步走了多久」
既然每帧间隔不固定,代码就不能假设「上一帧到现在刚好过了 1/60 秒」。我们需要在每帧开头,用真实时间戳算出这一帧实际经过了多久,这个量叫 dt(delta time)。
export interface FrameStamp {
now: number; // 本帧的时间戳,毫秒
dt: number; // 距离上一帧经过的秒数
}
export function makeSampler(): (now: number) => FrameStamp {
let last: number | null = null;
return (now: number) => {
if (last === null) {
last = now;
return { now, dt: 0 }; // 第一帧没有「上一帧」,dt 记为 0
}
const dt = (now - last) / 1000; // 毫秒差 → 秒
last = now;
return { now, dt };
};
}
三条约定,和 4.1 节的口径一致,这里先立下:
- 时间戳用
performance.now(),单位毫秒。 它单调递增,不会被系统校时影响。Date.now()可能因 NTP 校时而回跳,导致dt为负、物理倒着走。 dt的单位是秒。 物理公式里的「每秒多少」要用秒,所以时间戳的毫秒差要除以 1000。少除一次,重力就大一千倍。- 第一帧的
dt是 0。 因为没有「上一帧」可减。这个特殊处理是后面固定步长循环里最容易漏的一步。
注意 dt 是浮点数,不是常量。60fps 时它约等于 0.0167,但可能是 0.016、0.021、0.014……正因为如此,速度类代码必须写成 x += v * dt,而不是 x += v。这是 1.3 节和整个第 4 章的主线。
1.2.3 坐标系要回答的三个问题
坐标系听起来抽象,其实只要回答三个问题,一套约定就定死了:
| 问题 | 候选答案 | 谁选了它 |
|---|---|---|
| 原点在哪? | 左上角 / 左下角 / 中心 | Canvas 左上、数学课本中心 |
| y 轴朝哪? | 向下 / 向上 | 屏幕向下、数学向上 |
| 单位是什么? | CSS 像素 / 设备像素 / 世界单位 | 浏览器、DPR、游戏世界 |
这三条一旦定错,症状是「东西画反了」「点偏了」「拉长了」。下面把两套最常用的坐标摊开讲:屏幕坐标(浏览器给的)和世界坐标(游戏逻辑用的)。
1.2.4 屏幕坐标:左上原点,y 向下
浏览器的 Canvas 沿用了印刷与图像处理的传统:原点在左上角,x 向右增大,y 向下增大。这跟你在数学课上画的坐标系正好上下颠倒。
// Canvas 的绘制调用就用这套坐标
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#4ea1ff';
ctx.fillRect(0, 0, 32, 32); // 左上角的一个方块
ctx.fillRect(100, 50, 32, 32); // 往右下移动 → x、y 都变大
}
除了方向,屏幕坐标还有一层坑:同一个像素位置,有「CSS 像素」和「设备像素」两种量法。CSS 像素是你在样式表里写的逻辑尺寸;设备像素是屏幕上真实的发光点。二者之差由 DPR(device pixel ratio)决定——Retina 屏上 DPR 通常是 2 或 3。
export function resizeCanvas(canvas: HTMLCanvasElement, cssW: number, cssH: number): number {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssW * dpr); // 位图分辨率
canvas.height = Math.round(cssH * dpr);
canvas.style.width = `${cssW}px`; // 显示尺寸
canvas.style.height = `${cssH}px`;
return dpr;
}
位图尺寸 = CSS 尺寸 × DPR,显示尺寸 = CSS 尺寸。 忘掉后半句,画面会被放大成模糊的马赛克;忘掉前半句,在 Retina 上画面只有左上角四分之一。这两行是第 2.3 节分辨率适配的起点。
用一组具体数字感受一下差异。假设画布在页面里占 400 × 300 个 CSS 像素:
| 设备 | DPR | canvas.width × height | 显示尺寸 | 若忘记乘 DPR |
|---|---|---|---|---|
| 普通显示器 | 1 | 400 × 300 | 400 × 300 | 正常 |
| MacBook Retina | 2 | 800 × 600 | 400 × 300 | 模糊(被放大 2 倍) |
| 部分安卓旗舰 | 3 | 1200 × 900 | 400 × 300 | 更糊 |
所以「设备像素」和「CSS 像素」不是一回事。凡是和绘制有关的尺寸(canvas.width、相机视口、清屏区域)都用设备像素;凡是和布局有关的尺寸(元素位置、CSS 宽高)都用 CSS 像素。mini2d 内部统一用设备像素,把 CSS 像素只在入口处换算一次。
1.2.5 世界坐标:中心原点,y 向上
游戏逻辑内部不该直接用屏幕坐标。原因有三:
- 屏幕坐标的原点在左上角,让「角色在原点上方」这种描述变成一堆负号。
- 屏幕坐标绑死了像素,而游戏世界想要的是与分辨率无关的「世界单位」。
- 相机要能滚动——玩家往右跑,世界在屏幕上往左移,这个变换必须由相机统一做,而不是让每个对象自己减偏移。
所以 mini2d 采用和 Cocos、Unity 一致的约定:世界原点在视口中心,x 向右、y 向上,单位是「世界单位」。 y 向上是为了和数学课本一致——重力是 -g,抛物线开口向下,三角函数直接用标准定义,不用到处翻符号。
屏幕坐标和世界坐标之间隔着相机。相机有位置 (camX, camY) 和缩放 zoom,一次换算就是「先平移、再翻转 y、再缩放」:
export interface Vec2 { x: number; y: number; }
export interface Camera { x: number; y: number; zoom: number; }
// 世界坐标 → 屏幕坐标(设备像素,左上原点,y 向下)
export function worldToScreen(w: Vec2, cam: Camera, viewW: number, viewH: number): Vec2 {
return {
x: (w.x - cam.x) * cam.zoom + viewW / 2,
y: (cam.y - w.y) * cam.zoom + viewH / 2, // 注意这里有个减法:y 被翻转了
};
}
// 屏幕坐标 → 世界坐标:把上面的式子反解
export function screenToWorld(s: Vec2, cam: Camera, viewW: number, viewH: number): Vec2 {
return {
x: (s.x - viewW / 2) / cam.zoom + cam.x,
y: cam.y - (s.y - viewH / 2) / cam.zoom,
};
}
盯住 worldToScreen 第二行那个减法:(cam.y - w.y)。正是这个减号把「y 向上」的世界翻成了「y 向下」的屏幕。 世界里的高处(y 大)对应屏幕上的上方(y 小)。忘了它,你的角色就会头朝下。
1.2.6 单位、像素与视口
三套坐标各自的单位要记牢,混用是另一类高频 bug:
| 坐标空间 | 原点 | y 方向 | 单位 | 典型来源 |
|---|---|---|---|---|
| CSS 像素 | 页面左上 | 向下 | CSS px | 样式表、getBoundingClientRect |
| 设备像素 | 位图左上 | 向下 | 物理 px | canvas.width/height |
| 世界 | 视口中心 | 向上 | 世界单位 | 游戏逻辑内部 |
mini2d 里还有一个约定:1 世界单位默认对应 1 个设备像素(zoom = 1 时)。这不是必须的,但它让「世界单位」和「像素」在默认情况下数值一致,调试时不用心算。等第 2.3 节做分辨率适配时,我们会用 zoom 把「设计分辨率」映射到任意实际视口——那时世界单位才真正和像素解耦。
一个具体的换算是这样走的。假设鼠标事件给出 clientX = 410, clientY = 220,画布在页面左上角、没有滚动,视口 800×600:
// 1. 浏览器坐标 → 画布局部坐标(这里画布贴左上角,偏移为 0)
const local = { x: 410 - 0, y: 220 - 0 };
// 2. CSS 像素 → 设备像素
const dpr = 2;
const device = { x: local.x * dpr, y: local.y * dpr }; // (820, 440)
// 3. 设备像素 → 世界坐标(相机在原点、zoom=2、视口 800×600 设备像素)
const world = screenToWorld(device, { x: 0, y: 0, zoom: 2 }, 800, 600);
// → { x: 210, y: -70 }
手工验算第三步:x = (820 - 400) / 2 + 0 = 210;y = 0 - (440 - 300) / 2 = -70。世界里的 y 是负的,说明点在中心下方——和屏幕上「y 比中心大」一致。三步里任何一步漏掉,点击位置就会偏几十到几百像素。第 5.1 节会把这三步封装进 PointerRouter,这里先把它们显式摊开,好让你看清每一步在做什么。
1.2.7 一个可单测的往返实验
上面那段换算的正确性,最该用单测锁住的属性是往返一致:世界点 → 屏幕 → 世界,必须回到原点。这是第 16 章「确定性」思路的雏形——先证明变换可逆,再往上叠逻辑。
import { describe, expect, it } from 'vitest';
import { worldToScreen, screenToWorld } from './coord';
describe('坐标往返', () => {
const cam = { x: 0, y: 0, zoom: 2 };
const viewW = 800;
const viewH = 600;
it('世界 (10, 20) 落在屏幕 (420, 260)', () => {
expect(worldToScreen({ x: 10, y: 20 }, cam, viewW, viewH)).toEqual({ x: 420, y: 260 });
});
it('屏幕点能精确还原回世界点', () => {
const w = { x: 10, y: 20 };
const s = worldToScreen(w, cam, viewW, viewH);
const back = screenToWorld(s, cam, viewW, viewH);
expect(back.x).toBeCloseTo(w.x);
expect(back.y).toBeCloseTo(w.y);
});
});
第一组数字手工验算:x = (10 - 0) × 2 + 400 = 420;y = (0 - 20) × 2 + 300 = 260。世界里的 y 是正的(在上方),屏幕里的 y 是 260(在中心 300 的上方,所以更小)——方向关系对上了。
这个测试还有一个隐藏价值:它固定了「相机在原点、zoom=2、视口 800×600」这组参数。第 5.1 节处理指针换算时,用的就是同一组数字,两次实现可以互相印证。
1.2.8 顺带看一眼 Cocos 3.8 的约定
Cocos Creator 3.8 的坐标约定和 mini2d 一致:世界原点在场景原点,y 轴向上,单位是「单位」而非像素。下面这段 L2 片段可以直接对上前面的心智模型(仅保证与 cc.d.ts 类型一致,不参与 mini2d 测试):
import { _decorator, Component, Vec3 } from 'cc';
const { ccclass } = _decorator;
@ccclass('OriginProbe')
export class OriginProbe extends Component {
onLoad(): void {
// 世界坐标:y 越大越靠上,与 mini2d 同向
this.node.setPosition(new Vec3(120, 60, 0));
const p = this.node.position;
console.log(p.x, p.y); // 120 60
}
}
几个对应关系:
- y 向上:
Vec3(0, 60, 0)在原点上方,和mini2d一致,公式可以照搬。 - 两套坐标:Cocos 里节点的
position是相对父节点的局部坐标,worldPosition是世界坐标;UI 节点(UITransform)另有以锚点为基准的坐标系。层次比mini2d多,但「局部 / 世界」的二分是同一个思想。 - 像素换算:屏幕像素与设计分辨率的换算由引擎的
view模块统一处理,你不用像 1.2.4 那样自己乘 DPR。
这里只顺带点一下对应关系。完整的 Cocos 对照在 1.3 节末——那里会讲引擎的
director主循环与Component.update怎么替我们完成这一节的所有约定。
现在「时间怎么走、坐标怎么量」这两件事已经定死。剩下最后一个问题:怎么把「一帧一帧的采样」真正跑起来。 下一节我们写出 mini2d 的第一个 requestAnimationFrame 主循环,把 1.2.2 的 dt 采样接进去,并引入固定步长累加器——让逻辑更新与帧率彻底解耦,再用假时钟在 Node 里把它复现一遍。
阅读导航:上一节:1.1 环境与仓库骨架 · 下一节:1.3 第一个可跑循环:requestAnimationFrame 与固定步长 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。