通过网盘分享的文件: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]

  1. k=0: line[0]=2, line[1]=2, 相同,合并成4,merged=[4],k=2
  2. k=2: line[2]=4, line[3]=4, 相同,合并成8,merged=[4, 8],k=4
  3. 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;
}

如果有移动:

  1. 用新棋盘替换旧棋盘
  2. 随机添加一个新方块
  3. 检查游戏是否结束

如果没有移动(比如向左滑但所有方块已经靠左),什么都不做。

游戏结束检查

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;
}

三种情况可以继续玩:

  1. 有空格子:可以放新方块
  2. 垂直方向有相邻相同:可以合并
  3. 水平方向有相邻相同:可以合并

都不满足就游戏结束。

提前返回

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]

  1. k=0: 2和2合并成4,merged=[4],k=2
  2. k=2: 2和2合并成4,merged=[4, 4],k=4
  3. 结束

结果:[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,滑动结束后重置。这样一次滑动只触发一次移动。

动画效果

当前实现没有动画,方块是瞬间移动的。如果想加动画,需要更复杂的状态管理。

基本思路

  1. 记录每个方块的起始位置和目标位置
  2. 用AnimationController控制动画进度
  3. 根据进度计算当前位置
  4. 动画结束后更新实际数据

这需要把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]

  1. k=0: 2和2合并成4,merged=[4],k=2
  2. k=2: 只剩一个2,直接加入,merged=[4, 2],k=3
  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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