请添加图片描述
请添加图片描述

一、前言:为什么在鸿蒙上写游戏

HarmonyOS NEXT 是华为完全自主研发的操作系统,从底层内核到上层框架彻底去除了 Android AOSP 代码。ArkTS 作为其主力应用开发语言,基于 TypeScript 语法扩展了声明式 UI 能力。

很多人以为 ArkTS 只能写业务应用(列表、表单、图表),但其实鸿蒙的 Canvas 组件CanvasRenderingContext2D API 提供了完整的 2D 图形绘制能力,足以支撑轻量级游戏的开发。

本文将以一个「跑酷躲避障碍物」游戏为例,从零开始,逐行讲解如何在鸿蒙 NEXT 上使用纯 ArkTS + Canvas 构建一个可玩的游戏。文章涵盖了:

  • 游戏主循环的设计模式
  • 2D 物理引擎(重力、碰撞)的实现
  • 游戏对象的渲染与动画
  • 状态机驱动的交互逻辑

无论你是鸿蒙应用开发者想尝试游戏方向,还是游戏开发者想了解鸿蒙生态,这篇文章都能给你一个完整的参考。


二、跑酷游戏的玩法设计

一个典型的跑酷游戏(如《神庙逃亡》《Chrome Dinosaur》)核心逻辑极其简单,但要做好却需要稳健的架构设计。

2.1 核心玩法

玩家自动向右奔跑 → 前方出现障碍物 → 点击屏幕跳跃躲避
→ 成功则继续 + 得分 → 失败则游戏结束 → 点击重开

2.2 游戏要素

要素 说明
玩家 一个可爱的卡通角色,跑步时有腿部动画,跳跃时有旋转效果
障碍物 三种类型:仙人掌(地面)、箱子(地面)、飞鸟(空中)
地面 不断向左滚动的色块地面,模拟前进感
背景 渐变星空 + 缓缓飘动的星星 + 波动山丘(视差效果)
分数 存活时间越长分数越高,碰撞时记录最高分
状态 READY → PLAYING → GAME_OVER 三态切换

2.3 设计目标

  • 纯 ArkTS 实现,不引入第三方引擎
  • 利用 Canvas 2D 完成全部渲染
  • 60 FPS 流畅运行
  • 代码模块化清晰,便于扩展

三、鸿蒙ArkTS Canvas 2D 渲染管线

在鸿蒙 ArkTS 中,Canvas 是一个原生组件,与 Column、Row、Text 等布局组件平级。它提供了一个矩形区域供开发者自由绘制。

3.1 Canvas 组件的基本用法

// 创建渲染上下文设置
private settings: RenderingContextSettings = new RenderingContextSettings(true);

// 创建 2D 渲染上下文(传入 settings 启用抗锯齿)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

// 在 build() 中使用 Canvas 组件
Canvas(this.ctx)
  .width('100%')
  .height(600)

RenderingContextSettings(true) 中的 true 表示启用抗锯齿(antialiasing),让绘制的图形边缘更平滑。

3.2 CanvasRenderingContext2D 的常用 API

我们在游戏中频繁使用了以下绘图方法:

API 用途
clearRect(x, y, w, h) 清除画布区域
fillRect(x, y, w, h) 填充矩形
strokeRect(x, y, w, h) 描边矩形
fillText(text, x, y) 填充文字
beginPath() + moveTo() + lineTo() + closePath() + fill() / stroke() 绘制任意路径
arc(x, y, r, startAngle, endAngle) 绘制圆弧/圆
arcTo(x1, y1, x2, y2, r) 绘制圆角连接
createLinearGradient(x0, y0, x1, y1) 创建线性渐变
save() / restore() 保存 / 恢复画布状态栈
translate(x, y) 平移坐标系
rotate(angle) 旋转坐标系
globalAlpha 全局透明度
fillStyle / strokeStyle / lineWidth / font / textAlign 样式属性

3.3 渲染管线顺序

每一帧的渲染都遵循固定的「从远到近」顺序——远层先画,近层后画(覆盖):

1. clearRect        → 清空整张画布
2. renderBackground → 绘制天空渐变 + 山丘 + 星星
3. renderGround     → 绘制地面色块 + 纹理
4. renderObstacles  → 绘制所有障碍物
5. renderPlayer     → 绘制玩家角色
6. renderUI         → 绘制分数 + 状态提示文字

这个顺序保证了:角色永远在障碍物上面,UI 文字永远在最上层。如果顺序错乱(例如先画角色再画障碍物),就会出现角色被障碍物"挡住"的视觉错误。


四、项目结构与代码组织

4.1 文件位置

entry/src/main/ets/pages/Index.ets   ← 全部游戏代码(约 778 行)

整个游戏仅用一个 .ets 文件完成。对于小型游戏来说,单文件可以降低复杂度,便于理解全貌。如果是大型项目,可以拆分为:

pages/
  Index.ets              ← 入口页面
