HarmonyOS游戏开发:Canvas动画与游戏循环实现

举报
Jack20 发表于 2026/06/22 22:19:01 2026/06/22
【摘要】 HarmonyOS游戏开发:Canvas动画与游戏循环实现📌 核心要点:从Canvas动画原理到Game Loop架构设计,掌握requestAnimationFrame的精确使用、固定时间步长的游戏循环、状态驱动的动画管理,最终实现一个完整的2D小游戏——让动画从"能动"升级到"流畅"。 一、背景与动机“我的动画怎么一卡一卡的?”——这大概是HarmonyOS Canvas动画开发中被...

HarmonyOS游戏开发:Canvas动画与游戏循环实现

📌 核心要点:从Canvas动画原理到Game Loop架构设计,掌握requestAnimationFrame的精确使用、固定时间步长的游戏循环、状态驱动的动画管理,最终实现一个完整的2D小游戏——让动画从"能动"升级到"流畅"。


一、背景与动机

“我的动画怎么一卡一卡的?”——这大概是HarmonyOS Canvas动画开发中被问得最多的问题。

很多人写Canvas动画的方式是这样的:用setInterval定时器,每隔16ms画一帧,然后发现动画在低端设备上疯狂掉帧,在高端设备上又快得不像话。于是又去调定时器间隔——16ms不行就10ms,10ms不行就8ms……结果呢?高端设备上CPU占用飙升,低端设备上还是卡。

问题出在哪?出在你用错了"时钟"。

setIntervalsetTimeout是JavaScript的定时器,它们不关心屏幕的刷新频率,也不关心浏览器/渲染引擎是否准备好绘制下一帧。你让它16ms执行一次,它就"尽力"16ms执行一次——但如果主线程正忙,它可能20ms、30ms甚至50ms才执行一次。这就是动画卡顿的根源。

正确的做法是使用requestAnimationFrame(简称rAF)。 它是专门为动画设计的API,会与屏幕的VSync信号同步——屏幕刷新一次,它就执行一次。不早不晚,刚刚好。

但光用rAF还不够。一个真正的游戏或复杂动画,还需要一个精心设计的游戏循环(Game Loop)——它决定了动画的更新逻辑、渲染时机、帧率控制、时间步长等核心机制。游戏循环是动画的"心脏",心脏跳得稳不稳,直接决定了动画流不流畅。

今天我们就从零开始,一步步搭建一个专业级的Canvas动画系统,最终实现一个完整的2D小游戏。


二、核心原理

2.1 Canvas动画原理

Canvas动画的本质是快速切换静态画面。就像翻页动画书一样——每页画一个稍微不同的姿势,快速翻动就产生了运动的错觉。

1: ●          帧2:  ●         帧3:   ●        帧4:|                |                |                |
——————————————————————————————————————————————————————————→ 时间
     16.6ms           16.6ms           16.6ms

每一帧要做三件事:

  1. 清屏——擦除上一帧的画面
  2. 更新——计算下一帧的状态(位置、颜色、大小等)
  3. 绘制——把新的状态画到Canvas上

这三步必须在16.6ms(60FPS)内完成,否则就会掉帧。

2.2 requestAnimationFrame vs 定时器

graph TD
    A[动画驱动方式]:::primary --> B{选择驱动}:::warning
    B --> C[setInterval/setTimeout]:::error
    B --> D[requestAnimationFrame]:::info
    
    C --> C1[❌ 不与VSync同步]:::error
    C --> C2[❌ 主线程忙时延迟]:::error
    C --> C3[❌ 后台标签页继续执行]:::error
    C --> C4[❌ 帧率不可控]:::error
    
    D --> D1[✅ 与VSync同步]:::info
    D --> D2[✅ 自动降帧不卡顿]:::info
    D --> D3[✅ 后台自动暂停]:::info
    D --> D4[✅ 帧率稳定60fps]:::info

    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
特性 setInterval setTimeout requestAnimationFrame
与VSync同步
帧率稳定性 不稳定 不稳定 稳定
后台标签页 继续执行 继续执行 自动暂停
主线程忙时 延迟累积 延迟累积 跳过帧不累积
电池消耗
适用场景 非动画定时任务 延迟执行 动画/游戏

2.3 游戏循环(Game Loop)设计

游戏循环是游戏开发中最核心的架构模式。它控制着游戏的"心跳"——每帧更新游戏状态、处理输入、渲染画面。

固定时间步长游戏循环:

while (游戏运行中) {
    处理输入()
    
    累积时间 += 当前时间 - 上一帧时间
    
    while (累积时间 >= 固定步长) {
        更新逻辑(固定步长)  // 物理模拟、碰撞检测等
        累积时间 -= 固定步长
    }
    
    渲染(累积时间 / 固定步长)  // 插值渲染,消除抖动
}

固定时间步长的优势:

  • 物理模拟确定性:无论帧率如何,物理计算结果一致
  • 网络同步友好:所有客户端使用相同的步长,状态一致
  • 消除帧率波动影响:高帧率不会让游戏变快,低帧率不会让游戏变慢

2.4 动画状态管理

动画的本质是状态随时间的变化。一个好的动画系统需要:

  1. 状态定义——每个动画对象有哪些属性(位置、速度、颜色等)
  2. 状态更新——每帧如何更新这些属性
  3. 状态插值——在帧之间平滑过渡
  4. 状态转换——从一种状态切换到另一种状态(如从"行走"到"跳跃")

