Canvas 游戏开发

Canvas 2D游戏开发完整指南,涵盖游戏循环、实体系统、碰撞检测、关卡设计等核心技术。Canvas是实现Web 2D游戏的理想选择,本节将介绍游戏开发的核心概念和实践技巧。

游戏开发基础

游戏循环

游戏循环是游戏的核心,它不断更新游戏状态并渲染画面:

class GameLoop {
  constructor(update, render) {
    this.update = update
    this.render = render
    this.isRunning = false
    this.lastTime = 0
    this.deltaTime = 0
    this.fps = 60
    this.frameInterval = 1000 / this.fps
  }
  
  start() {
    if (this.isRunning) return
    this.isRunning = true
    this.lastTime = performance.now()
    this.loop()
  }
  
  stop() {
    this.isRunning = false
  }
  
  loop(currentTime = performance.now()) {
    if (!this.isRunning) return
    
    this.deltaTime = currentTime - this.lastTime
    
    if (this.deltaTime >= this.frameInterval) {
      this.lastTime = currentTime - (this.deltaTime % this.frameInterval)
      
      this.update(this.deltaTime / 1000)
      this.render()
    }
    
    requestAnimationFrame((time) => this.loop(time))
  }
}

固定时间步长

为了确保游戏在不同设备上表现一致,使用固定时间步长:

class FixedTimeStepGame {
  constructor() {
    this.accumulator = 0
    this.fixedDeltaTime = 1 / 60
    this.maxAccumulator = 0.2
  }
  
  update(deltaTime) {
    this.accumulator += deltaTime
    
    if (this.accumulator > this.maxAccumulator) {
      this.accumulator = this.maxAccumulator
    }
    
    while (this.accumulator >= this.fixedDeltaTime) {
      this.fixedUpdate(this.fixedDeltaTime)
      this.accumulator -= this.fixedDeltaTime
    }
    
    const alpha = this.accumulator / this.fixedDeltaTime
    this.interpolateRender(alpha)
  }
  
  fixedUpdate(dt) {
    // 物理更新、碰撞检测等
  }
  
  interpolateRender(alpha) {
    // 使用alpha进行插值渲染
  }
}

游戏实体系统

基础实体类

class GameObject {
  constructor(x, y) {
    this.x = x
    this.y = y
    this.vx = 0
    this.vy = 0
    this.width = 32
    this.height = 32
    this.rotation = 0
    this.scale = 1
    this.active = true
    this.visible = true
  }
  
  update(dt) {
    this.x += this.vx * dt
    this.y += this.vy * dt
  }
  
  render(ctx) {
    if (!this.visible) return
    
    ctx.save()
    ctx.translate(this.x, this.y)
    ctx.rotate(this.rotation)
    ctx.scale(this.scale, this.scale)
    
    this.draw(ctx)
    
    ctx.restore()
  }
  
  draw(ctx) {
    ctx.fillStyle = '#3498db'
    ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height)
  }
  
  getBounds() {
    return {
      x: this.x - this.width / 2,
      y: this.y - this.height / 2,
      width: this.width,
      height: this.height
    }
  }
}

精灵实体

class Sprite extends GameObject {
  constructor(x, y, image) {
    super(x, y)
    this.image = image
    this.frameWidth = 32
    this.frameHeight = 32
    this.frameX = 0
    this.frameY = 0
    this.animations = new Map()
    this.currentAnimation = null
    this.animationTime = 0
    this.frameDuration = 100
  }
  
  addAnimation(name, frames, frameDuration = 100) {
    this.animations.set(name, {
      frames: frames,
      frameDuration: frameDuration,
      loop: true
    })
  }
  
  playAnimation(name) {
    if (this.currentAnimation !== name) {
      this.currentAnimation = name
      this.animationTime = 0
    }
  }
  
  update(dt) {
    super.update(dt)
    
    if (this.currentAnimation) {
      const anim = this.animations.get(this.currentAnimation)
      if (anim) {
        this.animationTime += dt * 1000
        
        let totalDuration = anim.frames.length * anim.frameDuration
        if (this.animationTime >= totalDuration) {
          if (anim.loop) {
            this.animationTime = this.animationTime % totalDuration
          } else {
            this.animationTime = totalDuration - anim.frameDuration
          }
        }
        
        const frameIndex = Math.floor(this.animationTime / anim.frameDuration)
        const frame = anim.frames[frameIndex]
        this.frameX = frame.x
        this.frameY = frame.y
      }
    }
  }
  
