HarmonyOS开发:游戏实战——完整2D游戏开发

举报
Jack20 发表于 2026/06/28 21:02:30 2026/06/28
【摘要】 HarmonyOS开发:游戏实战——完整2D游戏开发📌 核心要点:把前面学的游戏循环、精灵系统、碰撞检测、音效管理、关卡设计全部串起来,从零到一做出一个完整的2D跑酷游戏,包含开始画面、游戏循环、关卡系统、计分排名和结束画面。 背景与动机前面9篇文章,我们学了游戏循环、精灵动画、碰撞检测、物理模拟、音频管理、输入处理、网络同步、性能优化、发布上架。每篇都有代码,但都是片段。你肯定在想:这...

HarmonyOS开发:游戏实战——完整2D游戏开发

📌 核心要点:把前面学的游戏循环、精灵系统、碰撞检测、音效管理、关卡设计全部串起来,从零到一做出一个完整的2D跑酷游戏,包含开始画面、游戏循环、关卡系统、计分排名和结束画面。

背景与动机

前面9篇文章,我们学了游戏循环、精灵动画、碰撞检测、物理模拟、音频管理、输入处理、网络同步、性能优化、发布上架。每篇都有代码,但都是片段。

你肯定在想:这些东西拼在一起到底长什么样?

这篇就回答这个问题。我们要做一个完整的2D跑酷游戏——“鸿蒙跑酷”。角色自动向前跑,玩家控制跳跃和二段跳,躲避障碍物,收集金币,跑得越远分数越高。

这个游戏包含:

  • 开始画面(标题、最高分、开始按钮)
  • 游戏循环(角色跑步、障碍生成、金币收集)
  • 碰撞检测(角色与障碍、角色与金币)
  • 简单物理(重力、跳跃、二段跳)
  • 关卡系统(难度递增)
  • 计分系统(距离+金币)
  • 结束画面(分数、最高分、重试按钮)

麻雀虽小,五脏俱全。

核心原理

游戏架构总览

graph TB
    A[GameStateManager] --> B[MenuState]
    A --> C[PlayState]
    A --> D[GameOverState]
    
    C --> E[GameLoop]
    E --> F[InputHandler]
    E --> G[PhysicsEngine]
    E --> H[CollisionSystem]
    E --> I[Spawner]
    E --> J[ScoreSystem]
    
    G --> G1[重力模拟]
    G --> G2[跳跃逻辑]
    G --> G3[地面检测]
    
    H --> H1[角色vs障碍]
    H --> H2[角色vs金币]
    H --> H3[角色vs地面]
    
    I --> I1[障碍物生成]
    I --> I2[金币生成]
    I --> I3[难度递增]
    
    classDef stateStyle fill:#9B59B6,stroke:#8E44AD,color:#fff,font-weight:bold
    classDef loopStyle fill:#E74C3C,stroke:#C0392B,color:#fff
    classDef moduleStyle fill:#3498DB,stroke:#2980B9,color:#fff
    classDef detailStyle fill:#27AE60,stroke:#229954,color:#fff
    
    class A,B,C,D stateStyle
    class E loopStyle
    class F,G,H,I,J moduleStyle
    class G1,G2,G3,H1,H2,H3,I1,I2,I3 detailStyle

游戏状态机

游戏有三个核心状态:菜单、游戏中、游戏结束。状态之间的转换很清晰:

  • 菜单 → 点击开始 → 游戏中
  • 游戏中 → 撞到障碍 → 游戏结束
  • 游戏结束 → 点击重试 → 游戏中

跑酷游戏的核心参数

参数 说明
重力 1500 像素/秒²
跳跃力 -600 像素/秒(向上)
二段跳力 -500 像素/秒
跑步速度 300 像素/秒(初始)
最大速度 600 像素/秒
速度递增 10/秒 每秒加速
地面高度 620 像素(从顶部算)
障碍间距 200-400 像素
金币间距 80-150 像素

代码实战

基础用法:游戏状态管理与角色系统

先搭好骨架——状态管理和角色。

// RunnerGame.ets - 跑酷游戏核心

