HarmonyOS开发:游戏实战——完整2D游戏开发
HarmonyOS开发:游戏实战——完整2D游戏开发
📌 核心要点:把前面学的游戏循环、精灵系统、碰撞检测、音效管理、关卡设计全部串起来,从零到一做出一个完整的2D跑酷游戏,包含开始画面、游戏循环、关卡系统、计分排名和结束画面。
背景与动机
前面9篇文章,我们学了游戏循环、精灵动画、碰撞检测、物理模拟、音频管理、输入处理、网络同步、性能优化、发布上架。每篇都有代码,但都是片段。
你肯定在想:这些东西拼在一起到底长什么样?
这篇就回答这个问题。我们要做一个完整的2D跑酷游戏——“鸿蒙跑酷”。角色自动向前跑,玩家控制跳跃和二段跳,躲避障碍物,收集金币,跑得越远分数越高。
这个游戏包含:
- 开始画面(标题、最高分、开始按钮)
- 游戏循环(角色跑步、障碍生成、金币收集)
- 碰撞检测(角色与障碍、角色与金币)
- 简单物理(重力、跳跃、二段跳)
- 关卡系统(难度递增)
- 计分系统(距离+金币)
- 结束画面(分数、最高分、重试按钮)
麻雀虽小,五脏俱全。
核心原理
游戏架构总览
graph TB
A[GameStateManager] --> B[MenuState]
A --> C[PlayState]
A --> D[GameOverState]
C --> E[GameLoop]
E --> F[InputHandler]
E --> G[PhysicsEngine]
E --> H[CollisionSystem]
E --> I[Spawner]
E --> J[ScoreSystem]
G --> G1[重力模拟]
G --> G2[跳跃逻辑]
G --> G3[地面检测]
H --> H1[角色vs障碍]
H --> H2[角色vs金币]
H --> H3[角色vs地面]
I --> I1[障碍物生成]
I --> I2[金币生成]
I --> I3[难度递增]
classDef stateStyle fill:#9B59B6,stroke:#8E44AD,color:#fff,font-weight:bold
classDef loopStyle fill:#E74C3C,stroke:#C0392B,color:#fff
classDef moduleStyle fill:#3498DB,stroke:#2980B9,color:#fff
classDef detailStyle fill:#27AE60,stroke:#229954,color:#fff
class A,B,C,D stateStyle
class E loopStyle
class F,G,H,I,J moduleStyle
class G1,G2,G3,H1,H2,H3,I1,I2,I3 detailStyle
游戏状态机
游戏有三个核心状态:菜单、游戏中、游戏结束。状态之间的转换很清晰:
- 菜单 → 点击开始 → 游戏中
- 游戏中 → 撞到障碍 → 游戏结束
- 游戏结束 → 点击重试 → 游戏中
跑酷游戏的核心参数
| 参数 | 值 | 说明 |
|---|---|---|
| 重力 | 1500 | 像素/秒² |
| 跳跃力 | -600 | 像素/秒(向上) |
| 二段跳力 | -500 | 像素/秒 |
| 跑步速度 | 300 | 像素/秒(初始) |
| 最大速度 | 600 | 像素/秒 |
| 速度递增 | 10/秒 | 每秒加速 |
| 地面高度 | 620 | 像素(从顶部算) |
| 障碍间距 | 200-400 | 像素 |
| 金币间距 | 80-150 | 像素 |
代码实战
基础用法:游戏状态管理与角色系统
先搭好骨架——状态管理和角色。
// RunnerGame.ets - 跑酷游戏核心
// 游戏状态
enum RunnerGameState {
MENU,
PLAYING,
GAME_OVER
}
// 角色
class Runner {
x: number = 60
y: number = 620
width: number = 36
height: number = 48
vy: number = 0
isOnGround: boolean = true
jumpCount: number = 0 // 已跳跃次数
maxJumps: number = 2 // 最大跳跃次数(二段跳)
alive: boolean = true
runFrame: number = 0 // 跑步动画帧
runTimer: number = 0
// 跳跃
jump(): boolean {
if (this.jumpCount >= this.maxJumps) return false
const jumpForce = this.jumpCount === 0 ? -600 : -500
this.vy = jumpForce
this.isOnGround = false
this.jumpCount++
return true
}
// 更新
update(dt: number, gravity: number, groundY: number): void {
if (!this.alive) return
// 重力
this.vy += gravity * dt
this.y += this.vy * dt
// 地面检测
if (this.y >= groundY) {
this.y = groundY
this.vy = 0
this.isOnGround = true
this.jumpCount = 0
}
// 跑步动画
this.runTimer += dt
if (this.runTimer > 0.1) {
this.runTimer = 0
this.runFrame = (this.runFrame + 1) % 4
}
}
// 获取AABB
getBounds(): { left: number; top: number; right: number; bottom: number } {
return {
left: this.x - this.width / 2,
top: this.y - this.height,
right: this.x + this.width / 2,
bottom: this.y
}
}
}
// 障碍物
class Obstacle {
x: number = 0
y: number = 0
width: number = 0
height: number = 0
type: string = 'box' // box, spike, tall
passed: boolean = false
constructor(x: number, groundY: number, type: string) {
this.x = x
this.type = type
switch (type) {
case 'box':
this.width = 30
this.height = 30
this.y = groundY - this.height
break
case 'spike':
this.width = 24
this.height = 36
this.y = groundY - this.height
break
case 'tall':
this.width = 25
this.height = 55
this.y = groundY - this.height
break
case 'double':
this.width = 50
this.height = 30
this.y = groundY - this.height
break
}
}
// 更新位置
update(speed: number, dt: number): void {
this.x -= speed * dt
}
// 是否在屏幕外
isOffScreen(): boolean {
return this.x + this.width < -50
}
// 获取AABB
getBounds(): { left: number; top: number; right: number; bottom: number } {
// 缩小碰撞体,让碰撞判定更宽容
const shrink = 4
return {
left: this.x + shrink,
top: this.y + shrink,
right: this.x + this.width - shrink,
bottom: this.y + this.height - shrink
}
}
}
// 金币
class Coin {
x: number = 0
y: number = 0
radius: number = 10
collected: boolean = false
animTimer: number = 0
constructor(x: number, y: number) {
this.x = x
this.y = y
}
update(speed: number, dt: number): void {
this.x -= speed * dt
this.animTimer += dt
}
isOffScreen(): boolean {
return this.x < -20
}
// 获取碰撞圆
getCenter(): { x: number; y: number } {
return { x: this.x, y: this.y + Math.sin(this.animTimer * 5) * 3 }
}
}
// 粒子效果
class Particle {
x: number = 0
y: number = 0
vx: number = 0
vy: number = 0
life: number = 0
maxLife: number = 1
color: string = '#ffffff'
size: number = 3
update(dt: number): boolean {
this.x += this.vx * dt
this.y += this.vy * dt
this.vy += 300 * dt
this.life -= dt
return this.life > 0
}
}
进阶用法:关卡生成与碰撞系统
关卡生成是跑酷游戏的灵魂——难度要递增,但不能太难。
// LevelGenerator.ets - 关卡生成器
class LevelGenerator {
private groundY: number = 620
private nextObstacleX: number = 400
private nextCoinX: number = 300
private difficulty: number = 1.0 // 难度系数
private minGap: number = 200 // 最小障碍间距
private maxGap: number = 400 // 最大障碍间距
// 根据游戏时间更新难度
updateDifficulty(elapsedTime: number): void {
this.difficulty = 1.0 + elapsedTime / 30 // 每30秒难度+1
this.minGap = Math.max(120, 200 - this.difficulty * 10)
this.maxGap = Math.max(200, 400 - this.difficulty * 15)
}
// 生成障碍物
generateObstacles(screenRight: number): Obstacle[] {
const newObstacles: Obstacle[] = []
while (this.nextObstacleX < screenRight + 200) {
// 随机选择障碍物类型
const types = ['box', 'spike', 'tall', 'double']
// 高难度时更多tall和double
let type: string
if (this.difficulty > 3) {
type = types[Math.floor(Math.random() * types.length)]
} else {
type = types[Math.floor(Math.random() * 2)] // 低难度只有box和spike
}
newObstacles.push(new Obstacle(this.nextObstacleX, this.groundY, type))
// 下一个障碍的位置
const gap = this.minGap + Math.random() * (this.maxGap - this.minGap)
this.nextObstacleX += gap
}
return newObstacles
}
// 生成金币
generateCoins(screenRight: number): Coin[] {
const newCoins: Coin[] = []
while (this.nextCoinX < screenRight + 200) {
// 金币在地面以上不同高度
const heights = [this.groundY - 40, this.groundY - 80, this.groundY - 130]
const y = heights[Math.floor(Math.random() * heights.length)]
// 生成1-3个连续金币
const count = 1 + Math.floor(Math.random() * 3)
for (let i = 0; i < count; i++) {
newCoins.push(new Coin(this.nextCoinX + i * 30, y))
}
this.nextCoinX += 80 + Math.random() * 150
}
return newCoins
}
// 重置
reset(): void {
this.nextObstacleX = 400
this.nextCoinX = 300
this.difficulty = 1.0
}
// 滚动偏移(当障碍物移出屏幕时更新生成位置)
onObstaclePassed(offset: number): void {
this.nextObstacleX -= offset
this.nextCoinX -= offset
}
}
// 碰撞检测
class GameCollision {
// AABB vs AABB
static aabbVsAabb(
a: { left: number; top: number; right: number; bottom: number },
b: { left: number; top: number; right: number; bottom: number }
): boolean {
return a.left < b.right && a.right > b.left &&
a.top < b.bottom && a.bottom > b.top
}
// AABB vs 圆
static aabbVsCircle(
rect: { left: number; top: number; right: number; bottom: number },
cx: number, cy: number, radius: number
): boolean {
const closestX = Math.max(rect.left, Math.min(cx, rect.right))
const closestY = Math.max(rect.top, Math.min(cy, rect.bottom))
const dx = cx - closestX
const dy = cy - closestY
return dx * dx + dy * dy <= radius * radius
}
}
完整示例:完整游戏实现
把所有模块组装起来,这就是一个完整的2D跑酷游戏:
// RunnerGamePage.ets - 完整跑酷游戏
@Entry
@Component
struct RunnerGamePage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private canvasW: number = 360
private canvasH: number = 720
// 游戏状态
private gameState: RunnerGameState = RunnerGameState.MENU
private runner: Runner = new Runner()
private obstacles: Obstacle[] = []
private coins: Coin[] = []
private particles: Particle[] = []
private levelGen: LevelGenerator = new LevelGenerator()
// 游戏参数
private speed: number = 300
private gravity: number = 1500
private groundY: number = 620
private elapsedTime: number = 0
// 分数
private score: number = 0
private coinCount: number = 0
private highScore: number = 0
private distance: number = 0
// 控制
private running: boolean = false
private lastTime: number = 0
private timer: number = -1
// 背景滚动
private bgOffset: number = 0
private bgSpeed: number = 0.5
aboutToAppear(): void {
this.running = true
this.loadHighScore()
}
aboutToDisappear(): void {
this.running = false
if (this.timer !== -1) clearTimeout(this.timer)
}
// 加载最高分
private loadHighScore(): void {
// 实际项目中用Preferences持久化存储
this.highScore = 0
}
// 保存最高分
private saveHighScore(): void {
if (this.score > this.highScore) {
this.highScore = this.score
}
}
build() {
Column() {
Canvas(this.ctx)
.width('100%')
.height('100%')
.onReady(() => {
this.lastTime = Date.now()
this.gameLoop()
})
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.handleTap()
}
})
}
.width('100%')
.height('100%')
}
// 处理点击
private handleTap(): void {
switch (this.gameState) {
case RunnerGameState.MENU:
this.startGame()
break
case RunnerGameState.PLAYING:
this.runner.jump()
break
case RunnerGameState.GAME_OVER:
this.startGame()
break
}
}
// 开始游戏
private startGame(): void {
this.gameState = RunnerGameState.PLAYING
this.runner = new Runner()
this.obstacles = []
this.coins = []
this.particles = []
this.levelGen.reset()
this.speed = 300
this.elapsedTime = 0
this.score = 0
this.coinCount = 0
this.distance = 0
}
// 游戏结束
private gameOver(): void {
this.gameState = RunnerGameState.GAME_OVER
this.runner.alive = false
this.saveHighScore()
// 死亡粒子效果
this.spawnDeathParticles()
}
// 游戏循环
private gameLoop(): void {
if (!this.running) return
const now = Date.now()
const dt = Math.min((now - this.lastTime) / 1000, 0.05)
this.lastTime = now
if (this.gameState === RunnerGameState.PLAYING) {
this.update(dt)
}
this.render()
this.timer = setTimeout(() => this.gameLoop(), 16)
}
// 更新逻辑
private update(dt: number): void {
this.elapsedTime += dt
// 速度递增
this.speed = Math.min(600, 300 + this.elapsedTime * 10)
// 更新难度
this.levelGen.updateDifficulty(this.elapsedTime)
// 更新角色
this.runner.update(dt, this.gravity, this.groundY)
// 生成障碍和金币
const newObstacles = this.levelGen.generateObstacles(this.canvasW + this.distance)
this.obstacles.push(...newObstacles)
const newCoins = this.levelGen.generateCoins(this.canvasW + this.distance)
this.coins.push(...newCoins)
// 更新障碍
for (const obs of this.obstacles) {
obs.update(this.speed, dt)
}
// 更新金币
for (const coin of this.coins) {
coin.update(this.speed, dt)
}
// 更新粒子
this.particles = this.particles.filter(p => p.update(dt))
// 碰撞检测:角色 vs 障碍
const playerBounds = this.runner.getBounds()
for (const obs of this.obstacles) {
if (obs.passed) continue
if (GameCollision.aabbVsAabb(playerBounds, obs.getBounds())) {
this.gameOver()
return
}
}
// 碰撞检测:角色 vs 金币
for (const coin of this.coins) {
if (coin.collected) continue
const center = coin.getCenter()
if (GameCollision.aabbVsCircle(playerBounds, center.x, center.y, coin.radius)) {
coin.collected = true
this.coinCount++
this.spawnCoinParticles(coin.x, coin.y)
}
}
// 清理屏幕外的对象
this.obstacles = this.obstacles.filter(obs => !obs.isOffScreen())
this.coins = this.coins.filter(coin => !coin.isOffScreen() && !coin.collected)
// 更新分数
this.distance += this.speed * dt
this.score = Math.floor(this.distance / 10) + this.coinCount * 10
// 背景滚动
this.bgOffset += this.speed * this.bgSpeed * dt
}
// 生成金币粒子
private spawnCoinParticles(x: number, y: number): void {
for (let i = 0; i < 5; i++) {
const p = new Particle()
p.x = x; p.y = y
p.vx = (Math.random() - 0.5) * 100
p.vy = -50 - Math.random() * 80
p.life = 0.4 + Math.random() * 0.3
p.maxLife = p.life
p.color = '#ffdd00'
p.size = 2 + Math.random() * 3
this.particles.push(p)
}
}
// 生成死亡粒子
private spawnDeathParticles(): void {
for (let i = 0; i < 15; i++) {
const p = new Particle()
p.x = this.runner.x; p.y = this.runner.y - this.runner.height / 2
p.vx = (Math.random() - 0.5) * 200
p.vy = -100 - Math.random() * 150
p.life = 0.5 + Math.random() * 0.5
p.maxLife = p.life
p.color = i % 2 === 0 ? '#ff4444' : '#ff8800'
p.size = 3 + Math.random() * 5
this.particles.push(p)
}
}
// 渲染
private render(): void {
const ctx = this.ctx
ctx.clearRect(0, 0, this.canvasW, this.canvasH)
switch (this.gameState) {
case RunnerGameState.MENU:
this.renderMenu(ctx)
break
case RunnerGameState.PLAYING:
this.renderGame(ctx)
break
case RunnerGameState.GAME_OVER:
this.renderGame(ctx) // 先画游戏画面
this.renderGameOver(ctx) // 再画覆盖层
break
}
}
// 渲染菜单
private renderMenu(ctx: CanvasRenderingContext2D): void {
// 背景
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, this.canvasW, this.canvasH)
// 装饰性地面
ctx.fillStyle = '#16213e'
ctx.fillRect(0, this.groundY, this.canvasW, this.canvasH - this.groundY)
ctx.fillStyle = '#0f3460'
ctx.fillRect(0, this.groundY, this.canvasW, 4)
// 标题
ctx.fillStyle = '#e94560'
ctx.font = 'bold 42px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('鸿蒙跑酷', this.canvasW / 2, 200)
// 副标题
ctx.fillStyle = '#a8a8a8'
ctx.font = '16px sans-serif'
ctx.fillText('点击屏幕跳跃,躲避障碍收集金币', this.canvasW / 2, 250)
// 最高分
if (this.highScore > 0) {
ctx.fillStyle = '#ffdd00'
ctx.font = '20px sans-serif'
ctx.fillText(`最高分: ${this.highScore}`, this.canvasW / 2, 310)
}
// 开始按钮
ctx.fillStyle = '#e94560'
const btnW = 160
const btnH = 50
const btnX = this.canvasW / 2 - btnW / 2
const btnY = 380
ctx.beginPath()
ctx.roundRect(btnX, btnY, btnW, btnH, 25)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 22px sans-serif'
ctx.fillText('开始游戏', this.canvasW / 2, btnY + 33)
// 操作说明
ctx.fillStyle = '#666666'
ctx.font = '14px sans-serif'
ctx.fillText('支持二段跳!', this.canvasW / 2, 480)
ctx.textAlign = 'start'
}
// 渲染游戏画面
private renderGame(ctx: CanvasRenderingContext2D): void {
// 天空渐变
const gradient = ctx.createLinearGradient(0, 0, 0, this.groundY)
gradient.addColorStop(0, '#0a0a2e')
gradient.addColorStop(1, '#1a1a3e')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, this.canvasW, this.groundY)
// 远景星星
ctx.fillStyle = '#ffffff'
for (let i = 0; i < 30; i++) {
const sx = ((i * 97 + this.bgOffset * 0.1) % (this.canvasW + 20)) - 10
const sy = (i * 73) % (this.groundY - 100) + 30
const size = (i % 3 === 0) ? 2 : 1
ctx.fillRect(sx, sy, size, size)
}
// 远景山丘
ctx.fillStyle = '#16213e'
ctx.beginPath()
ctx.moveTo(0, this.groundY)
for (let x = 0; x <= this.canvasW; x += 40) {
const hillY = this.groundY - 30 - Math.sin((x + this.bgOffset * 0.3) * 0.02) * 20
ctx.lineTo(x, hillY)
}
ctx.lineTo(this.canvasW, this.groundY)
ctx.closePath()
ctx.fill()
// 地面
ctx.fillStyle = '#16213e'
ctx.fillRect(0, this.groundY, this.canvasW, this.canvasH - this.groundY)
ctx.fillStyle = '#0f3460'
ctx.fillRect(0, this.groundY, this.canvasW, 4)
// 地面纹理
ctx.fillStyle = '#1a2744'
for (let x = 0; x < this.canvasW; x += 30) {
const offset = (this.bgOffset + x) % this.canvasW
ctx.fillRect(offset < this.canvasW ? offset : offset - this.canvasW, this.groundY + 10, 15, 3)
}
// 绘制金币
for (const coin of this.coins) {
if (coin.collected) continue
const center = coin.getCenter()
const bobY = Math.sin(coin.animTimer * 5) * 3
ctx.beginPath()
ctx.arc(coin.x, center.y + bobY, coin.radius, 0, Math.PI * 2)
ctx.fillStyle = '#ffdd00'
ctx.fill()
ctx.strokeStyle = '#ffaa00'
ctx.lineWidth = 2
ctx.stroke()
// 金币高光
ctx.beginPath()
ctx.arc(coin.x - 3, center.y + bobY - 3, 3, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fill()
}
// 绘制障碍物
for (const obs of this.obstacles) {
ctx.save()
switch (obs.type) {
case 'box':
ctx.fillStyle = '#e94560'
ctx.fillRect(obs.x, obs.y, obs.width, obs.height)
ctx.fillStyle = '#c73652'
ctx.fillRect(obs.x + 3, obs.y + 3, obs.width - 6, obs.height - 6)
break
case 'spike':
ctx.fillStyle = '#e94560'
ctx.beginPath()
ctx.moveTo(obs.x + obs.width / 2, obs.y)
ctx.lineTo(obs.x, obs.y + obs.height)
ctx.lineTo(obs.x + obs.width, obs.y + obs.height)
ctx.closePath()
ctx.fill()
break
case 'tall':
ctx.fillStyle = '#e94560'
ctx.fillRect(obs.x, obs.y, obs.width, obs.height)
ctx.fillStyle = '#c73652'
ctx.fillRect(obs.x + 3, obs.y + 3, obs.width - 6, obs.height - 6)
// 顶部装饰
ctx.fillStyle = '#ff6b6b'
ctx.fillRect(obs.x - 2, obs.y, obs.width + 4, 5)
break
case 'double':
ctx.fillStyle = '#e94560'
ctx.fillRect(obs.x, obs.y, obs.width, obs.height)
ctx.fillStyle = '#c73652'
ctx.fillRect(obs.x + 3, obs.y + 3, obs.width / 2 - 4, obs.height - 6)
ctx.fillRect(obs.x + obs.width / 2 + 1, obs.y + 3, obs.width / 2 - 4, obs.height - 6)
break
}
ctx.restore()
}
// 绘制角色
if (this.runner.alive) {
ctx.save()
ctx.translate(this.runner.x, this.runner.y)
// 身体
ctx.fillStyle = '#00ff88'
ctx.fillRect(-this.runner.width / 2, -this.runner.height, this.runner.width, this.runner.height)
// 眼睛
ctx.fillStyle = '#ffffff'
ctx.fillRect(4, -this.runner.height + 12, 8, 8)
ctx.fillStyle = '#000000'
ctx.fillRect(8, -this.runner.height + 14, 4, 4)
// 跑步腿部动画
if (this.runner.isOnGround) {
const legOffset = Math.sin(this.runner.runFrame * Math.PI / 2) * 6
ctx.fillStyle = '#00cc66'
ctx.fillRect(-8, -4, 7, 8 + legOffset)
ctx.fillRect(2, -4, 7, 8 - legOffset)
} else {
// 跳跃姿态:腿收起
ctx.fillStyle = '#00cc66'
ctx.fillRect(-8, -4, 7, 5)
ctx.fillRect(2, -4, 7, 5)
}
ctx.restore()
}
// 绘制粒子
for (const p of this.particles) {
const alpha = Math.max(0, p.life / p.maxLife)
ctx.globalAlpha = alpha
ctx.fillStyle = p.color
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size)
}
ctx.globalAlpha = 1.0
// HUD
this.renderHUD(ctx)
}
// 渲染HUD
private renderHUD(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = 'rgba(0,0,0,0.4)'
ctx.fillRect(0, 0, this.canvasW, 40)
ctx.fillStyle = '#ffffff'
ctx.font = '16px sans-serif'
ctx.textAlign = 'start'
ctx.fillText(`距离: ${Math.floor(this.distance / 10)}m`, 10, 27)
ctx.fillStyle = '#ffdd00'
ctx.fillText(`★ ${this.coinCount}`, 160, 27)
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'end'
ctx.fillText(`分数: ${this.score}`, this.canvasW - 10, 27)
ctx.textAlign = 'start'
}
// 渲染游戏结束
private renderGameOver(ctx: CanvasRenderingContext2D): void {
// 半透明遮罩
ctx.fillStyle = 'rgba(0,0,0,0.7)'
ctx.fillRect(0, 0, this.canvasW, this.canvasH)
// 游戏结束文字
ctx.fillStyle = '#e94560'
ctx.font = 'bold 36px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('游戏结束', this.canvasW / 2, this.canvasH / 2 - 80)
// 分数
ctx.fillStyle = '#ffffff'
ctx.font = '24px sans-serif'
ctx.fillText(`分数: ${this.score}`, this.canvasW / 2, this.canvasH / 2 - 30)
// 金币
ctx.fillStyle = '#ffdd00'
ctx.font = '18px sans-serif'
ctx.fillText(`金币: ${this.coinCount}`, this.canvasW / 2, this.canvasH / 2 + 5)
// 距离
ctx.fillStyle = '#a8a8a8'
ctx.font = '18px sans-serif'
ctx.fillText(`距离: ${Math.floor(this.distance / 10)}m`, this.canvasW / 2, this.canvasH / 2 + 35)
// 最高分
if (this.score >= this.highScore) {
ctx.fillStyle = '#ffdd00'
ctx.font = 'bold 20px sans-serif'
ctx.fillText('新纪录!', this.canvasW / 2, this.canvasH / 2 + 70)
} else {
ctx.fillStyle = '#a8a8a8'
ctx.font = '16px sans-serif'
ctx.fillText(`最高分: ${this.highScore}`, this.canvasW / 2, this.canvasH / 2 + 70)
}
// 重试按钮
ctx.fillStyle = '#e94560'
const btnW = 160
const btnH = 50
const btnX = this.canvasW / 2 - btnW / 2
const btnY = this.canvasH / 2 + 100
ctx.beginPath()
ctx.roundRect(btnX, btnY, btnW, btnH, 25)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 22px sans-serif'
ctx.fillText('再来一次', this.canvasW / 2, btnY + 33)
ctx.textAlign = 'start'
}
}
踩坑与注意事项
坑1:碰撞体太精确
跑酷游戏的碰撞检测不能太精确。如果碰撞体和视觉完全一致,玩家会觉得"明明没碰到就死了"。把碰撞体缩小4-6像素,给玩家一点"宽容度",体验会好很多。
坑2:难度曲线太陡
第一关就出tall障碍,新手直接劝退。前10秒只有box,10-30秒加spike,30秒以后才出tall和double。让玩家有学习的时间。
坑3:二段跳的手感
二段跳的力度要比一段跳小(-500 vs -600),这样二段跳的感觉是"小跳"而不是"大跳"。如果二段跳力度太大,角色飞太高,落地时间太长,节奏感全无。
坑4:金币和障碍的位置冲突
金币生成在障碍物正上方,玩家跳起来吃金币正好撞上障碍。生成金币时要检查附近有没有障碍物,有就换个位置。
坑5:帧率对游戏速度的影响
如果用speed * dt来移动障碍物,帧率低的时候障碍物移动距离大,帧率高的时候移动距离小——这是对的。但如果你用固定步长(比如每帧移动5像素),帧率低的时候游戏就变慢了。一定要用dt做插值。
HarmonyOS 6适配说明
HarmonyOS 6对2D游戏开发的适配要点:
-
Canvas roundRect支持:HarmonyOS 6的Canvas 2D原生支持
roundRect()方法,不需要自己画圆角矩形了。上面的代码已经使用了这个API。 -
高刷新率适配:通过
DisplaySyncAPI获取屏幕实际刷新率,动态调整游戏循环频率。120Hz屏幕上游戏更流畅。 -
触控采样率:HarmonyOS 6支持240Hz触控采样率,跳跃操作的响应更灵敏。
-
游戏模式:使用
@ohos.game模块进入游戏模式,系统会优化CPU调度和内存管理,减少后台应用对游戏的影响。
总结
一个完整的2D游戏,从架构到实现,核心就是这几样东西:
游戏循环驱动一切,状态机管理流程,精灵系统管理对象,碰撞检测处理交互,关卡生成控制节奏,计分系统给玩家反馈。
代码量看起来不少,但拆开看每个模块都不复杂。关键是模块之间的配合——游戏循环调用各模块的update,渲染调用各模块的draw,碰撞检测连接角色和障碍/金币,关卡生成控制障碍和金币的出现节奏。
做游戏没有捷径,就是写→跑→调→写→跑→调。先让基本功能跑通,再优化体验,再打磨细节。
| 评估维度 | 学习难度 | 使用频率 | 重要程度 |
|---|---|---|---|
| 游戏状态机 | ★★★☆☆ | ★★★★★ | ★★★★★ |
| 角色控制 | ★★★☆☆ | ★★★★★ | ★★★★★ |
| 关卡生成 | ★★★★☆ | ★★★★☆ | ★★★★★ |
| 碰撞检测 | ★★☆☆☆ | ★★★★★ | ★★★★★ |
| 粒子效果 | ★★☆☆☆ | ★★★☆☆ | ★★★☆☆ |
| UI渲染 | ★★★☆☆ | ★★★★★ | ★★★★☆ |
| 难度曲线 | ★★★★☆ | ★★★★☆ | ★★★★★ |
到这里,鸿蒙游戏开发的10篇文章就全部完成了。从引擎选型到2D/3D开发,从物理引擎到音频管理,从输入处理到网络同步,从性能优化到发布上架,最后用一个完整的2D跑酷游戏把所有知识串了起来。
游戏开发是鸿蒙生态中最有挑战也最有意思的领域之一。鸿蒙的分布式能力、多设备协同、高刷新率屏幕,都为游戏创新提供了独特的可能性。希望这10篇文章能帮你打开鸿蒙游戏开发的大门。
- 点赞
- 收藏
- 关注作者
评论(0)