  draw(ctx) {
    if (!this.image) return
    
    ctx.drawImage(
      this.image,
      this.frameX * this.frameWidth,
      this.frameY * this.frameHeight,
      this.frameWidth,
      this.frameHeight,
      -this.width / 2,
      -this.height / 2,
      this.width,
      this.height
    )
  }
}

碰撞检测

AABB碰撞检测

function aabbCollision(a, b) {
  const boundsA = a.getBounds()
  const boundsB = b.getBounds()
  
  return boundsA.x < boundsB.x + boundsB.width &&
         boundsA.x + boundsA.width > boundsB.x &&
         boundsA.y < boundsB.y + boundsB.height &&
         boundsA.y + boundsA.height > boundsB.y
}

function resolveAABBCollision(a, b) {
  const boundsA = a.getBounds()
  const boundsB = b.getBounds()
  
  const overlapX = Math.min(
    boundsA.x + boundsA.width - boundsB.x,
    boundsB.x + boundsB.width - boundsA.x
  )
  
  const overlapY = Math.min(
    boundsA.y + boundsA.height - boundsB.y,
    boundsB.y + boundsB.height - boundsA.y
  )
  
  if (overlapX < overlapY) {
    if (a.x < b.x) {
      a.x -= overlapX / 2
      b.x += overlapX / 2
    } else {
      a.x += overlapX / 2
      b.x -= overlapX / 2
    }
  } else {
    if (a.y < b.y) {
      a.y -= overlapY / 2
      b.y += overlapY / 2
    } else {
      a.y += overlapY / 2
      b.y -= overlapY / 2
    }
  }
}

圆形碰撞检测

function circleCollision(a, b) {
  const dx = b.x - a.x
  const dy = b.y - a.y
  const distance = Math.sqrt(dx * dx + dy * dy)
  
  return distance < a.radius + b.radius
}

function resolveCircleCollision(a, b) {
  const dx = b.x - a.x
  const dy = b.y - a.y
  const distance = Math.sqrt(dx * dx + dy * dy)
  
  if (distance === 0) return
  
  const overlap = (a.radius + b.radius) - distance
  const nx = dx / distance
  const ny = dy / distance
  
  a.x -= nx * overlap / 2
  a.y -= ny * overlap / 2
  b.x += nx * overlap / 2
  b.y += ny * overlap / 2
  
  const relativeVx = a.vx - b.vx
  const relativeVy = a.vy - b.vy
  const relativeVelocity = relativeVx * nx + relativeVy * ny
  
  if (relativeVelocity > 0) return
  
  const restitution = 0.8
  const impulse = -(1 + restitution) * relativeVelocity / 2
  
  a.vx += impulse * nx
  a.vy += impulse * ny
  b.vx -= impulse * nx
  b.vy -= impulse * ny
}

空间分区优化

class SpatialHash {
  constructor(cellSize) {
    this.cellSize = cellSize
    this.cells = new Map()
  }
  
  clear() {
    this.cells.clear()
  }
  
  getKey(x, y) {
    const cellX = Math.floor(x / this.cellSize)
    const cellY = Math.floor(y / this.cellSize)
    return `${cellX},${cellY}`
  }
  
  insert(obj) {
    const bounds = obj.getBounds()
    const startX = Math.floor(bounds.x / this.cellSize)
    const endX = Math.floor((bounds.x + bounds.width) / this.cellSize)
    const startY = Math.floor(bounds.y / this.cellSize)
    const endY = Math.floor((bounds.y + bounds.height) / this.cellSize)
    
    for (let x = startX; x <= endX; x++) {
      for (let y = startY; y <= endY; y++) {
        const key = `${x},${y}`
        if (!this.cells.has(key)) {
          this.cells.set(key, [])
        }
        this.cells.get(key).push(obj)
      }
    }
  }
  