game/
  GameLoop.ets           ← 游戏循环
  Physics.ts             ← 物理引擎
  Player.ts              ← 玩家对象
  Obstacle.ts            ← 障碍物对象
  Renderer.ts            ← 渲染器
  constants.ts           ← 常量

4.2 代码模块划分(按行号)

行号范围 模块 内容
1–15 文件头注释 游戏简介与实现要点
17–18 import 导入 promptAction
20–36 游戏常量 GAME_WIDTH, GRAVITY, OBSTACLE_SPEED…
38–81 类型定义 Player, Obstacle, GroundTile, BgStar, GameState
83–105 组件属性 ctx, @State 变量, 运行时数据
107–118 生命周期 aboutToAppear / aboutToDisappear
120–170 初始化 initPlayer / initGround / initBackground
172–193 游戏循环 startGameLoop / stopGameLoop / gameTick
195–362 游戏逻辑 update (物理 + 碰撞 + 生成)
364–668 渲染 render (背景 + 地面 + 障碍物 + 玩家 + UI)
670–719 游戏控制 startGame / resetGame / handleTap
721–777 UI 构建 build() 中的 Column + Canvas + Button

五、游戏常量与类型定义

5.1 游戏常量

常量集中定义在文件顶部,方便全局调优。调游戏手感时只需要改这里,不需要在整个代码中搜索散落的魔法数字。

const GAME_WIDTH: number = 360;
const GAME_HEIGHT: number = 600;
const GRAVITY: number = 0.65;
const JUMP_VELOCITY: number = -11;
const GROUND_Y: number = 480;
const OBSTACLE_SPEED: number = 4.5;
const SPAWN_INTERVAL: number = 1600;
const FRAME_TIME: number = 16;

调参经验

  • GRAVITY = 0.65:数值越大下落越快,手感越"重"。0.65 是经过测试的适中值,跳跃曲线自然。
  • JUMP_VELOCITY = -11:负值 = 向上(屏幕坐标系 Y 轴向下)。绝对值越大跳得越高。
  • OBSTACLE_SPEED = 4.5:像素/帧,在 60 FPS 下每秒移动约 270 像素。
  • SPAWN_INTERVAL = 1600:毫秒,约 1.6 秒生成一个障碍物,节奏适中。
  • FRAME_TIME = 16:毫秒,约等于 1000ms / 60 ≈ 16.67ms,精确 60 FPS。

5.2 接口定义

使用 TypeScript 的 interface 定义游戏对象的结构,保证类型安全。配合命名规范一目了然:

interface Player {
  x: number;          // 左上角 X 坐标
  y: number;          // 左上角 Y 坐标
  width: number;      // 宽
  height: number;     // 高
  vy: number;         // 垂直速度(像素/帧),正=向下,负=向上
  isOnGround: boolean; // 是否在地面上
}

interface Obstacle {
  x: number;
  y: number;
  width: number;
  height: number;
  color: string;
  type: 'cactus' | 'box' | 'bird';  // 联合类型,限定三种
}

interface GroundTile {
  x: number;
  width: number;
  color: string;
}

interface BgStar {
  x: number;
  y: number;
  size: number;
  alpha: number;
  speed: number;
}

Obstacle.type 使用了 TypeScript 的字符串字面量联合类型 'cactus' | 'box' | 'bird',确保只能赋这三个值,在 switch 中可以穷举检查。

5.3 枚举定义

游戏状态使用 enum 枚举,比字符串字面量更规范,IDE 支持也更好:

enum GameState {
  READY = 'READY',
  PLAYING = 'PLAYING',
  GAME_OVER = 'GAME_OVER'
}

六、页面组件与生命周期

6.1 @Entry 和 @Component 装饰器

