一、引言

数字华容道(Sliding Puzzle)是一款有 140 年历史的经典滑块拼图——4×4 的方格里排列着 1 到 15 的数字和一个空格,每次只能将空格相邻的数字滑块推入空格,目标是最终排列成 1-15 的顺序。它由美国邮政局长 Noyes Palmer Chapman 在 1880 年发明,一经问世便风靡欧美,甚至在 1880 年代的美国引发过"15-puzzle fever(15 拼图热潮)"。

从技术角度看,数字华容道是一个空间推理游戏。与井字棋的"回合制落子"和记忆翻牌的"翻牌配对"不同,华容道的核心操作是滑动——每次滑动改变两个格子(空格和数字滑块)的位置。这个看似简单的操作有一个关键的约束:只有与空格相邻的数字滑块才能滑动。因此,玩华容道的过程实际上是在规划一条从初始状态到目标状态的空间路径。

本文用 ArkUI 从零构建一个数字华容道游戏,包含 4×4 滑块拼图、保证可解的随机打乱、步数计时统计和通关检测。15 个数字滑块使用不同颜色标识,空格用浅灰色区分——每种颜色帮助玩家快速定位数字位置。

阅读完本文,你将能够:

  • 用一维数组表示 2D 滑块拼图(number[16]
  • 实现基于随机移动的保证可解打乱算法
  • 计算网格中的相邻关系(上下左右)
  • 用不可变数组更新实现滑动操作
  • 用颜色映射增强 15 个滑块的视觉区分度

二、游戏设计

2.1 规则与目标

数字华容道的规则极为简洁:

  • 4×4 网格中有 15 个数字滑块(1-15)和 1 个空格
  • 每次点击与空格相邻的滑块,滑块滑入空格(等价于空格与滑块交换位置)
  • 目标是将滑块排列为从上到下、从左到右的 1-15 顺序,空格在右下角

目标状态(SOLVED):

 1  2  3  4
 5  6  7  8
 9 10 11 12
13 14 15  _

游戏只有一个操作(点击滑块),但每次操作的"合法空间"是动态变化的——空格在哪里,只有它上下左右四个邻居才是合法点击目标。这个动态约束是华容道与其他点击式游戏(井字棋、记忆翻牌)的本质区别。

2.2 棋盘数据结构

棋盘使用一维数组 number[16] 表示,其中 0 代表空格:

// 目标状态
const SOLVED: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0];

@State tiles: number[] = [];

索引与 4×4 网格的映射:

 0   1   2   3    → 第一行
 4   5   6   7    → 第二行
 8   9  10  11    → 第三行
12  13  14  15    → 第四行

一维数组的选择使所有格子操作(交换、查找、比较)都可以用简单的整数索引完成。indexOf(0) 在 16 个元素的数组中查找空格位置只需要 O(n) 时间,对性能无影响。

2.3 相邻关系计算

给定一个格子的索引,计算其上下左右邻居:

getNeighbors(idx: number): number[] {
  const result: number[] = [];
  if (idx >= 4) result.push(idx - 4);       // 上方邻居(不在第一行)
  if (idx < 12) result.push(idx + 4);       // 下方邻居(不在第四行)
  if (idx % 4 !== 0) result.push(idx - 1);  // 左侧邻居(不在第一列)
  if (idx % 4 !== 3) result.push(idx + 1);  // 右侧邻居(不在最后一列)
  return result;
}

四个边界条件确保不会越界:

  • idx >= 4:只有第二行及之后的格子有上方邻居
  • idx < 12:只有第三行及之前的格子有下方邻居
  • idx % 4 !== 0:只有第二列及之后的格子有左侧邻居
  • idx % 4 !== 3:只有第三列及之前的格子有右侧邻居

这四个条件将"2D 网格边界"映射为"1D 数组索引约束",是华容道所有操作的基础。

2.4 交互流程

