在手机屏幕上开飞机——用 HarmonyOS 从零搭一个简易射击游戏

前言

小时候在红白机上玩"小蜜蜂"和"雷电",屏幕上的飞机从左移到右,子弹向上飞,敌机从顶部落下。手指按着方向键,眼睛盯着碰撞的那一刻,那种紧张感现在还记得。如今手机性能已经足够强,但这类简单的射击游戏依然让人上瘾------它们规则直白、反馈即时,三五分钟就能打一局。

我一直想试试用 HarmonyOS 的 Canvas 写一个这类游戏。不需要花哨的素材,就几个几何图形代表飞机、子弹和敌机,加上一套简单的运动逻辑和碰撞检测,就能在模拟器上跑起来。上周末我打开 DevEco Studio 6.1.1 Beta1,在 Pura X Max 模拟器上把这个想法变成了一段实际可玩的代码。这篇文章就是把整个过程从头到尾讲给你听,包含游戏循环怎么搭建、飞机怎么跟着手指滑动、子弹怎么自动发射、敌机怎么随机出现以及碰撞怎么判断。完整的代码也会附上,你拷进模拟器就能当一个小游戏玩。

一、游戏世界的骨架------游戏循环与状态管理

所有动作游戏背后都有一个共同的"心脏",叫做游戏循环。它的任务是一秒钟重复几十次"更新状态 → 重绘画布"的过程。我们看到的飞机移动、子弹飞过、敌机下坠,都是因为在每一帧里,它们的坐标被稍微改变了一点,然后立刻画到屏幕上。

在 HarmonyOS 里,最方便的游戏循环就是 setInterval。设定每 33 毫秒(约 30 帧/秒)执行一次回调,在里面做四件事:

  1. 更新位置:移动所有子弹、所有敌机,检查是否有对象飞出屏幕。
  2. 生成对象:按一定概率随机生成新的敌机。
  3. 碰撞检测:检查子弹和敌机是否重叠。
  4. 重绘画面:清空画布,依次画背景、飞机、子弹、敌机、得分文字。

这个循环一旦启动就持续运行,直到游戏结束。它不依赖于任何用户操作,即使玩家不动,敌机也会自己掉下来。

游戏的状态我用几个 @State 变量来管:gameOver(是否结束)、score(得分)、playerX(飞机水平位置)。但子弹和敌机数量多、变化快,不适合每个都作为 @State 变量,否则频繁的状态更新会导致 UI 重绘开销太大。所以子弹和敌机我用普通数组来存,只在游戏循环内部操作它们,Canvas 的绘制则直接在循环里手动调用,不需要依赖 ArkUI 的状态刷新机制。这样性能更好,画面也更流畅。

二、画一架属于自己的战斗机------Canvas 绘制几何图形

因为没有图片资源,游戏里的所有角色都用简单的几何形状来画。我方飞机在屏幕底部,画成一个等腰三角形,上面加一个小小的矩形座舱,看起来就像一架微型的战斗机。颜色用蓝色填充,白色描边,简单但有辨识度。

敌机不需要太复杂,用一个红色的倒三角形表示,或者用小矩形加两个小机翼。为了区分难度,敌机也可以画成不同大小。这里为了代码简洁,统一用红色小矩形表示敌机,尺寸约 30×30 像素。

子弹是一颗颗小黄点,用 fillRect 画成 4×10 的细长矩形,从飞机头部向上射出。每颗子弹的速度固定为每帧向上移动 10 像素。

绘制顺序很重要:先画背景(深蓝到黑的纵向渐变模拟夜空),再画敌机,然后画子弹,最后画我方飞机。这样飞机总是显示在最上层,子弹覆盖在敌机上方,视觉上合理。得分文字放在画面左上角,用白色大号字体,游戏结束时就显示在半透明遮罩之上。

飞机的位置由 playerX 决定,它表示飞机中心点的 x 坐标。绘制时以 playerX 为中心,算出三角形三个顶点。飞机宽度设为 50 像素,高度 40 像素,正好适合手指操作。

三、手指就是操纵杆------触摸事件与平滑移动

玩家通过手指触摸来移动飞机。游戏应该响应两种触摸事件:手指按下并移动时,飞机跟随手指水平滑动;手指离开时,飞机停在原位。

HarmonyOS 的 Canvas 支持 onTouch 事件,可以拿到 TouchEventtouches[0].x 就是手指在画布上的 x 坐标。我们把它直接赋给 playerX,飞机就会"瞬移"到手指位置。但为了让移动更平滑,也可以加一点插值,让飞机追着手指走,而不是瞬间到达。不过手指滑动本身已经够平滑,直接赋值也能获得跟手的体验。唯一要限制的是飞机不能移出画布左右边界,所以赋值时要用 Math.maxMath.minplayerX 钳制在飞机半宽到画布宽度减半宽之间。

