前言

拼图游戏这种案例,表面上看像是一个轻量互动页面,实际上很适合拿来练状态建模。因为它不像普通表单那样只有“输入”和“提交”,而是需要持续追踪棋盘布局、可移动范围、计时器、步数统计和完成判定。只要其中一条状态链没有理顺,游戏就会出现各种不一致的问题。

这个 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 思路打乱位置。
  • 生成带有 idcurrentPosisEmpty 的方块集合。
  • 清空步数、完成状态和计时。
  • 清理旧的定时器句柄。

换句话说,initGame() 并不是简单把几个字段设回默认值,而是在重新构造一个新的棋盘世界。这种函数在互动类页面里特别重要,因为它往往决定了“重新开始”是否真的彻底。

很多人做小游戏时常见的问题,就是按钮点了叫重开,但计时器没停、步数没清、旧布局还残留。这个示例把这些收尾动作都放进了初始化函数里,结构上是对的。

能不能移动,不是看点击了谁,而是看它和空白格的关系

拼图游戏的关键规则很简单:只有空白格上下左右相邻的方块才能移动。源码把这条规则拆成两步:

  1. 先找出空白格当前位置。
  2. 再判断目标位置和空白格之间的曼哈顿距离是不是 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() 就负责执行规则。它的执行顺序非常像一个标准状态流:

  1. 先找到当前被点击的方块。
  2. 如果点的是空白格,直接返回。
  3. 判断它当前是否允许移动。
  4. 第一次移动时启动计时器。
  5. 交换目标方块和空白格的位置。
  6. 步数加一。
  7. 再去检查是否完成。

这个顺序值得仔细看,因为它说明一个交互动作不是“点一下就改个值”,而是一串有先后关系的状态变化。尤其是“先启动计时器,再交换位置,再检查完成”这种顺序,决定了统计是不是准确、完成提示会不会晚一拍。

从教学角度讲,这也是这篇文章最适合深入的地方。很多状态管理案例都停留在“点按钮改状态”,而这个函数展示的是更完整的交互事务。

计时器处理得很克制,这反而是优点

游戏页里计时器经常是 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() 不只是给点击逻辑用,还被拿来做视觉反馈。

这就是一个很成熟的交互思路:同一条业务规则,同时服务可点击性和可理解性。用户不用试错就知道哪些块能动,代码也不用再额外维护一套“高亮状态”。

这种写法很适合在教程里强调,因为它说明好的状态建模能自然长出更好的交互表现,而不是先做一套逻辑,再额外补一套提示。

如果继续扩展,这个案例会往“小型状态机”方向发展

A hand-drawn doodle illustration on pure white pap

当前版本已经能完整跑通一局游戏,但如果继续做深,很自然会往下面几个方向演化:

  • 增加“打乱是否合法”的校验,避免出现无解棋盘。
  • 给“查看目标”按钮补真正的目标预览弹层。
  • 增加难度级别,把 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)
  }
}

A hand-drawn doodle illustration on pure white pap

A hand-drawn doodle illustration on pure white pap

最后总结

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