🎮 Flutter + HarmonyOS 实战:从零开发经典推箱子游戏
运行效果图


📋 文章导读
| 章节 | 内容概要 | 预计阅读 |
|---|---|---|
| 一 | 推箱子游戏介绍与设计思路 | 3分钟 |
| 二 | 游戏核心数据结构设计 | 5分钟 |
| 三 | 游戏逻辑实现详解 | 10分钟 |
| 四 | UI界面与交互设计 | 8分钟 |
| 五 | 关卡设计与扩展 | 5分钟 |
| 六 | 完整源码与运行 | 3分钟 |
💡 写在前面:推箱子(Sokoban)是一款诞生于1982年的经典益智游戏,玩家需要在有限空间内将所有箱子推到指定位置。别看规则简单,想要用最少步数通关可不容易。本文将带你用Flutter从零实现这款经典游戏,顺便聊聊游戏开发中的一些设计思想。
一、游戏设计思路
1.1 推箱子的游戏规则
推箱子的规则可以用一句话概括:把所有箱子推到目标点上。但这里面有几个关键约束:
:
玩家只能从箱子的一侧推动它,不能站在箱子前面把它拉过来
一次推一个 不能同时推动两个或多个箱子 不能推到墙角 箱子一旦被推到墙角,就再也推不出来了(死局) 步数越少越好 虽然没有步数限制,但用最少步数通关才是高手
1.2 游戏元素定义
在开始编码之前,我们先明确游戏中有哪些元素:
游戏元素
静态元素
墙壁 Wall
地板 Floor
目标点 Target
动态元素
玩家 Player
箱子 Box
组合状态
箱子在目标点上
玩家在目标点上
1.3 状态机设计
游戏的核心是状态管理。每个格子都有自己的状态,我们用数字来表示:
| 数值 | 含义 | 说明 |
|---|---|---|
| 0 | 地板 | 可以自由通行 |
| 1 | 墙壁 | 不可通行 |
| 2 | 目标点 | 箱子的目的地 |
| 3 | 箱子 | 需要被推动的对象 |
| 4 | 玩家 | 初始位置标记 |
| 5 | 箱子+目标点 | 箱子已到达目标 |
二、核心数据结构
2.1 地图表示
推箱子的地图本质上是一个二维数组。我们用 List<List<int>> 来存储:
dart
// 关卡数据示例:7x7 的入门关卡
final List<List<int>> level1 = [
[1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 1],
[1, 0, 3, 2, 0, 0, 1], // 3=箱子, 2=目标点
[1, 0, 0, 0, 3, 2, 1],
[1, 0, 4, 0, 0, 0, 1], // 4=玩家初始位置
[1, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1],
];
用表格来可视化这个关卡:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | 🧱 | 🧱 | 🧱 | 🧱 | 🧱 | 🧱 | 🧱 |
| 1 | 🧱 | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | 🧱 |
| 2 | 🧱 | ⬜ | 📦 | 🎯 | ⬜ | ⬜ | 🧱 |
| 3 | 🧱 | ⬜ | ⬜ | ⬜ | 📦 | 🎯 | 🧱 |
| 4 | 🧱 | ⬜ | 🧑 | ⬜ | ⬜ | ⬜ | 🧱 |
| 5 | 🧱 | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | 🧱 |
| 6 | 🧱 | 🧱 | 🧱 | 🧱 | 🧱 | 🧱 | 🧱 |
2.2 坐标系统
Flutter的坐标系统是左上角为原点,向右为X正方向,向下为Y正方向:
X →
┌─────────────┐
Y │ (0,0) (1,0) │
↓ │ (0,1) (1,1) │
└─────────────┘
移动方向与坐标变化的对应关系:
| 方向 | dx | dy | 说明 |
|---|---|---|---|
| ↑ 上 | 0 | -1 | Y坐标减小 |
| ↓ 下 | 0 | +1 | Y坐标增大 |
| ← 左 | -1 | 0 | X坐标减小 |
| → 右 | +1 | 0 | X坐标增大 |
三、游戏逻辑实现
3.1 移动逻辑流程图
玩家每次移动都需要经过一系列判断,这是整个游戏最核心的逻辑:
是
否
墙壁
地板/目标点
箱子
墙壁/箱子
地板
目标点
是
否
玩家按下方向键
目标位置是否越界?
不移动
目标位置是什么?
玩家移动到目标位置
箱子前方是什么?
箱子移动
箱子移动到目标点
步数+1
所有目标点都有箱子?
游戏胜利!
继续游戏
3.2 移动函数实现
dart
/// 移动玩家
/// [dx] X方向偏移量 (-1, 0, 1)
/// [dy] Y方向偏移量 (-1, 0, 1)
void _move(int dx, int dy) {
int newX = playerX + dx;
int newY = playerY + dy;
// 第一步:边界检查
if (newY < 0 || newY >= gameMap.length ||
newX < 0 || newX >= gameMap[0].length) {
return;
}
int targetTile = gameMap[newY][newX];
// 第二步:墙壁检查
if (targetTile == 1) return;
// 第三步:箱子处理
if (targetTile == 3 || targetTile == 5) {
int boxNewX = newX + dx;
int boxNewY = newY + dy;
// 箱子目标位置检查
if (boxNewY < 0 || boxNewY >= gameMap.length ||
boxNewX < 0 || boxNewX >= gameMap[0].length) {
return;
}
int boxTargetTile = gameMap[boxNewY][boxNewX];
// 箱子不能推到墙或另一个箱子上
if (boxTargetTile == 1 || boxTargetTile == 3 || boxTargetTile == 5) {
return;
}
setState(() {
// 更新箱子位置
gameMap[boxNewY][boxNewX] = (boxTargetTile == 2) ? 5 : 3;
// 恢复原箱子位置
gameMap[newY][newX] = (targetTile == 5) ? 2 : 0;
// 更新玩家位置
playerX = newX;
playerY = newY;
moves++;
});
} else {
// 普通移动(地板或目标点)
setState(() {
playerX = newX;
playerY = newY;
moves++;
});
}
// 第四步:胜利检查
_checkWin();
}
3.3 状态转换表
箱子移动时的状态转换逻辑:
| 箱子原位置 | 箱子目标位置 | 原位置变为 | 目标位置变为 |
|---|---|---|---|
| 箱子(3) | 地板(0) | 地板(0) | 箱子(3) |
| 箱子(3) | 目标点(2) | 地板(0) | 箱子在目标点(5) |
| 箱子在目标点(5) | 地板(0) | 目标点(2) | 箱子(3) |
| 箱子在目标点(5) | 目标点(2) | 目标点(2) | 箱子在目标点(5) |
3.4 胜利条件判断
胜利的条件很简单:地图上不存在空的目标点(值为2的格子)
dart
/// 检查是否胜利
void _checkWin() {
for (var row in gameMap) {
if (row.contains(2)) return; // 还有未完成的目标点
}
// 所有目标点都被覆盖,游戏胜利!
_showWinDialog();
}
四、UI界面设计
4.1 整体布局结构
游戏区域
Body
Scaffold
AppBar - 标题栏
Body - 主体
步数显示区
游戏区域
控制按钮区
GridView
格子1
格子2
...
4.2 配色方案
游戏采用大地色系,营造温馨的视觉效果:
| 元素 | 颜色 | 色值 | 说明 |
|---|---|---|---|
| 墙壁 | 🟫 | brown.shade800 |
深棕色,厚重感 |
| 地板 | 🟨 | brown.shade200 |
浅棕色,柔和 |
| 目标点 | 🟩 | green.shade300 |
浅绿色,醒目 |
| 箱子 | 🟧 | orange.shade600 |
橙色,突出 |
| 箱子完成 | 🟢 | green.shade600 |
深绿色,成就感 |
| 玩家 | 🔵 | blue.shade700 |
蓝色,易识别 |
4.3 格子渲染逻辑
dart
/// 获取格子颜色
Color _getTileColor(int tile, bool isPlayer) {
if (isPlayer) return Colors.blue.shade700;
switch (tile) {
case 1: return Colors.brown.shade800; // 墙
case 2: return Colors.green.shade300; // 目标点
case 3: return Colors.orange.shade600; // 箱子
case 5: return Colors.green.shade600; // 箱子在目标点
default: return Colors.brown.shade200; // 地板
}
}
/// 获取格子图标
Widget? _getTileIcon(int tile, bool isPlayer) {
if (isPlayer) {
return const Icon(Icons.person, color: Colors.white, size: 28);
}
switch (tile) {
case 2: return Icon(Icons.close, color: Colors.green.shade700, size: 20);
case 3: return const Icon(Icons.inventory_2, color: Colors.white, size: 24);
case 5: return const Icon(Icons.check_box, color: Colors.white, size: 24);
default: return null;
}
}
4.4 键盘与触控支持
游戏同时支持键盘操作和屏幕触控:
| 操作方式 | 上 | 下 | 左 | 右 |
|---|---|---|---|---|
| 键盘方向键 | ↑ | ↓ | ← | → |
| WASD键 | W | S | A | D |
| 屏幕按钮 | 🔼 | 🔽 | ◀️ | ▶️ |
dart
KeyboardListener(
focusNode: FocusNode()..requestFocus(),
onKeyEvent: (event) {
if (event is KeyDownEvent) {
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowUp:
case LogicalKeyboardKey.keyW:
_move(0, -1);
break;
case LogicalKeyboardKey.arrowDown:
case LogicalKeyboardKey.keyS:
_move(0, 1);
break;
// ... 其他方向
}
}
},
child: // 游戏界面
)
五、关卡设计
5.1 关卡难度曲线
好的关卡设计应该遵循循序渐进的原则:
第1关
入门
第2关
进阶
第3关
挑战
2个箱子
直线推动
4个箱子
需要规划
4个箱子
空间受限
5.2 关卡数据结构
dart
final List<List<List<int>>> levels = [
// 第1关 - 入门:2个箱子,空间充足
[
[1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 1],
[1, 0, 3, 2, 0, 0, 1],
[1, 0, 0, 0, 3, 2, 1],
[1, 0, 4, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1],
],
// 第2关 - 进阶:4个箱子,对称布局
[
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 2, 3, 0, 3, 2, 1],
[1, 0, 0, 0, 4, 0, 0, 1],
[1, 0, 2, 3, 0, 3, 2, 1],
[1, 0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
],
// 第3关 - 挑战:中间有障碍
[
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 1, 0, 0, 0, 1],
[1, 0, 3, 0, 3, 0, 0, 1],
[1, 2, 0, 0, 0, 2, 0, 1],
[1, 0, 3, 4, 3, 0, 0, 1],
[1, 2, 0, 0, 0, 2, 0, 1],
[1, 0, 0, 1, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
],
];
5.3 关卡设计原则
设计一个好玩的推箱子关卡,需要注意以下几点:
- 避免死局:确保玩家不会一开始就把箱子推到无法移动的位置
- 有解且唯一:最好有一个最优解,让玩家有追求的目标
- 空间适中:太大显得空旷,太小则操作困难
- 渐进难度:从简单到复杂,给玩家成长的空间
5.4 最优步数参考
| 关卡 | 箱子数 | 参考最优步数 | 难度评级 |
|---|---|---|---|
| 第1关 | 2 | 12步 | ⭐ |
| 第2关 | 4 | 24步 | ⭐⭐ |
| 第3关 | 4 | 32步 | ⭐⭐⭐ |
六、完整源码
6.1 项目结构
flutter_sokoban/
├── lib/
│ └── main.dart # 游戏主代码(约200行)
├── ohos/ # 鸿蒙平台配置
├── pubspec.yaml # 依赖配置
└── README.md # 项目说明
6.2 运行命令
bash
# 获取依赖
flutter pub get
# 运行到模拟器/设备
flutter run
# 运行到鸿蒙设备
flutter run -d ohos
# 构建发布包
flutter build hap --release
6.3 核心代码统计
| 模块 | 代码行数 | 功能说明 |
|---|---|---|
| 数据定义 | ~30行 | 关卡数据、状态变量 |
| 游戏逻辑 | ~80行 | 移动、碰撞、胜利判断 |
| UI渲染 | ~90行 | 界面布局、样式 |
| 合计 | ~200行 | 完整可运行的推箱子游戏 |
七、扩展方向
如果你想继续完善这个游戏,这里有一些思路:
7.1 功能扩展
推箱子游戏
撤销功能
关卡编辑器
云端存档
排行榜
音效系统
记录历史状态
可视化编辑
跨设备同步
7.2 撤销功能实现思路
dart
// 使用栈来保存历史状态
List<GameState> history = [];
class GameState {
final List<List<int>> map;
final int playerX;
final int playerY;
GameState(this.map, this.playerX, this.playerY);
}
void _move(int dx, int dy) {
// 移动前保存状态
history.add(GameState(
gameMap.map((row) => List<int>.from(row)).toList(),
playerX,
playerY,
));
// ... 执行移动逻辑
}
void _undo() {
if (history.isEmpty) return;
final state = history.removeLast();
setState(() {
gameMap = state.map;
playerX = state.playerX;
playerY = state.playerY;
moves--;
});
}
八、常见问题
Q1: 为什么玩家位置要单独存储,而不是放在地图数组里?
将玩家位置单独存储有两个好处:
- 简化状态管理:玩家移动时不需要修改地图数组
- 处理"玩家站在目标点上"的情况更简单
如果把玩家放在地图里,当玩家站在目标点上时,需要额外的状态来记住这个位置原本是目标点。
Q2: 如何判断一个关卡是否有解?
这是一个经典的算法问题。可以使用 BFS(广度优先搜索)或 A* 算法来求解:
dart
// 伪代码
bool canSolve(GameState initial) {
Queue<GameState> queue = Queue();
Set<String> visited = {};
queue.add(initial);
while (queue.isNotEmpty) {
var state = queue.removeFirst();
if (isWin(state)) return true;
for (var direction in [up, down, left, right]) {
var newState = move(state, direction);
if (!visited.contains(newState.hash)) {
visited.add(newState.hash);
queue.add(newState);
}
}
}
return false;
}
Q3: 如何避免箱子被推到死角?
可以在移动后检测"死锁"情况:
- 角落死锁:箱子被推到两面都是墙的角落
- 边缘死锁:箱子沿着墙边,且该边没有目标点
检测到死锁后可以给玩家提示,或者自动撤销这一步。
九、总结
本文从零开始,用约200行Dart代码实现了一个完整的推箱子游戏。整个开发过程涵盖了:
- 游戏设计:分析规则,定义元素和状态
- 数据结构:用二维数组表示地图,用数字编码状态
- 核心逻辑:移动判断、碰撞检测、胜利条件
- UI实现:GridView渲染、键盘监听、触控支持
- 关卡设计:难度曲线、设计原则
推箱子虽然是个小游戏,但麻雀虽小五脏俱全。通过这个项目,你可以学到状态管理、碰撞检测、关卡设计等游戏开发的基础知识。
希望这篇文章对你有所帮助,如果觉得不错,别忘了点赞收藏!
🎮 完整源码已上传,欢迎Star支持!
作者 :Flutter游戏开发者
发布日期 :2026年1月15日
版权声明:本文为原创文章,转载请注明出处
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net