Skip to content

Canvas 2D 游戏循环基础

游戏循环是你的游戏的心跳。它持续运行,按浏览器允许的速度更新游戏状态和绘制画面。这一步做对了,后面一切都好办。做错了,你会在某些机器上追查那些只在特定环境复现的 bug。

试一试(用方向键或 WASD):

最简版本

下面是游戏循环最精简的样子:

js
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

js
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 对简单移动够用,但对物理模拟会出问题。如果某一帧很慢,角色可能会穿墙——因为碰撞检测错过了撞击瞬间。

解决办法是固定时间步。你累计时间,按固定频率运行物理更新,无论渲染多快。

js
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 次物理更新。

组织游戏状态

游戏长大后,所有数据需要个家。我喜欢用一个对象装下所有东西:

js
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 打到控制台调试都简单。有人偏好类,对中小型游戏来说普通对象就够了。

拼到一起

js
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 函数读取这些标志。这种解耦很重要,因为键盘事件在不可预测的时机触发,而你想让游戏逻辑以稳定速率运行。

加载并绘制精灵

js
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 在绘制前会检查精灵是否存在,避免你画一个还没加载的东西时出错。

帧动画精灵

js
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 裁出当前帧。

用法:

js
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// 在 update 里:
playerWalk.update(dt)

// 在 render 里:
playerWalk.draw(ctx, player.x, player.y)

碰撞检测

js
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,轴对齐包围盒)最简单也最快,大多数场景用它。圆形碰撞更适合圆形物体,或想让碰撞手感更宽容时。

实体不多时,两两比较没问题。如果实体上百,就要用空间分区(四叉树或网格),那是另一篇教程的话题。

一个简单的相机

js
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 会得到漂亮的拖尾效果。

暂停游戏

js
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 年的网页游戏技术栈 帮你梳理权衡。网页游戏引擎对比 讲那些帮你管好游戏循环的框架。

外部资源