onTouch 里,我监听 TouchType.DownTouchType.Move 两种类型。当手指按下或移动时,更新 playerX;当手指抬起时,不做任何事。这个逻辑非常简单,几行代码搞定。

为了防止手指误触导致游戏开始(比如还没点"开始游戏"按钮),只有在 gameStartedtruegameOverfalse 时才更新飞机位置。

四、子弹从哪来------自动发射与对象池管理

飞机需要自动发射子弹,而不是等玩家点击才发。最简单的方式是:在每一帧更新时,让飞机产生一颗新子弹,把它加入子弹数组。这样每 33 毫秒就有一颗子弹射出,火力密集,符合直觉。

但如果每帧都产生子弹,子弹数量会很快爆炸,而且子弹飞得太密不真实。所以要加一个发射间隔。我用一个计数器 fireCooldown,初始为 0,每帧加 1,当计数达到某个阈值(比如 8,即约 0.26 秒一发)时,才发射一颗子弹,同时计数器归零。这样子弹的产生就均匀且可控。

子弹发射后,以固定速度向上移动。每帧遍历子弹数组,更新 y 坐标。如果子弹飞出屏幕顶部(y < 0),就从数组中删除它,防止数组无限增长。ArkTS 里用 splice 删除时要注意反向遍历或使用 filter,因为正向删除会导致索引错乱。我用 filter 直接创建一个新数组,保留未出界的子弹,代码简洁且不容易出错。

敌机也类似,从顶部生成,随机出现在屏幕宽度内的任意 x 位置,然后向下移动。生成敌机也用一个间隔计数器,约每秒产生一架新敌机。敌机数量不宜过多,否则手机性能吃紧。我控制在屏幕上同时最多有 5 架敌机。敌机飞出底部后也通过 filter 移除。

五、碰撞怎么判------矩形重叠与边界检测

碰撞检测是射击游戏的灵魂。判断一颗子弹和一架敌机是否相撞,最直接的方法是矩形重叠检测。每个游戏对象都可以用一个矩形来描述:子弹的矩形是它当前位置和宽高,敌机同理。两个矩形如果在 x 轴和 y 轴上都有重叠部分,就算碰撞。

具体实现:对于每颗子弹,遍历所有敌机,检查:

复制代码
子弹左 < 敌机右 且 子弹右 > 敌机左 且 子弹上 < 敌机下 且 子弹下 > 敌机上

条件全部满足,就说明它们撞上了。此时,这颗子弹和这架敌机都应该被移除,同时得分加 10。为了简化,我在一帧中只处理一颗子弹和一架敌机的一次碰撞,其他碰撞留到下一帧再处理(因为子弹会消失,敌机也会消失)。更常见的做法是遍历时标记要删除的对象,统一在遍历结束后删除,避免数组操作影响循环。我用两层循环,但发现用 filter 在遍历结束后重建数组更安全。在这里我们可以在检测到碰撞时直接记录它们的索引,然后一次性删除。

另外,敌机如果和我方飞机碰撞,游戏也应该结束。检测飞机和敌机是否碰撞,同样用矩形重叠。飞机的矩形可以近似为一个包围三角形的小矩形框。如果任何一架敌机与飞机重叠,gameOver 设为 true,游戏循环停止,画面显示最终得分。

最后,如果敌机飞出屏幕底部而未被击落,虽然不影响玩家,但为了保持难度,也可以不扣分。在这个简易版里,飞出底部的敌机只是被移除,不额外惩罚。

六、完整代码------一架飞机在 Pura X Max 上起飞

以下代码适配 DevEco Studio 6.1.1 Beta1、SDK22 语法,Pura X Max 模拟器。新建 Empty Ability 项目,把 entry/src/main/ets/pages/Index.ets 全部替换即可。无需任何权限,纯本地游戏。

复制代码
/*
 * 飞机大战简易版 --- Canvas 射击游戏
 * 环境:DevEco Studio 6.1.1 Beta1,Pura X Max 模拟器,SDK22
 */
import { CanvasRenderingContext2D } from '@ohos.graphics.canvas';

interface Bullet {
  x: number;
  y: number;
  w: number;
  h: number;
}

interface Enemy {
  x: number;
  y: number;
  w: number;
  h: number;
}

@Entry
@Component
struct Index {
  @State gameOver: boolean = false;
  @State gameStarted: boolean = false;
  @State score: number = 0;

  private ctx: CanvasRenderingContext2D | null = null;
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;
  private playerX: number = 0;
  private playerY: number = 0;
  private playerW: number = 50;
  private playerH: number = 40;
  private bullets: Bullet[] = [];
  private enemies: Enemy[] = [];
  private bulletCooldown: number = 0;
  private enemyCooldown: number = 0;
  private animTimer: number = -1;

