Skip to content

03 游戏开发核心概念

预计阅读 35 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0(最低兼容 ≥ 2.30.0)· 微信开发者工具:稳定版 1.06+

本章讲解游戏开发的十大核心概念。每个概念都从前端视角切入,帮助你建立正确的游戏开发心智模型。掌握这些概念后,引擎的本质就是在这些基础概念之上加了抽象层和编辑器。


3.1 游戏循环 (Game Loop)

游戏循环是游戏开发中最重要的概念——没有之一。

前端开发中你从不手动控制渲染时机(React/Vue 自动响应数据变化),而游戏中你需要主动在每一帧中更新和渲染所有内容。

typescript
let lastTime = 0

function gameLoop(timestamp: number) {
  const deltaTime = (timestamp - lastTime) / 1000
  lastTime = timestamp

  update(deltaTime)
  render()

  requestAnimationFrame(gameLoop)
}

requestAnimationFrame(gameLoop)

帧率无关的更新

不同设备的帧率不同(60fps / 90fps / 120fps / 掉帧)。如果你每帧让角色移动固定的像素数,游戏在 120fps 设备上会运行得更快:

typescript
// ❌ 错误:帧率相关
player.x += 5

// ✅ 正确:按秒移动,与帧率无关
player.x += speed * deltaTime
固定时间步 (Fixed Timestep)

不同设备的帧率不同(60fps / 120fps / 掉帧),直接用 deltaTime 可能导致物理模拟不一致。使用固定时间步更新 + 可变帧率渲染:

typescript
const FIXED_DT = 1 / 60
let accumulator = 0

function gameLoop(timestamp: number) {
  const frameTime = (timestamp - lastTime) / 1000
  lastTime = timestamp

  // 限制最大帧间隔,避免切后台后物理爆炸
  accumulator += Math.min(frameTime, 0.25)

  while (accumulator >= FIXED_DT) {
    fixedUpdate(FIXED_DT) // 物理/逻辑始终按固定步长
    accumulator -= FIXED_DT
  }

  render(accumulator / FIXED_DT) // 用剩余比例做插值渲染
  requestAnimationFrame(gameLoop)
}

3.2 渲染 (Rendering)

Canvas 2D 渲染管线

清空画布 → 绘制背景 → 绘制游戏对象 → 绘制 UI → 呈现

每一帧都需要手动重绘,没有浏览器那样的"局部更新"。

CSS 布局 vs Canvas 渲染
概念CSS/DOMCanvas 2D
元素定位position + top/leftctx.drawImage(img, x, y)
层级管理z-index绘制顺序(先绘制的在底层)
变换transformctx.translate/rotate/scale
动画CSS Transition每帧手动更新坐标并重绘
文本排版自动换行、行高需手动测量和拆分

绘制顺序与层级

在 Canvas 中,后绘制的像素会覆盖先绘制的像素。因此典型的绘制顺序是:

  1. 背景(最底层)
  2. 远处装饰/远景
  3. 游戏对象(玩家、敌人、道具)
  4. 前景/粒子效果
  5. UI(最上层)
typescript
function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)

  drawBackground()
  drawPipes()
  drawBird()
  drawParticles()
  drawUI()
}

3.3 输入处理 (Input)

typescript
wx.onTouchStart((event) => {
  const { clientX: x, clientY: y } = event.touches[0]
  // 处理触摸开始
})

触摸事件三件套

事件触发时机典型用途
touchstart手指按下按钮点击、角色跳跃
touchmove手指移动拖拽、滑屏、摇杆
touchend手指抬起释放技能、结束拖拽

多点触控与 UI 冲突

小游戏同时支持多点触控。处理输入时要注意:

typescript
// 仅响应第一个触点的跳跃操作
let jumpPointerId: number | null = null

wx.onTouchStart((e) => {
  const touch = e.touches[0]
  if (jumpPointerId === null) {
    jumpPointerId = touch.identifier
    bird.flap()
  }
})

wx.onTouchEnd((e) => {
  for (const touch of e.changedTouches) {
    if (touch.identifier === jumpPointerId) {
      jumpPointerId = null
    }
  }
})
前端经验迁移

前端中 click 事件约 300ms 延迟(移动端),游戏中通常使用 touchstart 获得即时反馈。但这也意味着更容易误触,需要为按钮添加合适的触控热区。


3.4 碰撞检测 (Collision Detection)

AABB(轴对齐包围盒)

最简单实用的碰撞检测,适合管道、砖块、平台等规则物体:

typescript
interface Rect {
  x: number
  y: number
  width: number
  height: number
}

