鸿蒙原生ArkTS游戏开发实战:从零构建一个完整的跑酷游戏


一、前言:为什么在鸿蒙上写游戏
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.y 和 p.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.65 和 JUMP_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_TIME 到 spawnTimer,达到 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 的 fillRect、arc 等绘图操作在 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.008 和 0.015 控制山丘起伏的"密度",振幅 20 和 15 控制高度。
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 的 onClick 和 onTouch:
Canvas(this.ctx)
.onClick(() => { this.handleTap(); })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.handleTap();
}
})
为什么同时绑定 onClick 和 onTouch?
onClick 在点击释放后才触发,比 onTouch 的 Down 事件晚约 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 中。如需跨会话持久化,可以使用鸿蒙的 AppStorage 或 PersistentStorage:
// 使用 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填满剩余空间,绑定点击和触摸事件 - 底部
Row在GAME_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 坐标检查 |
| 离开页面后游戏还在后台运行 | 忘记在 aboutToDisappear 中 stopGameLoop |
补充生命周期清理 |
十八、扩展方向与性能优化建议
18.1 功能扩展
1. 难度递增
随着分数增加,逐步加快障碍物速度、缩短生成间隔:
const speedMultiplier = 1 + this.score * 0.001;
const currentSpeed = OBSTACLE_SPEED * speedMultiplier;
2. 道具系统
增加金币(加分)、护盾(免死一次)、磁铁(自动吸取金币)等道具。
3. 多种角色
通过 @State selectedSkin 切换不同的角色配色方案,在 AppStorage 中持久化。
4. 音效系统
使用 @kit.AudioKit 的 AudioPlayer 播放跳跃、碰撞、得分音效。
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(如
requestAnimationFrame、OffscreenCanvas) - 大量
@State变量会导致不必要的布局重排 - 缺少成熟的游戏社区和开源库生态
19.3 给读者的建议
如果你之前只有应用开发经验,想尝试游戏开发:
- 不要一开始就追求复杂的 3D 渲染或网络联机
- 从一个像本文这样的小作品开始,先跑通"循环→逻辑→渲染"的完整管线
- 学会使用 Canvas 基本绘图 API,它们能表达的游戏效果远超你的想象
- 让代码保持"数据更新"和"画面渲染"分离,这是所有游戏引擎的底层设计模式
鸿蒙原生游戏开发虽然还很年轻,但前景广阔。随着 HarmonyOS NEXT 生态的成熟,未来一定会有更多优秀工具和框架涌现,让在鸿蒙上做游戏像写应用一样便捷高效。
本文所有代码均可在 HarmonyOS NEXT 6.1.1(API 24)上编译运行。
将Index.ets直接替换项目中的对应文件,连接真机或模拟器即可体验。
更多推荐



所有评论(0)