通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip
前言
数字拼图(15 Puzzle)是一个经典的滑块游戏,把打乱的数字按顺序排好。
打乱是游戏的起点,但不能随便打乱------有些排列是无解的。这篇来聊聊怎么正确地打乱拼图。

数据结构
dart
static const int size = 4;
late List<int> tiles;
这里定义了两个关键变量:
size是一个编译时常量,值为4,表示拼图是4x4的网格tiles是一个延迟初始化的整数列表,用来存储16个格子的值
用一维数组而不是二维数组来存储,是因为一维数组操作更简单,而且GridView本身就是用线性索引的。
0表示空格,1-15是数字。空格是可以移动的位置,数字块需要滑动到空格位置。
初始化
dart
tiles = List.generate(size * size, (i) => i);
List.generate是Dart创建列表的便捷方法:
- 第一个参数
size * size是列表长度,4x4=16 - 第二个参数是一个函数,接收索引i,返回该位置的值
这行代码生成[0, 1, 2, ..., 15],0在第一个位置。初始状态下,0(空格)在左上角。
打乱方法
dart
void _shuffle() {
final random = Random();
for (int i = 0; i < 100; i++) {
int empty = tiles.indexOf(0);
List<int> neighbors = [];
if (empty % size > 0) neighbors.add(empty - 1);
if (empty % size < size - 1) neighbors.add(empty + 1);
if (empty >= size) neighbors.add(empty - size);
if (empty < size * (size - 1)) neighbors.add(empty + size);
int swap = neighbors[random.nextInt(neighbors.length)];
tiles[empty] = tiles[swap];
tiles[swap] = 0;
}
}
这个方法是打乱拼图的核心。让我逐段解释:
创建随机数生成器
dart
final random = Random();
Random()创建一个随机数生成器实例。final表示这个变量只赋值一次,但Random对象本身是可变的(可以生成不同的随机数)。
模拟移动循环
dart
for (int i = 0; i < 100; i++) {
循环100次,每次模拟一次合法的滑动操作。
不是随机打乱数组,而是模拟100次合法移动。这样保证打乱后的状态一定有解------因为是从有解状态(初始状态)通过合法移动得到的。
找到空格
dart
int empty = tiles.indexOf(0);
indexOf方法返回元素在列表中的索引。这里找到0(空格)的位置。
因为每次移动后空格位置会变,所以每次循环都要重新找。
找相邻格子
dart
List<int> neighbors = [];
if (empty % size > 0) neighbors.add(empty - 1); // 左
if (empty % size < size - 1) neighbors.add(empty + 1); // 右
if (empty >= size) neighbors.add(empty - size); // 上
if (empty < size * (size - 1)) neighbors.add(empty + size); // 下
这段代码找出空格的相邻格子(可以移动到空格的格子)。
边界检查的逻辑:
-
左边界 :
empty % size > 0empty % size得到空格在当前行的列号(0-3)- 如果列号大于0,说明不在最左列,左边有格子
- 左边格子的索引是
empty - 1
-
右边界 :
empty % size < size - 1- 如果列号小于3(size-1),说明不在最右列,右边有格子
- 右边格子的索引是
empty + 1
-
上边界 :
empty >= size- 如果索引大于等于4(size),说明不在第一行,上面有格子
- 上面格子的索引是
empty - size(往上一行就是减4)
-
下边界 :
empty < size * (size - 1)- 如果索引小于12(4x3),说明不在最后一行,下面有格子
- 下面格子的索引是
empty + size
💡 这个边界检查我调试了好几次 。一开始下边界写成
empty < size * size - 1,结果最后一行中间的格子也不能往下移了。正确的应该是size * (size - 1),也就是12,最后一行的起始索引。
随机选择并交换
dart
int swap = neighbors[random.nextInt(neighbors.length)];
tiles[empty] = tiles[swap];
tiles[swap] = 0;
这三行完成实际的交换:
random.nextInt(neighbors.length)生成0到neighbors.length-1的随机整数neighbors[swap]取出随机选中的相邻格子索引- 把相邻格子的值移到空格位置
- 把相邻格子设为0(变成新的空格)
为什么是100次
100次移动足够打乱,但不会太慢。
我试过不同的次数:
- 20次:打乱不够,经常几步就能还原
- 50次:还行,但有时候太简单
- 100次:比较合适,难度适中
- 200次:没必要,100次已经够乱了
100是个经验值,你也可以根据需要调整。
为什么不能随机打乱
如果直接用tiles.shuffle()随机打乱,有50%的概率生成无解的排列。
dart
// 错误的做法!
tiles.shuffle(); // 可能生成无解的排列
数字拼图有个数学性质:只有"逆序数"为偶数的排列才有解。
什么是逆序数
逆序数是指:对于每对数字(i, j),如果i在j前面但i > j,就是一个逆序。
举个例子,排列[2, 1, 3]:
- 2在1前面,2 > 1,这是一个逆序
- 2在3前面,2 < 3,不是逆序
- 1在3前面,1 < 3,不是逆序
- 逆序数 = 1(奇数)
排列[2, 3, 1]:
- 2在3前面,2 < 3,不是逆序
- 2在1前面,2 > 1,这是一个逆序
- 3在1前面,3 > 1,这是一个逆序
- 逆序数 = 2(偶数)
为什么逆序数决定有解性
这涉及到群论的知识。简单说,每次合法移动(滑动一个数字块)会改变逆序数的奇偶性。
初始状态[1,2,3,...,15,0]的逆序数是0(偶数)。如果目标状态的逆序数是奇数,就永远无法通过合法移动达到。
计算逆序数的代码:
dart
int countInversions(List<int> arr) {
int inversions = 0;
for (int i = 0; i < arr.length; i++) {
for (int j = i + 1; j < arr.length; j++) {
if (arr[i] != 0 && arr[j] != 0 && arr[i] > arr[j]) {
inversions++;
}
}
}
return inversions;
}
但这个计算比较麻烦,不如直接用模拟移动的方法,简单又保证有解。
拼图渲染
dart
itemBuilder: (_, i) {
int value = tiles[i];
if (value == 0) return Container(decoration: BoxDecoration(color: Colors.brown[200], borderRadius: BorderRadius.circular(4)));
return GestureDetector(
onTap: () => _tap(i),
child: Container(
decoration: BoxDecoration(
color: Colors.blue[400],
borderRadius: BorderRadius.circular(4),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 2, offset: const Offset(1, 1))],
),
child: Center(child: Text('$value', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white))),
),
);
},
这是GridView.builder的itemBuilder回调,负责构建每个格子的Widget。
获取格子值
dart
int value = tiles[i];
i是GridView的线性索引(0-15),从tiles数组取出对应的值。
空格渲染
dart
if (value == 0) return Container(decoration: BoxDecoration(color: Colors.brown[200], borderRadius: BorderRadius.circular(4)));
空格用浅棕色Container,和背景接近但能区分。
Colors.brown[200]是Material Design的棕色色板中较浅的一个borderRadius: BorderRadius.circular(4)给四个角加4像素的圆角
空格不需要GestureDetector,因为点击空格没有意义。
数字块渲染
dart
return GestureDetector(
onTap: () => _tap(i),
child: Container(
decoration: BoxDecoration(
color: Colors.blue[400],
borderRadius: BorderRadius.circular(4),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 2, offset: const Offset(1, 1))],
),
child: Center(child: Text('$value', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white))),
),
);
数字块的结构:
- GestureDetector: 包裹整个块,检测点击事件
- onTap : 点击时调用
_tap(i),传入当前索引 - Container: 数字块的容器
- BoxDecoration: 装饰,包括颜色、圆角、阴影
- Center + Text: 居中显示数字
阴影的参数:
color: Colors.black26:26%透明度的黑色blurRadius: 2:模糊半径2像素offset: const Offset(1, 1):向右下偏移1像素
棋盘背景
dart
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.brown[300], borderRadius: BorderRadius.circular(8)),
棋盘容器的设置:
- margin: 外边距16像素,让棋盘不贴着屏幕边缘
- padding: 内边距4像素,让数字块不贴着棋盘边缘
- color: 棕色背景,模拟木质拼图板
- borderRadius: 8像素圆角,比数字块的圆角大一点
padding配合GridView的间距,形成网格线效果。
GridView间距
dart
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: size, mainAxisSpacing: 4, crossAxisSpacing: 4),
GridView的布局配置:
- crossAxisCount: size: 每行4个格子
- mainAxisSpacing: 4: 主轴(垂直)方向间距4像素
- crossAxisSpacing: 4: 交叉轴(水平)方向间距4像素
4像素间距露出棕色背景,形成网格效果。
胜利条件
dart
bool _checkWin() {
for (int i = 0; i < tiles.length - 1; i++) {
if (tiles[i] != i + 1) return false;
}
return tiles.last == 0;
}
这个方法检查拼图是否完成:
检查前15个位置
dart
for (int i = 0; i < tiles.length - 1; i++) {
if (tiles[i] != i + 1) return false;
}
遍历索引0-14,检查每个位置的值是否等于索引+1。
- 索引0应该是1
- 索引1应该是2
- ...
- 索引14应该是15
任何一个不对就返回false。
检查最后一个位置
dart
return tiles.last == 0;
最后一个位置(索引15)应该是0(空格)。
正确顺序是[1, 2, 3, ..., 15, 0]。
完整的游戏初始化
dart
void _initGame() {
tiles = List.generate(size * size, (i) => i);
moves = 0;
_shuffle();
}
初始化游戏的步骤:
- 生成有序数组[0, 1, 2, ..., 15]
- 重置步数为0
- 调用_shuffle打乱
注意_shuffle是在有序数组基础上打乱的,这保证了有解。
小结
这篇讲了数字拼图的打乱排列,核心知识点:
- 一维数组: 用16个元素表示4x4拼图,比二维数组操作更简单
- 模拟移动打乱: 从有解状态出发,通过合法移动打乱,保证结果有解
- 边界检查: 用取余和比较判断相邻格子,注意边界条件
- 100次移动: 经验值,足够打乱又不会太慢
- 逆序数: 随机打乱可能无解的数学原因
- 空格显示: 浅棕色,和背景区分
- 数字块设计: 蓝色背景 + 白色数字 + 圆角 + 阴影
打乱是数字拼图的关键,用模拟移动的方法既简单又可靠。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net