function aabbCollision(a: Rect, b: Rect): boolean {
  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
}

圆形碰撞

适合子弹、球体、范围技能:

typescript
function circleCollision(
  a: { x: number; y: number; r: number },
  b: { x: number; y: number; r: number }
) {
  const dx = a.x - b.x
  const dy = a.y - b.y
  const dist = Math.sqrt(dx * dx + dy * dy)
  return dist < a.r + b.r
}

圆与矩形碰撞

飞机射击游戏中常用:子弹(圆)vs 敌机(矩形)。

typescript
function circleRectCollision(circle: { x: number; y: number; r: number }, rect: Rect): boolean {
  // 找到矩形上离圆心最近的点
  const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width))
  const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height))

  const dx = circle.x - closestX
  const dy = circle.y - closestY
  return dx * dx + dy * dy < circle.r * circle.r
}

性能优化方案:空间分区(四叉树/网格)、分层检测(粗筛→精检)、对象池。


3.5 状态管理 (State Machine)

typescript
type GameState = 'loading' | 'menu' | 'playing' | 'paused' | 'gameOver'

class GameStateMachine {
  private current: GameState = 'loading'
  private transitions: Record<GameState, GameState[]> = {
    loading: ['menu'],
    menu: ['playing'],
    playing: ['paused', 'gameOver', 'menu'],
    paused: ['playing', 'menu'],
    gameOver: ['menu', 'playing'],
  }

  transition(to: GameState) {
    if (!this.transitions[this.current].includes(to)) {
      console.warn(`非法状态转换: ${this.current} -> ${to}`)
      return
    }
    this.exit(this.current)
    this.current = to
    this.enter(to)
  }

  private exit(state: GameState) {
    console.log(`退出状态: ${state}`)
  }

  private enter(state: GameState) {
    console.log(`进入状态: ${state}`)
  }
}
状态管理对比
前端游戏
Redux/Pinia StoreGameStateMachine
React useState游戏对象内部属性
URL RouterScene 切换

3.6 资源管理

图片加载

typescript
// 微信资源加载示例
const img = wx.createImage()
img.onload = () => console.log('loaded')
img.src = 'https://example.com/sprite.png'

资源加载队列

实际项目中通常需要批量加载资源,并显示进度条:

typescript
class AssetLoader {
  private cache = new Map<string, HTMLImageElement>()

  loadImage(src: string): Promise<HTMLImageElement> {
    if (this.cache.has(src)) return Promise.resolve(this.cache.get(src)!)

    return new Promise((resolve, reject) => {
      const img = wx.createImage()
      img.onload = () => {
        this.cache.set(src, img)
        resolve(img)
      }
      img.onerror = reject
      img.src = src
    })
  }

  async loadAll(urls: string[], onProgress?: (p: number) => void): Promise<void> {
    let loaded = 0
    await Promise.all(
      urls.map(async (url) => {
        await this.loadImage(url)
        loaded++
        onProgress?.(loaded / urls.length)
      })
    )
  }
}
资源管理原则
  1. 预加载首屏资源 — 游戏启动时加载必须资源,避免运行时卡顿
  2. 按需加载/分包 — 非首屏资源(关卡、皮肤、音效)延迟加载或放入分包
  3. 及时释放 — 大纹理、音频实例不用时释放,避免内存泄漏
  4. 版本化 URL — 如 sprite.png?v=20260708,避免缓存导致更新不生效

3.7 坐标系与变换

Canvas 坐标系

Canvas 的默认坐标系:

  • 原点 (0, 0) 在左上角
  • X 轴向右为正
  • Y 轴向下为正
  • 角度顺时针为正
typescript
// 在 (100, 200) 绘制一个 50×50 的矩形
ctx.fillRect(100, 200, 50, 50)

变换矩阵

Canvas 2D 使用变换栈管理平移、旋转、缩放:

typescript
ctx.save() // 保存当前状态
ctx.translate(x, y) // 移动坐标原点到 (x, y)
ctx.rotate(angle) // 绕当前原点旋转(弧度)
ctx.scale(sx, sy) // 缩放
ctx.drawImage(img, -w / 2, -h / 2, w, h) // 以原点为中心绘制
ctx.restore() // 恢复状态
CSS transform vs Canvas transform
CSSCanvas
transform: translate(x, y)ctx.translate(x, y)
transform: rotate(deg)ctx.rotate(rad)(注意弧度)
transform-origin: center手动将绘制原点移到中心
自动重排重绘每帧手动保存/恢复/应用

本地坐标与世界坐标

