🎮 Flutter + HarmonyOS 实战:从零开发经典2048游戏


运行效果图预览
在这里插入图片描述
在这里插入图片描述

📋 文章导读

章节 内容概要 预计阅读
2048游戏规则与设计思路 3分钟
核心数据结构设计 5分钟
滑动合并算法详解 12分钟
游戏状态管理 6分钟
UI界面与交互实现 8分钟
完整源码与运行 3分钟

💡 写在前面:2048是一款风靡全球的数字益智游戏,玩法简单却让人欲罢不能。本文将带你用Flutter从零实现这款经典游戏,重点讲解滑动合并算法的设计思路。无论你是想学习游戏开发,还是想深入理解算法,这篇文章都值得一读。


一、游戏规则与设计

1.1 2048游戏规则

2048的规则可以用三句话概括:

滑动合并
向任意方向滑动,相同数字的方块会合并成它们的和
随机生成
每次滑动后,空白处会随机出现一个2或4
目标与结束
合并出2048即为胜利;无法移动时游戏结束

1.2 功能设计

2048游戏

核心玩法

四方向滑动

数字合并

随机生成

胜负判定

界面元素

4×4棋盘

数字方块

分数显示

最高分记录

交互方式

触屏滑动

键盘控制

重新开始

1.3 方块颜色设计

2048的经典配色是游戏体验的重要组成部分:

数值 背景色 文字色 说明
0 #CDC1B4 - 空格
2 #EEE4DA #776E65 浅米色
4 #EDE0C8 #776E65 米色
8 #F2B179 白色 橙色
16 #F59563 白色 深橙
32 #F67C5F 白色 红橙
64 #F65E3B 白色 红色
128 #EDCF72 白色 金黄
256 #EDCC61 白色 深金
512 #EDC850 白色 橙金
1024 #EDC53F 白色 亮金
2048 #EDC22E 白色 金色
>2048 #3C3A32 白色 深灰

二、核心数据结构

2.1 棋盘表示

使用4×4的二维数组存储棋盘状态:

// 棋盘大小
static const int gridSize = 4;

// 棋盘数据:0表示空格,其他为数字
late List<List<int>> grid;

// 初始化棋盘
void _initGame() {
  grid = List.generate(
    gridSize, 
    (_) => List.generate(gridSize, (_) => 0),
  );
  _addRandomTile();  // 添加第一个方块
  _addRandomTile();  // 添加第二个方块
}

2.2 棋盘状态示例

初始状态:          滑动后:
┌────┬────┬────┬────┐    ┌────┬────┬────┬────┐
│    │  2 │    │    │    │  2 │    │    │    │
├────┼────┼────┼────┤    ├────┼────┼────┼────┤
│    │    │    │  2 │ ←  │  2 │    │    │    │
├────┼────┼────┼────┤    ├────┼────┼────┼────┤
│    │    │    │    │    │    │    │    │    │
├────┼────┼────┼────┤    ├────┼────┼────┼────┤
│    │    │    │    │    │  4 │    │    │    │ ← 新生成
└────┴────┴────┴────┘    └────┴────┴────┴────┘

2.3 游戏状态变量

// 当前分数
int score = 0;

// 历史最高分
int bestScore = 0;

// 游戏是否结束
bool gameOver = false;

// 是否已达到2048
bool hasWon = false;

// 达到2048后是否继续
bool continueAfterWin = false;

三、滑动合并算法

3.1 算法核心思想

滑动合并是2048的核心算法。我们只需要实现向左滑动的逻辑,其他三个方向可以通过旋转/翻转来复用。

原始行

移除零

合并相邻相同数字

再次移除零

补齐零到原长度

返回新行

3.2 向左滑动算法