一局游戏的交互流程:

  1. 初始打乱:页面加载时自动执行 100 次随机滑动,生成一个保证可解的初始局面
  2. 滑动操作:点击与空格相邻的数字滑块 → 滑块滑入空格 → 步数 +1 → 首次点击启动计时
  3. 持续解题:重复步骤 2,逐步将数字排列归位
  4. 通关:排列达到目标状态 → 计时停止 → 显示通关横幅
  5. 新一局:点击按钮 → 重新打乱,步数和计时清零
    在这里插入图片描述

三、打乱算法

3.1 保证可解性

华容道有一个不明显的数学性质:随机排列 15 个数字后,恰好有一半的排列是"不可解的"——无论你怎么滑动,永远无法达到目标状态。这是因为滑动操作对应排列群中的偶置换,而所有可能的排列中恰好一半是奇置换。

解决这个问题的标准方法是:不从目标状态随机排列数字,而是从目标状态出发,模拟若干次合法的滑动操作。由于每一步滑动都是合法的(等价于空格与邻居交换),从目标状态出发逆向滑动产生的任何状态都必然可以通过正向滑动回到目标状态。

newGame(): void {
  // 从目标状态出发
  const t: number[] = [...SOLVED];

  // 执行 100 次随机合法滑动进行打乱
  for (let step = 0; step < 100; step++) {
    const ei = t.indexOf(0);               // 空格位置
    const neighbors = this.getNeighbors(ei); // 可交换的邻居
    const r = neighbors[Math.floor(Math.random() * neighbors.length)];
    // 交换空格与随机邻居
    const tmp = t[ei];
    t[ei] = t[r];
    t[r] = tmp;
  }

  this.tiles = t;
  this.moves = 0;
  this.elapsedSec = 0;
  this.gameStarted = false;
  this.gameWon = false;
}

为什么是 100 次? 100 是一个经验值。经过测试,100 次随机滑动产生的初始状态具有足够的混乱度——大部分数字远离目标位置,玩家需要 40-80 步才能完成复原。如果打乱步数太少(如 20 次),初始状态接近目标,游戏太简单;如果太多(如 500 次),不仅浪费计算时间,而且不会显著增加混乱度——随机游走在大约 80-100 步后就接近均匀分布了。

3.2 与 Fisher-Yates 的对比

前面的文章多次使用 Fisher-Yates 洗牌对数组进行随机排列。但华容道不能使用 Fisher-Yates——因为 Fisher-Yates 产生的随机排列有 50% 的概率是不可解的。这里的"随机滑动打乱"是另一种随机化方式:它不是对数组元素的重新排列,而是对游戏状态的随机演化。两者都是"随机化",但适用于不同的约束条件。
在这里插入图片描述

四、滑动操作

4.1 moveTile 方法

点击滑块后执行的核心逻辑:

moveTile(idx: number): void {
  // 守卫条件
  if (this.gameWon) return;           // 已通关
  if (this.tiles[idx] === 0) return;  // 点击的是空格
  if (!this.isAdjacent(idx)) return;  // 不与空格相邻

  // 首次移动启动计时
  if (!this.gameStarted) {
    this.gameStarted = true;
    this.timerId = setInterval(() => { this.elapsedSec++; }, 1000);
  }

  // 交换滑块与空格(不可变更新)
  const ei = this.tiles.indexOf(0);
  const newTiles: number[] = [];
  for (let i = 0; i < this.tiles.length; i++) {
    if (i === idx) newTiles.push(0);           // 滑块移走,空格占据此位置
    else if (i === ei) newTiles.push(this.tiles[idx]); // 空格原位置填入滑块数字
    else newTiles.push(this.tiles[i]);          // 其余位置不变
  }
  this.tiles = newTiles;
  this.moves++;

  // 通关检测
  let win = true;
  for (let i = 0; i < SOLVED.length; i++) {
    if (newTiles[i] !== SOLVED[i]) { win = false; break; }
  }
  if (win) {
    clearInterval(this.timerId);
    this.timerId = -1;
    this.gameWon = true;
  }
}

