
最近在折腾一个基于 Flutter for OpenHarmony 的躲避类小游戏项目玩法不复杂玩家控制一个小球在屏幕下方左右移动躲开从上方不断掉落的障碍物坚持时间越长分数越高。项目本身是个练手向的交互 Demo但真正动手做了之后才发现这种看起来简单的游戏有两个地方直接决定了成品好不好玩一个是碰撞检测算法准不准、快不快另一个是游戏结束后的整套处理流程能不能让玩家体面地退出、重开。这篇文章就围绕这两条主线把我实际用到的方案、踩过的坑、以及最后怎么调优的过程都写下来给想在 Flutter for OpenHarmony 上做类似交互游戏的人一个参考。1. 为什么在 OpenHarmony 上选 Flutter 做游戏技术选型与工程背景先说清楚这个项目是怎么来的以及为什么最后没有用系统原生 UI 框架而是选择了 Flutter for OpenHarmony。OpenHarmony 本身是个面向多设备形态的开源操作系统应用开发可以走系统自带的声明式 UI 范式也可以接入 Flutter 这类跨平台 UI 框架。我这次做的小游戏核心诉求是能快速验证玩法并且后续如果要把游戏逻辑复用到其他设备端代码可以实现最大程度共享。1.1 项目场景与目标设备小游戏的目标设备是一块屏幕尺寸不大的 OpenHarmony 开发板触摸输入为主性能中等内存算不上富裕。游戏画面完全由自定义绘制产生没有使用任何系统原生控件也没有依赖第三方游戏引擎。项目名我内部叫模拟项目X玩法逻辑都封装在 Flutter 侧OpenHarmony 平台只需要提供窗口和触摸事件通道。之所以这样设计是因为 OpenHarmony 的生态还在逐步成熟如果直接依赖系统原生 UI 组件去写游戏界面一是动画性能难以精细控制二是跨端复用会很麻烦。Flutter 的自绘渲染机制天然适合这种全屏自绘的游戏场景无论屏幕尺寸如何所有的绘制都由 Skia 引擎直接完成跟系统组件树基本解耦。1.2 Flutter 自绘渲染在游戏场景中的优势游戏里的每个对象无论是玩家小球还是下落障碍物本质就是一个带有位置、半径、颜色的圆形。我通过 CustomPainter 在每一帧里重新绘制整个游戏场景而不是用一个个独立的 Widget 去搭建。这样做的最大好处是帧率可控碰撞检测和绘制逻辑可以在同一个刷新周期内完成避免 Widget 树频繁重建导致的开销。很多入门者容易犯一个错误把游戏里的每个小物体都包成一个 StatefulWidget然后用 AnimatedPositioned 或 Transform 移动它。在小规模游戏里这可能勉强能跑但一旦对象数量上升到几十个Widget 树和状态刷新带来的开销会非常明显。正确做法是保持 Widget 树极简游戏状态全部保存在帧循环之外的对象模型中每帧用 repaint 一次性把整个画面画出来。1.3 为什么不引入物理引擎做碰撞检测时最自然的疑问是“为什么不直接用现成的物理引擎”。我实际对比过这个项目只需要检测碰撞发生的位置并且做出简单响应——玩家扣血、障碍物消失、播放特效。引入 Box2D 这类成熟物理引擎确实能解决碰撞检测问题但会带来额外的编译体积、内存占用和调试成本。对于这种休闲小游戏来说手写一套基于简单几何形状的碰撞检测反而能让逻辑更加透明可控性能开销也更小。还有一个现实原因是 Flutter for OpenHarmony 的第三方插件生态还在发展中物理引擎的绑定到底稳定不稳定、有没有针对 OpenHarmony 适配过都需要额外验证。与其把精力花在排查集成问题上不如直接手写碰撞检测逻辑简单出了问题一眼就能定位。2. 碰撞检测的算法选型为什么以圆形检测为主、AABB 辅助碰撞检测的算法选型直接决定游戏手感。我最早想直接把所有物体都当作矩形处理因为矩形检测的公式更简单但实际测试后发现手感很怪。原因在于玩家和障碍物都是圆形用矩形包围盒去检测两个圆形的碰撞会在视觉上产生“还没碰到就扣血”的偏差玩家对这种不公平判定非常敏感。2.1 圆形碰撞检测的核心公式圆形碰撞检测的数学基础很简单判断两个圆心的距离是否小于等于两个圆的半径之和。如果满足说明两个圆发生了重叠。实际实现时我不会直接计算距离因为距离需要开平方而在 Dart 中没有内置的快速开平方函数每帧对大量对象做开平方运算会带来不必要的 CPU 消耗。我统一使用距离平方和半径平方做比较公式变为distanceSq (dx * dx) (dy * dy) collision distanceSq (r1 r2) * (r1 r2)dx是两个圆心的横向坐标差dy是纵向坐标差r1和r2是两个圆的半径。这样整帧检测过程中完全不需要开平方只做整数或浮点乘法性能表现会好很多。2.2 什么时候用 AABB 辅助检测虽然说主碰撞检测用圆形但有一类场景圆形检测并不方便就是判断游戏物体是否超出屏幕边界。屏幕边界本质是个矩形区域如果还用圆形去检测是否越界需要分别计算圆与四条边的位置关系反而绕。这里直接用 AABB轴对齐包围盒的思路拿物体的中心坐标和半径与屏幕左右边界和上下边界做比较物体中心 x 坐标减去半径如果小于 0说明物体已经越出左边界。物体中心 x 坐标加上半径如果大于屏幕宽度说明越出右边界。这种边界检测计算量非常小本质就是四次减法加四次比较在每帧的更新循环里做一次并不会影响性能。当时我还尝试过要不要做更复杂的 OBB有向包围盒检测但游戏里所有物体都是正圆不存在旋转角度问题引入 OBB 属于过度设计直接放弃。2.3 空间划分避免碰撞检测退化成 O(n^2)如果障碍物数量很少比如屏幕上同时只有三五个遍历所有物体两两比较没有任何问题。但我的游戏里障碍物会持续生成后期屏幕上同时存在二三十个下落物体如果每次检测都把所有物体两两比较一遍一帧的检测次数就是 n(n-1)/2这个数字会随着障碍物数量快速膨胀。我的优化方案是做一个简单的网格空间划分。把整个游戏屏幕按照固定尺寸切成若干网格单元每个物体根据自身坐标算出落在哪个网格里检测时只需要检查自己所在网格以及相邻八个网格里的物体就够了。网格大小的选择很关键太大会退化回全量比较太小会导致一个物体跨越多个网格增加重复检测次数。经过实测200x200 的网格尺寸在大多数屏幕上都能达到比较好的效果。网格划分的实现也很直接用一个哈希表把网格坐标映射到物体列表每帧更新物体的网格归属。虽然增加了少量维护成本但碰撞检测的有效比较次数大幅下降游戏后期帧率明显更稳定。3. 碰撞检测的工程实现从数据模型到碰撞响应算法选型定下来之后最难的不是公式而是怎么把检测逻辑嵌套进游戏的帧循环里。这个环节最容易出问题也是我调试最久的部分。下面按数据模型、帧循环、碰撞响应三个阶段展开。3.1 游戏对象的数据模型我定义了一个 GameObject 类来保存所有游戏物体的公共属性。包括物体类型、横纵坐标、半径、速度向量、是否存活等字段。玩家和障碍物都共用这个类只是不同对象的类型标记不同。这样碰撞检测逻辑只需要面向 GameObject 编写不需要为玩家和障碍物分别写两套检测代码。这里有一个容易忽视的设计点碰撞检测和绘制共用了同一份数据。每帧先是更新所有物体的位置然后执行碰撞检测最后才用这份数据做绘制。这样避免绘制时读到的位置和碰撞检测时的位置不一致否则会出现视觉上已经碰撞了但逻辑层没触发的情况或者反过来。3.2 帧循环与碰撞检测主流程Flutter 里驱动游戏帧循环我用了 AnimationController设置成 unbounded 模式通过 addListener 监听每一帧回调。这样每一帧都会执行一遍完整的更新流程。核心代码如下所示void _onTick(double delta) { // 1. 更新所有对象位置 _updatePositions(delta); // 2. 边界检测处理越界物体 _checkBoundaries(); // 3. 碰撞检测玩家与所有障碍物逐一判断 _checkCollisions(); // 4. 清理已死亡对象 _removeDeadObjects(); // 5. 触发重绘 _repaint(); }碰撞检测主流程里我维护了一个玩家对象和障碍物对象列表检测玩家和每个障碍物是否发生了圆形碰撞。代码如下void _checkCollisions() { for (final obstacle in _obstacles) { final dx _player.x - obstacle.x; final dy _player.y - obstacle.y; final distSq dx * dx dy * dy; final radiusSum _player.radius obstacle.radius; if (distSq radiusSum * radiusSum) { _handleCollision(obstacle); } } }这里注意每次都是拿玩家对象去遍历障碍物列表不会出现玩家和玩家之间的检测也不会出现障碍物和障碍物之间的检测。后两种在本项目里没有实际意义排除在检测范围之外可以减少无效计算。3.3 碰撞响应扣血、移除与特效同时进行碰撞发生时我做的响应包含三件事玩家生命值减一、把当前障碍物标记为死亡、在碰撞位置生成一个简单的扩散特效。这三件事如果分开做很容易遗漏。我是集中写在一个_handleCollision方法里保证每次碰撞响应都是完整的事务。生成特效我用的是一个独立的特效对象列表特效对象只包含坐标、存活时间和当前扩散半径绘制时根据存活时间插值算出透明度。特效不参与碰撞检测纯粹是视觉反馈用于抵消“碰撞判定成功但画面不明显”的尴尬。这里我要特别提醒一个坑碰撞响应里如果直接修改障碍物列表比如在遍历列表的同时把当前障碍物 remove 掉Dart 的 List 在迭代过程中修改自身会抛出并发修改错误。我的做法是不在碰撞检测过程中直接移除对象而是把需要移除的对象放进一个待清理集合等碰撞检测全部完成后再统一清理。这样既安全又直观。3.4 多障碍物同帧碰撞的处理顺序玩家小球一次移动可能同时撞到多个障碍物尤其当障碍物密集下落时。如果玩家只有一滴血一次碰撞就死了那处理顺序影响不大但如果玩家有多条命就要注意碰撞响应的叠加方式。我的策略是同帧内一旦玩家生命值降为 0立即退出碰撞检测循环不再继续处理后面的障碍物同时标记生命值已经归零防止同一帧内重复触发游戏结束逻辑。void _checkCollisions() { for (final obstacle in _obstacles) { if (_player.hp 0) break; // 碰撞检测逻辑 } }这个 break 非常关键写漏了会导致游戏结束状态被触发多次后续的重玩流程也会跟着乱掉。我在项目初期就踩过这个坑症状是游戏结束后点击重玩界面却仍然显示 Game Over定位下来就是游戏结束标志位被重复触发状态机的重置被覆盖了。4. 游戏结束处理状态机设计、界面切换与生命周期问题游戏结束处理是另一个容易被低估的部分。很多教程只写怎么判断游戏结束比如生命值小于零就弹窗却没有处理重玩流程、状态恢复、后台切换等一系列问题。实际上游戏结束不只是弹一个界面而是一整套状态流转的闭环。4.1 游戏状态机playing、paused、gameOver我设计了三种游戏状态分别是游玩中、暂停中、已结束。整个项目的所有逻辑都围绕状态机展开不同状态对帧循环、碰撞检测、用户输入的处理方式都有区别。状态定义用枚举实现enum GameState { playing, paused, gameOver }playing 状态正常执行位置更新、碰撞检测、绘制。paused 状态停止位置更新和碰撞检测但不销毁界面玩家可以看到暂停时的画面。gameOver 状态停止所有游戏逻辑仅保留重玩按钮的交互响应。状态切换的触发点有严格的顺序。当玩家生命值降为 0 时先更新游戏状态为 gameOver再停止帧循环最后显示结束界面。如果顺序反了会出现一个问题结束界面还没显示帧循环已经停止画面停留在最后几帧用户以为游戏卡死了。4.2 Game Over 界面与重玩流程Game Over 界面我没有用 Navigator 跳转新页面而是直接在游戏画面上方叠加一个半透明面板。这样做的好处是重玩时不需要处理页面路由的压栈和弹栈只需要重置游戏数据并隐藏面板就能无缝回到游戏画面。重玩流程是整个结束处理里最重要的一环。我封装了一个resetGame()方法负责把玩家生命值恢复为初始值、清空所有障碍物列表、重置分数为 0、清理所有特效对象、将游戏状态切回 playing。这个方法必须在设置游戏状态为 gameOver 之后调用不能在结束界面弹出的过程中直接调用否则会出现占了结束弹窗但游戏已经重新开始的情况视觉和逻辑层不同步。实测中我发现还有一个细节重玩时最好先把 Game Over 面板隐藏再重置游戏数据。如果先重置数据用户在画面上会看到一瞬间的空白场景体验不够顺滑。我是通过一个布尔变量控制面板的显示先置为 false 触发重绘紧接着在下一帧初始化游戏数据这样玩家基本感知不到重置过程。4.3 OpenHarmony 生命周期对游戏的影响这是 Flutter for OpenHarmony 上比较特殊的一环。开发板上的应用如果被切到后台或设备息屏Flutter 的 Ticker 默认会停止回调导致游戏自动暂停。表面上看这好像是好事但实际上如果不处理生命周期事件恢复前台时 Ticker 的 delta 时间会出现一次异常大的跳变游戏里的物体位置会瞬间“瞬移”甚至直接穿过障碍物形成一帧穿越。我的处理方式是监听应用生命周期在切换到后台时先把游戏状态记录为 paused并把当前的游戏状态保存到一个临时变量里回到前台时检测到之前是 playing则恢复帧循环并且把本次帧回调的时间增量强制归零防止时间跳变导致的位置突变。AppLifecycleListener( onHide: () { _stateBeforeBackground _gameState; _setGameState(GameState.paused); }, onResume: () { if (_stateBeforeBackground GameState.playing) { _resetDeltaTime(); _setGameState(GameState.playing); } }, )关于状态机的设计我整理了一张简表方便理解触发场景当前状态切换后状态需要执行的动作生命值归零playinggameOver停止帧循环显示结束面板用户按下暂停按钮playingpaused记录当前状态暂停帧循环切到后台playingpaused记录当前状态暂停帧循环恢复前台pausedplaying重置时间增量恢复帧循环点击重玩按钮gameOverplaying重置所有游戏数据隐藏结束面板这套状态流转在实际运行中比较稳定没有出现状态卡死或重复进入的问题。5. 真机实测与性能优化帧率、触摸坐标与三个边界条件项目开发接近尾声时我在 OpenHarmony 真机上做了几轮完整测试。碰撞检测本身在低对象数量下并不吃力真正暴露问题的是触摸坐标适配、帧率抖动和一些边界条件下的逻辑异常。这些问题如果不实际跑真机很难在模拟器里发现。5.1 触摸坐标适配问题Flutter for OpenHarmony 上获取手势坐标的 API 和标准 Flutter 基本一致我用 GestureDetector 监听 onPanUpdate通过 event.localPosition 拿到触摸位置的局部坐标。但实测中发现在某些设备上 localPosition 的坐标系和 CustomPainter 的绘制坐标系并不完全一致会有固定比例的偏移。排查了半天发现问题出在设备的像素密度和系统状态栏高度上。CustomPainter 的绘制坐标基于逻辑像素而触摸事件在某些设备上返回的坐标可能包含了系统层的偏移。我的解决办法是在初始化时读取当前设备的 DPR 值并计算绘制区域的实际逻辑尺寸然后在触摸回调里对坐标做统一换算将触摸坐标映射到游戏对象坐标系中。具体做法是写一个适配类统一封装屏幕尺寸和 DPR 信息所有涉及坐标的转换都经过它。这样比在每处手势回调里手动塞偏移量要靠谱得多。5.2 帧率与内存优化碰撞检测这个环节容易出现的另一种性能问题是对象频繁创建导致的内存抖动。我的障碍物对象每 1.5 秒生成一个但碰撞响应时又需要生成特效对象如果生成和销毁的频率太高GC 会频繁触发帧率就会出现周期性掉帧。针对这个问题我做了三个优化。第一个是复用对象池障碍物在被移除后不立即销毁而是回收到对象池中下次生成时优先从池里取。第二个是尽量使用不可变数据减少对象属性修改引起的重建。第三个是绘制时避免创建新的 Paint 对象而是把 Paint 对象作为成员变量复用只在绘制参数变化时更新状态。经过优化后游戏中后期同时存在约 30 个障碍物时真机帧率能稳定在 55 到 60 帧相比优化前有明显改善。5.3 三个容易忽略的边界条件最后分享三个我在测试中反复踩到、也最容易被新手忽略的边界条件。第一个是高速物体的一帧穿越问题。当游戏难度提升障碍物下落速度变快时可能出现上一帧还在玩家上方很远处下一帧已经越过玩家位置的情况。如果用简单的逐帧距离比较这一帧会检测不到碰撞玩家没有扣血但视觉上穿过了障碍物。解决办法是引入连续碰撞检测的概念——计算本帧位移量如果一帧内移动的距离超过了碰撞半径之和就在运动路径上细分多个检测点逐个做碰撞判断。第二个是物体重叠状态下的重复扣血。玩家与障碍物碰撞后如果玩家没有立即死亡、障碍物也没有消失下一帧两个物体可能仍然重叠碰撞逻辑会再次触发导致一秒钟内连续扣掉好几滴血。解决方法是碰撞发生后给玩家设置一小段无敌时间比如 0.5 秒期间不再检测与障碍物的碰撞。这个设计在多数休闲游戏中都很常见既避免重复扣血也给玩家操作喘息的空间。第三个是列表遍历时删除元素的问题。前面提到过碰撞检测期间修改对象列表会报错或导致漏检。我在项目里统一使用“待清理列表”方案所有需要删除的对象先进入待清理集合等整轮检测结束后再统一处理。这个方法虽然看起来多了一步但避免了大量诡异的运行时错误稳定性大幅提升。这三个边界条件都是在真实设备上反复测试后才暴露出来的如果只在模拟器里跑几圈很难遇到这些组合场景。建议读者在写自己的游戏时提前把这些边界条件纳入设计考量不要等到崩溃或出现手感异常时才回头补。