/// 向左滑动一行
/// 输入: [2, 0, 2, 4]
/// 输出: [4, 4, 0, 0]
List<int> _slideLeft(List<int> row) {
  // 第一步:移除所有零,让数字靠左
  // [2, 0, 2, 4] → [2, 2, 4]
  List<int> newRow = row.where((x) => x != 0).toList();
  
  // 第二步:合并相邻相同的数字
  for (int i = 0; i < newRow.length - 1; i++) {
    if (newRow[i] == newRow[i + 1]) {
      newRow[i] *= 2;           // 合并:2+2=4
      score += newRow[i];       // 加分
      newRow[i + 1] = 0;        // 被合并的位置置零
      
      // 检查是否达到2048
      if (newRow[i] == 2048 && !hasWon) {
        hasWon = true;
      }
    }
  }
  // [2, 2, 4] → [4, 0, 4]
  
  // 第三步:再次移除零
  // [4, 0, 4] → [4, 4]
  newRow = newRow.where((x) => x != 0).toList();
  
  // 第四步:补齐零到原长度
  // [4, 4] → [4, 4, 0, 0]
  while (newRow.length < gridSize) {
    newRow.add(0);
  }
  
  return newRow;
}

3.3 合并过程图解

[2, 2, 4, 4] 向左滑动为例:

原始:     [2, 2, 4, 4]
          ↓
移除零:   [2, 2, 4, 4]  (无变化)
          ↓
合并:     [4, 0, 8, 0]  (2+2=4, 4+4=8)
          ↓
移除零:   [4, 8]
          ↓
补零:     [4, 8, 0, 0]

3.4 四方向移动实现

bool _move(String direction) {
  // 保存旧状态,用于判断是否有变化
  List<List<int>> oldGrid = grid.map((row) => List<int>.from(row)).toList();
  
  switch (direction) {
    case 'left':
      // 直接对每行应用向左滑动
      for (int i = 0; i < gridSize; i++) {
        grid[i] = _slideLeft(grid[i]);
      }
      break;
      
    case 'right':
      // 先翻转,向左滑动,再翻转回来
      for (int i = 0; i < gridSize; i++) {
        grid[i] = _slideLeft(grid[i].reversed.toList()).reversed.toList();
      }
      break;
      
    case 'up':
      // 对每列应用向左滑动(列转行)
      for (int j = 0; j < gridSize; j++) {
        List<int> column = [for (int i = 0; i < gridSize; i++) grid[i][j]];
        column = _slideLeft(column);
        for (int i = 0; i < gridSize; i++) {
          grid[i][j] = column[i];
        }
      }
      break;
      
    case 'down':
      // 列翻转后向左滑动,再翻转回来
      for (int j = 0; j < gridSize; j++) {
        List<int> column = [for (int i = 0; i < gridSize; i++) grid[i][j]];
        column = _slideLeft(column.reversed.toList()).reversed.toList();
        for (int i = 0; i < gridSize; i++) {
          grid[i][j] = column[i];
        }
      }
      break;
  }
  
  // 检查是否有变化
  return _hasChanged(oldGrid, grid);
}

3.5 方向转换关系

方向 转换方式 说明
← 左 直接处理 基础算法
→ 右 翻转 → 左滑 → 翻转 利用对称性
↑ 上 列转行 → 左滑 → 行转列 转置处理
↓ 下 列转行 → 翻转 → 左滑 → 翻转 → 行转列 组合转换

3.6 算法复杂度

操作 时间复杂度 空间复杂度
单行滑动 O(n)O(n)O(n) O(n)O(n)O(n)
整体移动 O(n2)O(n^2)O(n2) O(n2)O(n^2)O(n2)
检查变化 O(n2)O(n^2)O(n2) O(1)O(1)O(1)

其中 n=4n = 4n=4(棋盘边长),实际运算量很小。


四、游戏状态管理

4.1 随机生成方块

