Canvas 2D 游戏循环基础
游戏循环是你的游戏的心跳。它持续运行,按浏览器允许的速度更新游戏状态和绘制画面。这一步做对了,后面一切都好办。做错了,你会在某些机器上追查那些只在特定环境复现的 bug。
试一试(用方向键或 WASD):
最简版本
下面是游戏循环最精简的样子:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function gameLoop() {
update()
render()
requestAnimationFrame(gameLoop)
}
function update() {
// 游戏逻辑
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制
}
gameLoop()这能跑,但有个问题。update 函数不知道距离上一帧过了多久。在 144Hz 显示器上,它跑得比 60Hz 快一倍。你的游戏物理在不同机器上表现就不一样。
用 delta time 解决帧率问题
要让游戏在哪儿都跑得一样快,必须跟踪每帧之间经过了多少时间。这叫 delta time,通常缩写为 dt。
let lastTime = 0
function gameLoop(currentTime) {
const dt = (currentTime - lastTime) / 1000 // 换成秒
lastTime = currentTime
update(dt)
render()
requestAnimationFrame(gameLoop)
}
function update(dt) {
player.x += player.speed * dt // 现在任意帧率都对
}
requestAnimationFrame(gameLoop)requestAnimationFrame 的回调收到一个毫秒级时间戳。我除以 1000 换成秒,是因为这样数学更直观。速度 200 就表示每秒 200 像素,符合直觉。
要注意一件事:第一帧时 lastTime 是 0,dt 会非常大。一些人会在启动循环前用 performance.now() 初始化 lastTime,或者跳过第一帧的 update。
为什么用 requestAnimationFrame 而不是 setInterval
你可能想问为什么不用 setInterval。有几个原因。
第一,requestAnimationFrame 与显示器刷新率同步,画面流畅且不撕裂。第二,标签页隐藏时它会自动暂停,省电省 CPU。第三,浏览器对它做了更好的优化。
这种"自动暂停"通常正合你意,但也意味着用户切到别的标签时游戏会冻结。如果你做的是多人游戏或需要持续运行的东西,得换一种方法。
物理用固定时间步
变长的 delta time 对简单移动够用,但对物理模拟会出问题。如果某一帧很慢,角色可能会穿墙——因为碰撞检测错过了撞击瞬间。
解决办法是固定时间步。你累计时间,按固定频率运行物理更新,无论渲染多快。
const FIXED_DT = 1 / 60 // 每秒 60 次物理更新
let accumulator = 0
let lastTime = 0
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}那个 Math.min(..., 0.25) 很重要。如果标签页被隐藏后又切回来,frameTime 可能巨大。没有这个上限,while 循环会跑几百次更新去追赶,把游戏卡死。封顶 0.25 秒意味着一帧最多跑 15 次物理更新。
组织游戏状态
游戏长大后,所有数据需要个家。我喜欢用一个对象装下所有东西:
const game = {
state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
score: 0,
player: {
x: 100,
y: 100,
vx: 0,
vy: 0,
speed: 200,
width: 32,
height: 32,
},
entities: [],
}这样存取游戏状态、玩家死亡时重置、把 game 打到控制台调试都简单。有人偏好类,对中小型游戏来说普通对象就够了。
拼到一起
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0
const input = { left: false, right: false, up: false, down: false }
const game = {
player: { x: 100, y: 100, speed: 200 },
}
function update(dt) {
const p = game.player
if (input.left) p.x -= p.speed * dt
if (input.right) p.x += p.speed * dt
if (input.up) p.y -= p.speed * dt
if (input.down) p.y += p.speed * dt
// 限制在范围内
p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}
function render() {
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#4ade80'
ctx.fillRect(game.player.x, game.player.y, 32, 32)
}
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}
// 输入处理
window.addEventListener('keydown', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})
window.addEventListener('keyup', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})
requestAnimationFrame(gameLoop)这是一个完整、可跑的游戏循环。玩家用方向键或 WASD 移动,待在范围内,不管帧率多少速度都一致。可以当成任何 Canvas 2D 游戏的起点拷贝。
注意输入处理与游戏循环是分开的。事件监听器只设置标志,update 函数读取这些标志。这种解耦很重要,因为键盘事件在不可预测的时机触发,而你想让游戏逻辑以稳定速率运行。
加载并绘制精灵
const sprites = {}
async function loadSprite(name, url) {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
sprites[name] = img
resolve(img)
}
img.src = url
})
}
function drawSprite(name, x, y, width, height) {
const sprite = sprites[name]
if (sprite) {
ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
}
}图像是异步加载的,所以游戏开始之前要等加载完。常见做法是在一个 loadAssets 函数里加载所有精灵并返回 Promise,等它 resolve 后再启动游戏循环。
drawSprite 在绘制前会检查精灵是否存在,避免你画一个还没加载的东西时出错。
帧动画精灵
class AnimatedSprite {
constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
this.image = image
this.frameWidth = frameWidth
this.frameHeight = frameHeight
this.frameCount = frameCount
this.frameDuration = 1 / fps
this.currentFrame = 0
this.elapsed = 0
}
update(dt) {
this.elapsed += dt
if (this.elapsed >= this.frameDuration) {
this.currentFrame = (this.currentFrame + 1) % this.frameCount
this.elapsed = 0
}
}
draw(ctx, x, y) {
ctx.drawImage(
this.image,
this.currentFrame * this.frameWidth, 0,
this.frameWidth, this.frameHeight,
x, y,
this.frameWidth, this.frameHeight
)
}
}这个类假设精灵表的帧在一行里横向排列,每帧大小一致。update 按时间推进帧,draw 用 9 参数的 drawImage 裁出当前帧。
用法:
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)
// 在 update 里:
playerWalk.update(dt)
// 在 render 里:
playerWalk.draw(ctx, player.x, player.y)碰撞检测
function rectCollision(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
)
}
function circleCollision(a, b) {
const dx = a.x - b.x
const dy = a.y - b.y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < a.radius + b.radius
}
// 在 update 里:
for (const enemy of game.enemies) {
if (rectCollision(game.player, enemy)) {
handleCollision(game.player, enemy)
}
}矩形碰撞(也叫 AABB,轴对齐包围盒)最简单也最快,大多数场景用它。圆形碰撞更适合圆形物体,或想让碰撞手感更宽容时。
实体不多时,两两比较没问题。如果实体上百,就要用空间分区(四叉树或网格),那是另一篇教程的话题。
一个简单的相机
const camera = {
x: 0,
y: 0,
follow(target) {
this.x = target.x - canvas.width / 2
this.y = target.y - canvas.height / 2
}
}
function render() {
ctx.save()
ctx.translate(-camera.x, -camera.y)
// 画世界
drawWorld()
drawEntities()
ctx.restore()
// 画 UI(不受相机影响)
drawUI()
}诀窍是 ctx.save() 和 ctx.restore()。保存当前变换状态,用 translate 加上相机偏移,在世界空间画所有东西,然后恢复,让 UI 不受相机影响。
要让相机移动更平滑,可以用 lerp 朝目标靠近,而不是直接对齐。比如 this.x += (targetX - this.x) * 0.1 会得到漂亮的拖尾效果。
暂停游戏
let paused = false
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape') {
paused = !paused
}
})
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
if (!paused) {
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
}
render()
if (paused) {
renderPauseOverlay()
}
requestAnimationFrame(gameLoop)
}
function renderPauseOverlay() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#fff'
ctx.font = '32px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('PAUSED', canvas.width / 2, canvas.height / 2)
}关键洞察是:暂停时不再累计时间,但继续渲染。这样暂停覆盖层立刻出现,游戏对取消暂停的输入也仍然有响应。
可以把这个模式扩展到处理多种游戏状态——菜单、过场动画、游戏结束。只要检查当前状态,路由到对应的 update/render。
常见错误
我见过的一些坑:
**忘记清空 canvas。**如果不在 render 开头调 clearRect,你会在上一帧之上画,得到拖影。
**没有限制 delta time。**用户切走再回来时,巨大的 delta time 会破坏物理,或者让游戏"补帧"补好几秒。
**在图片加载前绘制。**Canvas 会静默忽略未加载图片的 drawImage 调用。你的精灵就是不出来,没有报错。
**用 setInterval 跑循环。**它不和显示器同步,标签页隐藏时也不会暂停。用 requestAnimationFrame。
**混淆世界坐标和屏幕坐标。**加相机后,要清楚一个位置是世界空间还是屏幕空间。鼠标点击是屏幕空间,需要换算。
下一步
这篇教程涵盖基础。等你熟悉这些模式后,可以看看:
响应式游戏画布 处理不同屏幕尺寸和高 DPI 显示器。
游戏输入处理 触控、手柄和更复杂的输入系统。
像素艺术渲染 不模糊的清晰像素图。
游戏物理库 给游戏循环加上刚体物理、碰撞和关节。
如果你在 Canvas 2D、WebGL 和 WebGPU 之间选不定,2026 年的网页游戏技术栈 帮你梳理权衡。网页游戏引擎对比 讲那些帮你管好游戏循环的框架。
外部资源
- MDN: requestAnimationFrame —— 浏览器 API 参考
- MDN: Canvas API —— 完整 Canvas 2D 文档
- MDN: KeyboardEvent —— 键码和事件属性
- Fix Your Timestep! —— Glenn Fiedler 关于固定时间步的经典文章