HarmonyOS7九宫格拼图游戏实战:棋盘状态更新与完成检测
文章目录
前言
拼图游戏这种案例,表面上看像是一个轻量互动页面,实际上很适合拿来练状态建模。因为它不像普通表单那样只有“输入”和“提交”,而是需要持续追踪棋盘布局、可移动范围、计时器、步数统计和完成判定。只要其中一条状态链没有理顺,游戏就会出现各种不一致的问题。
这个 HarmonyOS 示例的价值就在这里:它没有追求复杂玩法,而是用一个 3x3 九宫格,把最基本的一套游戏状态机搭了出来。对初学者来说,这种体量刚好,既能看清楚数据怎么流动,又不会被太多额外机制分散注意力。
这个案例最值得看的,是“棋盘”被当成数据来处理
很多人写拼图类页面时,第一反应是先画九个格子,然后再想怎么让它动。这样的思路很容易把重点放错,因为真正决定游戏能不能跑起来的,不是格子长什么样,而是每一个方块当前处在什么位置。
这份源码一开始就把问题抓得很准:它没有把棋盘当成一组静态 UI,而是把它建模成一组 PuzzleTile 数据。每个方块知道自己的 id、当前位置 currentPos,以及自己是不是空白格 isEmpty。
这样一来,界面只是数据的投影,移动逻辑也变成了纯粹的数据变换。对游戏类页面来说,这是一个非常关键的思维转变。
核心状态其实就五个,但覆盖了整局游戏
这页的状态字段不少,但都很有必要:
@State tiles: PuzzleTile[] = []
@State moveCount: number = 0
@State isCompleted: boolean = false
@State seconds: number = 0
@State gameStarted: boolean = false
private timerHandle: number = -1
如果你按职责去看,会发现它们刚好对应一局游戏里的几个核心问题:
tiles:当前棋盘长什么样。moveCount:玩家已经走了多少步。isCompleted:当前局是不是已经完成。seconds:从正式开始到现在用了多久。gameStarted:计时器是不是已经启动过。timerHandle:当前计时任务的控制句柄。
这组状态设计有一个明显优点:没有把展示字段和游戏字段混在一起。比如“蓝色边框表示可移动”并没有单独存成状态,而是运行时根据棋盘关系计算出来。这样能避免状态爆炸,也更接近真正的游戏逻辑。
initGame() 不是简单的重置,它在重新定义一局游戏的初始世界
这个案例里最重要的函数之一,就是 initGame()。它做的事情看似是“重新开始”,实际上包含了整局游戏的初始化流程:
- 根据
gridSize生成位置数组。 - 用 Fisher-Yates 思路打乱位置。
- 生成带有
id、currentPos和isEmpty的方块集合。 - 清空步数、完成状态和计时。
- 清理旧的定时器句柄。
换句话说,initGame() 并不是简单把几个字段设回默认值,而是在重新构造一个新的棋盘世界。这种函数在互动类页面里特别重要,因为它往往决定了“重新开始”是否真的彻底。
很多人做小游戏时常见的问题,就是按钮点了叫重开,但计时器没停、步数没清、旧布局还残留。这个示例把这些收尾动作都放进了初始化函数里,结构上是对的。
能不能移动,不是看点击了谁,而是看它和空白格的关系
拼图游戏的关键规则很简单:只有空白格上下左右相邻的方块才能移动。源码把这条规则拆成两步:
- 先找出空白格当前位置。
- 再判断目标位置和空白格之间的曼哈顿距离是不是 1。
canMove(pos: number): boolean {
const emptyPos = this.getEmptyPos()
const row = Math.floor(pos / this.gridSize)
const col = pos % this.gridSize
const emptyRow = Math.floor(emptyPos / this.gridSize)
const emptyCol = emptyPos % this.gridSize
return (Math.abs(row - emptyRow) + Math.abs(col - emptyCol)) === 1
}
这段代码很适合拿来讲“规则表达”。因为它不是写一堆 if 去硬判断左边右边上边下边,而是先把位置转换成二维坐标,再用统一数学关系判断是否相邻。
这种写法的好处是:
- 逻辑更清楚,不容易漏掉边界。
- 如果以后从 3x3 扩成 4x4 或 5x5,规则函数几乎不用重写。
- UI 和规则彻底分开,界面换样式也不影响移动判断。
这就是教程里应该重点讲清楚的地方。读者真正需要学到的,不是“这里有个 canMove()”,而是“规则最好用统一模型表达”。
moveTile() 体现了完整的单步状态流转
如果说 canMove() 负责定义规则,那 moveTile() 就负责执行规则。它的执行顺序非常像一个标准状态流:
- 先找到当前被点击的方块。
- 如果点的是空白格,直接返回。
- 判断它当前是否允许移动。
- 第一次移动时启动计时器。
- 交换目标方块和空白格的位置。
- 步数加一。
- 再去检查是否完成。
这个顺序值得仔细看,因为它说明一个交互动作不是“点一下就改个值”,而是一串有先后关系的状态变化。尤其是“先启动计时器,再交换位置,再检查完成”这种顺序,决定了统计是不是准确、完成提示会不会晚一拍。
从教学角度讲,这也是这篇文章最适合深入的地方。很多状态管理案例都停留在“点按钮改状态”,而这个函数展示的是更完整的交互事务。
计时器处理得很克制,这反而是优点
游戏页里计时器经常是 bug 重灾区。这个示例没有搞复杂封装,但把关键边界都照顾到了:
- 页面出现时先初始化棋盘。
- 第一次有效移动时才开始计时,而不是一进页面就计时。
- 页面离开时清掉定时器。
- 拼图完成后也会停止定时器。
- 重新开始时如果旧计时器还在,也会先清理。
这说明作者对“定时器属于资源”这件事是有意识的。很多教程只会写 setInterval,却不提醒读者清理时机。对于 ArkUI 页面来说,生命周期里的资源释放同样属于必须讲的工程常识。
完成判定为什么要按 id 排序后再检查位置
checkCompletion() 这段也很典型:
checkCompletion() {
const sorted = this.tiles.slice().sort((a: PuzzleTile, b: PuzzleTile) => a.id - b.id)
const isWin = sorted.every((t: PuzzleTile, idx: number) => t.currentPos === idx)
if (isWin) {
this.isCompleted = true
if (this.timerHandle !== -1) {
clearInterval(this.timerHandle)
this.timerHandle = -1
}
}
}
这里不是直接遍历当前 tiles 顺序,而是先按 id 排序,再判断每个方块是不是回到了自己的目标位置。这样做说明“数组里的存储顺序”和“棋盘上的展示位置”是两回事。
这点很关键。因为当前实现里,tiles 存的是方块对象集合,真正决定显示在哪的是 currentPos。如果把这层关系讲明白,读者会更容易理解为什么要分开保存“方块是谁”和“方块在哪”。
tileCellBuilder() 把规则结果直接投影成交互提示
这个示例还有一个很好的细节:可移动方块会显示蓝色边框和浅蓝背景。也就是说,canMove() 不只是给点击逻辑用,还被拿来做视觉反馈。
这就是一个很成熟的交互思路:同一条业务规则,同时服务可点击性和可理解性。用户不用试错就知道哪些块能动,代码也不用再额外维护一套“高亮状态”。
这种写法很适合在教程里强调,因为它说明好的状态建模能自然长出更好的交互表现,而不是先做一套逻辑,再额外补一套提示。
如果继续扩展,这个案例会往“小型状态机”方向发展