整个方法分为五个阶段:

  • 守卫条件(三道防线):游戏已结束、点击空格、点击不相邻的格子——任一条件满足则忽略
  • 计时启动:首次合法移动时启动 1 秒间隔的计时器
  • 数组更新:创建全新的 16 元素数组,交换空格位置和目标位置的值
  • 步数累加:每次合法移动使 moves++
  • 通关检测:逐元素比较当前排列与 SOLVED,全等则通关

4.2 不可变数组更新

moveTile 中的数组更新方式与前面所有 Demo 一致:

const newTiles: number[] = [];
for (let i = 0; i < this.tiles.length; i++) {
  if (i === idx) newTiles.push(0);
  else if (i === ei) newTiles.push(this.tiles[idx]);
  else newTiles.push(this.tiles[i]);
}
this.tiles = newTiles;

每次滑动创建一个全新的 16 元素数组,然后整体赋值给 @State tiles。这种模式经过前面 15 篇 Demo 的验证,是 ArkUI 状态更新的可靠方式——只有替换整个数组引用,ForEach 才会触发完整的重新渲染。

五、UI 设计

5.1 信息架构

页面从上到下分为四个区域:

┌────────────────────────────┐
│  🧩 数字华容道(深色标题栏)   │
├────────────────────────────┤
│   步数 12       用时 01:23  │  ← 统计栏
├────────────────────────────┤
│ ┌───┬───┬───┬───┐        │
│ │ 1 │ 3 │ 5 │ 7 │        │  ← 4×4 滑块拼图
│ ├───┼───┼───┼───┤        │
│ │ 2 │ 4 │ 6 │ 8 │        │    15 个彩色数字块
│ ├───┼───┼───┼───┤        │    + 1 个灰色空格
│ │ 9 │10 │   │12 │        │
│ ├───┼───┼───┼───┤        │
│ │13 │14 │11 │15 │        │
│ └───┴───┴───┴───┘        │
├────────────────────────────┤
│      🔄 新一局              │
└────────────────────────────┘

5.2 棋盘构建

4×4 棋盘使用双层 ForEach 构建——外层遍历行(0-3),内层遍历列(0-3):

Column() {
  ForEach(this.rowsArr, (row: number, ri: number) => {
    Row() {
      ForEach(this.rowsArr, (col: number, ci: number) => {
        Column() {
          if (this.tiles[ri * 4 + ci] !== 0) {
            Text(`${this.tiles[ri * 4 + ci]}`)
              .fontSize(20)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
          }
        }
        .width(64)
        .height(64)
        .backgroundColor(this.tileColor(this.tiles[ri * 4 + ci]))
        .borderRadius(BorderRadius.MD)
        .onClick(() => { this.moveTile(ri * 4 + ci); })
      }, (col: number, ci: number) => `${ci}`)
    }
  }, (row: number, ri: number) => `${ri}`)
}

每个格子 64vp × 64vp,BorderRadius.MD(8vp 圆角),20sp 白色粗体数字。空格显示为空(无文字内容),背景色为浅灰 #D8D8E0,形成明显的"缺失"感——引导玩家将注意力集中在空格附近的滑块上。

5.3 颜色映射

15 个数字滑块使用 15 种不同的背景色:

const TILE_COLORS: string[] = [
  '#667eea', '#1677FF', '#52C41A', '#FF9800',
  '#FF4D4F', '#9C27B0', '#00BCD4', '#FF6F00',
  '#607D8B', '#E91E63', '#3F51B5', '#009688',
  '#FF5722', '#4CAF50', '#2196F3'
];