// 游戏状态
enum RunnerGameState {
  MENU,
  PLAYING,
  GAME_OVER
}

// 角色
class Runner {
  x: number = 60
  y: number = 620
  width: number = 36
  height: number = 48
  vy: number = 0
  isOnGround: boolean = true
  jumpCount: number = 0       // 已跳跃次数
  maxJumps: number = 2        // 最大跳跃次数(二段跳)
  alive: boolean = true
  runFrame: number = 0        // 跑步动画帧
  runTimer: number = 0

  // 跳跃
  jump(): boolean {
    if (this.jumpCount >= this.maxJumps) return false

    const jumpForce = this.jumpCount === 0 ? -600 : -500
    this.vy = jumpForce
    this.isOnGround = false
    this.jumpCount++
    return true
  }

  // 更新
  update(dt: number, gravity: number, groundY: number): void {
    if (!this.alive) return

    // 重力
    this.vy += gravity * dt
    this.y += this.vy * dt

    // 地面检测
    if (this.y >= groundY) {
      this.y = groundY
      this.vy = 0
      this.isOnGround = true
      this.jumpCount = 0
    }

    // 跑步动画
    this.runTimer += dt
    if (this.runTimer > 0.1) {
      this.runTimer = 0
      this.runFrame = (this.runFrame + 1) % 4
    }
  }

  // 获取AABB
  getBounds(): { left: number; top: number; right: number; bottom: number } {
    return {
      left: this.x - this.width / 2,
      top: this.y - this.height,
      right: this.x + this.width / 2,
      bottom: this.y
    }
  }
}

// 障碍物
class Obstacle {
  x: number = 0
  y: number = 0
  width: number = 0
  height: number = 0
  type: string = 'box'  // box, spike, tall
  passed: boolean = false

  constructor(x: number, groundY: number, type: string) {
    this.x = x
    this.type = type

    switch (type) {
      case 'box':
        this.width = 30
        this.height = 30
        this.y = groundY - this.height
        break
      case 'spike':
        this.width = 24
        this.height = 36
        this.y = groundY - this.height
        break
      case 'tall':
        this.width = 25
        this.height = 55
        this.y = groundY - this.height
        break
      case 'double':
        this.width = 50
        this.height = 30
        this.y = groundY - this.height
        break
    }
  }

  // 更新位置
  update(speed: number, dt: number): void {
    this.x -= speed * dt
  }

  // 是否在屏幕外
  isOffScreen(): boolean {
    return this.x + this.width < -50
  }

  // 获取AABB
  getBounds(): { left: number; top: number; right: number; bottom: number } {
    // 缩小碰撞体,让碰撞判定更宽容
    const shrink = 4
    return {
      left: this.x + shrink,
      top: this.y + shrink,
      right: this.x + this.width - shrink,
      bottom: this.y + this.height - shrink
    }
  }
}

// 金币
class Coin {
  x: number = 0
  y: number = 0
  radius: number = 10
  collected: boolean = false
  animTimer: number = 0

  constructor(x: number, y: number) {
    this.x = x
    this.y = y
  }

  update(speed: number, dt: number): void {
    this.x -= speed * dt
    this.animTimer += dt
  }

  isOffScreen(): boolean {
    return this.x < -20
  }

  // 获取碰撞圆
  getCenter(): { x: number; y: number } {
    return { x: this.x, y: this.y + Math.sin(this.animTimer * 5) * 3 }
  }
}

// 粒子效果
class Particle {
  x: number = 0
  y: number = 0
  vx: number = 0
  vy: number = 0
  life: number = 0
  maxLife: number = 1
  color: string = '#ffffff'
  size: number = 3

  update(dt: number): boolean {
    this.x += this.vx * dt
    this.y += this.vy * dt
    this.vy += 300 * dt
    this.life -= dt
    return this.life > 0
  }
}

进阶用法:关卡生成与碰撞系统

关卡生成是跑酷游戏的灵魂——难度要递增,但不能太难。

// LevelGenerator.ets - 关卡生成器