三、代码实战

3.1 基础用法——requestAnimationFrame动画

/**
 * Canvas动画基础:使用requestAnimationFrame实现平滑动画
 */
@Entry
@Component
struct BasicCanvasAnimation {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  
  // 动画状态
  private ballX: number = 50;
  private ballY: number = 150;
  private ballVX: number = 3;
  private ballVY: number = 2;
  private ballRadius: number = 20;
  private animRunning: boolean = false;
  private lastTime: number = 0;
  
  // Canvas尺寸
  private canvasWidth: number = 360;
  private canvasHeight: number = 300;
  
  build() {
    Column() {
      Canvas(this.context)
        .width(this.canvasWidth)
        .height(this.canvasHeight)
        .backgroundColor('#1a1a2e')
        .borderRadius(12)
        .onReady(() => {
          this.startAnimation();
        })
      
      Row() {
        Button('暂停')
          .fontSize(14)
          .height(36)
          .onClick(() => this.stopAnimation())
        
        Button('继续')
          .fontSize(14)
          .height(36)
          .margin({ left: 8 })
          .onClick(() => this.startAnimation())
        
        Button('重置')
          .fontSize(14)
          .height(36)
          .margin({ left: 8 })
          .onClick(() => this.resetAnimation())
      }
      .margin({ top: 12 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
  
  /**
   * 启动动画
   */
  private startAnimation(): void {
    if (this.animRunning) return;
    this.animRunning = true;
    this.lastTime = Date.now();
    this.gameLoop();
  }
  
  /**
   * 停止动画
   */
  private stopAnimation(): void {
    this.animRunning = false;
  }
  
  /**
   * 重置动画
   */
  private resetAnimation(): void {
    this.ballX = 50;
    this.ballY = 150;
    this.ballVX = 3;
    this.ballVY = 2;
  }
  
  /**
   * 游戏循环
   */
  private gameLoop = (): void => {
    if (!this.animRunning) return;
    
    // 计算时间增量
    const currentTime = Date.now();
    const deltaTime = (currentTime - this.lastTime) / 1000; // 转换为秒
    this.lastTime = currentTime;
    
    // 更新状态(基于时间增量,而非固定步长)
    this.update(deltaTime);
    
    // 渲染
    this.render();
    
    // 请求下一帧
    requestAnimationFrame(this.gameLoop);
  }
  
  /**
   * 更新动画状态
   */
  private update(deltaTime: number): void {
    // 基于时间增量更新位置(确保不同帧率下速度一致)
    this.ballX += this.ballVX * deltaTime * 60; // 60是基准帧率
    this.ballY += this.ballVY * deltaTime * 60;
    
    // 边界碰撞检测
    if (this.ballX - this.ballRadius < 0 || this.ballX + this.ballRadius > this.canvasWidth) {
      this.ballVX *= -1;
      this.ballX = Math.max(this.ballRadius, Math.min(this.canvasWidth - this.ballRadius, this.ballX));
    }
    if (this.ballY - this.ballRadius < 0 || this.ballY + this.ballRadius > this.canvasHeight) {
      this.ballVY *= -1;
      this.ballY = Math.max(this.ballRadius, Math.min(this.canvasHeight - this.ballRadius, this.ballY));
    }
  }
  
  /**
   * 渲染画面
   */
  private render(): void {
    const ctx = this.context;
    
    // 清屏
    ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    
    // 绘制背景
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
    
    // 绘制球的阴影
    ctx.beginPath();
    ctx.arc(this.ballX + 4, this.ballY + 4, this.ballRadius, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
    ctx.fill();
    
    // 绘制球
    const gradient = ctx.createRadialGradient(
      this.ballX - this.ballRadius * 0.3,
      this.ballY - this.ballRadius * 0.3,
      0,
      this.ballX,
      this.ballY,
      this.ballRadius
    );
    gradient.addColorStop(0, '#00b4d8');
    gradient.addColorStop(1, '#0077b6');
    
    ctx.beginPath();
    ctx.arc(this.ballX, this.ballY, this.ballRadius, 0, Math.PI * 2);
    ctx.fillStyle = gradient;
    ctx.fill();
    
    // 绘制高光
    ctx.beginPath();
    ctx.arc(
      this.ballX - this.ballRadius * 0.3,
      this.ballY - this.ballRadius * 0.3,
      this.ballRadius * 0.2,
      0, Math.PI * 2
    );
    ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
    ctx.fill();
  }
}

3.2 进阶用法——固定时间步长游戏循环与状态管理

/**
 * 固定时间步长游戏循环引擎
 * 核心特性:
 * 1. 固定物理步长(60Hz),确保物理模拟确定性
 * 2. 可变渲染帧率,适配不同设备性能
 * 3. 插值渲染,消除帧率波动导致的抖动
 */
class GameLoopEngine {
  // 固定时间步长(1/60秒)
  private readonly FIXED_TIME_STEP: number = 1 / 60;
  // 最大累积时间(防止死循环)
  private readonly MAX_ACCUMULATED_TIME: number = 0.25;
  
  private accumulatedTime: number = 0;
  private lastFrameTime: number = 0;
  private isRunning: boolean = false;
  private animationId: number = -1;
  
  // 游戏状态
  private gameState: GameState;
  // 渲染插值因子
  private alpha: number = 0;
  
  // 回调函数
  private onUpdate: (state: GameState, dt: number) => void;
  private onRender: (state: GameState, alpha: number) => void;
  
  constructor(
    gameState: GameState,
    onUpdate: (state: GameState, dt: number) => void,
    onRender: (state: GameState, alpha: number) => void
  ) {
    this.gameState = gameState;
    this.onUpdate = onUpdate;
    this.onRender = onRender;
  }
  
  /**
   * 启动游戏循环
   */
  start(): void {
    if (this.isRunning) return;
    this.isRunning = true;
    this.lastFrameTime = Date.now() / 1000;
    this.accumulatedTime = 0;
    this.loop();
  }
  
  /**
   * 停止游戏循环
   */
  stop(): void {
    this.isRunning = false;
    if (this.animationId !== -1) {
      cancelAnimationFrame(this.animationId);
    }
  }
  
  /**
   * 主循环
   */
  private loop = (): void => {
    if (!this.isRunning) return;
    
    const currentTime = Date.now() / 1000;
    const frameTime = currentTime - this.lastFrameTime;
    this.lastFrameTime = currentTime;
    
    // 防止帧时间过长(如切换标签页后回来)
    const clampedFrameTime = Math.min(frameTime, this.MAX_ACCUMULATED_TIME);
    
    // 累积时间
    this.accumulatedTime += clampedFrameTime;
    
    // 固定步长更新(可能一帧更新多次)
    let updateCount = 0;
    while (this.accumulatedTime >= this.FIXED_TIME_STEP) {
      this.onUpdate(this.gameState, this.FIXED_TIME_STEP);
      this.accumulatedTime -= this.FIXED_TIME_STEP;
      updateCount++;
      
      // 防止一帧更新过多(性能保护)
      if (updateCount > 5) {
        this.accumulatedTime = 0;
        break;
      }
    }
    
    // 计算插值因子(用于渲染时平滑过渡)
    this.alpha = this.accumulatedTime / this.FIXED_TIME_STEP;
    
    // 渲染(使用插值因子)
    this.onRender(this.gameState, this.alpha);
    
    // 请求下一帧
    this.animationId = requestAnimationFrame(this.loop);
  }
  
  /**
   * 获取当前帧率
   */
  getFps(): number {
    return 1 / this.FIXED_TIME_STEP;
  }
}

/**
 * 游戏状态
 */
interface GameState {
  entities: GameEntity[];
  score: number;
  time: number;
  paused: boolean;
}

/**
 * 游戏实体
 */
interface GameEntity {
  id: string;
  // 当前状态
  x: number;
  y: number;
  vx: number;
  vy: number;
  width: number;
  height: number;
  type: EntityType;
  active: boolean;
  // 上一帧状态(用于插值渲染)
  prevX: number;
  prevY: number;
}

enum EntityType {
  PLAYER,
  ENEMY,
  BULLET,
  PARTICLE,
}

/**
 * 动画状态机
 * 管理动画状态的转换
 */
class AnimationStateMachine {
  private currentState: string = '';
  private states: Map<string, AnimationState> = new Map();
  private transitions: Map<string, string[]> = new Map();
  
  /**
   * 添加动画状态
   */
  addState(name: string, state: AnimationState): void {
    this.states.set(name, state);
    if (!this.currentState) {
      this.currentState = name;
    }
  }
  
  /**
   * 添加状态转换规则
   */
  addTransition(from: string, to: string): void {
    if (!this.transitions.has(from)) {
      this.transitions.set(from, []);
    }
    this.transitions.get(from)!.push(to);
  }
  
  /**
   * 尝试转换状态
   */
  transitionTo(newState: string): boolean {
    // 检查是否允许转换
    const allowedTransitions = this.transitions.get(this.currentState);
    if (!allowedTransitions || !allowedTransitions.includes(newState)) {
      return false;
    }
    
    // 退出当前状态
    const current = this.states.get(this.currentState);
    if (current?.onExit) {
      current.onExit();
    }
    
    // 进入新状态
    this.currentState = newState;
    const next = this.states.get(newState);
    if (next?.onEnter) {
      next.onEnter();
    }
    
    return true;
  }
  
  /**
   * 更新当前状态
   */
  update(deltaTime: number): void {
    const state = this.states.get(this.currentState);
    if (state?.onUpdate) {
      state.onUpdate(deltaTime);
    }
  }
  
  /**
   * 获取当前状态名
   */
  getCurrentState(): string {
    return this.currentState;
  }
}

interface AnimationState {
  onEnter?: () => void;
  onUpdate?: (dt: number) => void;
  onExit?: () => void;
}

3.3 完整示例——2D小游戏(太空射击)

这是一个完整的2D太空射击游戏,集成了固定时间步长游戏循环、状态管理、碰撞检测、粒子效果等核心功能。

/**
 * 太空射击游戏
 * 技术要点:
 * 1. 固定时间步长游戏循环
 * 2. 状态驱动的动画管理
 * 3. 碰撞检测
 * 4. 粒子效果系统
 * 5. 对象池优化
 */
@Entry
@Component
struct SpaceShooterGame {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  
  // 游戏状态
  @State score: number = 0;
  @State lives: number = 3;
  @State gameState: string = 'menu'; // 'menu' | 'playing' | 'gameover'
  @State highScore: number = 0;
  
  // 游戏数据
  private player: Player = { x: 0, y: 0, width: 30, height: 30, speed: 200 };
  private bullets: Bullet[] = [];
  private enemies: Enemy[] = [];
  private particles: Particle[] = [];
  private stars: Star[] = [];
  
  // 对象池(避免频繁创建和销毁对象)
  private bulletPool: Bullet[] = [];
  private enemyPool: Enemy[] = [];
  private particlePool: Particle[] = [];
  
  // 游戏循环
  private loopEngine: GameLoopEngine | null = null;
  private lastEnemySpawn: number = 0;
  private lastBulletFire: number = 0;
  private gameTime: number = 0;
  
  // 输入状态
  private touchX: number = 0;
  private isTouching: boolean = false;
  
  // Canvas尺寸
  private canvasWidth: number = 360;
  private canvasHeight: number = 600;
  
  build() {
    Column() {
      // 顶部信息栏
      Row() {
        Text(`得分: ${this.score}`)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#00b4d8')
        
        Blank()
        
        Text(`生命: ${'❤️'.repeat(this.lives)}`)
          .fontSize(16)
      }
      .width('100%')
      .height(44)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#0d1b2a')
      
      // 游戏Canvas
      Stack() {
        Canvas(this.context)
          .width(this.canvasWidth)
          .height(this.canvasHeight)
          .backgroundColor('#0d1b2a')
          .onReady(() => {
            this.initGame();
          })
          .onTouch((event: TouchEvent) => {
            this.handleInput(event);
          })
        
        // 游戏状态覆盖层
        if (this.gameState === 'menu') {
          Column() {
            Text('🚀 太空射击')
              .fontSize(32)
              .fontWeight(FontWeight.Bold)
              .fontColor('#00b4d8')
            
            Text('点击屏幕开始游戏')
              .fontSize(16)
              .fontColor('#AAAAAA')
              .margin({ top: 16 })
            
            Text('触摸移动飞船,自动射击')
              .fontSize(12)
              .fontColor('#666666')
              .margin({ top: 8 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .backgroundColor('rgba(13, 27, 42, 0.8)')
        }
        
        if (this.gameState === 'gameover') {
          Column() {
            Text('游戏结束')
              .fontSize(28)
              .fontWeight(FontWeight.Bold)
              .fontColor('#e94560')
            
            Text(`最终得分: ${this.score}`)
              .fontSize(20)
              .fontColor('#FFFFFF')
              .margin({ top: 12 })
            
            if (this.score >= this.highScore) {
              Text('🏆 新纪录!')
                .fontSize(16)
                .fontColor('#FFD700')
                .margin({ top: 8 })
            }
            
            Text('点击屏幕重新开始')
              .fontSize(14)
              .fontColor('#AAAAAA')
              .margin({ top: 16 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .backgroundColor('rgba(13, 27, 42, 0.85)')
        }
      }
      .width(this.canvasWidth)
      .height(this.canvasHeight)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0d1b2a')
  }
  
  /**
   * 初始化游戏
   */
  private initGame(): void {
    // 初始化星空背景
    this.initStars();
    
    // 绘制初始画面
    this.renderMenu();
  }
  
  /**
   * 初始化星空
   */
  private initStars(): void {
    this.stars = [];
    for (let i = 0; i < 100; i++) {
      this.stars.push({
        x: Math.random() * this.canvasWidth,
        y: Math.random() * this.canvasHeight,
        speed: Math.random() * 100 + 30,
        size: Math.random() * 2 + 0.5,
        brightness: Math.random() * 0.7 + 0.3,
      });
    }
  }
  
  /**
   * 开始游戏
   */
  private startGame(): void {
    // 重置游戏状态
    this.score = 0;
    this.lives = 3;
    this.gameTime = 0;
    this.lastEnemySpawn = 0;
    this.lastBulletFire = 0;
    
    // 重置玩家
    this.player.x = this.canvasWidth / 2;
    this.player.y = this.canvasHeight - 80;
    
    // 清空所有实体
    this.bullets = [];
    this.enemies = [];
    this.particles = [];
    
    // 切换游戏状态
    this.gameState = 'playing';
    
    // 启动游戏循环
    const gameState: GameState = {
      entities: [],
      score: 0,
      time: 0,
      paused: false,
    };
    
    this.loopEngine = new GameLoopEngine(
      gameState,
      (state, dt) => this.update(dt),
      (state, alpha) => this.render(alpha)
    );
    this.loopEngine.start();
  }
  
  /**
   * 处理输入
   */
  private handleInput(event: TouchEvent): void {
    const touch = event.touches[0];
    
    if (this.gameState === 'menu') {
      this.startGame();
      return;
    }
    
    if (this.gameState === 'gameover') {
      this.startGame();
      return;
    }
    
    if (this.gameState === 'playing') {
      switch (event.type) {
        case TouchType.Down:
          this.isTouching = true;
          this.touchX = touch.x;
          break;
        case TouchType.Move:
          this.touchX = touch.x;
          break;
        case TouchType.Up:
          this.isTouching = false;
          break;
      }
    }
  }
  
  /**
   * 更新游戏逻辑(固定时间步长)
   */
  private update(dt: number): void {
    if (this.gameState !== 'playing') return;
    
    this.gameTime += dt;
    
    // 更新玩家位置
    if (this.isTouching) {
      const targetX = this.touchX;
      const diff = targetX - this.player.x;
      this.player.x += diff * 8 * dt; // 平滑跟随
    }
    
    // 限制玩家范围
    this.player.x = Math.max(this.player.width / 2, 
      Math.min(this.canvasWidth - this.player.width / 2, this.player.x));
    
    // 自动发射子弹
    if (this.gameTime - this.lastBulletFire > 0.15) {
      this.fireBullet();
      this.lastBulletFire = this.gameTime;
    }
    
    // 生成敌人
    if (this.gameTime - this.lastEnemySpawn > 0.8) {
      this.spawnEnemy();
      this.lastEnemySpawn = this.gameTime;
    }
    
    // 更新星空
    this.updateStars(dt);
    
    // 更新子弹
    this.updateBullets(dt);
    
    // 更新敌人
    this.updateEnemies(dt);
    
    // 更新粒子
    this.updateParticles(dt);
    
    // 碰撞检测
    this.checkCollisions();
  }
  
  /**
   * 发射子弹
   */
  private fireBullet(): void {
    // 从对象池获取或创建新子弹
    let bullet = this.bulletPool.pop();
    if (!bullet) {
      bullet = { x: 0, y: 0, vy: -400, width: 4, height: 12, active: true };
    }
    bullet.x = this.player.x;
    bullet.y = this.player.y - this.player.height / 2;
    bullet.active = true;
    this.bullets.push(bullet);
  }
  
  /**
   * 生成敌人
   */
  private spawnEnemy(): void {
    let enemy = this.enemyPool.pop();
    if (!enemy) {
      enemy = {
        x: 0, y: 0, vy: 0, width: 24, height: 24,
        type: 'normal', hp: 1, active: true
      };
    }
    enemy.x = Math.random() * (this.canvasWidth - 40) + 20;
    enemy.y = -30;
    enemy.vy = 80 + Math.random() * 60;
    enemy.type = Math.random() > 0.7 ? 'fast' : 'normal';
    enemy.hp = enemy.type === 'fast' ? 1 : 2;
    enemy.active = true;
    this.enemies.push(enemy);
  }
  
  /**
   * 更新星空
   */
  private updateStars(dt: number): void {
    for (const star of this.stars) {
      star.y += star.speed * dt;
      if (star.y > this.canvasHeight) {
        star.y = 0;
        star.x = Math.random() * this.canvasWidth;
      }
    }
  }
  
  /**
   * 更新子弹
   */
  private updateBullets(dt: number): void {
    for (let i = this.bullets.length - 1; i >= 0; i--) {
      const bullet = this.bullets[i];
      bullet.y += bullet.vy * dt;
      
      // 超出屏幕则回收
      if (bullet.y < -20) {
        bullet.active = false;
        this.bulletPool.push(this.bullets.splice(i, 1)[0]);
      }
    }
  }
  
  /**
   * 更新敌人
   */
  private updateEnemies(dt: number): void {
    for (let i = this.enemies.length - 1; i >= 0; i--) {
      const enemy = this.enemies[i];
      enemy.y += enemy.vy * dt;
      
      // 超出屏幕底部则回收
      if (enemy.y > this.canvasHeight + 30) {
        enemy.active = false;
        this.enemyPool.push(this.enemies.splice(i, 1)[0]);
      }
    }
  }
  
  /**
   * 更新粒子
   */
  private updateParticles(dt: number): void {
    for (let i = this.particles.length - 1; i >= 0; i--) {
      const p = this.particles[i];
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.life -= dt;
      p.alpha = Math.max(0, p.life / p.maxLife);
      
      if (p.life <= 0) {
        this.particlePool.push(this.particles.splice(i, 1)[0]);
      }
    }
  }
  
  /**
   * 碰撞检测
   */
  private checkCollisions(): void {
    // 子弹 vs 敌人
    for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
      const bullet = this.bullets[bi];
      
      for (let ei = this.enemies.length - 1; ei >= 0; ei--) {
        const enemy = this.enemies[ei];
        
        if (this.rectIntersect(
          bullet.x - bullet.width / 2, bullet.y - bullet.height / 2,
          bullet.width, bullet.height,
          enemy.x - enemy.width / 2, enemy.y - enemy.height / 2,
          enemy.width, enemy.height
        )) {
          enemy.hp--;
          
          // 回收子弹
          bullet.active = false;
          this.bulletPool.push(this.bullets.splice(bi, 1)[0]);
          
          if (enemy.hp <= 0) {
            // 敌人被消灭
            this.score += enemy.type === 'fast' ? 20 : 10;
            
            // 产生爆炸粒子
            this.spawnExplosion(enemy.x, enemy.y);
            
            // 回收敌人
            enemy.active = false;
            this.enemyPool.push(this.enemies.splice(ei, 1)[0]);
          }
          
          break;
        }
      }
    }
    
    // 敌人 vs 玩家
    for (let ei = this.enemies.length - 1; ei >= 0; ei--) {
      const enemy = this.enemies[ei];
      
      if (this.rectIntersect(
        this.player.x - this.player.width / 2,
        this.player.y - this.player.height / 2,
        this.player.width, this.player.height,
        enemy.x - enemy.width / 2, enemy.y - enemy.height / 2,
        enemy.width, enemy.height
      )) {
        // 玩家被击中
        this.lives--;
        
        // 产生爆炸粒子
        this.spawnExplosion(enemy.x, enemy.y);
        
        // 回收敌人
        enemy.active = false;
        this.enemyPool.push(this.enemies.splice(ei, 1)[0]);
        
        if (this.lives <= 0) {
          this.gameOver();
        }
      }
    }
  }
  
  /**
   * 生成爆炸粒子
   */
  private spawnExplosion(x: number, y: number): void {
    const count = 12;
    for (let i = 0; i < count; i++) {
      let particle = this.particlePool.pop();
      if (!particle) {
        particle = {
          x: 0, y: 0, vx: 0, vy: 0,
          size: 0, color: '', alpha: 1,
          life: 0, maxLife: 0,
        };
      }
      
      const angle = (i / count) * Math.PI * 2;
      const speed = 80 + Math.random() * 120;
      
      particle.x = x;
      particle.y = y;
      particle.vx = Math.cos(angle) * speed;
      particle.vy = Math.sin(angle) * speed;
      particle.size = Math.random() * 4 + 2;
      particle.color = ['#e94560', '#FF9800', '#FFD700', '#00b4d8'][Math.floor(Math.random() * 4)];
      particle.alpha = 1;
      particle.life = 0.5 + Math.random() * 0.3;
      particle.maxLife = particle.life;
      
      this.particles.push(particle);
    }
  }
  
  /**
   * 矩形碰撞检测
   */
  private rectIntersect(
    x1: number, y1: number, w1: number, h1: number,
    x2: number, y2: number, w2: number, h2: number
  ): boolean {
    return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2;
  }
  
  /**
   * 游戏结束
   */
  private gameOver(): void {
    this.gameState = 'gameover';
    if (this.score > this.highScore) {
      this.highScore = this.score;
    }
    if (this.loopEngine) {
      this.loopEngine.stop();
    }
  }
  
  /**
   * 渲染(使用插值因子平滑动画)
   */
  private render(alpha: number): void {
    const ctx = this.context;
    
    // 清屏
    ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    
    // 绘制背景
    ctx.fillStyle = '#0d1b2a';
    ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
    
    // 绘制星空
    this.renderStars(ctx);
    
    if (this.gameState !== 'playing') return;
    
    // 绘制玩家飞船
    this.renderPlayer(ctx);
    
    // 绘制子弹
    this.renderBullets(ctx);
    
    // 绘制敌人
    this.renderEnemies(ctx);
    
    // 绘制粒子
    this.renderParticles(ctx);
  }
  
  /**
   * 渲染星空
   */
  private renderStars(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = '#FFFFFF';
    for (const star of this.stars) {
      ctx.globalAlpha = star.brightness;
      ctx.beginPath();
      ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1.0;
  }
  
  /**
   * 渲染玩家飞船
   */
  private renderPlayer(ctx: CanvasRenderingContext2D): void {
    const p = this.player;
    
    // 飞船主体(三角形)
    ctx.beginPath();
    ctx.moveTo(p.x, p.y - p.height / 2);
    ctx.lineTo(p.x - p.width / 2, p.y + p.height / 2);
    ctx.lineTo(p.x + p.width / 2, p.y + p.height / 2);
    ctx.closePath();
    
    const gradient = ctx.createLinearGradient(p.x, p.y - p.height / 2, p.x, p.y + p.height / 2);
    gradient.addColorStop(0, '#00b4d8');
    gradient.addColorStop(1, '#0077b6');
    ctx.fillStyle = gradient;
    ctx.fill();
    
    // 引擎火焰
    ctx.beginPath();
    ctx.moveTo(p.x - 6, p.y + p.height / 2);
    ctx.lineTo(p.x, p.y + p.height / 2 + 10 + Math.random() * 8);
    ctx.lineTo(p.x + 6, p.y + p.height / 2);
    ctx.fillStyle = '#FF9800';
    ctx.fill();
  }
  
  /**
   * 渲染子弹
   */
  private renderBullets(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = '#00b4d8';
    ctx.beginPath();
    for (const bullet of this.bullets) {
      ctx.moveTo(bullet.x + bullet.width / 2, bullet.y - bullet.height / 2);
      ctx.rect(
        bullet.x - bullet.width / 2,
        bullet.y - bullet.height / 2,
        bullet.width,
        bullet.height
      );
    }
    ctx.fill(); // 批量绘制所有子弹
  }
  
  /**
   * 渲染敌人
   */
  private renderEnemies(ctx: CanvasRenderingContext2D): void {
    for (const enemy of this.enemies) {
      ctx.save();
      ctx.translate(enemy.x, enemy.y);
      
      if (enemy.type === 'fast') {
        // 快速敌人:菱形
        ctx.beginPath();
        ctx.moveTo(0, -enemy.height / 2);
        ctx.lineTo(enemy.width / 2, 0);
        ctx.lineTo(0, enemy.height / 2);
        ctx.lineTo(-enemy.width / 2, 0);
        ctx.closePath();
        ctx.fillStyle = '#e94560';
      } else {
        // 普通敌人:方形
        ctx.beginPath();
        ctx.rect(-enemy.width / 2, -enemy.height / 2, enemy.width, enemy.height);
        ctx.fillStyle = '#533483';
      }
      ctx.fill();
      ctx.restore();
    }
  }
  
  /**
   * 渲染粒子
   */
  private renderParticles(ctx: CanvasRenderingContext2D): void {
    for (const p of this.particles) {
      ctx.globalAlpha = p.alpha;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
      ctx.fillStyle = p.color;
      ctx.fill();
    }
    ctx.globalAlpha = 1.0;
  }
  
  /**
   * 渲染菜单画面
   */
  private renderMenu(): void {
    const ctx = this.context;
    ctx.fillStyle = '#0d1b2a';
    ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
    this.renderStars(ctx);
  }
  
  aboutToDisappear(): void {
    if (this.loopEngine) {
      this.loopEngine.stop();
    }
  }
}

// 游戏数据接口
interface Player {
  x: number;
  y: number;
  width: number;
  height: number;
  speed: number;
}

interface Bullet {
  x: number;
  y: number;
  vy: number;
  width: number;
  height: number;
  active: boolean;
}

interface Enemy {
  x: number;
  y: number;
  vy: number;
  width: number;
  height: number;
  type: string;
  hp: number;
  active: boolean;
}

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  size: number;
  color: string;
  alpha: number;
  life: number;
  maxLife: number;
}

interface Star {
  x: number;
  y: number;
  speed: number;
  size: number;
  brightness: number;
}

四、踩坑与注意事项

坑点1:requestAnimationFrame的回调时间不均匀

rAF的回调间隔在正常情况下是16.6ms,但如果主线程繁忙,可能变成33ms甚至更长。如果你在update中用固定值(如x += 5)而不是基于deltaTime计算,动画速度就会忽快忽慢。

// ❌ 错误:固定步长,帧率波动时速度不一致
update() {
  this.ballX += 5; // 每帧固定移动5像素
}

// ✅ 正确:基于时间增量,速度与帧率无关
update(deltaTime: number) {
  this.ballX += 300 * deltaTime; // 每秒移动300像素
}

坑点2:deltaTime为0或负数

在游戏刚启动或从后台切换回来时,deltaTime可能出现异常值(0、负数或极大的数)。必须对deltaTime进行clamp处理。

// ✅ 安全的deltaTime处理
const rawDelta = (currentTime - lastTime) / 1000;
const deltaTime = Math.max(0, Math.min(rawDelta, 0.25)); // 限制在0-0.25秒之间

坑点3:对象频繁创建导致GC卡顿

在游戏循环中频繁创建新对象(如new Bullet()new Particle()),会导致ArkTS堆内存快速增长,触发频繁的GC(垃圾回收),GC时会暂停主线程,导致周期性卡顿。

解决方案:使用对象池。 预先创建一批对象,需要时从池中取,用完归还到池中,避免频繁的内存分配和回收。

坑点4:碰撞检测的精度问题

简单的矩形碰撞检测(AABB)在高速运动时可能"穿透"——子弹速度太快,一帧就穿过了敌人,碰撞检测完全失效。

解决方案: 对于高速物体,使用连续碰撞检测(CCD),或者在每帧中细分步长进行多次检测。

坑点5:Canvas状态泄漏

save()restore()必须成对使用。如果你在绘制某个对象时调用了save()但忘记restore(),后续所有绘制都会受到这个对象的变换和裁剪影响。

// ❌ 错误:save/restore不匹配
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
// ... 绘制 ...
// 忘了restore()!后续所有绘制都会被旋转

// ✅ 正确:save/restore成对使用
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
// ... 绘制 ...
ctx.restore(); // 恢复到save之前的状态

坑点6:动画循环未正确停止

如果你在组件销毁时没有停止动画循环,rAF会继续执行回调,但此时Canvas上下文可能已经失效,导致异常或内存泄漏。务必在aboutToDisappear中停止动画循环。

坑点7:触摸事件与Canvas坐标系不一致

触摸事件的坐标是相对于组件的,但如果Canvas有padding、margin或被其他组件包裹,触摸坐标可能与Canvas的绘制坐标系不一致。建议使用Canvas的onTouch事件,并确保坐标转换正确。


五、HarmonyOS 6适配说明

API差异

API HarmonyOS 5.0 HarmonyOS 6.0 迁移建议
requestAnimationFrame 基础rAF 新增高精度时间戳参数 使用高精度时间戳替代Date.now()
Canvas动画 手动实现游戏循环 新增CanvasAnimator工具类 使用官方工具类简化动画开发
触摸事件 基础Touch事件 新增手势识别器 使用手势识别器简化输入处理
对象池 需手动实现 新增ObjectPool工具类 使用官方对象池减少GC压力
粒子系统 需手动实现 新增ParticleEmitter组件 使用官方粒子系统

行为变更

  • requestAnimationFrame精度提升:6.0中rAF回调参数新增高精度时间戳(微秒级),替代Date.now()的毫秒级精度,动画时间计算更准确
  • Canvas自动批处理:6.0的Canvas渲染引擎自动合并连续的相同类型绘制调用,无需手动批处理
  • 触摸事件采样率提升:6.0中触摸事件的采样率从60Hz提升到120Hz(在支持的设备上),游戏输入响应更灵敏
  • 后台动画自动暂停:6.0中应用进入后台时rAF自动暂停,回到前台自动恢复,无需手动处理

适配代码

/**
 * HarmonyOS 6.0适配的游戏循环
 */
class GameLoopCompat {
  private isV6: boolean = false;
  private isRunning: boolean = false;
  private lastTime: number = 0;
  private animationId: number = -1;
  
  // 固定时间步长参数
  private readonly FIXED_STEP: number = 1 / 60;
  private accumulatedTime: number = 0;
  
  // 回调
  private onUpdate: (dt: number) => void;
  private onRender: (alpha: number) => void;
  
  constructor(
    onUpdate: (dt: number) => void,
    onRender: (alpha: number) => void
  ) {
    this.onUpdate = onUpdate;
    this.onRender = onRender;
    this.isV6 = this.detectApiVersion();
  }
  
  start(): void {
    if (this.isRunning) return;
    this.isRunning = true;
    this.lastTime = performance.now() / 1000;
    this.accumulatedTime = 0;
    this.loop();
  }
  
  stop(): void {
    this.isRunning = false;
    if (this.animationId !== -1) {
      cancelAnimationFrame(this.animationId);
    }
  }
  
  private loop = (): void => {
    if (!this.isRunning) return;
    
    // 6.0使用高精度时间戳
    const currentTime = performance.now() / 1000;
    const frameTime = Math.min(currentTime - this.lastTime, 0.25);
    this.lastTime = currentTime;
    
    // 固定时间步长更新
    this.accumulatedTime += frameTime;
    let updateCount = 0;
    
    while (this.accumulatedTime >= this.FIXED_STEP) {
      this.onUpdate(this.FIXED_STEP);
      this.accumulatedTime -= this.FIXED_STEP;
      updateCount++;
      if (updateCount > 5) {
        this.accumulatedTime = 0;
        break;
      }
    }
    
    // 插值渲染
    const alpha = this.accumulatedTime / this.FIXED_STEP;
    this.onRender(alpha);
    
    this.animationId = requestAnimationFrame(this.loop);
  }
  
  private detectApiVersion(): boolean {
    try {
      return typeof performance !== 'undefined' && typeof performance.now === 'function';
    } catch {
      return false;
    }
  }
}

/**
 * 6.0适配的对象池
 */
class ObjectPoolCompat<T> {
  private pool: T[] = [];
  private factory: () => T;
  private reset: (obj: T) => void;
  private maxSize: number;
  
  constructor(factory: () => T, reset: (obj: T) => void, maxSize: number = 100) {
    this.factory = factory;
    this.reset = reset;
    this.maxSize = maxSize;
  }
  
  /**
   * 从池中获取对象
   */
  acquire(): T {
    if (this.pool.length > 0) {
      return this.pool.pop()!;
    }
    return this.factory();
  }
  
  /**
   * 归还对象到池中
   */
  release(obj: T): void {
    if (this.pool.length < this.maxSize) {
      this.reset(obj);
      this.pool.push(obj);
    }
  }
  
  /**
   * 预热对象池
   */
  prewarm(count: number): void {
    for (let i = 0; i < count; i++) {
      this.pool.push(this.factory());
    }
  }
}

// 使用示例:子弹对象池
const bulletPool = new ObjectPoolCompat<Bullet>(
  () => ({ x: 0, y: 0, vy: -400, width: 4, height: 12, active: true }),
  (bullet) => { bullet.x = 0; bullet.y = 0; bullet.active = false; },
  50
);

// 发射子弹
const bullet = bulletPool.acquire();
bullet.x = playerX;
bullet.y = playerY - 20;
bullet.active = true;

// 回收子弹
bulletPool.release(bullet);

六、总结

维度 评价
学习难度 ⭐⭐⭐⭐⭐
使用频率 ⭐⭐⭐⭐
重要程度 ⭐⭐⭐⭐⭐

Canvas动画与游戏循环是HarmonyOS游戏开发的地基工程。今天我们从最基础的动画原理出发,一路走到了完整的2D游戏实现,这中间的每一步都至关重要。

核心知识回顾:

requestAnimationFrame是动画的唯一正解。 别再用setInterval了,rAF与VSync同步的特性决定了它是动画的"官方时钟"。用rAF,动画才能跟屏幕刷新率完美对齐,不早不晚,刚刚好。

固定时间步长是游戏循环的灵魂。 它让物理模拟和游戏逻辑与帧率解耦——60fps也好,30fps也好,游戏速度始终一致。这是从"能动"到"专业"的分水岭。

对象池是性能的守护神。 在游戏循环中频繁创建和销毁对象是GC卡顿的元凶。对象池用"借还"代替"创建销毁",让GC压力降到最低。

状态管理是复杂动画的导航仪。 当动画从简单的"一个球弹来弹去"升级到"多种状态、多种转换"时,状态机帮你理清逻辑,避免if-else的意大利面条代码。

最后,我想说的是:写一个能跑的游戏不难,写一个流畅的游戏很难。 流畅不是运气,而是对每一帧时间的精确把控、对每一个对象生命周期的严格管理、对每一次绘制调用的精心优化。当你能把60fps稳定住,在低端设备上也不掉帧,那你就真正掌握了Canvas动画的精髓。

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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