HarmonyOS游戏开发:Canvas动画与游戏循环实现
HarmonyOS游戏开发:Canvas动画与游戏循环实现
📌 核心要点:从Canvas动画原理到Game Loop架构设计,掌握requestAnimationFrame的精确使用、固定时间步长的游戏循环、状态驱动的动画管理,最终实现一个完整的2D小游戏——让动画从"能动"升级到"流畅"。
一、背景与动机
“我的动画怎么一卡一卡的?”——这大概是HarmonyOS Canvas动画开发中被问得最多的问题。
很多人写Canvas动画的方式是这样的:用setInterval定时器,每隔16ms画一帧,然后发现动画在低端设备上疯狂掉帧,在高端设备上又快得不像话。于是又去调定时器间隔——16ms不行就10ms,10ms不行就8ms……结果呢?高端设备上CPU占用飙升,低端设备上还是卡。
问题出在哪?出在你用错了"时钟"。
setInterval和setTimeout是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
每一帧要做三件事:
- 清屏——擦除上一帧的画面
- 更新——计算下一帧的状态(位置、颜色、大小等)
- 绘制——把新的状态画到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 动画状态管理
动画的本质是状态随时间的变化。一个好的动画系统需要:
- 状态定义——每个动画对象有哪些属性(位置、速度、颜色等)
- 状态更新——每帧如何更新这些属性
- 状态插值——在帧之间平滑过渡
- 状态转换——从一种状态切换到另一种状态(如从"行走"到"跳跃")
三、代码实战
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动画的精髓。
- 点赞
- 收藏
- 关注作者
评论(0)