  getNearby(obj) {
    const bounds = obj.getBounds()
    const startX = Math.floor(bounds.x / this.cellSize)
    const endX = Math.floor((bounds.x + bounds.width) / this.cellSize)
    const startY = Math.floor(bounds.y / this.cellSize)
    const endY = Math.floor((bounds.y + bounds.height) / this.cellSize)
    
    const nearby = new Set()
    
    for (let x = startX; x <= endX; x++) {
      for (let y = startY; y <= endY; y++) {
        const key = `${x},${y}`
        const cell = this.cells.get(key)
        if (cell) {
          cell.forEach(obj => nearby.add(obj))
        }
      }
    }
    
    return Array.from(nearby)
  }
}

输入处理

键盘输入

class KeyboardInput {
  constructor() {
    this.keys = new Map()
    this.justPressed = new Set()
    this.justReleased = new Set()
    
    window.addEventListener('keydown', (e) => {
      if (!this.keys.get(e.code)) {
        this.justPressed.add(e.code)
      }
      this.keys.set(e.code, true)
    })
    
    window.addEventListener('keyup', (e) => {
      this.keys.set(e.code, false)
      this.justReleased.add(e.code)
    })
  }
  
  isDown(code) {
    return this.keys.get(code) === true
  }
  
  isUp(code) {
    return !this.isDown(code)
  }
  
  isJustPressed(code) {
    return this.justPressed.has(code)
  }
  
  isJustReleased(code) {
    return this.justReleased.has(code)
  }
  
  update() {
    this.justPressed.clear()
    this.justReleased.clear()
  }
}

游戏手柄支持

class GamepadInput {
  constructor() {
    this.gamepads = new Map()
    
    window.addEventListener('gamepadconnected', (e) => {
      this.gamepads.set(e.gamepad.index, e.gamepad)
      console.log('手柄已连接:', e.gamepad.id)
    })
    
    window.addEventListener('gamepaddisconnected', (e) => {
      this.gamepads.delete(e.gamepad.index)
    })
  }
  
  update() {
    const gamepads = navigator.getGamepads()
    for (const gamepad of gamepads) {
      if (gamepad) {
        this.gamepads.set(gamepad.index, gamepad)
      }
    }
  }
  
  getButton(index, buttonIndex) {
    const gamepad = this.gamepads.get(index)
    if (gamepad && gamepad.buttons[buttonIndex]) {
      return gamepad.buttons[buttonIndex].pressed
    }
    return false
  }
  
  getAxis(index, axisIndex) {
    const gamepad = this.gamepads.get(index)
    if (gamepad) {
      const value = gamepad.axes[axisIndex]
      return Math.abs(value) > 0.1 ? value : 0
    }
    return 0
  }
}

游戏示例:简单平台游戏

平台游戏示例(方向键移动,空格跳跃)

游戏状态管理

class GameStateMachine {
  constructor() {
    this.states = new Map()
    this.currentState = null
  }
  
  addState(name, state) {
    this.states.set(name, state)
  }
  
  changeState(name, ...args) {
    if (this.currentState) {
      this.currentState.exit()
    }
    
    this.currentState = this.states.get(name)
    if (this.currentState) {
      this.currentState.enter(...args)
    }
  }
  
  update(dt) {
    if (this.currentState) {
      this.currentState.update(dt)
    }
  }
  
  render(ctx) {
    if (this.currentState) {
      this.currentState.render(ctx)
    }
  }
}

class MenuState {
  constructor(game) {
    this.game = game
  }
  
  enter() {
    console.log('进入菜单')
  }
  
  exit() {
    console.log('退出菜单')
  }
  
  update(dt) {
    // 菜单逻辑
  }
  
  render(ctx) {
    ctx.fillStyle = '#2c3e50'
    ctx.fillRect(0, 0, this.game.width, this.game.height)
    
    ctx.fillStyle = '#ecf0f1'
    ctx.font = '48px Arial'
    ctx.textAlign = 'center'
    ctx.fillText('游戏标题', this.game.width / 2, this.game.height / 2 - 50)
    
    ctx.font = '24px Arial'
    ctx.fillText('按 Enter 开始游戏', this.game.width / 2, this.game.height / 2 + 20)
  }
}

class PlayState {
  constructor(game) {
    this.game = game
    this.entities = []
  }
  
  enter() {
    this.entities = []
    this.initLevel()
  }
  
  exit() {
    this.entities = []
  }
  