class LevelGenerator {
  private groundY: number = 620
  private nextObstacleX: number = 400
  private nextCoinX: number = 300
  private difficulty: number = 1.0       // 难度系数
  private minGap: number = 200           // 最小障碍间距
  private maxGap: number = 400           // 最大障碍间距

  // 根据游戏时间更新难度
  updateDifficulty(elapsedTime: number): void {
    this.difficulty = 1.0 + elapsedTime / 30 // 每30秒难度+1
    this.minGap = Math.max(120, 200 - this.difficulty * 10)
    this.maxGap = Math.max(200, 400 - this.difficulty * 15)
  }

  // 生成障碍物
  generateObstacles(screenRight: number): Obstacle[] {
    const newObstacles: Obstacle[] = []

    while (this.nextObstacleX < screenRight + 200) {
      // 随机选择障碍物类型
      const types = ['box', 'spike', 'tall', 'double']
      // 高难度时更多tall和double
      let type: string
      if (this.difficulty > 3) {
        type = types[Math.floor(Math.random() * types.length)]
      } else {
        type = types[Math.floor(Math.random() * 2)] // 低难度只有box和spike
      }

      newObstacles.push(new Obstacle(this.nextObstacleX, this.groundY, type))

      // 下一个障碍的位置
      const gap = this.minGap + Math.random() * (this.maxGap - this.minGap)
      this.nextObstacleX += gap
    }

    return newObstacles
  }

  // 生成金币
  generateCoins(screenRight: number): Coin[] {
    const newCoins: Coin[] = []

    while (this.nextCoinX < screenRight + 200) {
      // 金币在地面以上不同高度
      const heights = [this.groundY - 40, this.groundY - 80, this.groundY - 130]
      const y = heights[Math.floor(Math.random() * heights.length)]

      // 生成1-3个连续金币
      const count = 1 + Math.floor(Math.random() * 3)
      for (let i = 0; i < count; i++) {
        newCoins.push(new Coin(this.nextCoinX + i * 30, y))
      }

      this.nextCoinX += 80 + Math.random() * 150
    }

    return newCoins
  }

  // 重置
  reset(): void {
    this.nextObstacleX = 400
    this.nextCoinX = 300
    this.difficulty = 1.0
  }

  // 滚动偏移(当障碍物移出屏幕时更新生成位置)
  onObstaclePassed(offset: number): void {
    this.nextObstacleX -= offset
    this.nextCoinX -= offset
  }
}

// 碰撞检测
class GameCollision {
  // AABB vs AABB
  static aabbVsAabb(
    a: { left: number; top: number; right: number; bottom: number },
    b: { left: number; top: number; right: number; bottom: number }
  ): boolean {
    return a.left < b.right && a.right > b.left &&
           a.top < b.bottom && a.bottom > b.top
  }

  // AABB vs 圆
  static aabbVsCircle(
    rect: { left: number; top: number; right: number; bottom: number },
    cx: number, cy: number, radius: number
  ): boolean {
    const closestX = Math.max(rect.left, Math.min(cx, rect.right))
    const closestY = Math.max(rect.top, Math.min(cy, rect.bottom))
    const dx = cx - closestX
    const dy = cy - closestY
    return dx * dx + dy * dy <= radius * radius
  }
}

完整示例:完整游戏实现

把所有模块组装起来,这就是一个完整的2D跑酷游戏:

// RunnerGamePage.ets - 完整跑酷游戏
@Entry
@Component
struct RunnerGamePage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private canvasW: number = 360
  private canvasH: number = 720

  // 游戏状态
  private gameState: RunnerGameState = RunnerGameState.MENU
  private runner: Runner = new Runner()
  private obstacles: Obstacle[] = []
  private coins: Coin[] = []
  private particles: Particle[] = []
  private levelGen: LevelGenerator = new LevelGenerator()

  // 游戏参数
  private speed: number = 300
  private gravity: number = 1500
  private groundY: number = 620
  private elapsedTime: number = 0

  // 分数
  private score: number = 0
  private coinCount: number = 0
  private highScore: number = 0
  private distance: number = 0

  // 控制
  private running: boolean = false
  private lastTime: number = 0
  private timer: number = -1

  // 背景滚动
  private bgOffset: number = 0
  private bgSpeed: number = 0.5

  aboutToAppear(): void {
    this.running = true
    this.loadHighScore()
  }

  aboutToDisappear(): void {
    this.running = false
    if (this.timer !== -1) clearTimeout(this.timer)
  }

  // 加载最高分
  private loadHighScore(): void {
    // 实际项目中用Preferences持久化存储
    this.highScore = 0
  }

  // 保存最高分
  private saveHighScore(): void {
    if (this.score > this.highScore) {
      this.highScore = this.score
    }
  }

  build() {
    Column() {
      Canvas(this.ctx)
        .width('100%')
        .height('100%')
        .onReady(() => {
          this.lastTime = Date.now()
          this.gameLoop()
        })
        .onTouch((event: TouchEvent) => {
          if (event.type === TouchType.Down) {
            this.handleTap()
          }
        })
    }
    .width('100%')
    .height('100%')
  }

  // 处理点击
  private handleTap(): void {
    switch (this.gameState) {
      case RunnerGameState.MENU:
        this.startGame()
        break
      case RunnerGameState.PLAYING:
        this.runner.jump()
        break
      case RunnerGameState.GAME_OVER:
        this.startGame()
        break
    }
  }

  // 开始游戏
  private startGame(): void {
    this.gameState = RunnerGameState.PLAYING
    this.runner = new Runner()
    this.obstacles = []
    this.coins = []
    this.particles = []
    this.levelGen.reset()
    this.speed = 300
    this.elapsedTime = 0
    this.score = 0
    this.coinCount = 0
    this.distance = 0
  }

  // 游戏结束
  private gameOver(): void {
    this.gameState = RunnerGameState.GAME_OVER
    this.runner.alive = false
    this.saveHighScore()

    // 死亡粒子效果
    this.spawnDeathParticles()
  }

  // 游戏循环
  private gameLoop(): void {
    if (!this.running) return

    const now = Date.now()
    const dt = Math.min((now - this.lastTime) / 1000, 0.05)
    this.lastTime = now

    if (this.gameState === RunnerGameState.PLAYING) {
      this.update(dt)
    }

    this.render()

    this.timer = setTimeout(() => this.gameLoop(), 16)
  }

  // 更新逻辑
  private update(dt: number): void {
    this.elapsedTime += dt

    // 速度递增
    this.speed = Math.min(600, 300 + this.elapsedTime * 10)

    // 更新难度
    this.levelGen.updateDifficulty(this.elapsedTime)

    // 更新角色
    this.runner.update(dt, this.gravity, this.groundY)

    // 生成障碍和金币
    const newObstacles = this.levelGen.generateObstacles(this.canvasW + this.distance)
    this.obstacles.push(...newObstacles)
    const newCoins = this.levelGen.generateCoins(this.canvasW + this.distance)
    this.coins.push(...newCoins)

    // 更新障碍
    for (const obs of this.obstacles) {
      obs.update(this.speed, dt)
    }

    // 更新金币
    for (const coin of this.coins) {
      coin.update(this.speed, dt)
    }

    // 更新粒子
    this.particles = this.particles.filter(p => p.update(dt))

    // 碰撞检测:角色 vs 障碍
    const playerBounds = this.runner.getBounds()
    for (const obs of this.obstacles) {
      if (obs.passed) continue
      if (GameCollision.aabbVsAabb(playerBounds, obs.getBounds())) {
        this.gameOver()
        return
      }
    }

    // 碰撞检测:角色 vs 金币
    for (const coin of this.coins) {
      if (coin.collected) continue
      const center = coin.getCenter()
      if (GameCollision.aabbVsCircle(playerBounds, center.x, center.y, coin.radius)) {
        coin.collected = true
        this.coinCount++
        this.spawnCoinParticles(coin.x, coin.y)
      }
    }

    // 清理屏幕外的对象
    this.obstacles = this.obstacles.filter(obs => !obs.isOffScreen())
    this.coins = this.coins.filter(coin => !coin.isOffScreen() && !coin.collected)

    // 更新分数
    this.distance += this.speed * dt
    this.score = Math.floor(this.distance / 10) + this.coinCount * 10

    // 背景滚动
    this.bgOffset += this.speed * this.bgSpeed * dt
  }

  // 生成金币粒子
  private spawnCoinParticles(x: number, y: number): void {
    for (let i = 0; i < 5; i++) {
      const p = new Particle()
      p.x = x; p.y = y
      p.vx = (Math.random() - 0.5) * 100
      p.vy = -50 - Math.random() * 80
      p.life = 0.4 + Math.random() * 0.3
      p.maxLife = p.life
      p.color = '#ffdd00'
      p.size = 2 + Math.random() * 3
      this.particles.push(p)
    }
  }

  // 生成死亡粒子
  private spawnDeathParticles(): void {
    for (let i = 0; i < 15; i++) {
      const p = new Particle()
      p.x = this.runner.x; p.y = this.runner.y - this.runner.height / 2
      p.vx = (Math.random() - 0.5) * 200
      p.vy = -100 - Math.random() * 150
      p.life = 0.5 + Math.random() * 0.5
      p.maxLife = p.life
      p.color = i % 2 === 0 ? '#ff4444' : '#ff8800'
      p.size = 3 + Math.random() * 5
      this.particles.push(p)
    }
  }

  // 渲染
  private render(): void {
    const ctx = this.ctx
    ctx.clearRect(0, 0, this.canvasW, this.canvasH)

    switch (this.gameState) {
      case RunnerGameState.MENU:
        this.renderMenu(ctx)
        break
      case RunnerGameState.PLAYING:
        this.renderGame(ctx)
        break
      case RunnerGameState.GAME_OVER:
        this.renderGame(ctx) // 先画游戏画面
        this.renderGameOver(ctx) // 再画覆盖层
        break
    }
  }

  // 渲染菜单
  private renderMenu(ctx: CanvasRenderingContext2D): void {
    // 背景
    ctx.fillStyle = '#1a1a2e'
    ctx.fillRect(0, 0, this.canvasW, this.canvasH)

    // 装饰性地面
    ctx.fillStyle = '#16213e'
    ctx.fillRect(0, this.groundY, this.canvasW, this.canvasH - this.groundY)
    ctx.fillStyle = '#0f3460'
    ctx.fillRect(0, this.groundY, this.canvasW, 4)

    // 标题
    ctx.fillStyle = '#e94560'
    ctx.font = 'bold 42px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText('鸿蒙跑酷', this.canvasW / 2, 200)

    // 副标题
    ctx.fillStyle = '#a8a8a8'
    ctx.font = '16px sans-serif'
    ctx.fillText('点击屏幕跳跃,躲避障碍收集金币', this.canvasW / 2, 250)

    // 最高分
    if (this.highScore > 0) {
      ctx.fillStyle = '#ffdd00'
      ctx.font = '20px sans-serif'
      ctx.fillText(`最高分: ${this.highScore}`, this.canvasW / 2, 310)
    }

    // 开始按钮
    ctx.fillStyle = '#e94560'
    const btnW = 160
    const btnH = 50
    const btnX = this.canvasW / 2 - btnW / 2
    const btnY = 380
    ctx.beginPath()
    ctx.roundRect(btnX, btnY, btnW, btnH, 25)
    ctx.fill()
    ctx.fillStyle = '#ffffff'
    ctx.font = 'bold 22px sans-serif'
    ctx.fillText('开始游戏', this.canvasW / 2, btnY + 33)

    // 操作说明
    ctx.fillStyle = '#666666'
    ctx.font = '14px sans-serif'
    ctx.fillText('支持二段跳!', this.canvasW / 2, 480)

    ctx.textAlign = 'start'
  }

  // 渲染游戏画面
  private renderGame(ctx: CanvasRenderingContext2D): void {
    // 天空渐变
    const gradient = ctx.createLinearGradient(0, 0, 0, this.groundY)
    gradient.addColorStop(0, '#0a0a2e')
    gradient.addColorStop(1, '#1a1a3e')
    ctx.fillStyle = gradient
    ctx.fillRect(0, 0, this.canvasW, this.groundY)

    // 远景星星
    ctx.fillStyle = '#ffffff'
    for (let i = 0; i < 30; i++) {
      const sx = ((i * 97 + this.bgOffset * 0.1) % (this.canvasW + 20)) - 10
      const sy = (i * 73) % (this.groundY - 100) + 30
      const size = (i % 3 === 0) ? 2 : 1
      ctx.fillRect(sx, sy, size, size)
    }

    // 远景山丘
    ctx.fillStyle = '#16213e'
    ctx.beginPath()
    ctx.moveTo(0, this.groundY)
    for (let x = 0; x <= this.canvasW; x += 40) {
      const hillY = this.groundY - 30 - Math.sin((x + this.bgOffset * 0.3) * 0.02) * 20
      ctx.lineTo(x, hillY)
    }
    ctx.lineTo(this.canvasW, this.groundY)
    ctx.closePath()
    ctx.fill()

    // 地面
    ctx.fillStyle = '#16213e'
    ctx.fillRect(0, this.groundY, this.canvasW, this.canvasH - this.groundY)
    ctx.fillStyle = '#0f3460'
    ctx.fillRect(0, this.groundY, this.canvasW, 4)

    // 地面纹理
    ctx.fillStyle = '#1a2744'
    for (let x = 0; x < this.canvasW; x += 30) {
      const offset = (this.bgOffset + x) % this.canvasW
      ctx.fillRect(offset < this.canvasW ? offset : offset - this.canvasW, this.groundY + 10, 15, 3)
    }

    // 绘制金币
    for (const coin of this.coins) {
      if (coin.collected) continue
      const center = coin.getCenter()
      const bobY = Math.sin(coin.animTimer * 5) * 3
      ctx.beginPath()
      ctx.arc(coin.x, center.y + bobY, coin.radius, 0, Math.PI * 2)
      ctx.fillStyle = '#ffdd00'
      ctx.fill()
      ctx.strokeStyle = '#ffaa00'
      ctx.lineWidth = 2
      ctx.stroke()
      // 金币高光
      ctx.beginPath()
      ctx.arc(coin.x - 3, center.y + bobY - 3, 3, 0, Math.PI * 2)
      ctx.fillStyle = 'rgba(255,255,255,0.5)'
      ctx.fill()
    }

    // 绘制障碍物
    for (const obs of this.obstacles) {
      ctx.save()
      switch (obs.type) {
        case 'box':
          ctx.fillStyle = '#e94560'
          ctx.fillRect(obs.x, obs.y, obs.width, obs.height)
          ctx.fillStyle = '#c73652'
          ctx.fillRect(obs.x + 3, obs.y + 3, obs.width - 6, obs.height - 6)
          break
        case 'spike':
          ctx.fillStyle = '#e94560'
          ctx.beginPath()
          ctx.moveTo(obs.x + obs.width / 2, obs.y)
          ctx.lineTo(obs.x, obs.y + obs.height)
          ctx.lineTo(obs.x + obs.width, obs.y + obs.height)
          ctx.closePath()
          ctx.fill()
          break
        case 'tall':
          ctx.fillStyle = '#e94560'
          ctx.fillRect(obs.x, obs.y, obs.width, obs.height)
          ctx.fillStyle = '#c73652'
          ctx.fillRect(obs.x + 3, obs.y + 3, obs.width - 6, obs.height - 6)
          // 顶部装饰
          ctx.fillStyle = '#ff6b6b'
          ctx.fillRect(obs.x - 2, obs.y, obs.width + 4, 5)
          break
        case 'double':
          ctx.fillStyle = '#e94560'
          ctx.fillRect(obs.x, obs.y, obs.width, obs.height)
          ctx.fillStyle = '#c73652'
          ctx.fillRect(obs.x + 3, obs.y + 3, obs.width / 2 - 4, obs.height - 6)
          ctx.fillRect(obs.x + obs.width / 2 + 1, obs.y + 3, obs.width / 2 - 4, obs.height - 6)
          break
      }
      ctx.restore()
    }

    // 绘制角色
    if (this.runner.alive) {
      ctx.save()
      ctx.translate(this.runner.x, this.runner.y)

      // 身体
      ctx.fillStyle = '#00ff88'
      ctx.fillRect(-this.runner.width / 2, -this.runner.height, this.runner.width, this.runner.height)

      // 眼睛
      ctx.fillStyle = '#ffffff'
      ctx.fillRect(4, -this.runner.height + 12, 8, 8)
      ctx.fillStyle = '#000000'
      ctx.fillRect(8, -this.runner.height + 14, 4, 4)

      // 跑步腿部动画
      if (this.runner.isOnGround) {
        const legOffset = Math.sin(this.runner.runFrame * Math.PI / 2) * 6
        ctx.fillStyle = '#00cc66'
        ctx.fillRect(-8, -4, 7, 8 + legOffset)
        ctx.fillRect(2, -4, 7, 8 - legOffset)
      } else {
        // 跳跃姿态:腿收起
        ctx.fillStyle = '#00cc66'
        ctx.fillRect(-8, -4, 7, 5)
        ctx.fillRect(2, -4, 7, 5)
      }

      ctx.restore()
    }

    // 绘制粒子
    for (const p of this.particles) {
      const alpha = Math.max(0, p.life / p.maxLife)
      ctx.globalAlpha = alpha
      ctx.fillStyle = p.color
      ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size)
    }
    ctx.globalAlpha = 1.0

    // HUD
    this.renderHUD(ctx)
  }

  // 渲染HUD
  private renderHUD(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = 'rgba(0,0,0,0.4)'
    ctx.fillRect(0, 0, this.canvasW, 40)

    ctx.fillStyle = '#ffffff'
    ctx.font = '16px sans-serif'
    ctx.textAlign = 'start'
    ctx.fillText(`距离: ${Math.floor(this.distance / 10)}m`, 10, 27)

    ctx.fillStyle = '#ffdd00'
    ctx.fillText(`${this.coinCount}`, 160, 27)

    ctx.fillStyle = '#ffffff'
    ctx.textAlign = 'end'
    ctx.fillText(`分数: ${this.score}`, this.canvasW - 10, 27)
    ctx.textAlign = 'start'
  }

  // 渲染游戏结束
  private renderGameOver(ctx: CanvasRenderingContext2D): void {
    // 半透明遮罩
    ctx.fillStyle = 'rgba(0,0,0,0.7)'
    ctx.fillRect(0, 0, this.canvasW, this.canvasH)

    // 游戏结束文字
    ctx.fillStyle = '#e94560'
    ctx.font = 'bold 36px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText('游戏结束', this.canvasW / 2, this.canvasH / 2 - 80)

    // 分数
    ctx.fillStyle = '#ffffff'
    ctx.font = '24px sans-serif'
    ctx.fillText(`分数: ${this.score}`, this.canvasW / 2, this.canvasH / 2 - 30)

    // 金币
    ctx.fillStyle = '#ffdd00'
    ctx.font = '18px sans-serif'
    ctx.fillText(`金币: ${this.coinCount}`, this.canvasW / 2, this.canvasH / 2 + 5)

    // 距离
    ctx.fillStyle = '#a8a8a8'
    ctx.font = '18px sans-serif'
    ctx.fillText(`距离: ${Math.floor(this.distance / 10)}m`, this.canvasW / 2, this.canvasH / 2 + 35)

    // 最高分
    if (this.score >= this.highScore) {
      ctx.fillStyle = '#ffdd00'
      ctx.font = 'bold 20px sans-serif'
      ctx.fillText('新纪录!', this.canvasW / 2, this.canvasH / 2 + 70)
    } else {
      ctx.fillStyle = '#a8a8a8'
      ctx.font = '16px sans-serif'
      ctx.fillText(`最高分: ${this.highScore}`, this.canvasW / 2, this.canvasH / 2 + 70)
    }

    // 重试按钮
    ctx.fillStyle = '#e94560'
    const btnW = 160
    const btnH = 50
    const btnX = this.canvasW / 2 - btnW / 2
    const btnY = this.canvasH / 2 + 100
    ctx.beginPath()
    ctx.roundRect(btnX, btnY, btnW, btnH, 25)
    ctx.fill()
    ctx.fillStyle = '#ffffff'
    ctx.font = 'bold 22px sans-serif'
    ctx.fillText('再来一次', this.canvasW / 2, btnY + 33)

    ctx.textAlign = 'start'
  }
}