@Entry
@Component
struct Index {
  • @Entry 标记该组件为页面入口,相当于页面路由的目标。
  • @Component 声明这是一个可复用的组件结构体。

6.2 @State 装饰的 UI 状态

@State gameState: GameState = GameState.READY;
@State score: number = 0;
@State highScore: number = 0;

@State 标记的属性,当值发生变化时,会自动触发 UI 重新渲染。但注意:游戏中的大量实时数据(玩家位置、障碍物数组、帧计数)如果也用 @State,每次变化都会触发 ArkUI 的重新布局,严重拖慢性能。

解决方案:运行时数据用 private 变量,不装饰 @State

private player!: Player;          // ! 表示延迟初始化
private obstacles: Obstacle[] = [];
private groundTiles: GroundTile[] = [];
private stars: BgStar[] = [];
private frameCount: number = 0;
private spawnTimer: number = 0;
private gameLoopId: number = 0;
private canvasWidth: number = GAME_WIDTH;
private canvasHeight: number = GAME_HEIGHT;

这些数据每秒变化几百上千次,但只在 Canvas 上自行绘制,不需要 ArkUI 的声明式框架介入。

6.3 生命周期钩子

aboutToAppear(): void {
  this.initBackground();
  this.initPlayer();
  this.initGround();
}

aboutToDisappear(): void {
  this.stopGameLoop();
}
  • aboutToAppear:组件渲染之前调用,初始化游戏数据、绘制第一帧预览画面。
  • aboutToDisappear:离开页面时调用,务必停止 setInterval 游戏循环,否则后台还在跑,浪费资源且可能引发异常。

七、游戏循环:setInterval 驱动的帧循环

游戏循环是一切游戏的核心骨架。它确保以下流程以固定的频率反复执行:

输入检测 → 状态更新 → 碰撞检测 → 渲染画面

7.1 启动与停止

startGameLoop(): void {
  this.stopGameLoop();                    // 避免重复启动
  this.gameLoopId = setInterval(() => this.gameTick(), FRAME_TIME);
}

stopGameLoop(): void {
  if (this.gameLoopId) {
    clearInterval(this.gameLoopId);
    this.gameLoopId = 0;
  }
}

setInterval 的优点是简单直接。FRAME_TIME = 16 对应 ~60 FPS。

为什么不使用 requestAnimationFrame

在 HarmonyOS NEXT 6.1.1 中,Canvas 组件尚不支持 requestAnimationFrame 标准 API,因此退而使用 setInterval 实现。setInterval 的缺点是不够精准——当页面被切到后台时它仍然会执行回调。但我们在 aboutToDisappear 中已经做了清理,且游戏中有 gameState 守卫,PLAYING 之外的状态不执行逻辑更新,所以 CPU 占用是可控的。

7.2 帧回调结构

gameTick(): void {
  this.frameCount++;
  this.update();
  this.render();
}

frameCount 是一个单调递增的帧计数器。它的用途:

  • 控制分数增长速度(每 10 帧加一分)
  • 控制 UI 文字的闪烁周期(frameCount / 30 % 2 === 0
  • 控制跑步动画的相位(legPhase = frameCount * 0.2
  • 控制山丘波动的动态偏移(Math.sin(x * 0.008 + frameCount * 0.005)

八、物理引擎:重力与跳跃

8.1 重力模拟

2D 平台游戏中最基础的物理模型:「每一帧给垂直速度加上重力常量,再根据速度更新位置」。

updatePlayer(): void {
  const p = this.player;

  // ① 应用重力:速度累加(正方向向下)
  p.vy += GRAVITY;

  // ② 更新位置
  p.y += p.vy;

  // ③ 地面碰撞检测与回弹
  if (p.y + p.height >= GROUND_Y) {
    p.y = GROUND_Y - p.height;   // 卡在地面线上
    p.vy = 0;                     // 停止下落
    p.isOnGround = true;          // 标记可跳跃
  } else {
    p.isOnGround = false;
  }

  // ④ 天花板碰撞(防止跳出屏幕顶部)
  if (p.y < 0) {
    p.y = 0;
    p.vy = 0;
  }
}

物理帧示意(以 60 FPS 为例,假设玩家在顶点开始下落):

帧数 vy (像素/帧) y (像素) 说明
0 0 430 跳跃到最高点
1 0.65 430.65 开始下落
2 1.30 431.95 加速
3 1.95 433.90 更快
n 11.15 ~480 触地,复位

为什么是 GROUND_Y - p.height

p.yp.height 定位的是玩家角色的左上角。要让角色的底部对齐地面线 GROUND_Y,需要 p.y + p.height = GROUND_Y,即 p.y = GROUND_Y - p.height

8.2 跳跃

跳跃就是给玩家一个瞬间向上的初速度,让重力接管剩下的过程:

// 在 handleTap() 中(PLAYING 状态)
if (this.player.isOnGround) {
  this.player.vy = JUMP_VELOCITY;  // -11
  this.player.isOnGround = false;
}

isOnGround 的作用是防止空中二段跳——玩家只有在触地状态才能起跳。如果想做"二段跳"机制,可以维护一个 jumpCount 计数。

8.3 跳跃轨迹分析

使用 GRAVITY = 0.65JUMP_VELOCITY = -11,可以算出:

  • 上升时间11 / 0.65 ≈ 16.9 帧 ≈ 0.28 秒
  • 最高点高度vy² / (2 × GRAVITY) = 121 / 1.3 ≈ 93 像素
  • 总滞空时间2 × 16.9 ≈ 34 帧 ≈ 0.56 秒

这个跳跃手感偏"轻快",适合快节奏的跑酷游戏。如果希望跳跃更沉、更高,可以减小 GRAVITY、增大 JUMP_VELOCITY 绝对值。


九、障碍物工厂:随机生成与对象池

9.1 生成逻辑

障碍物的生成由计时器驱动。每帧累加 FRAME_TIMEspawnTimer,达到 SPAWN_INTERVAL 就生成一个:

trySpawnObstacle(): void {
  this.spawnTimer += FRAME_TIME;
  if (this.spawnTimer >= SPAWN_INTERVAL) {
    this.spawnTimer = 0;
    this.spawnObstacle();
  }
}

9.2 三种障碍物的参数

类型 宽度 高度 Y 位置 特点
cactus 22–32 40–60 地面 带分支的仙人掌,有一定随机尺寸
box 30–40 30–45 地面 带 X 花纹的箱子,矮胖型
bird 35 固定 20 固定 空中 (距地面 60–100) 菱形飞鸟,需要跳跃时机更精准

9.3 障碍物回收

障碍物从右侧边界外进入画面(x = GAME_WIDTH),向左移动,移出左边界后删除:

updateObstacles(): void {
  for (let i = this.obstacles.length - 1; i >= 0; i--) {
    this.obstacles[i].x -= OBSTACLE_SPEED;
    if (this.obstacles[i].x + this.obstacles[i].width < 0) {
      this.obstacles.splice(i, 1);   // 移出屏幕,从数组中移除
    }
  }
}

为什么要从 length - 1 倒序遍历?

因为 splice(i, 1) 会改变数组长度,正序遍历时会跳过被删除元素后面的那一个。倒序遍历则不受影响——删除当前元素只会影响已遍历过的索引。


十、碰撞检测:AABB 矩形相交算法

10.1 原理

AABB(Axis-Aligned Bounding Box,轴对齐包围盒)是最基础、最高效的碰撞检测算法。两个矩形如果相交,那么它们在 X 轴和 Y 轴上的投影都必须有重叠。

矩形 A: (ax, ay, aw, ah)
矩形 B: (bx, by, bw, bh)

相交条件(四个不等式同时为真):
ax < bx + bw    ← A 的左边界在 B 的右边界左侧
ax + aw > bx    ← A 的右边界在 B 的左边界右侧
ay < by + bh    ← A 的上边界在 B 的下边界上方
ay + ah > by    ← A 的下边界在 B 的上边界下方

10.2 代码实现

checkCollision(): void {
  const p = this.player;
  for (const obs of this.obstacles) {
    if (
      p.x < obs.x + obs.width &&
      p.x + p.width > obs.x &&
      p.y < obs.y + obs.height &&
      p.y + p.height > obs.y
    ) {
      this.gameOver();
      break;   // 只触发一次
    }
  }
}

优化说明:这个算法的复杂度是 O(n),n 为障碍物数量。我们游戏中同时存在的障碍物很少(通常 2–4 个),因此完全够用。如果障碍物数量达到几百个,需要考虑空间分区(四叉树、网格)等优化。

10.3 碰撞后的处理

gameOver(): void {
  this.gameState = GameState.GAME_OVER;
  this.stopGameLoop();
  if (this.score > this.highScore) {
    this.highScore = this.score;   // 更新最高分
  }
}

停止游戏循环、冻结所有运动、显示结束画面。@State highScore 的变化会触发 UI 更新(底部按钮出现)。


十一、渲染系统分解

11.1 render() 主入口

render(): void {
  const ctx = this.ctx;
  ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.renderBackground(ctx);
  this.renderGround(ctx);
  this.renderObstacles(ctx);
  this.renderPlayer(ctx);
  this.renderUI(ctx);
}

每一帧都完全重绘整个画面(clearRect 清空全部),然后按照远→近的顺序依次绘制各层。这与游戏引擎的工作方式一致。

11.2 为什么不用增量更新?

对于 2D 小游戏,全帧重绘是最简单可靠的方式。Canvas 的 fillRectarc 等绘图操作在 CPU 上执行,对于 600×360 像素的画布,60 FPS 全帧重绘的开销完全可以接受。如果使用增量更新(只画变化的部分),代码复杂度会大幅上升,而收益甚微。


十二、玩家角色的绘制与动画

12.1 整体结构

玩家角色使用 ctx.save() / ctx.restore() 包裹了一次坐标系变换,以便在角色中心点进行旋转:

renderPlayer(ctx: CanvasRenderingContext2D): void {
  const p = this.player;
  const cx = p.x + p.width / 2;   // 中心 X
  const cy = p.y + p.height / 2;  // 中心 Y
  const rotation = p.isOnGround ? 0 : p.vy * 0.03;  // 跳跃时旋转

  ctx.save();
  ctx.translate(cx, cy);   // 原点移到角色中心
  ctx.rotate(rotation);    // 旋转坐标系

  // ... 绘制身体、头、眼睛、嘴巴、腿 ...

  ctx.restore();
}

ctx.save()ctx.restore() 配对使用,restore 会把坐标系恢复回 save 之前的状态。这样旋转只影响当前角色的绘制,不影响后续障碍物和 UI 的绘制。

12.2 身体(圆角矩形)

身体是一个圆角矩形,使用 arcTo 绘制四个圆角:

const r = 5;   // 圆角半径
const w = p.width;
const h = p.height;

ctx.beginPath();
ctx.moveTo(-w / 2 + r, -h / 2);
ctx.lineTo(w / 2 - r, -h / 2);
ctx.arcTo(w / 2, -h / 2, w / 2, -h / 2 + r, r);
ctx.lineTo(w / 2, h / 2 - r);
ctx.arcTo(w / 2, h / 2, w / 2 - r, h / 2, r);
ctx.lineTo(-w / 2 + r, h / 2);
ctx.arcTo(-w / 2, h / 2, -w / 2, h / 2 - r, r);
ctx.lineTo(-w / 2, -h / 2 + r);
ctx.arcTo(-w / 2, -h / 2, -w / 2 + r, -h / 2, r);
ctx.closePath();
ctx.fill();

arcTo(x1, y1, x2, y2, radius) 是 Canvas 2D 中绘制圆角的核心 API。它从当前位置画一条连接到 (x1, y1) 的直线,再在此处用半径为 radius 的圆弧过渡到 (x2, y2)

12.3 头部与五官

头部是一个圆形,眼睛是两个小圆 + 高光白点,嘴巴是一条弧线,根据状态改变弧度:

// 嘴巴状态切换
if (this.gameState === GameState.GAME_OVER) {
  ctx.arc(0, -h / 2 + 4, 4, Math.PI, Math.PI * 2);      // 向下弯 = 难过
} else if (!p.isOnGround) {
  ctx.arc(0, -h / 2 + 4, 3, 0, Math.PI);                // 张开 = 跳跃
} else {
  ctx.arc(0, -h / 2 + 2, 4, 0.1, Math.PI - 0.1);        // 向上弯 = 微笑
}

ctx.arc(x, y, radius, startAngle, endAngle) 的起始角和结束角从正右方开始顺时针计算:

  • 0 → Math.PI:上半圆(张嘴)
  • Math.PI → Math.PI * 2:下半圆(难过)
  • 0.1 → Math.PI - 0.1:接近上半圆但略微收缩(微笑)

12.4 腿的跑步动画

使用正弦函数让两条腿交替摆动:

const legPhase = this.frameCount * 0.2;

// 左腿:使用 Math.sin(legPhase) 作为摆动偏移
ctx.moveTo(-5, h / 2);
ctx.lineTo(-5 + Math.sin(legPhase) * 8, h / 2 + 12);

// 右腿:相位偏移 π,与左腿错开
ctx.moveTo(5, h / 2);
ctx.lineTo(5 + Math.sin(legPhase + Math.PI) * 8, h / 2 + 12);

sin(phase) 的范围是 [-1, 1],乘以 8 后腿的摆动幅度为 [-8, 8] 像素。两条腿相位相差 π,所以永远一条向前一条向后,形成自然的跑步交替。


十三、背景与视差滚动

13.1 天空渐变

const gradient = ctx.createLinearGradient(0, 0, 0, GROUND_Y);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(0.5, '#16213e');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.canvasWidth, GROUND_Y);

三种深色从紫色渐变到深蓝,营造夜空氛围。

13.2 波动山丘(动态远景)

使用正弦波叠加生成山丘轮廓,并利用 frameCount 让山丘缓慢波动,产生"呼吸感":

ctx.beginPath();
ctx.moveTo(0, GROUND_Y);
for (let x = 0; x <= this.canvasWidth; x += 5) {
  const waveY = GROUND_Y - 50
    - 20 * Math.sin(x * 0.008 + this.frameCount * 0.005)
    - 15 * Math.sin(x * 0.015 + this.frameCount * 0.008);
  ctx.lineTo(x, waveY);
}
ctx.lineTo(this.canvasWidth, GROUND_Y);
ctx.closePath();
ctx.fill();

两个不同频率和振幅的正弦波叠加,产生不规则的自然山丘效果。频率参数 0.0080.015 控制山丘起伏的"密度",振幅 2015 控制高度。

13.3 星星系统

// 初始化 30 颗星星,随机位置、大小、透明度、移动速度
for (let i = 0; i < 30; i++) {
  this.stars.push({
    x: Math.random() * GAME_WIDTH,
    y: 20 + Math.random() * 200,
    size: 1 + Math.random() * 2,
    alpha: 0.3 + Math.random() * 0.5,
    speed: 0.2 + Math.random() * 0.3
  });
}

更新与视差

每帧星星以各自的速度左移,移出左侧后从右侧重新进入:

updateStars(): void {
  for (const star of this.stars) {
    star.x -= star.speed;
    if (star.x < 0) {
      star.x = GAME_WIDTH;
      star.y = 20 + Math.random() * 200;
    }
  }
}

星星的移动速度(0.2–0.5)远小于障碍物速度(4.5),形成视差效果——远处的星星比近处的障碍物移动得慢,增强空间纵深感。

13.4 地面滚动

地面由宽度 60 像素的色块拼接而成。每帧所有色块以 OBSTACLE_SPEED 左移。当最左侧的色块完全移出屏幕,将其移到右侧复用:

updateGround(): void {
  for (const tile of this.groundTiles) {
    tile.x -= OBSTACLE_SPEED;
  }
  const firstTile = this.groundTiles[0];
  if (firstTile && firstTile.x + firstTile.width < 0) {
    const lastTile = this.groundTiles[this.groundTiles.length - 1];
    firstTile.x = lastTile.x + lastTile.width;
    firstTile.color = this.randomGroundColor();  // 换颜色
    this.groundTiles.shift();
    this.groundTiles.push(firstTile);
  }
}

这种"循环复用"模式避免了无限创建新对象,不会增加内存压力。


十四、状态机与用户交互

14.1 三态状态机

游戏使用简单的状态机管理行为:

┌─────────┐  点击屏幕  ┌──────────┐  碰撞  ┌────────────┐
│  READY  │ ────────→ │ PLAYING  │ ──────→ │ GAME_OVER  │
└─────────┘           └──────────┘         └────────────┘
    ↑                                            │
    └──────────────────── 点击屏幕 ───────────────┘

14.2 事件处理

handleTap(): void {
  switch (this.gameState) {
    case GameState.READY:
      this.startGame();        // 开始游戏
      break;
    case GameState.PLAYING:
      if (this.player.isOnGround) {
        this.player.vy = JUMP_VELOCITY;   // 跳跃
        this.player.isOnGround = false;
      }
      break;
    case GameState.GAME_OVER:
      this.startGame();        // 重新开始
      break;
  }
}

绑定到 Canvas 的 onClickonTouch

Canvas(this.ctx)
  .onClick(() => { this.handleTap(); })
  .onTouch((event: TouchEvent) => {
    if (event.type === TouchType.Down) {
      this.handleTap();
    }
  })

为什么同时绑定 onClickonTouch

onClick 在点击释放后才触发,比 onTouchDown 事件晚约 100–200 毫秒。对于跑酷游戏,这 0.1 秒的延迟可能决定能否跳过障碍物。因此我们同时绑定了 TouchType.Down——手指刚接触屏幕就触发跳跃,响应更快。

14.3 startGame 方法

startGame(): void {
  this.score = 0;
  this.obstacles = [];       // 清空旧障碍物
  this.spawnTimer = 0;
  this.frameCount = 0;
  this.initPlayer();
  this.initGround();
  this.gameState = GameState.PLAYING;
  this.startGameLoop();
}

每次重新开始必须重置所有状态,否则旧的障碍物还在屏幕上,生成计时器也不会归零。


十五、计分系统与最高分持久化

15.1 实时计分

分数每 10 帧增加 1 分。60 FPS 下约每秒加 6 分:

if (this.frameCount % 10 === 0) {
  this.score += 1;
}

如果希望分数随难度递增,可以改为:

const difficultyMultiplier = 1 + Math.floor(this.frameCount / 1800) * 0.5;
if (this.frameCount % Math.max(5, 10 - Math.floor(this.frameCount / 3600)) === 0) {
  this.score += Math.floor(difficultyMultiplier);
}

15.2 最高分

当前会话的最高分暂存在 @State highScore 中。如需跨会话持久化,可以使用鸿蒙的 AppStoragePersistentStorage

// 使用 PersistentStorage 持久化最高分
PersistentStorage.persistProp('highScore', 0);

@StorageLink('highScore') highScore: number = 0;

// 游戏结束时更新
if (this.score > this.highScore) {
  this.highScore = this.score;  // 自动同步到磁盘
}

我们的示例简化了这个过程,只在内存中保存。

15.3 新纪录彩蛋

if (this.score >= this.highScore && this.score > 0) {
  ctx.fillStyle = '#FFD54F';
  ctx.font = 'bold 20px sans-serif';
  ctx.fillText('🎉 新纪录!', this.canvasWidth / 2, this.canvasHeight / 2 + 55);
}

当玩家刷新最高分时,显示金色"新纪录"提示,增强成就感。


十六、完整代码逐段精讲

16.1 导入与常量段(第 1–36 行)

import { promptAction } from '@kit.ArkUI';

const GAME_WIDTH: number = 360;
const GAME_HEIGHT: number = 600;
const GRAVITY: number = 0.65;
const JUMP_VELOCITY: number = -11;
const GROUND_Y: number = 480;
const OBSTACLE_SPEED: number = 4.5;
const SPAWN_INTERVAL: number = 1600;
const FRAME_TIME: number = 16;

promptAction 用于跳转页面的 toast 提示(本游戏中未使用,但保留作为 ArkUI 交互能力的演示)。

所有常量使用 const 声明、SCREAMING_SNAKE_CASE 命名、显式标注 :number 类型。这是 ArkTS 推荐的最佳实践——ArkTS 是 TypeScript 的严格超集,要求所有声明有明确的类型注解。

16.2 类型定义段(第 38–81 行)

interface Player { ... }
interface Obstacle { ... }
interface GroundTile { ... }
interface BgStar { ... }
enum GameState { ... }

接口和枚举放在组件外部,可以在整个文件范围内访问。命名使用 PascalCase。

16.3 组件类与属性段(第 83–105 行)

@Entry
@Component
struct Index {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  @State gameState: GameState = GameState.READY;
  @State score: number = 0;
  @State highScore: number = 0;

  private player!: Player;
  private obstacles: Obstacle[] = [];
  // ...

注意 private player!: Player 中的 ! 非空断言——告诉 TypeScript 编译器"这个属性虽然现在没有初始值,但会在 aboutToAppear 中被赋值,请相信我不会访问到 undefined"。

16.4 初始化段(第 106–170 行)

initPlayer:玩家初始位置为 (60, GROUND_Y - 50),即地面线上方 50 像素。vy 初始为 0,isOnGround 为 true。

initGround:生成 Math.ceil(360 / 60) + 2 = 8 个地面色块,覆盖屏幕宽度加上一个额外的缓冲块。

initBackground:30 颗星星均匀分布在上半屏(y: 20 + Math.random() * 200)。

16.5 游戏循环段(第 172–193 行)

gameTick(): void {
  this.frameCount++;
  this.update();
  this.render();
}

update()render() 严格分离——update 只改变数据,render 只读取数据。这种数据-渲染分离架构是游戏开发的核心设计模式,便于调试和测试。

16.6 逻辑更新段(第 195–362 行)

这是游戏的大脑。每一步都有清晰的注释编号:

① updatePlayer     → 物理 + 重力 + 碰撞
② updateObstacles  → 障碍物左移 + 回收
③ updateGround     → 地面色块滚动 + 循环
④ updateStars      → 星星左移 + 视差
⑤ trySpawnObstacle → 生成新障碍物
⑥ checkCollision   → AABB 碰撞检测
⑦ 分数更新          → 每 10 帧加一分

16.7 渲染段(第 364–668 行)

渲染按 5 层严格排序:

渲染顺序 方法 内容
1 renderBackground 天空渐变 + 山丘 + 星星
2 renderGround 地面色块 + 纹理短线
3 renderObstacles 所有障碍物(switch 分发三种类型)
4 renderPlayer 玩家角色(圆角身体 + 头部 + 动画腿)
5 renderUI 分数 / 最高分 / 状态提示文字

16.8 游戏控制与 UI 段(第 670–777 行)

handleTap 是输入到状态的桥梁。build() 方法中:

  • Column 外容器占满全屏,背景色 #0d1117(极深灰蓝)
  • 顶部 Row 显示标题 “🏃 跑酷大作战”
  • 中间 Canvas 填满剩余空间,绑定点击和触摸事件
  • 底部 RowGAME_OVER 时显示 “🔄 重新开始” 按钮

十七、运行效果与调试技巧

17.1 预期效果

状态 画面特征
READY 深蓝渐变背景 + 星星 + 山丘 + 角色站立 + “点击开始” 闪烁文字
PLAYING 地面向左滚动 + 从右侧出现障碍物 + 角色跑步动画 + 分数不断增加
GAME_OVER 半透明黑色遮罩 + 红色"游戏结束" + 得分 + “点击重新开始” 闪烁文字

17.2 调试技巧

技巧一:渲染频率验证

render() 中加入调试文字可以快速确认 FPS:

ctx.fillStyle = '#00FF00';
ctx.font = '12px monospace';
ctx.fillText(`FPS: ${Math.round(1000 / FRAME_TIME)}`, 10, 20);
ctx.fillText(`Obstacles: ${this.obstacles.length}`, 10, 35);

技巧二:碰撞盒可视化

在绘制障碍物和玩家时,用半透明描边把碰撞盒画出来,调试碰撞边界:

ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.lineWidth = 1;
ctx.strokeRect(p.x, p.y, p.width, p.height);

技巧三:慢速模式

FRAME_TIME 临时改为 100(即 10 FPS),可以看清楚每一帧的物理变化和碰撞时机。

17.3 常见问题

问题 原因 解决方案
画面不更新 忘记调用 startGameLoop 检查 gameState 是否为 PLAYING
跳跃无响应 isOnGround 未正确更新 检查地面碰撞逻辑中的赋值
障碍物不出现 spawnTimer 未重置 startGame 中重置 spawnTimer = 0
碰撞不准确 碰撞盒与视觉尺寸不匹配 打印 player 和 obstacle 坐标检查
离开页面后游戏还在后台运行 忘记在 aboutToDisappearstopGameLoop 补充生命周期清理

十八、扩展方向与性能优化建议

18.1 功能扩展

1. 难度递增

随着分数增加,逐步加快障碍物速度、缩短生成间隔:

const speedMultiplier = 1 + this.score * 0.001;
const currentSpeed = OBSTACLE_SPEED * speedMultiplier;

2. 道具系统

增加金币(加分)、护盾(免死一次)、磁铁(自动吸取金币)等道具。

3. 多种角色

通过 @State selectedSkin 切换不同的角色配色方案,在 AppStorage 中持久化。

4. 音效系统

使用 @kit.AudioKitAudioPlayer 播放跳跃、碰撞、得分音效。

5. 排行榜

对接华为游戏服务(Game Service Kit)实现全球排行榜。

6. 背景主题切换

准备多套配色方案(白天/夜晚/森林/沙漠),通过设置页面切换。

18.2 性能优化

1. 使用离屏 Canvas(offscreen canvas)

将静态背景绘制到离屏 Canvas 缓存,每帧只需 drawImage 而非重绘复杂渐变:

// 但注意:HarmonyOS Canvas 是否支持离屏 canvas 需要查阅 API 文档

2. 减少 Math.sin 调用

山丘的 Math.sin 每帧计算 72 次(GAME_WIDTH / 5),可以通过预计算并缓存来优化。

3. 对象池(Object Pool)

障碍物和星星反复创建和销毁会造成 GC 压力。可以预分配对象池,用"激活/失活"替代"创建/删除":

class ObjectPool<T> {
  private pool: T[] = [];
  acquire(): T { return this.pool.pop() ?? this.create(); }
  release(obj: T): void { this.pool.push(obj); }
  private create(): T { return { ... }; }
}

4. 降低分辨率渲染

在高分辨率设备上,可以将逻辑分辨率固定为 360×600,利用 CSS 缩放放大,减少实际绘制的像素数。

5. 帧率自适应

检测设备性能,在帧率不足时动态降低绘制精度(如跳过山丘的逐点绘制,改用简单三角形):

let lastFrameTime = 0;
gameTick(timestamp: number): void {
  const realFPS = 1000 / (timestamp - lastFrameTime);
  if (realFPS < 30) { this.reduceQuality(); }
  // ...
}

18.3 从 setInterval 迁移到 Worker

对于更复杂的游戏,可以创建一个 Worker 线程运行游戏循环和物理计算,主线程只用 postMessage 接收渲染数据,避免 UI 线程的卡顿影响游戏流畅度:

// GameWorker.ets
worker.onmessage = (msg) => {
  const { player, obstacles, score } = msg.data;
  // 主线程只负责渲染
};

十九、总结

19.1 本文做了什么

我们用鸿蒙 ArkTS 从零构建了一个完整的跑酷游戏。回顾一下涉及的核心技术:

技术领域 实现内容
声明式 UI @Entry / @Component / @State 装饰器
Canvas 2D Canvas 组件 + CanvasRenderingContext2D
游戏架构 数据-渲染分离的帧循环(setInterval)
物理引擎 重力模拟 + 跳跃抛物线 + 地面碰撞
碰撞检测 AABB 矩形相交算法
动画系统 跑步腿部动画(正弦波)+ 身体旋转 + 山丘波动
视差滚动 多层不同速度滚动(星星/障碍物/地面)
状态管理 READY → PLAYING → GAME_OVER 状态机
输入处理 onClick + onTouch(TouchType.Down) 双绑定
资源管理 循环复用地面色块 + 障碍物出屏回收

19.2 鸿蒙 NEXT 游戏开发的优劣势

优势

  • 原生 Canvas 组件,无需安装第三方引擎
  • ArkTS 语法友好,有 TypeScript 基础的开发者上手成本低
  • 与鸿蒙系统能力无缝对接(Ability、Service、手势……)
  • 没有第三方引擎的许可限制和体积开销

劣势

  • 尚无类似 Cocos/Unity 的游戏引擎集成方案
  • Canvas 2D API 可能缺少某些现代 Web API(如 requestAnimationFrameOffscreenCanvas
  • 大量 @State 变量会导致不必要的布局重排
  • 缺少成熟的游戏社区和开源库生态

19.3 给读者的建议

如果你之前只有应用开发经验,想尝试游戏开发:

  • 不要一开始就追求复杂的 3D 渲染或网络联机
  • 从一个像本文这样的小作品开始,先跑通"循环→逻辑→渲染"的完整管线
  • 学会使用 Canvas 基本绘图 API,它们能表达的游戏效果远超你的想象
  • 让代码保持"数据更新"和"画面渲染"分离,这是所有游戏引擎的底层设计模式

鸿蒙原生游戏开发虽然还很年轻,但前景广阔。随着 HarmonyOS NEXT 生态的成熟,未来一定会有更多优秀工具和框架涌现,让在鸿蒙上做游戏像写应用一样便捷高效。


本文所有代码均可在 HarmonyOS NEXT 6.1.1(API 24)上编译运行。
Index.ets 直接替换项目中的对应文件,连接真机或模拟器即可体验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