游戏对象通常有两层坐标:

  • 本地坐标:相对于对象自身原点的坐标
  • 世界坐标:相对于游戏场景的坐标
typescript
// 简单的坐标变换:子节点跟随父节点
function toWorld(localX: number, localY: number, parent: { x: number; y: number; angle: number }) {
  const rad = (parent.angle * Math.PI) / 180
  return {
    x: parent.x + localX * Math.cos(rad) - localY * Math.sin(rad),
    y: parent.y + localX * Math.sin(rad) + localY * Math.cos(rad),
  }
}

3.8 基础物理

速度、加速度与重力

游戏中的运动通常由速度和加速度驱动:

typescript
interface Kinematic {
  x: number
  y: number
  vx: number // 速度(像素/秒)
  vy: number
  ax: number // 加速度(像素/秒²)
  ay: number
}

function updatePhysics(obj: Kinematic, dt: number) {
  // v = v0 + a * t
  obj.vx += obj.ax * dt
  obj.vy += obj.ay * dt

  // s = s0 + v * t
  obj.x += obj.vx * dt
  obj.y += obj.vy * dt
}

重力示例:Flappy Bird 小鸟下落

typescript
const GRAVITY = 1200 // 像素/秒²
const JUMP_FORCE = -400

class Bird {
  x = 100
  y = 200
  vy = 0

  update(dt: number) {
    this.vy += GRAVITY * dt // 重力增加下落速度
    this.y += this.vy * dt
  }

  flap() {
    this.vy = JUMP_FORCE
  }
}
前端经验迁移

前端动画常用 transition: 0.3s ease 这种时间函数,而游戏物理是按真实时间积分。不要混淆"动画时长"和"物理模拟"——前者是视觉表现,后者是游戏规则。

速度反射(弹球碰撞)

typescript
function reflectVelocity(vx: number, vy: number, normalX: number, normalY: number) {
  // dot = v · n
  const dot = vx * normalX + vy * normalY
  return {
    vx: vx - 2 * dot * normalX,
    vy: vy - 2 * dot * normalY,
  }
}

3.9 摄像机与视口

世界坐标与屏幕坐标

当游戏世界大于屏幕时,需要摄像机将世界坐标转换为屏幕坐标:

typescript
class Camera {
  x = 0
  y = 0
  width: number
  height: number

  constructor(width: number, height: number) {
    this.width = width
    this.height = height
  }

  follow(target: { x: number; y: number }) {
    // 摄像机中心跟随目标
    this.x = target.x - this.width / 2
    this.y = target.y - this.height / 2
  }

  worldToScreen(worldX: number, worldY: number) {
    return {
      x: worldX - this.x,
      y: worldY - this.y,
    }
  }
}

视口裁剪

只绘制进入屏幕的对象,可以显著提升性能:

typescript
function isInViewport(
  obj: { x: number; y: number; width: number; height: number },
  camera: Camera
) {
  const screen = camera.worldToScreen(obj.x, obj.y)
  return (
    screen.x + obj.width > 0 &&
    screen.x < camera.width &&
    screen.y + obj.height > 0 &&
    screen.y < camera.height
  )
}

3.10 精灵动画

帧动画

通过快速切换一组图片实现动画:

typescript
class SpriteAnimation {
  frames: HTMLImageElement[] = []
  frameDuration = 0.1 // 每帧持续 100ms
  elapsed = 0
  currentFrame = 0

  update(dt: number) {
    this.elapsed += dt
    if (this.elapsed >= this.frameDuration) {
      this.currentFrame = (this.currentFrame + 1) % this.frames.length
      this.elapsed = 0
    }
  }

  draw(ctx: CanvasRenderingContext2D, x: number, y: number) {
    const frame = this.frames[this.currentFrame]
    ctx.drawImage(frame, x, y)
  }
}

精灵图(Sprite Sheet)

将多帧动画合并到一张大图,减少 Draw Call 和加载请求:

typescript
// 从精灵图中绘制指定帧
function drawFrameFromSheet(
  ctx: CanvasRenderingContext2D,
  sheet: HTMLImageElement,
  frameX: number,
  frameY: number,
  frameW: number,
  frameH: number,
  screenX: number,
  screenY: number
) {
  ctx.drawImage(
    sheet,
    frameX * frameW,
    frameY * frameH,
    frameW,
    frameH, // 源矩形
    screenX,
    screenY,
    frameW,
    frameH // 目标矩形
  )
}
动画优化建议
  1. 合并精灵图:用 TexturePacker 或引擎内置工具打包,减少 Draw Call
  2. 按需播放:不在屏幕内的动画暂停更新
  3. 对象池复用:爆炸、粒子等临时动画对象池化
  4. 骨骼动画替代帧动画:角色动画优先考虑 Spine/DragonBones,内存更省

