HarmonyOS 6.0 游戏开发实战:用 ArkUI 从零打造消消乐小游戏
一直想在鸿蒙上做个小游戏试试,消消乐是个很经典的选题——规则简单,但实现起来涉及的技术点还挺多:网格布局、状态驱动刷新、动画效果、消除判断逻辑。正好把这些在 HarmonyOS 6.0 的 ArkUI 框架里系统走一遍。这篇文章会完整实现一个可以玩的消消乐:点击两个相邻的格子交换位置,交换后如果有三个或三个以上相同颜色连成一排或一列,就消除并得分,上方的方块自动下落补位,空缺处随机生成新方块。技术
一、前言
一直想在鸿蒙上做个小游戏试试,消消乐是个很经典的选题——规则简单,但实现起来涉及的技术点还挺多:网格布局、状态驱动刷新、动画效果、消除判断逻辑。正好把这些在 HarmonyOS 6.0 的 ArkUI 框架里系统走一遍。
这篇文章会完整实现一个可以玩的消消乐:点击两个相邻的格子交换位置,交换后如果有三个或三个以上相同颜色连成一排或一列,就消除并得分,上方的方块自动下落补位,空缺处随机生成新方块。
技术上重点讲几件事:网格布局怎么用 Grid 组件搭、方块的选中/交换/消除动画怎么做、游戏状态怎么管理。代码全部可以直接运行,不依赖任何第三方库。
二、项目结构
entry/src/main/ets/
├── entryability/
│ └── EntryAbility.ets
├── model/
│ └── GameModel.ets // 游戏数据模型与核心逻辑
├── pages/
│ └── GamePage.ets // 游戏主页面
└── components/
├── GameGrid.ets // 游戏棋盘组件
├── BlockCell.ets // 单个方块组件
└── ScoreBoard.ets // 得分面板组件

