主题切换
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/DOM | Canvas 2D |
|---|---|---|
| 元素定位 | position + top/left | ctx.drawImage(img, x, y) |
| 层级管理 | z-index | 绘制顺序(先绘制的在底层) |
| 变换 | transform | ctx.translate/rotate/scale |
| 动画 | CSS Transition | 每帧手动更新坐标并重绘 |
| 文本排版 | 自动换行、行高 | 需手动测量和拆分 |
绘制顺序与层级
在 Canvas 中,后绘制的像素会覆盖先绘制的像素。因此典型的绘制顺序是:
- 背景(最底层)
- 远处装饰/远景
- 游戏对象(玩家、敌人、道具)
- 前景/粒子效果
- 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 Store | GameStateMachine |
| React useState | 游戏对象内部属性 |
| URL Router | Scene 切换 |
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)
})
)
}
}资源管理原则
- 预加载首屏资源 — 游戏启动时加载必须资源,避免运行时卡顿
- 按需加载/分包 — 非首屏资源(关卡、皮肤、音效)延迟加载或放入分包
- 及时释放 — 大纹理、音频实例不用时释放,避免内存泄漏
- 版本化 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
| CSS | Canvas |
|---|---|
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 // 目标矩形
)
}动画优化建议
- 合并精灵图:用 TexturePacker 或引擎内置工具打包,减少 Draw Call
- 按需播放:不在屏幕内的动画暂停更新
- 对象池复用:爆炸、粒子等临时动画对象池化
- 骨骼动画替代帧动画:角色动画优先考虑 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 = []
}
}iOS 自动播放限制
iOS 设备要求音频必须在用户触摸事件回调中首次播放。建议在游戏首个交互点统一"解锁"音频上下文:
typescript
let audioUnlocked = false
wx.onTouchStart(() => {
if (!audioUnlocked) {
audioManager.playSFX('audio/click.mp3')
audioUnlocked = true
}
})本章总结
前端工程师的核心转变
| 游戏概念 | 前端等价物 | 核心差异 |
|---|---|---|
| 游戏循环 | 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:碰撞检测选型
题目: 你的游戏中有以下碰撞检测需求,为每种场景选择最合适的算法:
- 飞机射击游戏:子弹(圆形)vs 敌机(矩形)
- 推箱子游戏:判断角色能否移动到目标格子
- 物理弹球游戏:球的反弹
参考答案:
- 圆与矩形碰撞 — 检测圆心到矩形最近点的距离 ≤ 半径
- 网格坐标判断 — 直接检查
grid[x][y] === empty,无需通用碰撞算法 - 圆形碰撞 + 速度反射 — 借助物理引擎(Box2D/Matter.js)更为可靠
练习 3:状态机设计
题目: 为一个塔防游戏设计状态机(菜单、战斗中、暂停、胜利、失败)。画出状态转换图,列出合法/非法转换。
参考答案:
- 合法:菜单→战斗中、战斗中→暂停、暂停→战斗中、战斗中→胜利、战斗中→失败、胜利→菜单、失败→菜单
- 非法:菜单→胜利(未开始)、胜利→失败(不可逆)、暂停→失败(需先恢复)
练习 4:摄像机跟随与视口裁剪
题目: 实现一个横向卷轴跑酷游戏的摄像机:
- 摄像机中心始终跟随玩家
- 只绘制进入屏幕的地面块和敌人
- 玩家跑到世界边缘时限制摄像机不超出边界
参考答案思路:
- 用
Camera.follow(player)每帧更新摄像机位置 - 绘制前用
isInViewport(platform, camera)做粗筛 - 在
follow方法中限制camera.x = clamp(camera.x, 0, worldWidth - camera.width)
📚 相关阅读
- 04 前端视角对比 — 将核心概念映射到你的前端知识体系
- 05 引擎对比与选型 — 基于核心概念选择合适的游戏引擎
- Flappy Bird 原生 Canvas 实战 — 用纯 Canvas 实现本章所有概念
- 游戏设计模式 — 对象池、状态机、游戏循环的工程化实现