Flutter for OpenHarmony游戏集合App实战之2048滑动合并
通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip
前言
2048的核心玩法就是滑动合并。手指往一个方向滑,所有方块往那个方向移动,相同数字的方块合并成更大的数字。
这个看似简单的操作,实现起来有不少细节。这篇来聊聊滑动和合并的逻辑。
手势检测
GestureDetector(
onVerticalDragEnd: (d) {
if (d.primaryVelocity! < 0) setState(() => _move(0, -1));
else if (d.primaryVelocity! > 0) setState(() => _move(0, 1));
},
onHorizontalDragEnd: (d) {
if (d.primaryVelocity! < 0) setState(() => _move(-1, 0));
else if (d.primaryVelocity! > 0) setState(() => _move(1, 0));
},
用GestureDetector检测滑动手势。
垂直滑动
onVerticalDragEnd: (d) {
if (d.primaryVelocity! < 0) setState(() => _move(0, -1));
else if (d.primaryVelocity! > 0) setState(() => _move(0, 1));
},
primaryVelocity是滑动速度:
- 负值:向上滑(Y轴向上是负方向)
- 正值:向下滑
向上滑调用_move(0, -1),向下滑调用_move(0, 1)。
水平滑动
onHorizontalDragEnd: (d) {
if (d.primaryVelocity! < 0) setState(() => _move(-1, 0));
else if (d.primaryVelocity! > 0) setState(() => _move(1, 0));
},
同样的逻辑:
- 负值:向左滑
- 正值:向右滑
方向参数
_move(dx, dy)的参数表示方向:
- (0, -1): 上
- (0, 1): 下
- (-1, 0): 左
- (1, 0): 右
_move方法
移动和合并的核心逻辑:
bool _move(int dx, int dy) {
bool moved = false;
List<List<int>> newGrid = List.generate(gridSize, (i) => List.from(grid[i]));
复制棋盘
List<List<int>> newGrid = List.generate(gridSize, (i) => List.from(grid[i]));
先复制一份棋盘,在副本上操作。这样如果没有移动,原棋盘不变。
List.from创建列表的浅拷贝。
遍历每一行/列
for (int i = 0; i < gridSize; i++) {
List<int> line = [];
for (int j = 0; j < gridSize; j++) {
int r = dy == 0 ? i : (dy == 1 ? gridSize - 1 - j : j);
int c = dx == 0 ? i : (dx == 1 ? gridSize - 1 - j : j);
if (newGrid[r][c] != 0) line.add(newGrid[r][c]);
}
这段代码有点绕,我来解释一下。
坐标计算
int r = dy == 0 ? i : (dy == 1 ? gridSize - 1 - j : j);
int c = dx == 0 ? i : (dx == 1 ? gridSize - 1 - j : j);
根据滑动方向,决定遍历顺序。
向左滑(dx=-1, dy=0):
- r = i(行号不变)
- c = j(从左到右遍历)
向右滑(dx=1, dy=0):
- r = i
- c = gridSize - 1 - j(从右到左遍历)
向上滑(dx=0, dy=-1):
- r = j(从上到下遍历)
- c = i(列号不变)
向下滑(dx=0, dy=1):
- r = gridSize - 1 - j(从下到上遍历)
- c = i
💡 为什么要这样? 因为合并是从滑动方向的起点开始的。向左滑,左边的方块先合并;向右滑,右边的方块先合并。遍历顺序要和滑动方向一致。
提取非零元素
if (newGrid[r][c] != 0) line.add(newGrid[r][c]);
把这一行/列的非零元素提取出来,放到line数组里。
比如一行是[2, 0, 2, 4],提取后line是[2, 2, 4]。
合并逻辑
List<int> merged = [];
int k = 0;
while (k < line.length) {
if (k + 1 < line.length && line[k] == line[k + 1]) {
merged.add(line[k] * 2);
score += line[k] * 2;
k += 2;
} else {
merged.add(line[k]);
k++;
}
}
相邻相同则合并
if (k + 1 < line.length && line[k] == line[k + 1]) {
merged.add(line[k] * 2);
score += line[k] * 2;
k += 2;
}
如果当前元素和下一个元素相同,合并成两倍的值,加到merged里。
同时加分,分数就是合并后的值。
k += 2跳过两个元素,因为它们已经合并了。
不同则保留
} else {
merged.add(line[k]);
k++;
}
如果不同,直接加到merged里,k加1。
举例
line = [2, 2, 4, 4]
- k=0: line[0]=2, line[1]=2, 相同,合并成4,merged=[4],k=2
- k=2: line[2]=4, line[3]=4, 相同,合并成8,merged=[4, 8],k=4
- k=4: 超出范围,结束
结果:merged = [4, 8]
补零
while (merged.length < gridSize) merged.add(0);
合并后元素变少了,用0补齐到4个。
merged = [4, 8] → [4, 8, 0, 0]
写回棋盘
for (int j = 0; j < gridSize; j++) {
int r = dy == 0 ? i : (dy == 1 ? gridSize - 1 - j : j);
int c = dx == 0 ? i : (dx == 1 ? gridSize - 1 - j : j);
if (newGrid[r][c] != merged[j]) moved = true;
newGrid[r][c] = merged[j];
}
}
把merged的值写回棋盘,用同样的坐标计算方式。
如果有任何一个位置的值变了,moved设为true。
移动后的处理
if (moved) {
grid = newGrid;
_addRandomTile();
_checkGameOver();
}
return moved;
}
如果有移动:
- 用新棋盘替换旧棋盘
- 随机添加一个新方块
- 检查游戏是否结束
如果没有移动(比如向左滑但所有方块已经靠左),什么都不做。
游戏结束检查
void _checkGameOver() {
for (int i = 0; i < gridSize; i++) {
for (int j = 0; j < gridSize; j++) {
if (grid[i][j] == 0) return;
if (i < gridSize - 1 && grid[i][j] == grid[i + 1][j]) return;
if (j < gridSize - 1 && grid[i][j] == grid[i][j + 1]) return;
}
}
gameOver = true;
}
三种情况可以继续玩:
- 有空格子:可以放新方块
- 垂直方向有相邻相同:可以合并
- 水平方向有相邻相同:可以合并
都不满足就游戏结束。
提前返回
if (grid[i][j] == 0) return;
一旦发现可以继续玩的情况,立即返回,不设置gameOver。
只有遍历完所有格子都没找到,才设置gameOver = true。
滑动的边界情况
空行滑动
如果一行全是0,line是空数组,while循环不执行,merged也是空的,补零后是[0, 0, 0, 0],和原来一样,moved不变。
已经靠边
如果向左滑,但所有方块已经在左边了,比如[2, 4, 0, 0]。
line = [2, 4],合并后merged = [2, 4, 0, 0],和原来一样,moved不变。
连续相同
[2, 2, 2, 2]向左滑:
line = [2, 2, 2, 2]
- k=0: 2和2合并成4,merged=[4],k=2
- k=2: 2和2合并成4,merged=[4, 4],k=4
- 结束
结果:[4, 4, 0, 0]
注意:一次滑动中,每个方块最多参与一次合并。[2, 2, 2, 2]不会变成[8, 0, 0, 0]。
分数计算
score += line[k] * 2;
每次合并加分,分数是合并后的值。
2+2=4,加4分
4+4=8,加8分
…
分数反映了你合并了多少、合并了多大的方块。
滑动的手感优化
当前实现用的是onDragEnd,滑动结束后才触发。如果想要更灵敏的响应,可以用onDragUpdate:
onVerticalDragUpdate: (d) {
if (d.delta.dy.abs() > 5) { // 滑动距离超过5像素
if (d.delta.dy < 0) setState(() => _move(0, -1));
else setState(() => _move(0, 1));
}
},
delta是滑动的增量,dy是垂直方向的增量。
这样滑动过程中就会触发,响应更快。但要加个阈值(5像素),避免误触。
防止重复触发
用onDragUpdate有个问题:一次滑动可能触发多次。
可以加个标记:
bool _isMoving = false;
onVerticalDragUpdate: (d) {
if (_isMoving) return;
if (d.delta.dy.abs() > 5) {
_isMoving = true;
if (d.delta.dy < 0) setState(() => _move(0, -1));
else setState(() => _move(0, 1));
}
},
onVerticalDragEnd: (_) => _isMoving = false,
滑动开始后设置_isMoving = true,滑动结束后重置。这样一次滑动只触发一次移动。
动画效果
当前实现没有动画,方块是瞬间移动的。如果想加动画,需要更复杂的状态管理。
基本思路
- 记录每个方块的起始位置和目标位置
- 用AnimationController控制动画进度
- 根据进度计算当前位置
- 动画结束后更新实际数据
这需要把grid从List<List<int>>改成List<List<Tile>>,Tile包含值、位置、动画状态等信息。
复杂度会增加很多,当前实现简化了。
简单的动画方案
如果只想要简单的动画效果,可以用AnimatedContainer:
AnimatedContainer(
duration: const Duration(milliseconds: 100),
decoration: BoxDecoration(
color: _getTileColor(value),
borderRadius: BorderRadius.circular(4),
),
child: Center(child: Text(...)),
)
颜色变化会有平滑过渡,但位置移动还是瞬间的。
撤销功能
很多2048实现有撤销功能,让玩家可以悔棋。
实现思路
每次移动前保存当前状态:
List<List<List<int>>> history = [];
bool _move(int dx, int dy) {
// 保存当前状态
history.add(grid.map((row) => List<int>.from(row)).toList());
// ... 移动逻辑 ...
if (!moved) {
history.removeLast(); // 没有移动,不保存
}
return moved;
}
撤销时恢复上一个状态:
void _undo() {
if (history.isEmpty) return;
setState(() {
grid = history.removeLast();
gameOver = false;
});
}
限制撤销次数
无限撤销会让游戏太简单,可以限制次数:
int undoCount = 3;
void _undo() {
if (history.isEmpty || undoCount <= 0) return;
setState(() {
grid = history.removeLast();
undoCount--;
gameOver = false;
});
}
每局游戏只能撤销3次,用完就没了。
最高分记录
可以用SharedPreferences保存最高分:
import 'package:shared_preferences/shared_preferences.dart';
int highScore = 0;
Future<void> _loadHighScore() async {
final prefs = await SharedPreferences.getInstance();
highScore = prefs.getInt('highScore') ?? 0;
}
Future<void> _saveHighScore() async {
if (score > highScore) {
highScore = score;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('highScore', highScore);
}
}
游戏结束时调用_saveHighScore(),启动时调用_loadHighScore()。
这样玩家可以看到自己的历史最高分,有动力挑战。
合并的特殊情况
三个相同
[2, 2, 2, 0]向左滑:
line = [2, 2, 2]
- k=0: 2和2合并成4,merged=[4],k=2
- k=2: 只剩一个2,直接加入,merged=[4, 2],k=3
- 结束
结果:[4, 2, 0, 0]
注意:是左边两个2合并,不是右边两个。因为遍历是从左到右的。
四个相同
[2, 2, 2, 2]向左滑:
结果:[4, 4, 0, 0]
两对分别合并,不会变成[8, 0, 0, 0]。
这是2048的规则:一次滑动中,每个方块最多参与一次合并。
交替数字
[2, 4, 2, 4]向左滑:
line = [2, 4, 2, 4]
没有相邻相同的,不合并。
结果:[2, 4, 2, 4](不变)
胜利条件
当前实现没有胜利判断。标准2048是合成2048就算赢:
void _checkWin() {
for (int i = 0; i < gridSize; i++) {
for (int j = 0; j < gridSize; j++) {
if (grid[i][j] == 2048) {
// 显示胜利对话框
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('恭喜!'),
content: const Text('你合成了2048!'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('继续游戏'),
),
],
),
);
return;
}
}
}
}
胜利后可以继续玩,挑战更高分数(4096、8192…)。
小结
这篇讲了2048的滑动合并,核心知识点:
- GestureDetector:检测垂直和水平滑动,区分四个方向
- primaryVelocity:判断滑动方向,负值向上/左,正值向下/右
- 方向参数:用(dx, dy)表示四个方向,方便坐标计算
- 坐标计算:根据方向决定遍历顺序,确保合并方向正确
- 提取非零元素:简化合并逻辑,只处理有值的格子
- 相邻合并:相同数字合并成两倍,每个方块最多合并一次
- 补零:合并后用0填充,保持数组长度
- moved标记:判断是否有实际移动,没移动不添加新方块
- 游戏结束检查:没有空格且没有可合并的,游戏结束
- 手感优化:可以用onDragUpdate提高响应速度
- 撤销功能:保存历史状态,支持悔棋
滑动合并是2048的核心,理解了这个,游戏的主要逻辑就清楚了。剩下的就是UI美化和功能扩展。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)