三、数据模型与游戏逻辑
3.1 方块数据结构
// model/GameModel.ets
// 方块颜色类型,用数字枚举方便后续比较
export enum BlockColor {
RED = 0,
BLUE = 1,
GREEN = 2,
YELLOW = 3,
PURPLE = 4,
ORANGE = 5
}
// 单个方块
export interface Block {
id: number; // 唯一标识,用于动画 key
color: BlockColor;
row: number;
col: number;
isSelected: boolean; // 是否被选中
isMatched: boolean; // 是否在消除中
}
// 棋盘尺寸
export const BOARD_SIZE = 7; // 7x7 的棋盘
export const BLOCK_COLORS = 6; // 6种颜色
3.2 核心游戏逻辑
游戏逻辑全部封装在 GameModel 里,主要包含:初始化棋盘、判断消除、执行消除和下落、随机补位。
// model/GameModel.ets (续)
export class GameModel {
private static idCounter = 0;
// 生成一个新方块
static createBlock(row: number, col: number): Block {
return {
id: ++GameModel.idCounter,
color: Math.floor(Math.random() * BLOCK_COLORS) as BlockColor,
row,
col,
isSelected: false,
isMatched: false
};
}
// 初始化棋盘,确保初始状态没有可消除的组合
static initBoard(): Block[][] {
const board: Block[][] = [];
for (let r = 0; r < BOARD_SIZE; r++) {
board[r] = [];
for (let c = 0; c < BOARD_SIZE; c++) {
let block: Block;
do {
block = GameModel.createBlock(r, c);
} while (GameModel.wouldMatch(board, r, c, block.color));
board[r][c] = block;
}
}
return board;
}
// 判断在 (r, c) 放置某颜色是否会立即形成三连
static wouldMatch(board: Block[][], r: number, c: number, color: BlockColor): boolean {
// 检查横向
if (c >= 2 &&
board[r][c - 1]?.color === color &&
board[r][c - 2]?.color === color) return true;
// 检查纵向
if (r >= 2 &&
board[r - 1]?.[c]?.color === color &&
board[r - 2]?.[c]?.color === color) return true;
return false;
}
// 检查两个方块是否相邻
static isAdjacent(r1: number, c1: number, r2: number, c2: number): boolean {
return (Math.abs(r1 - r2) === 1 && c1 === c2) ||
(Math.abs(c1 - c2) === 1 && r1 === r2);
}
// 交换两个方块
static swap(board: Block[][], r1: number, c1: number, r2: number, c2: number): void {
const tmp = board[r1][c1].color;
board[r1][c1] = { ...board[r1][c1], color: board[r2][c2].color };
board[r2][c2] = { ...board[r2][c2], color: tmp };
}
// 找出所有可消除的方块,返回 Set<"row,col"> 格式
static findMatches(board: Block[][]): Set<string> {
const matched = new Set<string>();
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE - 2; c++) {
// 横向三连检查
if (board[r][c].color === board[r][c + 1].color &&
board[r][c].color === board[r][c + 2].color) {
matched.add(`${r},${c}`);
matched.add(`${r},${c + 1}`);
matched.add(`${r},${c + 2}`);
}
}
}
for (let c = 0; c < BOARD_SIZE; c++) {
for (let r = 0; r < BOARD_SIZE - 2; r++) {
// 纵向三连检查
if (board[r][c].color === board[r + 1][c].color &&
board[r][c].color === board[r + 2][c].color) {
matched.add(`${r},${c}`);
matched.add(`${r + 1},${c}`);
matched.add(`${r + 2},${c}`);
}
}
}
return matched;
}
// 执行消除:把匹配的方块标记,然后下落补位
// 返回本次消除的方块数量
static applyMatches(board: Block[][], matched: Set<string>): number {
if (matched.size === 0) return 0;
// 逐列处理下落
for (let c = 0; c < BOARD_SIZE; c++) {
// 找出这一列没有被消除的方块,从下到上保留
const surviving: Block[] = [];
for (let r = BOARD_SIZE - 1; r >= 0; r--) {
if (!matched.has(`${r},${c}`)) {
surviving.push(board[r][c]);
}
}
// 从底部开始重新填充这一列
let fillRow = BOARD_SIZE - 1;
for (const block of surviving) {
board[fillRow][c] = { ...block, row: fillRow, col: c };
fillRow--;
}
// 顶部空缺随机生成新方块
while (fillRow >= 0) {
board[fillRow][c] = GameModel.createBlock(fillRow, c);
fillRow--;
}
}
return matched.size;
}
// 计算得分:消除越多一次性加成越高
static calcScore(matchCount: number): number {
if (matchCount <= 3) return matchCount * 10;
if (matchCount <= 6) return matchCount * 20;
return matchCount * 50; // 大消除奖励
}
}
findMatches 只做横向和纵向的三连判断,没有做更长连击的特殊处理,这样代码简洁,后期要扩展加成逻辑也容易改。
applyMatches 用逐列处理的方式实现下落,思路是:把这一列没被消除的方块从底部开始摆,顶部空缺填新的随机方块。
四、方块组件
4.1 颜色配置
// components/BlockCell.ets
import { Block, BlockColor } from '../model/GameModel';
// 颜色映射表
const COLOR_MAP: Record<number, string> = {
0: '#f87171', // 红
1: '#60a5fa', // 蓝
2: '#4ade80', // 绿
3: '#facc15', // 黄
4: '#c084fc', // 紫
5: '#fb923c', // 橙
};
// 颜色对应的深色版本(选中时用)
const COLOR_DARK_MAP: Record<number, string> = {
0: '#dc2626',
1: '#2563eb',
2: '#16a34a',
3: '#d97706',
4: '#9333ea',
5: '#ea580c',
};
// 颜色对应的 Emoji 图标,增加辨识度
const COLOR_ICON: Record<number, string> = {
0: '🍎',
1: '💎',
2: '🍀',
3: '⭐',
4: '🔮',
5: '🍊',
};
4.2 方块组件实现
// components/BlockCell.ets (续)
@Component
export struct BlockCell {
@Prop block: Block = {} as Block;
cellSize: number = 44;
onClick: () => void = () => {};
// 选中时的缩放动画
@State scale: number = 1.0;
build() {
Column() {
Text(COLOR_ICON[this.block.color])
.fontSize(this.cellSize * 0.45)
.textAlign(TextAlign.Center)
}
.width(this.cellSize)
.height(this.cellSize)
.borderRadius(this.cellSize * 0.2)
.backgroundColor(
this.block.isSelected
? COLOR_DARK_MAP[this.block.color]
: COLOR_MAP[this.block.color]
)
.border({
width: this.block.isSelected ? 3 : 1,
color: this.block.isSelected ? Color.White : 'rgba(255,255,255,0.3)'
})
.shadow(this.block.isSelected ? {
radius: 12,
color: COLOR_MAP[this.block.color],
offsetX: 0,
offsetY: 4
} : {
radius: 4,
color: 'rgba(0,0,0,0.15)',
offsetX: 0,
offsetY: 2
})
.scale({ x: this.scale, y: this.scale })
.animation({
duration: 150,
curve: Curve.EaseOut
})
.onClick(() => {
// 点击时有个弹跳反馈
this.scale = 0.88;
setTimeout(() => { this.scale = 1.0; }, 150);
this.onClick();
})
}
}
选中状态通过背景色变深、加白色边框、添加发光阴影来体现,视觉反馈很直观。点击时用 scale 做了一个轻微的按压弹跳动画,增加操作手感。
五、得分面板组件
// components/ScoreBoard.ets
@Component
export struct ScoreBoard {
@Prop score: number = 0;
@Prop bestScore: number = 0;
@Prop moves: number = 0;
build() {
Row() {
// 当前分数
Column() {
Text('得分').fontSize(12).fontColor('#94a3b8')
Text(`${this.score}`)
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#1e293b')
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
// 分隔线
Divider().vertical(true).height(40).color('#e2e8f0')
// 最高分
Column() {
Text('最高').fontSize(12).fontColor('#94a3b8')
Text(`${this.bestScore}`)
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#7c3aed')
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Divider().vertical(true).height(40).color('#e2e8f0')
// 剩余步数
Column() {
Text('步数').fontSize(12).fontColor('#94a3b8')
Text(`${this.moves}`)
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(this.moves <= 5 ? '#ef4444' : '#1e293b')
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetX: 0, offsetY: 2 })
}
}
步数剩余 5 步以下时数字变红色,给玩家紧张感。
六、棋盘组件
// components/GameGrid.ets
import { Block, BOARD_SIZE } from '../model/GameModel';
import { BlockCell } from './BlockCell';
@Component
export struct GameGrid {
@Prop board: Block[][] = [];
cellSize: number = 44;
gap: number = 4;
onCellClick: (row: number, col: number) => void = () => {};
build() {
Column({ space: this.gap }) {
ForEach(
Array.from({ length: BOARD_SIZE }, (_, i) => i),
(rowIdx: number) => {
Row({ space: this.gap }) {
ForEach(
Array.from({ length: BOARD_SIZE }, (_, i) => i),
(colIdx: number) => {
if (this.board[rowIdx] && this.board[rowIdx][colIdx]) {
BlockCell({
block: this.board[rowIdx][colIdx],
cellSize: this.cellSize,
onClick: () => {
this.onCellClick(rowIdx, colIdx);
}
})
}
},
(colIdx: number) => `col-${colIdx}`
)
}
},
(rowIdx: number) => `row-${rowIdx}`
)
}
.padding(8)
.backgroundColor('#0f172a')
.borderRadius(16)
.shadow({ radius: 16, color: 'rgba(0,0,0,0.3)', offsetX: 0, offsetY: 6 })
}
}
棋盘背景用深色(#0f172a),方块的颜色在深色背景上对比度更高,视觉效果更好。
七、游戏主页面
主页面负责整合所有组件,处理点击交互、消除逻辑、步数和分数管理:
// pages/GamePage.ets
import { Block, BlockColor, BOARD_SIZE, GameModel } from '../model/GameModel';
import { GameGrid } from '../components/GameGrid';
import { ScoreBoard } from '../components/ScoreBoard';
import { BlockCell } from '../components/BlockCell';
const TOTAL_MOVES = 30; // 每局总步数
@Entry
@Component
struct GamePage {
@State board: Block[][] = [];
@State score: number = 0;
@State bestScore: number = 0;
@State movesLeft: number = TOTAL_MOVES;
@State isGameOver: boolean = false;
@State isProcessing: boolean = false; // 防止动画期间重复点击
@State comboText: string = ''; // 连击提示文字
// 当前选中的方块位置
private selectedRow: number = -1;
private selectedCol: number = -1;
aboutToAppear() {
this.startNewGame();
}
startNewGame() {
this.board = GameModel.initBoard();
this.score = 0;
this.movesLeft = TOTAL_MOVES;
this.isGameOver = false;
this.isProcessing = false;
this.selectedRow = -1;
this.selectedCol = -1;
this.comboText = '';
}
// 处理格子点击
async handleCellClick(row: number, col: number) {
if (this.isProcessing || this.isGameOver) return;
// 没有选中过,选中当前格子
if (this.selectedRow === -1) {
this.selectCell(row, col);
return;
}
// 点击同一个格子,取消选中
if (this.selectedRow === row && this.selectedCol === col) {
this.clearSelection();
return;
}
// 点击相邻格子,尝试交换
if (GameModel.isAdjacent(this.selectedRow, this.selectedCol, row, col)) {
await this.trySwap(this.selectedRow, this.selectedCol, row, col);
} else {
// 不相邻,改为选中新格子
this.clearSelection();
this.selectCell(row, col);
}
}
selectCell(row: number, col: number) {
// 取消上一个选中
if (this.selectedRow !== -1) {
this.board[this.selectedRow][this.selectedCol].isSelected = false;
}
this.board[row][col].isSelected = true;
this.board = [...this.board];
this.selectedRow = row;
this.selectedCol = col;
}
clearSelection() {
if (this.selectedRow !== -1) {
this.board[this.selectedRow][this.selectedCol].isSelected = false;
this.board = [...this.board];
}
this.selectedRow = -1;
this.selectedCol = -1;
}
async trySwap(r1: number, c1: number, r2: number, c2: number) {
this.isProcessing = true;
this.clearSelection();
// 执行交换
GameModel.swap(this.board, r1, c1, r2, c2);
this.board = [...this.board];
// 等待交换动画
await this.delay(200);
// 检查是否有消除
const matched = GameModel.findMatches(this.board);
if (matched.size === 0) {
// 没有消除,换回来
GameModel.swap(this.board, r1, c1, r2, c2);
this.board = [...this.board];
await this.delay(200);
} else {
// 有消除,扣步数,开始连锁消除
this.movesLeft--;
await this.processMatches(matched);
}
this.isProcessing = false;
// 检查游戏是否结束
if (this.movesLeft <= 0) {
this.isGameOver = true;
if (this.score > this.bestScore) {
this.bestScore = this.score;
}
}
}
// 处理消除,支持连锁消除
async processMatches(matched: Set<string>) {
let combo = 0;
while (matched.size > 0) {
combo++;
// 标记消除中的方块
matched.forEach(key => {
const [r, c] = key.split(',').map(Number);
this.board[r][c].isMatched = true;
});
this.board = [...this.board];
await this.delay(300);
// 计算得分
const points = GameModel.calcScore(matched.size) * combo;
this.score += points;
// 显示连击文字
if (combo > 1) {
this.comboText = `${combo}连击!+${points}`;
setTimeout(() => { this.comboText = ''; }, 1000);
}
// 执行消除和下落
GameModel.applyMatches(this.board, matched);
this.board = [...this.board];
await this.delay(300);
// 检查下落后是否触发新的消除
matched = GameModel.findMatches(this.board);
}
}
// 工具:延迟等待
delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
build() {
Column() {
// 顶部标题
Row() {
Text('消消乐')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#1e293b')
Blank()
Button('重新开始')
.fontSize(13)
.fontColor('#7c3aed')
.backgroundColor('#ede9fe')
.borderRadius(20)
.height(34)
.padding({ left: 14, right: 14 })
.onClick(() => this.startNewGame())
}
.width('100%')
.padding({ left: 20, right: 20, top: 16, bottom: 12 })
// 得分面板
ScoreBoard({
score: this.score,
bestScore: this.bestScore,
moves: this.movesLeft
})
.margin({ left: 16, right: 16 })
// 连击提示
if (this.comboText !== '') {
Text(this.comboText)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#f59e0b')
.margin({ top: 8 })
.transition({ type: TransitionType.Insert, opacity: 1 })
} else {
Text(' ').fontSize(20).margin({ top: 8 })
}
// 游戏棋盘
GameGrid({
board: this.board,
cellSize: 44,
gap: 4,
onCellClick: (row: number, col: number) => {
this.handleCellClick(row, col);
}
})
.margin({ top: 12, left: 8, right: 8 })
// 游戏结束遮罩
if (this.isGameOver) {
Column() {
Text('游戏结束').fontSize(28).fontWeight(FontWeight.Bold).fontColor(Color.White)
Text(`最终得分:${this.score}`)
.fontSize(20).fontColor('#facc15').margin({ top: 8 })
if (this.score >= this.bestScore && this.score > 0) {
Text('🎉 新纪录!').fontSize(16).fontColor('#4ade80').margin({ top: 4 })
}
Button('再来一局')
.width(160).height(48)
.backgroundColor('#7c3aed')
.fontColor(Color.White)
.borderRadius(24)
.fontSize(16)
.margin({ top: 24 })
.onClick(() => this.startNewGame())
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.75)')
.position({ x: 0, y: 0 })
}
}
.height('100%')
.backgroundColor('#f1f5f9')
}
}
八、EntryAbility 修改
// entryability/EntryAbility.ets
// onWindowStageCreate 里把页面改为 GamePage
onWindowStageCreate(windowStage: window.WindowStage) {
windowStage.loadContent('pages/GamePage', (err) => {
if (err.code) {
console.error('loadContent failed:', err.message);
}
});
}
九、main_pages.json
{
"src": [
"pages/GamePage"
]
}
十、几个开发细节
10.1 为什么棋盘用 ForEach 嵌套而不是 Grid 组件
HarmonyOS 的 Grid 组件更适合等宽等高的纯展示场景,但游戏棋盘需要精确控制每个格子的点击事件和状态,用 Row + Column 嵌套 ForEach 更灵活,每个格子都是独立的组件实例,状态隔离也更干净。
10.2 @Prop 和数组刷新
ArkTS 里直接修改 this.board[r][c].isSelected = true 不会触发 UI 刷新,必须在修改完之后执行 this.board = [...this.board] 重新赋值,让框架检测到数组引用变化才会重新渲染。这是 ArkTS 状态管理里最需要注意的一点。
10.3 isProcessing 防抖
交换和消除动画期间,如果用户继续点击会导致状态混乱。用 isProcessing 标志位在动画期间屏蔽所有点击,动画结束后再恢复,是游戏开发中很常用的处理方式。
10.4 连锁消除的实现
processMatches 用 while 循环处理连锁消除:每次消除后重新调用 findMatches 检查有没有新的三连,有的话继续消除并叠加连击倍率。循环直到没有新的消除才结束,这样就实现了连锁消除效果。


十一、总结
这个消消乐实现下来,用到的 HarmonyOS 6.0 ArkUI 技术点包括:
- ForEach 嵌套渲染二维网格,用字符串 key 保证组件复用
- @Prop + 数组重新赋值触发状态刷新
- @State scale + animation实现按压弹跳动画
- shadow / border / backgroundColor 随状态动态变化做视觉反馈
- async/await + setTimeout控制动画时序,实现交换→消除→下落的顺序动画
- isProcessing 标志位防止动画期间的重复操作
整个项目代码量不到 500 行,逻辑清晰,可以在这个基础上继续扩展:加音效、加道具方块、加关卡系统都不难。
如果跑起来碰到问题,欢迎评论区留言。
更多推荐

所有评论(0)