  // Canvas 就绪
  private onCanvasReady(ctx: CanvasRenderingContext2D): void {
    this.ctx = ctx;
    this.canvasWidth = ctx.canvas.width;
    this.canvasHeight = ctx.canvas.height;
    this.playerX = this.canvasWidth / 2;
    this.playerY = this.canvasHeight - 80;
    this.drawStartScreen();
  }

  // 开始游戏
  private startGame(): void {
    this.gameStarted = true;
    this.gameOver = false;
    this.score = 0;
    this.bullets = [];
    this.enemies = [];
    this.bulletCooldown = 0;
    this.enemyCooldown = 0;
    this.playerX = this.canvasWidth / 2;
    this.startLoop();
  }

  // 游戏循环
  private startLoop(): void {
    if (this.animTimer !== -1) return;
    this.animTimer = setInterval(() => {
      if (this.gameOver) return;
      this.update();
      this.drawAll();
    }, 33);
  }

  private stopLoop(): void {
    if (this.animTimer !== -1) {
      clearInterval(this.animTimer);
      this.animTimer = -1;
    }
  }

  // 游戏逻辑更新
  private update(): void {
    // 发射子弹
    this.bulletCooldown++;
    if (this.bulletCooldown >= 8) {
      this.bulletCooldown = 0;
      this.bullets.push({
        x: this.playerX - 2,
        y: this.playerY - 20,
        w: 4,
        h: 14
      });
    }

    // 子弹移动
    this.bullets = this.bullets.filter(b => {
      b.y -= 12;
      return b.y + b.h > 0;
    });

    // 生成敌机
    this.enemyCooldown++;
    if (this.enemyCooldown >= 30 && this.enemies.length < 5) {
      this.enemyCooldown = 0;
      let enemyW = 30;
      let enemyH = 30;
      this.enemies.push({
        x: Math.random() * (this.canvasWidth - enemyW),
        y: -enemyH,
        w: enemyW,
        h: enemyH
      });
    }

    // 敌机移动
    this.enemies = this.enemies.filter(e => {
      e.y += 5;
      return e.y < this.canvasHeight + 50;
    });

    // 碰撞检测:子弹 vs 敌机
    for (let i = this.bullets.length - 1; i >= 0; i--) {
      let bullet = this.bullets[i];
      let hit = false;
      for (let j = this.enemies.length - 1; j >= 0; j--) {
        let enemy = this.enemies[j];
        if (this.isCollide(bullet, enemy)) {
          // 移除敌机和子弹
          this.enemies.splice(j, 1);
          hit = true;
          this.score += 10;
          break; // 一颗子弹只打掉一架敌机
        }
      }
      if (hit) {
        this.bullets.splice(i, 1);
      }
    }

    // 碰撞检测:敌机 vs 玩家
    let playerRect = { x: this.playerX - this.playerW/2, y: this.playerY - this.playerH/2, w: this.playerW, h: this.playerH };
    for (let enemy of this.enemies) {
      if (this.isCollide(playerRect, enemy)) {
        this.gameOver = true;
        this.stopLoop();
        break;
      }
    }
  }

  // 矩形碰撞检测
  private isCollide(a: {x:number, y:number, w:number, h:number}, b: {x:number, y:number, w:number, h:number}): boolean {
    return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
  }

  // 绘制所有
  private drawAll(): void {
    if (!this.ctx) return;
    let ctx = this.ctx;
    let w = this.canvasWidth;
    let h = this.canvasHeight;
    ctx.clearRect(0, 0, w, h);

    // 背景渐变
    let gradient = ctx.createLinearGradient(0, 0, 0, h);
    gradient.addColorStop(0, '#0a0a2e');
    gradient.addColorStop(1, '#000010');
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, w, h);

    // 绘制敌机(红色方块)
    for (let e of this.enemies) {
      ctx.fillStyle = '#E53935';
      ctx.fillRect(e.x, e.y, e.w, e.h);
      ctx.strokeStyle = '#B71C1C';
      ctx.lineWidth = 1;
      ctx.strokeRect(e.x, e.y, e.w, e.h);
    }

    // 绘制子弹
    ctx.fillStyle = '#FFD600';
    for (let b of this.bullets) {
      ctx.fillRect(b.x, b.y, b.w, b.h);
    }

    // 绘制玩家飞机(三角形 + 座舱)
    let px = this.playerX;
    let py = this.playerY;
    ctx.fillStyle = '#42A5F5';
    ctx.strokeStyle = '#0D47A1';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(px, py - 20);
    ctx.lineTo(px - 25, py + 20);
    ctx.lineTo(px + 25, py + 20);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
    // 座舱
    ctx.fillStyle = '#BBDEFB';
    ctx.fillRect(px - 8, py - 5, 16, 10);