踩坑与注意事项

坑1:碰撞体太精确

跑酷游戏的碰撞检测不能太精确。如果碰撞体和视觉完全一致,玩家会觉得"明明没碰到就死了"。把碰撞体缩小4-6像素,给玩家一点"宽容度",体验会好很多。

坑2:难度曲线太陡

第一关就出tall障碍,新手直接劝退。前10秒只有box,10-30秒加spike,30秒以后才出tall和double。让玩家有学习的时间。

坑3:二段跳的手感

二段跳的力度要比一段跳小(-500 vs -600),这样二段跳的感觉是"小跳"而不是"大跳"。如果二段跳力度太大,角色飞太高,落地时间太长,节奏感全无。

坑4:金币和障碍的位置冲突

金币生成在障碍物正上方,玩家跳起来吃金币正好撞上障碍。生成金币时要检查附近有没有障碍物,有就换个位置。

坑5:帧率对游戏速度的影响

如果用speed * dt来移动障碍物,帧率低的时候障碍物移动距离大,帧率高的时候移动距离小——这是对的。但如果你用固定步长(比如每帧移动5像素),帧率低的时候游戏就变慢了。一定要用dt做插值。

HarmonyOS 6适配说明

HarmonyOS 6对2D游戏开发的适配要点:

  1. Canvas roundRect支持:HarmonyOS 6的Canvas 2D原生支持roundRect()方法,不需要自己画圆角矩形了。上面的代码已经使用了这个API。

  2. 高刷新率适配:通过DisplaySyncAPI获取屏幕实际刷新率,动态调整游戏循环频率。120Hz屏幕上游戏更流畅。

  3. 触控采样率:HarmonyOS 6支持240Hz触控采样率,跳跃操作的响应更灵敏。

  4. 游戏模式:使用@ohos.game模块进入游戏模式,系统会优化CPU调度和内存管理,减少后台应用对游戏的影响。