每块颜色通过 (v - 1) % 15 映射到对应索引。15 种颜色覆盖了色环的各个角度——蓝色系(#667eea, #1677FF, #2196F3)、绿色系(#52C41A, #4CAF50, #009688)、暖色系(#FF9800, #FF5722, #FF4D4F)、紫色系(#9C27B0, #E91E63)——确保相邻数字有足够的色相差异以区分。

多种颜色的引入不仅是为了美观。在华容道中,玩家需要快速定位特定数字(“15 在哪里?”),颜色提供了比数字文字更快的视觉定位——在 16 个格子中寻找紫色块比在 16 个数字中寻找"15"更快。颜色是视觉搜索的索引。

六、完整代码结构

SlidingPuzzlePage (~210 行)
├── 常量定义
│   ├── SOLVED[16] — 目标排列
│   └── TILE_COLORS[15] — 数字颜色映射
├── 状态变量
│   ├── @State tiles[16] — 棋盘(0=空格)
│   ├── @State moves / elapsedSec — 步数和用时
│   └── @State gameStarted / gameWon — 游戏阶段
├── 空间计算
│   ├── getNeighbors() — 计算上下左右邻居
│   └── isAdjacent() — 判断是否与空格相邻
├── 游戏逻辑
│   ├── newGame() — 100步随机打乱 + 初始化
│   └── moveTile() — 滑动(守卫→更新→检测)
├── 视图
│   ├── 标题栏 — 🧩 数字华容道
│   ├── 统计栏 — 步数 + 用时
│   ├── 通关横幅(条件渲染)
│   ├── 4×4 棋盘 — 双层ForEach网格
│   └── 新一局按钮
└── 生命周期
    └── aboutToDisappear() — 清理计时器

七、总结

本文从零构建了一个数字华容道游戏。与井字棋(策略博弈)和记忆翻牌(记忆力挑战)不同,华容道的核心是空间推理——每次滑动都在改变棋盘的空间配置,玩家的思维过程是在 4×4 的几何空间中进行路径规划。从技术角度看,华容道也是相邻关系计算和保证可解随机化的典型示例。

核心要点回顾:

  1. 一维数组表示 2D 网格number[16] 的索引通过 row*4+col 映射到 4×4 网格。indexOf(0) 快速定位空格位置,边界条件(idx >= 4idx < 12idx % 4 !== 0idx % 4 !== 3)将 2D 边界映射为 1D 索引约束。

  2. 保证可解的打乱:从目标状态出发,模拟 100 次随机合法滑动。这种"正向打乱"方法天然保证可解性(因为逆向就是解法),避免了 Fisher-Yates 随机排列中 50% 不可解的问题。100 次滑动的选择是在"足够混乱"和"不浪费计算"之间的平衡。

  3. 相邻关系动态计算getNeighbors() 每次基于空格当前位置计算四个方向的合法邻居。这个计算在打乱时执行 100 次,在游戏过程中通过 isAdjacent() 为每次点击提供守卫。

  4. 不可变数组更新moveTile() 中创建全新的 16 元素数组,交换两个位置的元素后整体赋值给 @State tiles。这种模式被前面 15 个 Demo 反复验证为 ArkUI 状态更新的可靠方式。

  5. 15 色映射:15 个数字滑块使用 15 种不同的背景色,覆盖蓝/绿/暖/紫四个色系。颜色不仅是视觉装饰,更是快速的视觉定位索引——在 16 个格子中找到特定颜色比找到特定数字更快。

  6. 动态合法区域:与其他点击式游戏不同,华容道的合法点击区域是动态变化的。空格在左上角时只有两个合法邻居(右和下),在中心时有四个(上下左右),在边缘时有三个。这个动态约束使游戏的守卫条件比其他点击式游戏更复杂。

数字华容道是一款"规则简单、解法无限"的经典游戏。这个 210 行的 ArkUI 实现抓住了它的核心乐趣:随机打乱带来的混沌感、滑动空格时的"实物移动"感、以及最终排列整齐时的秩序满足感。它是本系列第三篇游戏类文章,也是相邻关系计算和保证可解随机化的完整示例。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