    // 得分
    ctx.fillStyle = '#FFFFFF';
    ctx.font = 'bold 20px sans-serif';
    ctx.textAlign = 'left';
    ctx.fillText(`得分: ${this.score}`, 20, 50);

    // 游戏结束画面
    if (this.gameOver) {
      ctx.fillStyle = 'rgba(0,0,0,0.6)';
      ctx.fillRect(0, 0, w, h);
      ctx.fillStyle = '#FFFFFF';
      ctx.font = 'bold 36px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText('游戏结束', w/2, h/2 - 20);
      ctx.font = '24px sans-serif';
      ctx.fillText(`最终得分: ${this.score}`, w/2, h/2 + 30);
    }
  }

  // 开始画面
  private drawStartScreen(): void {
    if (!this.ctx) return;
    let ctx = this.ctx;
    let w = this.canvasWidth;
    let h = this.canvasHeight;
    ctx.clearRect(0, 0, w, h);
    let gradient = ctx.createLinearGradient(0, 0, 0, h);
    gradient.addColorStop(0, '#0a0a2e');
    gradient.addColorStop(1, '#000010');
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, w, h);
    ctx.fillStyle = '#FFFFFF';
    ctx.font = 'bold 30px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('飞机大战', w/2, h/2 - 30);
    ctx.font = '18px sans-serif';
    ctx.fillText('触摸屏幕移动飞机', w/2, h/2 + 20);
  }

  // 触摸事件
  private handleTouch(event: TouchEvent): void {
    if (!this.gameStarted || this.gameOver) return;
    if (event.type === TouchType.Down || event.type === TouchType.Move) {
      let touch = event.touches[0];
      let x = touch.x;
      // 限制飞机不超出边界
      this.playerX = Math.max(this.playerW/2, Math.min(this.canvasWidth - this.playerW/2, x));
    }
  }

  aboutToDisappear(): void {
    this.stopLoop();
  }

  build() {
    Column() {
      Text('飞机大战')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 4 })
        .fontColor('#FFFFFF')

      Canvas()
        .width('100%')
        .height(500)
        .backgroundColor('#000010')
        .onReady((event) => {
          let ctx = event.context as CanvasRenderingContext2D;
          this.onCanvasReady(ctx);
        })
        .onTouch((event: TouchEvent) => {
          this.handleTouch(event);
        })

      if (!this.gameStarted || this.gameOver) {
        Button(this.gameOver ? '再来一局' : '开始游戏')
          .fontSize(20)
          .type(ButtonType.Capsule)
          .backgroundColor('#E53935')
          .fontColor(Color.White)
          .margin({ top: 12 })
          .onClick(() => {
            if (this.gameOver) {
              this.gameOver = false;
              this.startGame();
            } else {
              this.startGame();
            }
          })
      }

      Text('💡 手指滑动移动飞机,自动发射子弹,躲避红色敌机')
        .fontSize(12)
        .fontColor('#888')
        .width('90%')
        .textAlign(TextAlign.Center)
        .margin({ top: 8 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000010')
  }
}

七、运行效果

代码粘贴进 DevEco Studio,Run 到 Pura X Max 模拟器。屏幕上方出现标题,点击"开始游戏",背景变成深邃的星空渐变,一架蓝色三角形战斗机出现在底部中央,手指在屏幕上左右滑动,飞机立刻跟随移动。不需要额外操作,子弹自动从机头射出,黄色光点飞向上方。大约一秒后,红色方块敌机从顶部落下,子弹击中敌机时,敌机消失,得分增加 10 分。如果敌机撞上我方飞机,游戏立即结束,画面蒙上黑色遮罩,显示最终得分。点击"再来一局"重新开始。整个过程动画流畅,触摸响应灵敏,达到了可玩的水准。

总结

这个简易的飞机大战游戏虽然画面朴素,却凝聚了 2D 射击游戏的几个核心知识点:

  • 游戏循环(Game Loop) :用 setInterval 创建固定帧率的更新和绘制循环,是所有动作游戏的基石。
  • Canvas 绘图与变换 :通过几何图形构建角色,渐变背景营造太空感,理解了 fillRectarcbeginPath 等 API 的组合使用。
  • 触摸事件与交互 :利用 TouchEvent 实现飞机跟随手指的平滑移动,掌握了如何将屏幕坐标映射到游戏世界。
  • 碰撞检测:矩形重叠算法简单高效,适用于大量小型游戏对象的实时判断。
  • 对象管理与性能 :子弹和敌机使用数组动态增删,用 filter 和定时生成控制数量,避免了内存泄漏和性能问题。

你可以继续为这个游戏添加音效、多种敌机、Boss 战、道具系统,甚至双人模式。但就目前而言,它已经是一架能在 Pura X Max 模拟器上自由飞翔的战斗机了。下次无聊的时候打开它,手指一滑,子弹一飞,几分钟就能打上一局------这就是自己写游戏的快乐。