HarmonyOS开发:2D游戏开发——Canvas游戏循环与精灵动画
HarmonyOS开发:2D游戏开发——Canvas游戏循环与精灵动画
📌 核心要点:2D游戏的核心就是"画、算、画"——每帧算位置、画画面,精灵动画是让2D游戏活起来的关键,帧动画+碰撞检测是2D游戏开发的三大件。
背景与动机
你有没有玩过那种2D小游戏——飞机大战、跑酷、消消乐?看起来简单对吧?但你真上手写一个,就会发现一堆问题:
画面怎么刷新?角色怎么动?动画怎么播?两个东西撞上了怎么判断?
这些问题,每一个都能卡你半天。
2D游戏开发的本质就是三件事:算(逻辑更新)、画(渲染绘制)、判(碰撞检测)。游戏循环管"算和画"的节奏,精灵动画管"画"的细节,碰撞检测管"判"的精度。这三样搞定了,2D游戏的基本功就过关了。
鸿蒙上的Canvas 2D API和Web上的基本一致,但性能表现有差异。你要是不注意一些细节,60帧的目标根本达不到。
核心原理
游戏主循环的三个阶段
游戏主循环不是简单的"画一帧"就完事了。每一帧要经历三个阶段:
graph LR
A[处理输入] --> B[更新逻辑]
B --> C[渲染画面]
C -->|下一帧| A
subgraph 更新逻辑
B1[更新精灵位置] --> B2[播放帧动画] --> B3[执行碰撞检测] --> B4[处理游戏事件]
end
B --> B1
classDef phaseStyle fill:#E74C3C,stroke:#C0392B,color:#fff,font-weight:bold
classDef subStyle fill:#3498DB,stroke:#2980B9,color:#fff
classDef detailStyle fill:#2ECC71,stroke:#27AE60,color:#fff
class A,B,C phaseStyle
class B1,B2,B3,B4 detailStyle
为什么是这个顺序?因为输入是最新的,逻辑要基于最新输入来算,渲染要基于最新逻辑来画。顺序反了,画面就会"慢一拍"。
精灵与帧动画
精灵(Sprite)是2D游戏里最基本的视觉单位。一个角色、一颗子弹、一个金币,都是一个精灵。
帧动画的原理很简单:把一个动作拆成一帧一帧的图片,快速切换,人就"动"起来了。就像翻页动画——每页画一个姿势,快速翻,看起来就在动。
精灵表(Sprite Sheet)示意:
┌────┬────┬────┬────┐
│ 帧0 │ 帧1 │ 帧2 │ 帧3 │ ← 行走动画
├────┼────┼────┼────┤
│ 帧4 │ 帧5 │ 帧6 │ 帧7 │ ← 攻击动画
└────┴────┴────┴────┘
每帧 64x64 像素
在鸿蒙Canvas上,我们用drawImage的裁剪参数来从精灵表中取出单帧绘制。
碰撞检测算法
2D碰撞检测从简单到复杂,有这么几层:
| 算法 | 精度 | 性能 | 适用场景 |
|---|---|---|---|
| AABB矩形碰撞 | 低 | 极快 | 粗略判断 |
| 圆形碰撞 | 中 | 快 | 弹幕、球类 |
| OBB旋转矩形 | 中高 | 中等 | 旋转物体 |
| 像素级碰撞 | 高 | 慢 | 精确判断 |
| SAT分离轴 | 高 | 中等 | 凸多边形 |
实际开发中,90%的场景用AABB就够了。先做AABB粗筛,筛出可能碰撞的对,再做精确检测——这是碰撞检测的黄金法则。
代码实战
基础用法:游戏主循环与精灵系统
先搞定最基础的——让东西动起来。
// Sprite2D.ets - 2D精灵与游戏循环
class Vec2 {
x: number = 0
y: number = 0
constructor(x: number = 0, y: number = 0) {
this.x = x
this.y = y
}
// 向量加法
add(other: Vec2): Vec2 {
return new Vec2(this.x + other.x, this.y + other.y)
}
// 向量长度
magnitude(): number {
return Math.sqrt(this.x * this.x + this.y * this.y)
}
// 归一化
normalize(): Vec2 {
const len = this.magnitude()
if (len === 0) return new Vec2(0, 0)
return new Vec2(this.x / len, this.y / len)
}
}
// 2D精灵类
class Sprite2D {
// 位置与大小
position: Vec2 = new Vec2()
size: Vec2 = new Vec2(50, 50)
velocity: Vec2 = new Vec2() // 速度
acceleration: Vec2 = new Vec2() // 加速度
// 旋转与缩放
rotation: number = 0
scale: number = 1.0
// 可见性
visible: boolean = true
alpha: number = 1.0
// 标签,用于碰撞分组
tag: string = ''
// 是否存活
alive: boolean = true
// 更新精灵状态
update(dt: number): void {
if (!this.alive) return
// 速度 += 加速度 * 时间
this.velocity = this.velocity.add(
new Vec2(this.acceleration.x * dt, this.acceleration.y * dt)
)
// 位置 += 速度 * 时间
this.position = this.position.add(
new Vec2(this.velocity.x * dt, this.velocity.y * dt)
)
}
// 获取AABB包围盒
getBounds(): Rect {
const halfW = this.size.x * this.scale / 2
const halfH = this.size.y * this.scale / 2
return {
left: this.position.x - halfW,
top: this.position.y - halfH,
right: this.position.x + halfW,
bottom: this.position.y + halfH
}
}
}
interface Rect {
left: number
top: number
right: number
bottom: number
}
// 游戏主循环
class GameLoop2D {
private sprites: Sprite2D[] = []
private lastTime: number = 0
private running: boolean = false
private timer: number = -1
private onUpdate?: (dt: number) => void
private onRender?: (sprites: Sprite2D[]) => void
// 添加精灵
addSprite(sprite: Sprite2D): void {
this.sprites.push(sprite)
}
// 移除精灵
removeSprite(sprite: Sprite2D): void {
const idx = this.sprites.indexOf(sprite)
if (idx > -1) this.sprites.splice(idx, 1)
}
// 设置回调
setCallbacks(onUpdate: (dt: number) => void, onRender: (sprites: Sprite2D[]) => void): void {
this.onUpdate = onUpdate
this.onRender = onRender
}
// 启动
start(): void {
this.running = true
this.lastTime = Date.now()
this.tick()
}
// 停止
stop(): void {
this.running = false
if (this.timer !== -1) clearTimeout(this.timer)
}
// 每帧执行
private tick(): void {
if (!this.running) return
const now = Date.now()
const dt = Math.min((now - this.lastTime) / 1000, 0.05)
this.lastTime = now
// 更新所有精灵
for (const sp of this.sprites) {
sp.update(dt)
}
// 清理已死亡的精灵
this.sprites = this.sprites.filter(sp => sp.alive)
// 执行外部回调
if (this.onUpdate) this.onUpdate(dt)
if (this.onRender) this.onRender(this.sprites)
this.timer = setTimeout(() => this.tick(), 16)
}
getSprites(): Sprite2D[] {
return this.sprites
}
}
进阶用法:帧动画系统
精灵不动是死的,动起来才是活的。帧动画系统就是让精灵"活"的关键。
// FrameAnimation.ets - 帧动画系统
// 单帧数据
interface FrameData {
srcX: number // 精灵表中X偏移
srcY: number // 精灵表中Y偏移
width: number // 帧宽度
height: number // 帧高度
duration: number // 帧持续时间(毫秒)
}
// 动画片段
class AnimationClip {
name: string = ''
frames: FrameData[] = []
loop: boolean = true
speed: number = 1.0 // 播放速度倍率
// 从精灵表自动生成帧数据
static fromSpriteSheet(
name: string,
sheetWidth: number,
sheetHeight: number,
frameWidth: number,
frameHeight: number,
frameCount: number,
frameDuration: number = 100,
loop: boolean = true
): AnimationClip {
const clip = new AnimationClip()
clip.name = name
clip.loop = loop
const cols = Math.floor(sheetWidth / frameWidth)
for (let i = 0; i < frameCount; i++) {
const col = i % cols
const row = Math.floor(i / cols)
clip.frames.push({
srcX: col * frameWidth,
srcY: row * frameHeight,
width: frameWidth,
height: frameHeight,
duration: frameDuration
})
}
return clip
}
}
// 动画播放器
class AnimationPlayer {
private clips: Map<string, AnimationClip> = new Map()
private currentClip: AnimationClip | null = null
private currentFrameIndex: number = 0
private elapsed: number = 0 // 已经过的时间(毫秒)
private playing: boolean = false
private finished: boolean = false
private onFinishCallback?: () => void
// 添加动画片段
addClip(clip: AnimationClip): void {
this.clips.set(clip.name, clip)
}
// 播放指定动画
play(name: string, reset: boolean = true): void {
const clip = this.clips.get(name)
if (!clip) {
console.warn(`动画片段不存在: ${name}`)
return
}
// 如果正在播放同一个动画,不重置
if (this.currentClip === clip && !reset) return
this.currentClip = clip
this.currentFrameIndex = 0
this.elapsed = 0
this.playing = true
this.finished = false
}
// 停止
stop(): void {
this.playing = false
}
// 设置播放完成回调
onFinish(cb: () => void): void {
this.onFinishCallback = cb
}
// 获取当前帧数据
getCurrentFrame(): FrameData | null {
if (!this.currentClip || this.currentClip.frames.length === 0) return null
return this.currentClip.frames[this.currentFrameIndex]
}
// 更新动画
update(dt: number): void {
if (!this.playing || !this.currentClip) return
this.elapsed += dt * 1000 * this.currentClip.speed
const frame = this.currentClip.frames[this.currentFrameIndex]
if (this.elapsed >= frame.duration) {
this.elapsed -= frame.duration
this.currentFrameIndex++
// 检查是否播完
if (this.currentFrameIndex >= this.currentClip.frames.length) {
if (this.currentClip.loop) {
this.currentFrameIndex = 0
} else {
this.currentFrameIndex = this.currentClip.frames.length - 1
this.playing = false
this.finished = true
if (this.onFinishCallback) this.onFinishCallback()
}
}
}
}
isPlaying(): boolean {
return this.playing
}
isFinished(): boolean {
return this.finished
}
}
// 带动画的精灵
class AnimatedSprite {
position: { x: number; y: number } = { x: 0, y: 0 }
scale: number = 1.0
rotation: number = 0
alpha: number = 1.0
visible: boolean = true
flipX: boolean = false // 水平翻转
private animator: AnimationPlayer = new AnimationPlayer()
private image?: ImageBitmap // 精灵表图片
// 添加动画
addClip(clip: AnimationClip): void {
this.animator.addClip(clip)
}
// 播放动画
play(name: string): void {
this.animator.play(name)
}
// 更新
update(dt: number): void {
this.animator.update(dt)
}
// 绘制到Canvas
draw(ctx: CanvasRenderingContext2D): void {
if (!this.visible) return
const frame = this.animator.getCurrentFrame()
if (!frame || !this.image) return
ctx.save()
ctx.translate(this.position.x, this.position.y)
ctx.rotate(this.rotation)
ctx.scale(this.flipX ? -this.scale : this.scale, this.scale)
ctx.globalAlpha = this.alpha
// 从精灵表中裁剪当前帧并绘制
ctx.drawImage(
this.image,
frame.srcX, frame.srcY, frame.width, frame.height, // 源区域
-frame.width / 2, -frame.height / 2, frame.width, frame.height // 目标区域
)
ctx.restore()
}
}
完整示例:飞机大战核心逻辑
把游戏循环、精灵动画、碰撞检测串起来,做一个能玩的飞机大战:
// PlaneWar.ets - 飞机大战核心
class Bullet extends Sprite2D {
damage: number = 1
isPlayerBullet: boolean = true
constructor(x: number, y: number, speed: number, isPlayer: boolean) {
super()
this.position = new Vec2(x, y)
this.size = new Vec2(6, 16)
this.velocity = new Vec2(0, isPlayer ? -speed : speed)
this.isPlayerBullet = isPlayer
this.tag = isPlayer ? 'playerBullet' : 'enemyBullet'
}
}
class Enemy extends Sprite2D {
hp: number = 1
score: number = 100
movePattern: number = 0 // 0=直线 1=正弦 3=追踪
private startX: number = 0
private time: number = 0
constructor(x: number, y: number, hp: number, speed: number, pattern: number) {
super()
this.position = new Vec2(x, y)
this.size = new Vec2(36, 36)
this.velocity = new Vec2(0, speed)
this.hp = hp
this.movePattern = pattern
this.startX = x
this.tag = 'enemy'
}
override update(dt: number): void {
super.update(dt)
this.time += dt
// 正弦移动模式
if (this.movePattern === 1) {
this.position.x = this.startX + Math.sin(this.time * 3) * 60
}
}
takeDamage(dmg: number): boolean {
this.hp -= dmg
if (this.hp <= 0) {
this.alive = false
return true
}
return false
}
}
class Player extends Sprite2D {
hp: number = 3
maxHp: number = 3
shootInterval: number = 0.2 // 射击间隔(秒)
private shootTimer: number = 0
private invincible: boolean = false
private invincibleTimer: number = 0
constructor(x: number, y: number) {
super()
this.position = new Vec2(x, y)
this.size = new Vec2(40, 48)
this.tag = 'player'
}
// 受伤
takeDamage(): void {
if (this.invincible) return
this.hp--
this.invincible = true
this.invincibleTimer = 1.5 // 无敌1.5秒
}
// 更新
override update(dt: number): void {
super.update(dt)
// 无敌时间倒计时
if (this.invincible) {
this.invincibleTimer -= dt
// 闪烁效果
this.alpha = Math.sin(Date.now() * 0.02) > 0 ? 1.0 : 0.3
if (this.invincibleTimer <= 0) {
this.invincible = false
this.alpha = 1.0
}
}
// 射击计时
this.shootTimer -= dt
}
// 是否可以射击
canShoot(): boolean {
if (this.shootTimer <= 0) {
this.shootTimer = this.shootInterval
return true
}
return false
}
}
// AABB碰撞检测
function checkAABB(a: Sprite2D, b: Sprite2D): boolean {
const ra = a.getBounds()
const rb = b.getBounds()
return ra.left < rb.right && ra.right > rb.left &&
ra.top < rb.bottom && ra.bottom > rb.top
}
// 游戏主控制器
@Entry
@Component
struct PlaneWarGame {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private canvasW: number = 360
private canvasH: number = 720
private player: Player = new Player(180, 650)
private bullets: Bullet[] = []
private enemies: Enemy[] = []
private score: number = 0
private spawnTimer: number = 0
private running: boolean = false
private lastTime: number = 0
private timer: number = -1
private touchX: number = 180
private touchY: number = 650
private isTouching: boolean = false
aboutToAppear(): void {
this.running = true
}
aboutToDisappear(): void {
this.running = false
if (this.timer !== -1) clearTimeout(this.timer)
}
private gameTick(): void {
if (!this.running) return
const now = Date.now()
const dt = Math.min((now - this.lastTime) / 1000, 0.05)
this.lastTime = now
this.update(dt)
this.render()
this.timer = setTimeout(() => this.gameTick(), 16)
}
private update(dt: number): void {
// 玩家跟随触控
if (this.isTouching) {
const dx = this.touchX - this.player.position.x
const dy = this.touchY - this.player.position.y
this.player.velocity = new Vec2(dx * 8, dy * 8)
} else {
this.player.velocity = new Vec2(0, 0)
}
this.player.update(dt)
// 限制玩家在屏幕内
this.player.position.x = Math.max(20, Math.min(this.canvasW - 20, this.player.position.x))
this.player.position.y = Math.max(20, Math.min(this.canvasH - 20, this.player.position.y))
// 自动射击
if (this.player.canShoot() && this.isTouching) {
this.bullets.push(new Bullet(this.player.position.x, this.player.position.y - 30, 500, true))
}
// 生成敌人
this.spawnTimer -= dt
if (this.spawnTimer <= 0) {
const x = 30 + Math.random() * (this.canvasW - 60)
const pattern = Math.random() > 0.5 ? 1 : 0
this.enemies.push(new Enemy(x, -30, 1, 80 + Math.random() * 60, pattern))
this.spawnTimer = 0.8 + Math.random() * 0.5
}
// 更新子弹
for (const b of this.bullets) {
b.update(dt)
// 超出屏幕则标记死亡
if (b.position.y < -20 || b.position.y > this.canvasH + 20) {
b.alive = false
}
}
// 更新敌人
for (const e of this.enemies) {
e.update(dt)
if (e.position.y > this.canvasH + 50) {
e.alive = false
}
}
// 碰撞检测:玩家子弹 vs 敌人
for (const bullet of this.bullets) {
if (!bullet.alive || !bullet.isPlayerBullet) continue
for (const enemy of this.enemies) {
if (!enemy.alive) continue
if (checkAABB(bullet, enemy)) {
bullet.alive = false
if (enemy.takeDamage(bullet.damage)) {
this.score += enemy.score
}
break
}
}
}
// 碰撞检测:敌人子弹 vs 玩家
for (const bullet of this.bullets) {
if (!bullet.alive || bullet.isPlayerBullet) continue
if (checkAABB(bullet, this.player)) {
bullet.alive = false
this.player.takeDamage()
}
}
// 碰撞检测:敌人 vs 玩家
for (const enemy of this.enemies) {
if (!enemy.alive) continue
if (checkAABB(enemy, this.player)) {
enemy.alive = false
this.player.takeDamage()
}
}
// 清理死亡对象
this.bullets = this.bullets.filter(b => b.alive)
this.enemies = this.enemies.filter(e => e.alive)
// 游戏结束
if (this.player.hp <= 0) {
this.running = false
}
}
private render(): void {
const ctx = this.ctx
ctx.clearRect(0, 0, this.canvasW, this.canvasH)
// 背景
ctx.fillStyle = '#0a0a1a'
ctx.fillRect(0, 0, this.canvasW, this.canvasH)
// 绘制星星背景
ctx.fillStyle = '#ffffff'
for (let i = 0; i < 50; i++) {
const sx = (i * 73 + Date.now() * 0.01) % this.canvasW
const sy = (i * 137 + Date.now() * 0.02) % this.canvasH
ctx.fillRect(sx, sy, 1, 1)
}
// 绘制玩家
ctx.save()
ctx.translate(this.player.position.x, this.player.position.y)
ctx.globalAlpha = this.player.alpha
// 简单三角形表示飞机
ctx.fillStyle = '#00ff88'
ctx.beginPath()
ctx.moveTo(0, -24)
ctx.lineTo(-20, 24)
ctx.lineTo(20, 24)
ctx.closePath()
ctx.fill()
ctx.restore()
// 绘制子弹
ctx.fillStyle = '#ffff00'
for (const b of this.bullets) {
if (b.isPlayerBullet) {
ctx.fillStyle = '#00ffff'
} else {
ctx.fillStyle = '#ff4444'
}
ctx.fillRect(b.position.x - 3, b.position.y - 8, 6, 16)
}
// 绘制敌人
for (const e of this.enemies) {
ctx.save()
ctx.translate(e.position.x, e.position.y)
ctx.fillStyle = '#ff4466'
ctx.beginPath()
ctx.moveTo(0, 18)
ctx.lineTo(-18, -18)
ctx.lineTo(18, -18)
ctx.closePath()
ctx.fill()
ctx.restore()
}
// 绘制UI
ctx.fillStyle = '#ffffff'
ctx.font = '18px sans-serif'
ctx.fillText(`分数: ${this.score}`, 10, 30)
ctx.fillText(`生命: ${'♥'.repeat(Math.max(0, this.player.hp))}`, 10, 55)
// 游戏结束
if (this.player.hp <= 0) {
ctx.fillStyle = 'rgba(0,0,0,0.7)'
ctx.fillRect(0, 0, this.canvasW, this.canvasH)
ctx.fillStyle = '#ff4444'
ctx.font = '36px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('游戏结束', this.canvasW / 2, this.canvasH / 2 - 20)
ctx.fillStyle = '#ffffff'
ctx.font = '20px sans-serif'
ctx.fillText(`最终分数: ${this.score}`, this.canvasW / 2, this.canvasH / 2 + 20)
ctx.textAlign = 'start'
}
}
build() {
Column() {
Canvas(this.ctx)
.width('100%')
.height('100%')
.onReady(() => {
this.lastTime = Date.now()
this.gameTick()
})
.onTouch((event: TouchEvent) => {
const touch = event.touches[0]
this.touchX = touch.x
this.touchY = touch.y
if (event.type === TouchType.Down) {
this.isTouching = true
} else if (event.type === TouchType.Up) {
this.isTouching = false
}
})
}
.width('100%')
.height('100%')
}
}
跑起来,你就能用手指控制飞机,自动发射子弹,击落从上方飞来的敌人。这就是一个完整的2D游戏核心了。
踩坑与注意事项
坑1:Canvas的drawImage裁剪参数
drawImage有9个参数的版本,源区域和目标区域别搞反了。源区域在前(精灵表上的坐标),目标区域在后(Canvas上的坐标)。参数顺序错了,要么画面错位,要么直接白屏。
坑2:精灵表图片加载时机
在鸿蒙上,图片加载是异步的。你在aboutToAppear里创建ImageBitmap,到onReady的时候图片可能还没加载完。解决方案:用Image组件的onComplete回调来确认图片加载完毕后再启动游戏循环。
坑3:碰撞检测的性能
子弹多了,碰撞检测就是O(n²)的复杂度。100颗子弹 × 50个敌人 = 5000次碰撞检查,每帧都要跑。解决方法:用空间分区(网格法或四叉树),只检查同一区域内的对象。具体实现后面性能优化的文章会讲。
坑4:帧动画的帧率与游戏帧率不同步
帧动画的帧率是固定的(比如每帧100ms),游戏帧率是波动的(16ms左右)。你不能用游戏帧数来驱动动画帧切换,必须用时间累积。上面代码里的elapsed就是干这个的。
坑5:对象清理不及时
子弹飞出屏幕、敌人被击毁,这些"死"对象如果不及时清理,会越积越多,内存和CPU都扛不住。每帧都做一次filter清理是必要的,但如果对象特别多,可以考虑分帧清理——每帧只清理一部分。
HarmonyOS 6适配说明
HarmonyOS 6在2D游戏开发方面有几个值得关注的更新:
-
Canvas 2D性能提升:底层渲染管线优化,批量绘制调用(batch draw)效率提升约30%。大量精灵场景下帧率更稳定。
-
新增CanvasRenderingContext2DV2:扩展了Canvas 2D的API,新增了
drawPixelMap方法,可以直接绘制PixelMap对象,省去了ImageBitmap的转换开销。
// HarmonyOS 6 新API示例
import { image } from '@kit.ImageKit'
// 使用PixelMap直接绘制,性能更好
async function drawWithPixelMap(ctx: CanvasRenderingContext2DV2) {
const pixelMap = await image.createPixelMap(data, {
size: { width: 64, height: 64 }
})
ctx.drawPixelMap(pixelMap, 100, 100)
}
-
硬件加速Canvas:HarmonyOS 6默认为Canvas开启GPU加速,2D绘制不再走CPU软渲染。这对复杂2D游戏是重大利好,帧率提升明显。
-
游戏帧率自适应:新增
DisplaySyncAPI,可以根据屏幕刷新率自动调整游戏循环频率,支持120Hz高刷屏。
总结
2D游戏开发,说到底就是三件事:让东西动起来(游戏循环+精灵系统)、让东西看起来像活的(帧动画)、让东西撞上了有反应(碰撞检测)。
游戏循环是心脏,每帧泵一次血。精灵系统是骨架,所有游戏对象都挂在上面。帧动画是皮肤,让角色看起来在动。碰撞检测是神经,让游戏有交互。
| 评估维度 | 学习难度 | 使用频率 | 重要程度 |
|---|---|---|---|
| 游戏主循环 | ★★★☆☆ | ★★★★★ | ★★★★★ |
| 精灵系统 | ★★★☆☆ | ★★★★★ | ★★★★★ |
| 帧动画 | ★★★★☆ | ★★★★☆ | ★★★★☆ |
| AABB碰撞检测 | ★★☆☆☆ | ★★★★★ | ★★★★★ |
| 精灵表裁剪绘制 | ★★★☆☆ | ★★★★☆ | ★★★★☆ |
| Canvas 2D API | ★★★☆☆ | ★★★★★ | ★★★★★ |
下一篇我们进3D领域,看看XComponent和OpenGL ES怎么在鸿蒙上搞3D渲染。维度升了一级,复杂度也升了一级,但原理还是那个原理——算、画、判。
- 点赞
- 收藏
- 关注作者
评论(0)