3.11 声音生命周期

游戏中的声音不是"播放一下"那么简单,需要完整生命周期管理:

加载 → 等待用户交互解锁 → 播放 → 暂停/恢复 → 停止 → 释放

音频管理器示例

typescript
class AudioManager {
  private bgm: WechatMinigame.InnerAudioContext | null = null
  private sfxPool: WechatMinigame.InnerAudioContext[] = []
  private maxSfxCount = 10

  playBGM(src: string) {
    this.bgm?.destroy() // 销毁旧实例释放资源,stop() 仅停止播放不释放底层资源
    this.bgm = wx.createInnerAudioContext()
    this.bgm.src = src
    this.bgm.loop = true
    this.bgm.play()
  }

  playSFX(src: string) {
    // 复用或创建新实例
    let sfx = this.sfxPool.find((s) => s.paused)
    if (!sfx && this.sfxPool.length < this.maxSfxCount) {
      sfx = wx.createInnerAudioContext()
      this.sfxPool.push(sfx)
    }
    if (sfx) {
      sfx.src = src
      sfx.play()
    }
  }

  pauseAll() {
    this.bgm?.pause()
    this.sfxPool.forEach((s) => s.pause())
  }

  destroy() {
    this.bgm?.destroy()
    this.sfxPool.forEach((s) => s.destroy())
    this.sfxPool = []
  }
}

本章总结

前端工程师的核心转变
游戏概念前端等价物核心差异
游戏循环requestAnimationFrame你需要控制一切,没有框架自动渲染
渲染Canvas/WebGL API没有 DOM,像素级控制
输入Event Listener更底层的触控事件,需处理多点
碰撞检测Intersection Observer手动实现数学算法
状态机Router + Store更严格的状态转换管理
资源管理<img> / import()手动管理加载、缓存、释放
坐标系与变换CSS transform需要手动管理变换栈和原点
基础物理CSS 动画/过渡基于时间积分,影响游戏规则
摄像机Viewport / Scroll手动世界-屏幕坐标转换与裁剪
精灵动画GIF / Lottie帧序列或精灵图,需要手动控制
声音生命周期<audio>需处理 iOS 解锁、并发限制、池化

掌握这些概念后,引擎的本质就是在这些基础概念之上加了抽象层和编辑器。


📝 课后练习

练习 1:游戏循环实现

题目: 使用 requestAnimationFrame 实现一个固定时间步长的游戏循环(60fps),要求:计算实时 FPS、限制最大帧间隔防止"死亡螺旋"。

参考答案: 参见 游戏设计模式 中"游戏循环模式"的 FixedTimestepLoop 完整实现。

练习 2:碰撞检测选型

题目: 你的游戏中有以下碰撞检测需求,为每种场景选择最合适的算法:

  1. 飞机射击游戏:子弹(圆形)vs 敌机(矩形)
  2. 推箱子游戏:判断角色能否移动到目标格子
  3. 物理弹球游戏:球的反弹

参考答案:

  1. 圆与矩形碰撞 — 检测圆心到矩形最近点的距离 ≤ 半径
  2. 网格坐标判断 — 直接检查 grid[x][y] === empty,无需通用碰撞算法
  3. 圆形碰撞 + 速度反射 — 借助物理引擎(Box2D/Matter.js)更为可靠
练习 3:状态机设计

题目: 为一个塔防游戏设计状态机(菜单、战斗中、暂停、胜利、失败)。画出状态转换图,列出合法/非法转换。

参考答案:

  • 合法:菜单→战斗中、战斗中→暂停、暂停→战斗中、战斗中→胜利、战斗中→失败、胜利→菜单、失败→菜单
  • 非法:菜单→胜利(未开始)、胜利→失败(不可逆)、暂停→失败(需先恢复)
练习 4:摄像机跟随与视口裁剪

题目: 实现一个横向卷轴跑酷游戏的摄像机:

  1. 摄像机中心始终跟随玩家
  2. 只绘制进入屏幕的地面块和敌人
  3. 玩家跑到世界边缘时限制摄像机不超出边界

参考答案思路:

  • Camera.follow(player) 每帧更新摄像机位置
  • 绘制前用 isInViewport(platform, camera) 做粗筛
  • follow 方法中限制 camera.x = clamp(camera.x, 0, worldWidth - camera.width)

📚 相关阅读

用心学习,持续实践