当前版本已经能完整跑通一局游戏,但如果继续做深,很自然会往下面几个方向演化:
- 增加“打乱是否合法”的校验,避免出现无解棋盘。
- 给“查看目标”按钮补真正的目标预览弹层。
- 增加难度级别,把
gridSize扩成 4x4、5x5。 - 接排行榜或最佳成绩记录,增加历史统计。
- 把棋盘逻辑抽成独立模块,页面层只负责渲染和事件转发。
尤其是最后一点,到了正式项目阶段,游戏规则层和 UI 层最好拆开。当前示例放在一个文件里便于教学,但如果功能继续涨,解耦会非常有价值。
完整代码
下面保留案例完整代码,方便你直接对照学习、复制和重构。
// 九宫格拼图游戏: 拼图游戏 - 3x3图片拼图、移动计数、完成检测
interface PuzzleTile {
id: number
currentPos: number
isEmpty: boolean
}
@Entry
@Component
struct SlidingPuzzleGame {
@State tiles: PuzzleTile[] = []
@State moveCount: number = 0
@State isCompleted: boolean = false
@State seconds: number = 0
@State gameStarted: boolean = false
private timerHandle: number = -1
private emojiBoard: string[] = ['🌸', '🌺', '🌻', '🌹', '🌷', '🌼', '🍀', '🌿']
private gridSize: number = 3
aboutToAppear() {
this.initGame()
}
aboutToDisappear() {
if (this.timerHandle !== -1) {
clearInterval(this.timerHandle)
}
}
initGame() {
const count: number = this.gridSize * this.gridSize
const positions: number[] = []
for (let i = 0; i < count; i++) {
positions.push(i)
}
// 简单打乱(Fisher-Yates)
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = positions[i]
positions[i] = positions[j]
positions[j] = tmp
}
this.tiles = positions.map((pos: number, idx: number): PuzzleTile => ({
id: idx,
currentPos: pos,
isEmpty: idx === count - 1,
}))
this.moveCount = 0
this.isCompleted = false
this.seconds = 0
this.gameStarted = false
if (this.timerHandle !== -1) {
clearInterval(this.timerHandle)
this.timerHandle = -1
}
}
startTimer() {
if (!this.gameStarted) {
this.gameStarted = true
this.timerHandle = setInterval(() => {
this.seconds++
}, 1000)
}
}
getTileAtPos(pos: number): PuzzleTile | undefined {
return this.tiles.find((t: PuzzleTile) => t.currentPos === pos)
}
getEmptyPos(): number {
const empty = this.tiles.find((t: PuzzleTile) => t.isEmpty)
return empty ? empty.currentPos : -1
}
canMove(pos: number): boolean {
const emptyPos = this.getEmptyPos()
const row = Math.floor(pos / this.gridSize)
const col = pos % this.gridSize
const emptyRow = Math.floor(emptyPos / this.gridSize)
const emptyCol = emptyPos % this.gridSize
return (Math.abs(row - emptyRow) + Math.abs(col - emptyCol)) === 1
}
moveTile(tileId: number) {
const tile = this.tiles.find((t: PuzzleTile) => t.id === tileId)
if (tile === undefined || tile.isEmpty) {
return
}
const tileCurrentPos: number = tile.currentPos
if (!this.canMove(tileCurrentPos)) {
return
}
this.startTimer()
const emptyPos: number = this.getEmptyPos()
this.tiles = this.tiles.map((t: PuzzleTile): PuzzleTile => {
if (t.id === tileId) {
t.currentPos = emptyPos
} else if (t.isEmpty) {
t.currentPos = tileCurrentPos
}
return t
})
this.moveCount++
this.checkCompletion()
}
checkCompletion() {
const sorted = this.tiles.slice().sort((a: PuzzleTile, b: PuzzleTile) => a.id - b.id)
const isWin = sorted.every((t: PuzzleTile, idx: number) => t.currentPos === idx)
if (isWin) {
this.isCompleted = true
if (this.timerHandle !== -1) {
clearInterval(this.timerHandle)
this.timerHandle = -1
}
}
}
getPositions(): number[] {
const arr: number[] = []
const total: number = this.gridSize * this.gridSize
for (let i = 0; i < total; i++) {
arr.push(i)
}
return arr
}
formatTime(s: number): string {
const m = Math.floor(s / 60)
const sec = s % 60
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
@Builder tileCellBuilder(pos: number) {
if (this.getTileAtPos(pos) !== undefined && !this.getTileAtPos(pos)!.isEmpty) {
Text(this.emojiBoard[this.getTileAtPos(pos)!.id] ?? '?')
.fontSize(36)
.width('100%').height('100%')
.textAlign(TextAlign.Center)
.lineHeight(90)
.backgroundColor(this.canMove(pos) ? '#E8F4FF' : '#FFFFFF')
.borderRadius(10)
.border({ width: 2, color: this.canMove(pos) ? '#007AFF' : '#E0E0E0' })
.shadow({ radius: 3, color: '#00000015', offsetX: 0, offsetY: 2 })
.onClick(() => this.moveTile(this.getTileAtPos(pos)!.id))
} else {
Column()
.width('100%').height('100%')
.backgroundColor('#F5F5F5')
.borderRadius(10)
}
}
build() {
Column() {
Text('拼图游戏').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
.margin({ top: 30, bottom: 20 })
// 统计行
Row({ space: 32 }) {
Column() {
Text(String(this.moveCount)).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#007AFF')
Text('步数').fontSize(12).fontColor('#888888')
}
Column() {
Text(this.formatTime(this.seconds)).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FF9500')
Text('用时').fontSize(12).fontColor('#888888')
}
}
.margin({ bottom: 24 })
// 完成提示
if (this.isCompleted) {
Column() {
Text('🎉 恭喜完成!').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#34C759')
Text(`用了 ${this.moveCount} 步,耗时 ${this.formatTime(this.seconds)}`)
.fontSize(13).fontColor('#666666').margin({ top: 4 })
}
.padding({ left: 20, right: 20, top: 12, bottom: 12 })
.backgroundColor('#EEFBF2')
.borderRadius(12)
.margin({ bottom: 16 })
}
// 拼图棋盘
Grid() {
ForEach(this.getPositions(), (pos: number) => {
GridItem() {
Column() {
this.tileCellBuilder(pos)
}
.width('100%').height('100%')
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.width(300)
.height(300)
.padding(4)
.backgroundColor('#F0F0F0')
.borderRadius(16)
// 提示
Column({ space: 4 }) {
Text('提示:点击空格旁边的方块移动')
.fontSize(12).fontColor('#AAAAAA')
Row({ space: 6 }) {
Column().width(12).height(12).backgroundColor('#E8F4FF').borderRadius(2).border({ width: 1.5, color: '#007AFF' })
Text('蓝色边框 = 可移动').fontSize(11).fontColor('#AAAAAA')
}
.margin({ top: 2 })
}
.margin({ top: 16 })
Blank()
// 操作按钮
Row({ space: 16 }) {
Button('重新开始')
.fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#FF3B30').borderRadius(20).height(44)
.padding({ left: 24, right: 24 })
.onClick(() => this.initGame())
Button('查看目标')
.fontSize(14).fontColor('#007AFF')
.backgroundColor('#EEF6FF').borderRadius(20).height(44)
.padding({ left: 24, right: 24 })
}
.margin({ bottom: 40 })
}
.width('100%').height('100%')
.backgroundColor('#FAFAFA')
.alignItems(HorizontalAlign.Center)
}
}


最后总结
这篇拼图示例最值得学的,是它把“棋盘当前状态”“可移动规则”“单步交互”“完成判定”串成了一条完整的数据链。只要你理解了这套写法,后面再做任何轻量互动游戏,都会更容易把玩法规则写稳,而不是被界面牵着走。
更多推荐

所有评论(0)