总结

一个完整的2D游戏,从架构到实现,核心就是这几样东西:

游戏循环驱动一切,状态机管理流程,精灵系统管理对象,碰撞检测处理交互,关卡生成控制节奏,计分系统给玩家反馈。

代码量看起来不少,但拆开看每个模块都不复杂。关键是模块之间的配合——游戏循环调用各模块的update,渲染调用各模块的draw,碰撞检测连接角色和障碍/金币,关卡生成控制障碍和金币的出现节奏。

做游戏没有捷径,就是写→跑→调→写→跑→调。先让基本功能跑通,再优化体验,再打磨细节。

评估维度 学习难度 使用频率 重要程度
游戏状态机 ★★★☆☆ ★★★★★ ★★★★★
角色控制 ★★★☆☆ ★★★★★ ★★★★★
关卡生成 ★★★★☆ ★★★★☆ ★★★★★
碰撞检测 ★★☆☆☆ ★★★★★ ★★★★★
粒子效果 ★★☆☆☆ ★★★☆☆ ★★★☆☆
UI渲染 ★★★☆☆ ★★★★★ ★★★★☆
难度曲线 ★★★★☆ ★★★★☆ ★★★★★

到这里,鸿蒙游戏开发的10篇文章就全部完成了。从引擎选型到2D/3D开发,从物理引擎到音频管理,从输入处理到网络同步,从性能优化到发布上架,最后用一个完整的2D跑酷游戏把所有知识串了起来。

游戏开发是鸿蒙生态中最有挑战也最有意思的领域之一。鸿蒙的分布式能力、多设备协同、高刷新率屏幕,都为游戏创新提供了独特的可能性。希望这10篇文章能帮你打开鸿蒙游戏开发的大门。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。