通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip
前言
上一篇讲了数字拼图的打乱,这篇来聊聊滑动交换的逻辑。
数字拼图的玩法是点击空格旁边的数字,数字就会滑到空格位置。只有和空格相邻的数字才能移动。

点击处理
dart
void _tap(int index) {
int empty = tiles.indexOf(0);
bool canSwap = (index == empty - 1 && empty % size > 0) ||
(index == empty + 1 && empty % size < size - 1) ||
(index == empty - size) || (index == empty + size);
if (canSwap) {
setState(() {
tiles[empty] = tiles[index];
tiles[index] = 0;
moves++;
if (_checkWin()) _showWinDialog();
});
}
}
这是点击处理的核心方法,接收点击的格子索引作为参数。让我逐段解释:
找到空格
dart
int empty = tiles.indexOf(0);
indexOf方法在列表中查找元素,返回第一个匹配元素的索引。
这里找到0(空格)的位置。因为每次移动后空格位置会变,所以每次点击都要重新找。
判断能否交换
dart
bool canSwap = (index == empty - 1 && empty % size > 0) ||
(index == empty + 1 && empty % size < size - 1) ||
(index == empty - size) || (index == empty + size);
这是一个复合布尔表达式,用||连接四个条件,任一条件为真就可以交换。
点击的位置必须和空格相邻才能交换。让我详细解释四个条件:
左边相邻
dart
index == empty - 1 && empty % size > 0
这个条件判断点击位置是否在空格左边:
index == empty - 1:点击位置的索引比空格小1empty % size > 0:空格不在最左列(列号大于0)
为什么要第二个条件?看这个例子:
索引布局:
0 1 2 3
4 5 6 7
8 9 10 11
12 13 14 15
如果空格在索引4(第二行第一个),索引3(第一行最后一个)虽然比4小1,但它们不相邻!
empty % size得到空格的列号,如果是0说明在最左列,左边没有格子。
右边相邻
dart
index == empty + 1 && empty % size < size - 1
同样的逻辑:
index == empty + 1:点击位置的索引比空格大1empty % size < size - 1:空格不在最右列(列号小于3)
如果空格在索引3(第一行最后一个),索引4虽然比3大1,但它们不相邻。
上边相邻
dart
index == empty - size
点击位置的索引比空格小size(4),说明在上一行同一列。
这个不需要额外的边界检查,因为如果空格在第一行,empty - size会是负数,不可能等于任何有效索引。
下边相邻
dart
index == empty + size
点击位置的索引比空格大size(4),说明在下一行同一列。
同样不需要额外检查,如果空格在最后一行,empty + size会超出数组范围。
为什么左右要额外检查而上下不用
一维数组里,索引连续但位置不一定相邻。
索引3和4的位置:
0 1 2 [3]
[4] 5 6 7
索引3和4相差1,但在棋盘上一个在右边缘,一个在左边缘,不相邻。
而索引0和4相差4(size),在棋盘上是上下关系,一定相邻。
所以左右移动要检查empty % size,确保不跨行;上下移动不需要额外检查。
执行交换
dart
setState(() {
tiles[empty] = tiles[index];
tiles[index] = 0;
moves++;
if (_checkWin()) _showWinDialog();
});
如果可以交换,在setState里执行:
- 把点击位置的数字移到空格位置
- 把点击位置设为0(变成新的空格)
- 步数加1
- 检查是否胜利
为什么用setState
setState通知Flutter框架状态变了,需要重新build。
如果不用setState,tiles数组虽然变了,但UI不会更新,玩家看不到变化。
胜利检查
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(tiles.length - 1 = 15,不包括15):
- 索引0应该是1
- 索引1应该是2
- ...
- 索引14应该是15
如果任何一个位置的值不等于索引+1,立即返回false。
检查最后一个位置
dart
return tiles.last == 0;
tiles.last是列表的最后一个元素,应该是0(空格)。
正确顺序是[1, 2, 3, ..., 15, 0]。
为什么分开检查
可以合并成一个循环:
dart
bool _checkWin() {
for (int i = 0; i < tiles.length; i++) {
int expected = (i + 1) % tiles.length; // 0,1,2,...,15 -> 1,2,3,...,0
if (tiles[i] != expected) return false;
}
return true;
}
但分开写更清晰,而且性能差不多。
点击事件绑定
dart
return GestureDetector(
onTap: () => _tap(i),
child: Container(...),
);
每个数字块都用GestureDetector包裹,检测点击事件。
onTap:点击回调() => _tap(i):箭头函数,调用_tap并传入当前索引i
为什么用箭头函数
dart
// 正确
onTap: () => _tap(i),
// 错误!
onTap: _tap(i), // 这会立即调用_tap,而不是等点击时调用
onTap需要一个函数,不是函数的返回值。
() => _tap(i)创建一个新函数,点击时才执行。
空格不绑定事件
dart
if (value == 0) return Container(...); // 没有GestureDetector
return GestureDetector(...);
空格的渲染代码在前面,直接return了Container,没有包裹GestureDetector。
点击空格没有意义,所以不需要处理。
步数显示
dart
Padding(padding: const EdgeInsets.all(16), child: Text('步数: $moves', style: const TextStyle(fontSize: 20))),
顶部显示当前步数。
$moves:字符串插值,把moves变量的值插入字符串fontSize: 20:字号20,比较醒目
玩家可以追求更少步数完成。15拼图的最优解通常在50-80步之间。
提示文字
dart
const Padding(padding: EdgeInsets.all(16), child: Text('点击数字移动到空位', style: TextStyle(color: Colors.grey))),
底部灰色提示,告诉玩家怎么操作。
color: Colors.grey:灰色,不抢眼const:整个Widget是编译时常量,性能更好
胜利对话框
dart
void _showWinDialog() {
showDialog(context: context, builder: (_) => AlertDialog(
title: const Text('恭喜!'), content: Text('完成拼图! 步数: $moves'),
actions: [TextButton(onPressed: () { Navigator.pop(context); setState(_initGame); }, child: const Text('再来一局'))],
));
}
游戏胜利时弹出对话框。
showDialog
dart
showDialog(context: context, builder: (_) => AlertDialog(...))
showDialog是Flutter显示对话框的方法:
context:BuildContext,用于定位对话框builder:构建对话框的函数,参数是context(这里用_忽略)
AlertDialog
dart
AlertDialog(
title: const Text('恭喜!'),
content: Text('完成拼图! 步数: $moves'),
actions: [...],
)
Material Design的标准对话框:
title:标题content:内容,显示步数actions:底部按钮
再来一局按钮
dart
TextButton(
onPressed: () { Navigator.pop(context); setState(_initGame); },
child: const Text('再来一局'),
)
点击按钮时:
Navigator.pop(context):关闭对话框setState(_initGame):重新初始化游戏
动画效果
当前实现是瞬间交换,没有动画。如果想加滑动动画,可以用AnimatedPositioned:
dart
AnimatedPositioned(
duration: Duration(milliseconds: 200),
left: (index % size) * cellSize,
top: (index ~/ size) * cellSize,
child: ...,
)
但这需要改用Stack布局,每个数字块用Positioned定位,比较复杂。
另一种方案是用AnimatedSwitcher:
dart
AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: Container(
key: ValueKey(tiles[i]), // key变化时触发动画
...
),
)
当前简化处理,没有动画也能玩。
索引和坐标的转换
一维索引和二维坐标的转换公式:
dart
// 索引 -> 坐标
int row = index ~/ size; // 整除得到行号
int col = index % size; // 取余得到列号
// 坐标 -> 索引
int index = row * size + col;
举例,size=4时:
- 索引5:row = 5 ~/ 4 = 1,col = 5 % 4 = 1,即第2行第2列
- 第3行第2列:index = 2 * 4 + 1 = 9
这个转换在很多地方用到,比如判断边界、计算相邻位置、渲染时计算坐标。
完整的游戏流程
- 初始化:生成有序数组,打乱
- 渲染:GridView显示16个格子
- 点击:判断是否相邻,交换
- 检查:每次移动后检查是否胜利
- 胜利:弹出对话框,可以重新开始
小结
这篇讲了数字拼图的滑动交换,核心知识点:
- indexOf找空格: 每次点击都要重新找0的位置
- 相邻判断: 四个方向的条件,左右要额外检查边界
- 边界检查 :
empty % size判断列号,防止跨行 - 数组交换: 两行代码完成,先移数字再设空格
- setState: 通知Flutter更新UI
- 胜利条件: [1,2,3,...,15,0]
- 步数统计: 每次有效移动加1
- GestureDetector: 检测点击事件
- 箭头函数 :
() => _tap(i)延迟执行
滑动交换是数字拼图的核心操作,理解了相邻判断的逻辑,游戏就完成了。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net