/// 在空白位置随机生成一个方块
/// 90%概率生成2,10%概率生成4
void _addRandomTile() {
  // 收集所有空白位置
  List<List<int>> emptyTiles = [];
  for (int i = 0; i < gridSize; i++) {
    for (int j = 0; j < gridSize; j++) {
      if (grid[i][j] == 0) {
        emptyTiles.add([i, j]);
      }
    }
  }
  
  // 随机选择一个空白位置
  if (emptyTiles.isNotEmpty) {
    final pos = emptyTiles[random.nextInt(emptyTiles.length)];
    // 90%概率为2,10%概率为4
    grid[pos[0]][pos[1]] = random.nextDouble() < 0.9 ? 2 : 4;
  }
}

4.2 游戏结束判定

检查游戏是否结束

有空格?

可以继续

有相邻相同数字?

游戏结束

/// 检查是否还能移动
bool _canMove() {
  // 检查是否有空格
  for (int i = 0; i < gridSize; i++) {
    for (int j = 0; j < gridSize; j++) {
      if (grid[i][j] == 0) return true;
    }
  }
  
  // 检查是否有相邻相同的数字
  for (int i = 0; i < gridSize; i++) {
    for (int j = 0; j < gridSize; j++) {
      // 检查右边
      if (j < gridSize - 1 && grid[i][j] == grid[i][j + 1]) return true;
      // 检查下边
      if (i < gridSize - 1 && grid[i][j] == grid[i + 1][j]) return true;
    }
  }
  
  return false;
}

4.3 滑动处理流程

void _handleSwipe(String direction) {
  if (gameOver) return;
  
  setState(() {
    // 1. 执行移动
    bool moved = _move(direction);
    
    if (moved) {
      // 2. 生成新方块
      _addRandomTile();
      
      // 3. 更新最高分
      if (score > bestScore) {
        bestScore = score;
      }
      
      // 4. 检查游戏是否结束
      if (!_canMove()) {
        gameOver = true;
      }
    }
  });
  
  // 5. 检查是否首次达到2048
  if (hasWon && !continueAfterWin) {
    _showWinDialog();
  }
}

五、UI界面与交互

5.1 整体布局

游戏棋盘

分数板

Body - Column

Scaffold

AppBar - 标题 + 重开按钮

Body

分数板

游戏棋盘

操作提示

当前分数

最高分

4×4 GridView

游戏结束遮罩

5.2 手势识别

GestureDetector(
  // 垂直滑动
  onVerticalDragEnd: (details) {
    if (details.velocity.pixelsPerSecond.dy < -100) {
      _handleSwipe('up');    // 向上滑动
    } else if (details.velocity.pixelsPerSecond.dy > 100) {
      _handleSwipe('down');  // 向下滑动
    }
  },
  // 水平滑动
  onHorizontalDragEnd: (details) {
    if (details.velocity.pixelsPerSecond.dx < -100) {
      _handleSwipe('left');  // 向左滑动
    } else if (details.velocity.pixelsPerSecond.dx > 100) {
      _handleSwipe('right'); // 向右滑动
    }
  },
  child: // 棋盘Widget
)

5.3 键盘支持

KeyboardListener(
  focusNode: FocusNode()..requestFocus(),
  onKeyEvent: (event) {
    if (event is KeyDownEvent) {
      switch (event.logicalKey) {
        case LogicalKeyboardKey.arrowUp:
        case LogicalKeyboardKey.keyW:
          _handleSwipe('up');
          break;
        case LogicalKeyboardKey.arrowDown:
        case LogicalKeyboardKey.keyS:
          _handleSwipe('down');
          break;
        case LogicalKeyboardKey.arrowLeft:
        case LogicalKeyboardKey.keyA:
          _handleSwipe('left');
          break;
        case LogicalKeyboardKey.arrowRight:
        case LogicalKeyboardKey.keyD:
          _handleSwipe('right');
          break;
      }
    }
  },
  child: // 游戏界面
)

5.4 方块渲染

