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

我一直想试试用 HarmonyOS 的 Canvas 写一个这类游戏。不需要花哨的素材,就几个几何图形代表飞机、子弹和敌机,加上一套简单的运动逻辑和碰撞检测,就能在模拟器上跑起来。上周末我打开 DevEco Studio 6.1.1 Beta1,在 Pura X Max 模拟器上把这个想法变成了一段实际可玩的代码。这篇文章就是把整个过程从头到尾讲给你听,包含游戏循环怎么搭建、飞机怎么跟着手指滑动、子弹怎么自动发射、敌机怎么随机出现以及碰撞怎么判断。完整的代码也会附上,你拷进模拟器就能当一个小游戏玩。
一、游戏世界的骨架------游戏循环与状态管理
所有动作游戏背后都有一个共同的"心脏",叫做游戏循环。它的任务是一秒钟重复几十次"更新状态 → 重绘画布"的过程。我们看到的飞机移动、子弹飞过、敌机下坠,都是因为在每一帧里,它们的坐标被稍微改变了一点,然后立刻画到屏幕上。
在 HarmonyOS 里,最方便的游戏循环就是 setInterval。设定每 33 毫秒(约 30 帧/秒)执行一次回调,在里面做四件事:

- 更新位置:移动所有子弹、所有敌机,检查是否有对象飞出屏幕。
- 生成对象:按一定概率随机生成新的敌机。
- 碰撞检测:检查子弹和敌机是否重叠。
- 重绘画面:清空画布,依次画背景、飞机、子弹、敌机、得分文字。
这个循环一旦启动就持续运行,直到游戏结束。它不依赖于任何用户操作,即使玩家不动,敌机也会自己掉下来。
游戏的状态我用几个 @State 变量来管:gameOver(是否结束)、score(得分)、playerX(飞机水平位置)。但子弹和敌机数量多、变化快,不适合每个都作为 @State 变量,否则频繁的状态更新会导致 UI 重绘开销太大。所以子弹和敌机我用普通数组来存,只在游戏循环内部操作它们,Canvas 的绘制则直接在循环里手动调用,不需要依赖 ArkUI 的状态刷新机制。这样性能更好,画面也更流畅。
二、画一架属于自己的战斗机------Canvas 绘制几何图形
因为没有图片资源,游戏里的所有角色都用简单的几何形状来画。我方飞机在屏幕底部,画成一个等腰三角形,上面加一个小小的矩形座舱,看起来就像一架微型的战斗机。颜色用蓝色填充,白色描边,简单但有辨识度。
敌机不需要太复杂,用一个红色的倒三角形表示,或者用小矩形加两个小机翼。为了区分难度,敌机也可以画成不同大小。这里为了代码简洁,统一用红色小矩形表示敌机,尺寸约 30×30 像素。
子弹是一颗颗小黄点,用 fillRect 画成 4×10 的细长矩形,从飞机头部向上射出。每颗子弹的速度固定为每帧向上移动 10 像素。

绘制顺序很重要:先画背景(深蓝到黑的纵向渐变模拟夜空),再画敌机,然后画子弹,最后画我方飞机。这样飞机总是显示在最上层,子弹覆盖在敌机上方,视觉上合理。得分文字放在画面左上角,用白色大号字体,游戏结束时就显示在半透明遮罩之上。
飞机的位置由 playerX 决定,它表示飞机中心点的 x 坐标。绘制时以 playerX 为中心,算出三角形三个顶点。飞机宽度设为 50 像素,高度 40 像素,正好适合手指操作。
三、手指就是操纵杆------触摸事件与平滑移动
玩家通过手指触摸来移动飞机。游戏应该响应两种触摸事件:手指按下并移动时,飞机跟随手指水平滑动;手指离开时,飞机停在原位。
HarmonyOS 的 Canvas 支持 onTouch 事件,可以拿到 TouchEvent。touches[0].x 就是手指在画布上的 x 坐标。我们把它直接赋给 playerX,飞机就会"瞬移"到手指位置。但为了让移动更平滑,也可以加一点插值,让飞机追着手指走,而不是瞬间到达。不过手指滑动本身已经够平滑,直接赋值也能获得跟手的体验。唯一要限制的是飞机不能移出画布左右边界,所以赋值时要用 Math.max 和 Math.min 把 playerX 钳制在飞机半宽到画布宽度减半宽之间。
在 onTouch 里,我监听 TouchType.Down 和 TouchType.Move 两种类型。当手指按下或移动时,更新 playerX;当手指抬起时,不做任何事。这个逻辑非常简单,几行代码搞定。

为了防止手指误触导致游戏开始(比如还没点"开始游戏"按钮),只有在 gameStarted 为 true 且 gameOver 为 false 时才更新飞机位置。
四、子弹从哪来------自动发射与对象池管理
飞机需要自动发射子弹,而不是等玩家点击才发。最简单的方式是:在每一帧更新时,让飞机产生一颗新子弹,把它加入子弹数组。这样每 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 绘图与变换 :通过几何图形构建角色,渐变背景营造太空感,理解了
fillRect、arc、beginPath等 API 的组合使用。 - 触摸事件与交互 :利用
TouchEvent实现飞机跟随手指的平滑移动,掌握了如何将屏幕坐标映射到游戏世界。 - 碰撞检测:矩形重叠算法简单高效,适用于大量小型游戏对象的实时判断。
- 对象管理与性能 :子弹和敌机使用数组动态增删,用
filter和定时生成控制数量,避免了内存泄漏和性能问题。
你可以继续为这个游戏添加音效、多种敌机、Boss 战、道具系统,甚至双人模式。但就目前而言,它已经是一架能在 Pura X Max 模拟器上自由飞翔的战斗机了。下次无聊的时候打开它,手指一滑,子弹一飞,几分钟就能打上一局------这就是自己写游戏的快乐。