1. 游戏集合里的推箱子:为什么“箱子目标点”才是关卡核心
做游戏集合App时,我一开始没把推箱子太当回事:地图不大、逻辑简单,感觉一个下午就能写完。真正动手了才发现,整个模块最容易出Bug、也最值得单独设计的,不是角色走路的动画,而是那些看起来不起眼的“箱子目标点”。目标点在推箱子关卡里同时承担三件事:决定地图上哪些格子可以走、决定箱子放上去后要不要高亮提示、决定整关到底算不算通关。这三件事挤在一个坐标集合上,牵一发而动全身。如果一开始不把“目标点”单独当成核心数据结构来设计,后面所有判定都会变得很别扭。
这篇文章我会围绕Flutter for OpenHarmony环境下开发游戏集合App时,推箱子模块里“箱子目标点”从建模、判定到渲染的完整落地过程来写。内容包含地图数据结构怎么设计、移动和碰撞的坐标系怎么处理、目标点命中与胜利条件怎么写,以及OpenHarmony平台适配时几个容易被忽略的坑。我会把关键代码、选型理由和踩坑过程都摊开讲,适合正在做类似项目、或者准备把Flutter游戏逻辑迁移到OpenHarmony上的同学参考。
1.1 推箱子模块在游戏集合里的定位
游戏集合App通常有很多小游戏,每一个模块都是独立的。推箱子在其中扮演的角色,我认为是“低资源、高逻辑密度”的代表:它不需要大量美术素材,不需要物理引擎,甚至连音频都可以不做,但它的状态判断复杂度一点都不低。正因为如此,推箱子非常适合用来检验Flutter在OpenHarmony上的基础渲染能力、输入响应速度以及状态管理链路。
在OpenHarmony这样一个还需要验证生态适配的平台上,优先引入推箱子这类逻辑型小游戏,比引入粒子特效、物理模拟类游戏要稳妥得多。它能把“Flutter能不能在平台上跑起来”和“复杂状态切换能不能稳定复现”分成两个问题逐一验证。我当时的做法是先把推箱子做成一个独立页面,再把它嵌入到游戏集合的导航框架里,这样出问题时可以快速定位是这个模块的问题,还是公共框架的问题。
1.2 “箱子目标点”身上有三份职责
很多人写推箱子时,会把目标点当成“地面上的一块特殊贴图”,只在地图渲染时画一下,逻辑上完全不参与碰撞。这种做法在关卡很简单时能跑通,但一旦关卡出现“箱子先推到目标点上、之后又被推开”的情况,就会暴露出各种问题。目标点至少要处理三层逻辑:
- 它必须是可通行地面:玩家和箱子都能踩上去,不能因为它是目标点就变成障碍物。
- 它要响应箱子占用:箱子停留在目标点上时,需要有状态变化,否则玩家看不出自己推对了没有。
- 它要参与胜利判定:通常关卡要求“所有箱子都在目标点上”,而不是“所有格子都被箱子占满”。如果只是判断有没有箱子,忽略了目标点集合,就会出现玩家把箱子推到普通墙角也判定获胜的假胜利情况。
所以“箱子目标点”不是一个渲染概念,而是一套独立的状态集合。理解了这一点,后面的建模和判定逻辑才会有清晰的方向。
2. 二维数组地图建模:把地板、墙、箱子和目标点统一成一张表
推箱子的地图本质是一个二维网格。最直白的建模方式,就是把整个地图写成二维数组,每个格子用一个数字代表它是什么。这样做有几个明显的好处:关卡数据容易手写、容易传输、也容易通过字符串转存。当时我设计推箱子关卡时,没有直接做关卡编辑器,而是先用二维数组跑通逻辑,这样改关卡只需要改数据,不需要动代码。
2.1 经典关卡地图的数字编码方案
我用的编码方案是从常见推箱子开源项目里提炼出来的,下面这套定义已经很成熟:
| 数字 | 含义 |
|---|---|
| 0 | 空地 |
| 1 | 墙 |
| 2 | 箱子 |
| 3 | 目标点 |
| 4 | 玩家 |
| 5 | 玩家站在目标点上 |
| 6 | 箱子在目标点上 |
其中5和6是“叠加状态”。它们存在的意义,是让关卡作者能直接在二维数组里表达“玩家已经站在目标点”或“箱子已经在目标点上”的初始状态。比如一个关卡保存到文件后再读取,玩家和箱子的位置都不应该丢失,所以必须用这两个特殊值记录重叠情况。
下面是一个典型的关卡示例,3个箱子、3个目标点,角色在最下面一行:
List<List<int>> levelMap = [ [1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 1], [1, 0, 3, 2, 0, 0, 3, 1], [1, 0, 0, 0, 2, 0, 0, 1], [1, 0, 3, 0, 0, 0, 2, 1], [1, 0, 0, 4, 0, 0, 0, 1], [1, 1, 1, 1, 1, 1, 1, 1], ];这个数组里的3就是目标点,2是箱子,4是玩家。注意看,这里没有出现5和6,因为这是一个纯粹的初始状态。如果关卡是从某个中间进度恢复的,就可能出现6,比如某个箱子已经在目标点上,我们需要保留这个信息。
2.2 单一矩阵方案和对象集合方案,我选择后者
二维数组编码适合“描述关卡”,但运行时如果直接改这个数组,会非常痛苦。举一个例子:玩家从4号位置走到目标点上,我们要么把旧值改成0、新值改成5,要么把整个数组复制一份,任何一步出错都会导致“玩家凭空消失一个”或者“地板多出一块”。
所以我采用了运行时“对象集合”的思路。也就是说,把二维数组当作用于创建关卡信息的原始数据,在加载时一次性解析成几个集合:
class SokobanLevel { final List<List<int>> rawMap; late Set<Point<int>> walls; late Set<Point<int>> goals; late Set<Point<int>> boxes; late Point<int> player; final Point<int> size; SokobanLevel(this.rawMap) : size = Point(rawMap[0].length, rawMap.length) { walls = {}; goals = {}; boxes = {}; for (int y = 0; y < rawMap.length; y++) { for (int x = 0; x < rawMap[y].length; x++) { final p = Point(x, y); switch (rawMap[y][x]) { case 1: walls.add(p); break; case 2: boxes.add(p); break; case 3: goals.add(p); break; case 4: player = p; break; case 5: goals.add(p); player = p; break; case 6: goals.add(p); boxes.add(p); break; } } } } }这样做的优势很明显:移动时只需要把玩家坐标赋值给新位置,不需要去改数组里的旧位置;判断箱子是否在目标点上,只需要判断goals.contains(boxPosition);判断某个格子能不能走,只需要检查walls.contains。二维数组变成了一种“关卡序列化格式”,真正的游戏状态则在集合上运行。
单一矩阵方案适合那种“所有状态都集中在数组里、不需要频繁查询集合归属”的场景,比如地图极小、没有复杂叠加状态的小Demo。但推箱子涉及大量目标点命中判断,集合查询是O(1),远比遍历矩阵再逐格判断高效。这也是我建议用对象集合方案的根本原因。
3. 移动判定与碰撞检测:“能推”和“能停”背后的坐标状态机
推箱子的移动逻辑看起来简单:角色往某个方向走一步,如果前面是墙就不动,如果是箱子就试着把箱子往前推一格。但真正实现时,最容易出问题的是判定顺序和边界条件。很多新手会先判断“箱子能不能动”,再判断“角色能不能动”,这会导致角色隔着箱子还能穿墙的诡异现象。我建议把判定拆成两步,顺序固定。
3.1 先判玩家能否走过去,再判箱子能否被推动
核心移动逻辑可以封装成一个tryMove方法。它的职责是:传入方向后,要么成功移动并改变内部状态,要么什么都不变。这是因为推箱子的每一步都需要是可回滚的,尤其在后面做“悔一步”功能时,如果移动方法内部的状态变更不是原子的,撤销就会非常麻烦。
bool tryMove(Point<int> dir) { final next = player + dir; // 1. 目标是墙,直接失败 if (walls.contains(next)) return false; // 2. 目标位置有箱子 if (boxes.contains(next)) { final boxNext = next + dir; // 箱子前方是墙或另一个箱子,推不动 if (walls.contains(boxNext) || boxes.contains(boxNext)) { return false; } // 先移出旧箱子位置,再加入新箱子位置 boxes.remove(next); boxes.add(boxNext); } // 3. 玩家移动 player = next; return true; }这里最关键的一行是boxes.remove(next); boxes.add(boxNext);。它必须保证“旧位置删除”和“新位置添加”是连续完成的,中间不能插入其他状态修改。我之前遇到过一种错误写法:只调用了boxes.add(boxNext),没有先remove(next),结果箱子列表里同时存在旧位置和新位置。后来排查出的现象非常诡异:箱子看起来动了一步,但原来的位置仍然像有个隐形箱子一样挡着玩家,甚至目标点判定会同时认为“箱子已经被推到位了”和“箱子还在地上”。
另一个要注意的点:目标点集合goals不会参与碰撞判断。目标点永远是可通行的地面,不管上面是否已经有箱子。所以代码里只需要查walls和boxes,不需要查goals。
3.2 用集合建模后,玩家和箱子都不需要“站在目标点”的特殊状态
在纯矩阵方案里,5和6这两个特殊数字会带来很多麻烦:玩家从普通地面踩上目标点,数组里要把4改成5;离开时又要把5改回4。如果目标点下面还有箱子,那就要同时处理6和2的转换。一旦地图有重叠状态,改起来非常容易漏。
用集合方案后,玩家和箱子本身就是独立实体,它们和目标点是通过goals.contains查询出来的叠加关系,而不是存储层面的特殊状态。代码里只需要保留player和boxes,渲染时再判断“玩家当前坐标是否在目标点里”、“箱子当前坐标是否在目标点里”,然后额外绘制一层高亮即可。这样建模,逻辑和表现层彻底分离,后面接渲染、接动画、接音效都会轻松很多。
实践下来,这套状态机的核心价值在于:它把“移动”从“修改二维数组”变成了“修改集合成员”。集合的增删是明确且可逆的,调试时只要打印几个集合,就能清楚知道当前关卡的真实状态。
4. 箱子目标点判定:从命中高亮到胜利条件的一整套规则
移动逻辑跑通之后,真正体现推箱子“游戏性”的部分就是目标点判定。目标点判定至少要回答两个问题:第一个,箱子现在是不是站在目标点上?第二个,所有箱子是不是都已经到达目标点了?这两个问题看似一样,实际是完全不同的判断,前者管渲染表现,后者管关卡结束。
4.1 目标点命中判定的时机和渲染表现
目标点命中的判定时机,一般放在一次成功的移动完成之后,也就是tryMove返回true之后立刻去做。此时玩家和箱子位置都已更新,正好可以用来刷新整个界面的状态。示意代码如下:
void onMove(Point<int> dir) { if (!tryMove(dir)) return; isWin = goals.every(goals.contains? // 注意这里要写清楚 ); setState(() { // 刷新渲染 }); }上面的代码里明显不能直接写goals.every(goals.contains),这里只是示意,真正写法应该是遍历所有箱子位置:
bool isWin = goals.every((goal) => boxes.contains(goal));命中反馈的渲染表现,是在画每个格子时叠加判断。如果一个坐标同时属于boxes和goals,就说明这是一个“已经放好箱子的目标点”。这时可以给它画一层高亮底色、加一个对勾图标,或者播放一个短暂的缩放动画。我个人建议不要在命中后把箱子改成“不可移动”状态,因为推箱子的规则允许玩家把已经放好的箱子再推走。如果你在命中时给箱子加了一个onGoal标记,之后箱子被推走,还要再清除这个标记,很容易漏。
4.2 胜利条件怎么写才严谨
我看过一些推箱子Demo,胜利判断写成了“箱子数量等于目标点数量”。这个写法漏洞很大。如果关卡里故意多放了一个箱子,玩家把所有目标点都占满了,但场地里还有一个多余的箱子在别处,按这个逻辑照样判胜利,这显然不对。正确的立足点是:目标点是“必须被满足”的一方,而不是箱子。
bool isWin() { if (goals.length != boxes.length) return false; return goals.every(boxes.contains); }这里先判断数量相等,再判断每个目标点都有箱子覆盖。为什么要先判断数量?因为如果每次只判断goals.every(boxes.contains),而关卡里箱子数量恰好大于目标点数量,那么即使所有目标点都被占满,我们也无法知道是否还有箱子散落在无关位置。虽然正常关卡设计不会出现这种情况,但写进逻辑里作为防御,成本极低。OpenHarmony上的游戏集合App会从不同渠道加载关卡文件,谁也不敢保证所有关卡数据都经过严格校验,防御性代码能让外部数据导致的假胜利概率降到最低。
4.3 多箱子与多目标点的分配逻辑
推箱子关卡里,箱子和目标点通常是一一对应的,但玩家并不需要知道哪个箱子必须归位到哪个目标点。判定时只需要看每一个目标点是否都有箱子,不需要关心它们是如何匹配的。这意味着目标点集合与箱子集合只要最终相等即可。某个箱子可能先被推到一个目标点,之后又被推走,换另一个箱子过来,只要最终每个目标点都被占据,关卡就算完成。
这种“无配对”的判定非常适合集合运算。Dart里可以直接使用SetEquality或者遍历写法,不必为每个目标点单独绑定箱子。我在做关卡校验时,还会额外检查箱子数量和目标点数量是不是一致,如果不一致,直接在加载关卡时抛出错误,而不是等玩家玩到一半才发现永远无法过关。
5. Flutter层渲染:用最少Widget让每个目标点看得清、盒子跑得动
推箱子的地图通常不超过10乘以10,用Flutter的Widget方案完全可以轻松应对。很多人一上来就考虑CustomPaint,认为游戏画面需要高性能绘制。但推箱子这种低密度网格,用CustomPaint反而会让点击检测、状态更新、动画处理都变得很繁琐。我自己用的是GridView配合Stack叠加的方案,代码直观,也好调试。
5.1 每个格子一个Widget,叠加关系通过Stack表达
渲染时,我按照“先地板、后目标点、再实体”的顺序来绘制每一格。这样即使一个格子里同时存在目标点和箱子,也不会相互覆盖。一个简单但完整的构建逻辑是这样的:
Widget buildCell(int x, int y) { final p = Point(x, y); final isWall = walls.contains(p); final isGoal = goals.contains(p); final isBox = boxes.contains(p); final isPlayer = player == p; return Container( decoration: BoxDecoration( color: isWall ? Colors.blueGrey : (isGoal ? Colors.amber.shade100 : Colors.grey.shade300), border: Border.all(color: Colors.black12), ), child: Stack( children: [ if (isGoal) Center(child: Icon(Icons.star_border, color: Colors.orange, size: 20)), if (isBox) Center(child: Container( width: 30, height: 30, color: Colors.brown, )), if (isPlayer) Center(child: CircleAvatar(radius: 12, backgroundColor: Colors.blue)), ], ), ); }这里我用了Material Icons里的星级图标来表示目标点,效果清晰,而且不需要额外引入图片资源。在实际项目里,使用Icons.star_border和Icons.star的切换,就可以表达“目标点待命中”和“目标点已命中”两种状态。需要注意,Flutter for OpenHarmony对Material字体图标的支持是完整的,我用下来没有遇到缺字问题。
5.2 移动动画:不要急着上复杂补间
推箱子移动最简单的处理方式,是每次移动后直接setState重建所有格子。这种方案在10乘以10的关卡上性能毫无压力,帧数稳定,而且逻辑最简单。如果想要些手感,可以给箱子和玩家包一层AnimatedPositioned,但前提是你用Stack而不是GridView来渲染。用GridView时,位置是随索引固定的,给格子内部元素做平移动画反而麻烦。
我的建议是:第一版先不上动画,等逻辑完全稳定后再增加动画。因为动画会让异常状态更难观察。比如箱子离奇丢失或出现假胜利Bug时,如果有动画干扰,很难一眼看出是坐标集合出了问题,还是动画状态残留导致的问题。等逻辑稳定之后再加,调试效率会高很多。
5.3 关卡切换和重置时的渲染问题
关卡重置时,最简单可靠的做法是:重新用原始levelMap构造一个全新的SokobanLevel对象,然后把所有界面状态绑定到新对象上。千万不要尝试在现有集合上把箱子一个个移回原位,那样做还不如重新加载来得干净。重新加载的优势在于,连之前解析出来的wall、goal集合的引用都会被替换,不会出现旧集合残留。
如果关卡列表较长,建议把每个关了的数据预先解析成SokobanLevel,并缓存起来。不要每次进入关卡都重新解析一遍二维数组。OpenHarmony设备上的Flutter内存和普通Android设备差异不大,但缓存复用本来就是好习惯,还能避免解析过程中出现临时对象触发频繁GC。
6. OpenHarmony平台适配:Flutter工程打包成游戏合集App要注意的细节
Flutter for OpenHarmony的适配工作,目前属于“已经可以跑、但环境细节还需要确认”的阶段。想要把推箱子模块顺利打包进游戏集合App,不能直接当成普通Flutter工程处理。下面是我实际遇到过以及建议提前确认的几点。
6.1 先确认Flutter发行版与OpenHarmony的对应关系
第一步,一定要使用专门适配OpenHarmony的Flutter发行版,而不是标准Flutter渠道。OpenHarmony的Flutter运行时不依赖Android的Skia等底层组件,所以发行版里包含了针对OpenHarmony平台的引擎实现。具体版本号变化得比较快,我不在这里写死,以免你看到的是过时信息。动手前,先去对应的开源仓库看分支和Release说明,确认目标OpenHarmony版本匹配,是最稳妥的做法。
工程目录上,OpenHarmony平台相关的原生代码通常放在ohos目录下,而不是android或ios。构建时也需要切换到对应的构建流程。如果直接使用标准Flutter命令,可能会在平台检测阶段直接失败,或者生成了不包含HAP的产物。这里建议在项目根目录放置一个环境说明文档,记录SDK路径和构建命令,否则过了两个月你自己都会忘记当时是怎么构建出来的。
6.2 输入事件要用统一的抽象层处理
推箱子依赖方向输入。OpenHarmony设备形态差异较大,有的可能是触屏,有的可能自带物理键盘,有的甚至可以通过手柄控制。写代码时不要直接监听“触摸按钮”或“键盘事件”,而是抽象出统一的方向输入:
void handleDirection(Direction dir) { Point<int> delta; switch (dir) { case Direction.up: delta = Point(0, -1); break; case Direction.down: delta = Point(0, 1); break; case Direction.left: delta = Point(-1, 0); break; case Direction.right: delta = Point(1, 0); break; } onMove(delta); }在界面上,方向按钮调用handleDirection;在桌面上,键盘事件也转换成Direction后调用同一个方法。这样后续如果要支持手柄,只需要在手柄映射层做一次键值转换即可,游戏逻辑完全不受影响。我在OpenHarmony上遇到过一个问题:某些设备的硬件键盘事件回调里,数字小键盘的箭头键和主键盘区箭头键返回的LogicalKeyboardKey不一样。这时候统一抽象层就派上用场了,在抽象层里把两个键值都映射到同一个方向,问题立刻解决。
6.3 HAP包大小和启动性能控制
游戏集合App往往不止一个游戏,包体积很容易膨胀。Flutter引擎本身已经占了不少体积,推箱子模块就不要再用大量图片素材了。用纯色方块、Icons图标、简单绘制就能做出足够清楚的视觉效果。目标点用图标、箱子用色块、玩家用圆形,既不增加包体,渲染速度也快。OpenHarmony上有些设备的GPU能力一般,但推箱子这样简单的画面几乎不会触发性能瓶颈。真正要留意的是Debug模式下的卡顿——OpenHarmony适配早期,Debug模式性能会比Release模式差很多,一定要用Release模式测试真实的流畅度。
还要注意Flutter插件生态的兼容性。很多第三方Flutter插件依赖Android系统能力,OpenHarmony上未必有对应实现。推箱子模块我尽量只用了Flutter SDK自带能力,没有引入任何平台插件,这也是它能在OpenHarmony平台上快速跑通的重要原因。
7. 一次“假胜利”Bug的完整排查链路:状态覆盖问题复盘
这部分我想完整复盘一次我在开发推箱子模块时遇到的Bug。这个问题非常隐蔽,而且恰好就出在“箱子目标点”这个主题上。它让我意识到,集合状态修改的顺序哪怕错一点,也会引发连锁的假象。
7.1 问题现象:箱子没全到位,游戏却提示胜利
测试关卡配置了3个箱子、3个目标点。我测试时先推了一个箱子到目标点,然后又把同一个箱子从目标点推走,推到另一个普通空地上。接下来我去推第二个箱子,推到目标点上的瞬间,游戏竟然提示胜利。我停下来数了数,场地上还有两个箱子没在目标点上,其中一个甚至刚从目标点被推走。这个画面让我非常困惑:胜利判定用的明明是goals.every(boxes.contains),为什么会出现目标点没占满却获胜的情况?
7.2 排查过程:打印箱子集合,发现问题出在集合残留
我先在胜利判定的位置打印了所有集合内容,不多久就发现了异常。打印结果显示,箱子集合里同时存在两个相同逻辑地址的坐标:一个是被推走之后的新位置,一个是之前停在目标点上的旧位置。也就是说,同一个箱子在集合里占了两个位置,旧位置还未被删除。这就解释了为什么goals.every(boxes.contains)会通过:目标点确实被“某个箱子坐标”覆盖着,因为旧坐标还残留在集合里。
问题出在移动逻辑的修改顺序。我最初写移动逻辑时,先判断可以推,就立刻执行了boxes.add(boxNext),然后才在另一个方法里尝试删除旧位置。由于某些分支没有走到删除逻辑,导致旧坐标一直留在集合里。
// 错误写法示意 if (boxes.contains(next)) { final boxNext = next + dir; if (walls.contains(boxNext) || boxes.contains(boxNext)) return false; boxes.add(boxNext); // 先添加 // 忘了或没执行 boxes.remove(next) } player = next; return true;这个Bug在“第一次把箱子推到目标点”时不会暴露,因为旧坐标被覆盖后,玩家站在箱子原来的位置,看起来一切都是对的。但一旦箱子被推走,旧坐标仍然存在,就会导致“同一个箱子两处都存在”的荒谬状态。渲染表现则是一个箱子可能显示在两个格子里,或者一个格子明明看起来没有箱子,但碰撞判定里却认为有箱子挡路。
7.3 修复方案:把集合修改做成原子操作
修复方法很简单,把boxes.remove(next)和boxes.add(boxNext)在同一个逻辑层级中连续执行,并且用条件判断保证两条语句一定成对执行。这也是我之前在第3节里反复强调的那两行代码:
boxes.remove(next); boxes.add(boxNext);改完之后再跑同一套操作序列,假胜利现象立刻消失。排查这个Bug给我最大的启发是:集合类的“添加”和“删除”看起来是独立操作,但在业务语义上,它们是一个整体。只要中间有分支、有提前返回、有另一个异步操作插进来,状态就会被撕裂。后来我给移动逻辑加了一条铁律:任何状态修改,要么全部完成,要么全部不发生。如果某个分支可能导致部分修改,就必须把修改重构到同一个不可分割的流程里。
7.4 复盘之后我给关卡数据加了一道校验
假胜利Bug修复后,我又在关卡加载逻辑里加了一个校验:箱子数量和目标点数量必须一致,否则直接拒绝加载。这道校验没有直接修这个Bug,但它能防止后续从外部导入关卡时出现“少个箱子但多个目标点”的先天性错误。一个异构的关卡,在目标点判定上必然会出现无法通关或提前通关的怪象。与其让玩家遇到问题后反馈,不如在数据入口就拦下来。
推箱子这个模块在游戏集合App里虽然不起眼,但“箱子目标点”这套状态设计涉及的边界情况非常多。把它彻底理清之后,再移植到其他平台、做关卡编辑器、增加步数统计等功能,都会非常顺手。在Flutter for OpenHarmony这个新平台组合上,把一个小而严谨的游戏逻辑跑通,实际收获比想象中更大。你也不妨从推箱子入手,先构建一个可靠的起点,再逐步扩展其他玩法,这条路会走得很稳。