Widget _buildTile(int value) {
  return AnimatedContainer(
    duration: const Duration(milliseconds: 100),
    decoration: BoxDecoration(
      color: _getTileColor(value),
      borderRadius: BorderRadius.circular(4),
    ),
    child: Center(
      child: value != 0
          ? Text(
              '$value',
              style: TextStyle(
                color: _getTextColor(value),
                fontSize: _getFontSize(value),
                fontWeight: FontWeight.bold,
              ),
            )
          : null,
    ),
  );
}

/// 根据数值大小调整字体
double _getFontSize(int value) {
  if (value < 100) return 40;
  if (value < 1000) return 32;
  if (value < 10000) return 26;
  return 22;
}

六、完整源码与运行

6.1 项目结构

flutter_2048/
├── lib/
│   └── main.dart       # 2048游戏代码(约350行)
├── ohos/               # 鸿蒙平台配置
├── pubspec.yaml        # 依赖配置
└── README.md           # 项目说明

6.2 运行命令

# 获取依赖
flutter pub get

# 运行游戏
flutter run

# 运行到鸿蒙设备
flutter run -d ohos

6.3 功能清单

功能 状态 说明
4×4游戏棋盘 经典尺寸
四方向滑动 触屏+键盘
数字合并 核心算法
随机生成 90%为2,10%为4
分数统计 实时更新
最高分记录 本次游戏内
胜利判定 达到2048
失败判定 无法移动
继续挑战 2048后可继续
重新开始 一键重置

七、扩展方向

7.1 功能扩展

2048游戏

撤销功能

动画效果

本地存储

排行榜

自定义棋盘

记录历史状态

滑动动画

合并动画

SharedPreferences

3×3 / 5×5 / 6×6

7.2 动画增强

// 使用 AnimatedPositioned 实现滑动动画
// 使用 ScaleTransition 实现合并动画
// 使用 FadeTransition 实现新方块出现动画

7.3 本地存储

// 使用 shared_preferences 保存最高分
Future<void> _saveBestScore() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setInt('bestScore', bestScore);
}

Future<void> _loadBestScore() async {
  final prefs = await SharedPreferences.getInstance();
  bestScore = prefs.getInt('bestScore') ?? 0;
}

八、常见问题

Q1: 为什么只实现向左滑动,其他方向怎么办?

这是一个经典的算法技巧:通过变换将问题统一化

  • 向右 = 翻转 → 向左 → 翻转
  • 向上 = 转置 → 向左 → 转置
  • 向下 = 转置 → 翻转 → 向左 → 翻转 → 转置

这样只需要维护一份核心逻辑,减少代码重复和bug。

Q2: 为什么新方块90%是2,10%是4?

这是原版2048的设定,目的是:

  1. 让游戏初期更容易上手(2更容易合并)
  2. 增加一定的随机性和挑战(偶尔出现4)
  3. 平衡游戏难度
Q3: 如何判断游戏是否真的无法移动?

需要同时满足两个条件:

  1. 没有空格(棋盘已满)
  2. 没有相邻的相同数字(无法合并)

只检查一个条件是不够的。比如棋盘满了但还有相邻相同数字,仍然可以继续。

Q4: 达到2048后为什么可以继续?

原版2048允许玩家在达到2048后继续挑战更高分数(4096、8192甚至更高)。这增加了游戏的可玩性,让高手有更多追求的目标。


九、总结

本文从零实现了经典的2048游戏,核心技术点包括:

  1. 滑动合并算法:移除零 → 合并 → 移除零 → 补零
  2. 方向统一化:通过翻转/转置复用向左滑动逻辑
  3. 状态管理:分数、最高分、游戏结束、胜利判定
  4. 交互设计:触屏滑动 + 键盘控制
  5. 视觉设计:经典配色方案

2048的魅力在于规则简单却策略丰富。希望这篇文章能帮你理解游戏背后的算法思想,也欢迎挑战更高分数!


🎮 完整源码已上传,欢迎Star支持!


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