  initLevel() {
    // 初始化关卡
  }
  
  update(dt) {
    this.entities.forEach(entity => entity.update(dt))
  }
  
  render(ctx) {
    this.entities.forEach(entity => entity.render(ctx))
  }
}

粒子系统

class Particle {
  constructor(x, y, options = {}) {
    this.x = x
    this.y = y
    this.vx = options.vx || (Math.random() - 0.5) * 100
    this.vy = options.vy || (Math.random() - 0.5) * 100
    this.life = options.life || 1
    this.decay = options.decay || 0.02
    this.size = options.size || 5
    this.color = options.color || '#fff'
    this.gravity = options.gravity || 0
  }
  
  update(dt) {
    this.x += this.vx * dt
    this.y += this.vy * dt
    this.vy += this.gravity * dt
    this.life -= this.decay
  }
  
  render(ctx) {
    ctx.globalAlpha = Math.max(0, this.life)
    ctx.fillStyle = this.color
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.size * this.life, 0, Math.PI * 2)
    ctx.fill()
    ctx.globalAlpha = 1
  }
  
  isDead() {
    return this.life <= 0
  }
}

class ParticleSystem {
  constructor() {
    this.particles = []
  }
  
  emit(x, y, count, options = {}) {
    for (let i = 0; i < count; i++) {
      this.particles.push(new Particle(x, y, options))
    }
  }
  
  update(dt) {
    for (let i = this.particles.length - 1; i >= 0; i--) {
      this.particles[i].update(dt)
      
      if (this.particles[i].isDead()) {
        this.particles.splice(i, 1)
      }
    }
  }
  
  render(ctx) {
    this.particles.forEach(particle => particle.render(ctx))
  }
}

最佳实践

1. 对象池

class ObjectPool {
  constructor(createFn, initialSize = 10) {
    this.createFn = createFn
    this.pool = []
    this.active = []
    
    for (let i = 0; i < initialSize; i++) {
      this.pool.push(this.createFn())
    }
  }
  
  get() {
    let obj = this.pool.pop()
    if (!obj) {
      obj = this.createFn()
    }
    this.active.push(obj)
    return obj
  }
  
  release(obj) {
    const index = this.active.indexOf(obj)
    if (index > -1) {
      this.active.splice(index, 1)
      this.pool.push(obj)
    }
  }
  
  releaseAll() {
    while (this.active.length > 0) {
      this.pool.push(this.active.pop())
    }
  }
}

2. 资源预加载

class AssetLoader {
  constructor() {
    this.assets = new Map()
    this.totalAssets = 0
    this.loadedAssets = 0
  }
  
  loadImages(images) {
    return new Promise((resolve) => {
      this.totalAssets = Object.keys(images).length
      
      for (const [key, src] of Object.entries(images)) {
        const img = new Image()
        img.onload = () => {
          this.assets.set(key, img)
          this.loadedAssets++
          
          if (this.loadedAssets === this.totalAssets) {
            resolve()
          }
        }
        img.src = src
      }
    })
  }
  
  get(key) {
    return this.assets.get(key)
  }
  
  getProgress() {
    return this.totalAssets > 0 ? this.loadedAssets / this.totalAssets : 0
  }
}

3. 调试工具

class DebugOverlay {
  constructor(game) {
    this.game = game
    this.enabled = false
    this.fps = 0
    this.frameCount = 0
    this.lastTime = performance.now()
    
    window.addEventListener('keydown', (e) => {
      if (e.code === 'F3') {
        this.enabled = !this.enabled
        e.preventDefault()
      }
    })
  }
  
  update() {
    this.frameCount++
    const now = performance.now()
    
    if (now - this.lastTime >= 1000) {
      this.fps = this.frameCount
      this.frameCount = 0
      this.lastTime = now
    }
  }
  
  render(ctx) {
    if (!this.enabled) return
    
    ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'
    ctx.fillRect(5, 5, 150, 60)
    
    ctx.fillStyle = '#fff'
    ctx.font = '12px monospace'
    ctx.textAlign = 'left'
    ctx.fillText(`FPS: ${this.fps}`, 10, 20)
    ctx.fillText(`Entities: ${this.game.entities.length}`, 10, 35)
    ctx.fillText(`Press F3 to toggle`, 10, 50)
  }
}