打砖块是我做游戏开发练习时每次都会想先碰一遍的经典玩法,规则一句话能讲清楚:接住球,打碎砖,别让球掉下去。但真要把手感调到舒服、把关卡做得有层次,碰撞检测、物理反弹、难度曲线这些硬骨头一个都躲不掉。这次我把这套经典玩法用 Flutter 完整实现了一遍,并且跑在了 OpenHarmony 设备上,整个过程中踩了不少坑,也积累了一些可复用的套路。这篇文章不写泛泛的概念介绍,直接把我从项目设计到最终上机的完整思路、核心代码、以及各种报错处理经验整理出来,想用 Flutter 做 2D 游戏、或者正在折腾 OpenHarmony 应用开发的朋友,应该都能从中拿到点能直接用的东西。
这个组合最吸引我的地方在于,Flutter 的 CustomPaint 做 2D 绘制非常顺手,游戏里的球、板、砖块本质上都是简单的几何图形;而 OpenHarmony 作为新兴系统生态,正好缺大量优质应用,用 Flutter 跨端开发一套代码同时覆盖移动端和开源鸿蒙设备,投入产出比非常高。下面我就按从设计到实现的顺序,把整个项目拆开讲清楚。
1. 项目整体设计与技术思路拆解
1.1 为什么选 Flutter + OpenHarmony 这个组合
先说选型。打砖块这类游戏,用原生开发当然可以做,但要么是 Android/iOS 两套代码,要么在 OpenHarmony 上还得再学一套 ArkUI 的写法。Flutter 的优势是渲染引擎自绘 UI,不依赖系统组件,逻辑层和绘制层都能做到跨端一致。这意味着我在 Flutter 里写好的碰撞检测算法、关卡数据结构、动画控制器,跑到 OpenHarmony 设备上不需要重写,只需要把平台工程配置好就能直接跑。
而 OpenHarmony 这边,很多开发者关心的是生态适配程度。实际上 Flutter 社区已经有针对 OpenHarmony 的适配方案,开发者可以用 DevEco Studio 配合对应版本的 Flutter SDK 来构建鸿蒙应用。实测下来,纯 Dart 层的代码几乎不用改,主要工作在平台工程的创建和签名配置上。这个组合特别适合验证业务逻辑的跨端能力,打砖块虽然简单,但涉及帧循环、触控事件、自定义绘制、状态管理这些 Flutter 核心能力,能完整跑通,就说明这套链路是可靠的。
还有一点很现实:面试和岗位需求里 Flutter 和 OpenHarmony 同时出现的频率越来越高,这类跨端游戏实战是很好的简历材料。能把游戏逻辑讲明白、能把跨端坑说清楚,比背一堆 API 有用得多。
1.2 打砖块的核心玩法模型
动手写代码之前,我先把玩法里的对象和规则理了一遍。打砖块虽然简单,但也是一个完整体验闭环,核心对象只有四个:
- 球(Ball):有一个圆心位置、一个速度向量、一个半径。每帧按速度移动,碰到边界、挡板、砖块就反弹。
- 挡板(Paddle):一个可左右移动的矩形,宽度可配置,接收用户的拖拽操作。
- 砖块(Brick):排列在画面上方的矩形列表,每个砖块有自己的位置、尺寸和血量。
- 边界:画布四周。左右上三面会把球弹回,底部是"漏球"判定线,球掉出底部就扣一条命。
除了对象之外,整个游戏还有一个状态机,我定义成四个阶段:
- ready:球贴在挡板上,等待用户点击发射。
- playing:球在运动,碰撞逻辑全开。
- win:所有可破坏砖块被清光,展示过关反馈。
- gameover:生命用完,游戏结束。
这个状态机非常关键。很多人第一次写游戏逻辑容易把"游戏结束"和"过关"直接写在碰撞回调里,结果一个砖块同时触发多条碰撞记录时,状态被反复切换,逻辑直接乱套。我的做法是:碰撞检测只负责标记事件,统一交给 GameController 处理后由状态机决定下一步行为。这样代码结构清晰,后续加音效、加动画也不会破坏核心逻辑。
1.3 模块划分与工程骨架
为了让项目后期可维护,我一开始就按模块把工程理清了,目录结构大致是这样:
lib/ main.dart // 应用入口 game/ game_controller.dart // 游戏状态机、核心逻辑调度 ball.dart // 球模型 paddle.dart // 挡板模型 brick.dart // 砖块模型 physics.dart // 碰撞检测与反弹算法 levels.dart // 关卡数据与难度参数 ui/ game_page.dart // 页面脚手架 game_painter.dart // CustomPainter 绘制游戏逻辑全部放在 game 层,不依赖 Flutter 的 Widget 层。这样做的目的很直接:Dart 的纯逻辑可以在任意平台上跑,甚至可以在本机写 Dart 单测来验证碰撞算法,不需要启动模拟器。我实际开发中用 flutter test 跑了很多次碰撞检测用例,这个收益在后期调 bug 时帮了大忙。
UI 层只做两件事:把 GameController 的状态用 CustomPaint 画出来,把用户的拖拽手势转成挡板位置指令。状态管理我用的是 ChangeNotifier,和 ValueListenableBuilder 搭配使用。游戏帧率要求比较高,不适合每帧都 setState 整棵树重建,后面我会专门讲渲染性能的优化。
2. 物理反弹与碰撞检测:游戏的核心手感来源
2.1 球的运动模型与帧循环设计
打砖块的"物理"并不需要 Box2D 这种重量级引擎,核心就是一个匀速直线运动模型加反弹法则。球有一个位置和速度向量,每一帧执行 position += velocity * dt。
但这里有个细节值得单独说:dt 到底怎么取。我最初直接用渲染帧的时间间隔,也就是 Ticker 回调拿到的 elapsed 时间差。问题很快浮现:不同设备帧率不一样,帧率高的时候球明显更快,帧率一波动,球的移动就一顿一顿的。后来我改成固定时间步长逻辑,逻辑帧固定在每秒 60 次,渲染帧跟随屏幕刷新率,两项解耦。
用 Flutter 实现时,我是这样处理的:
class GameLoop { final Ticker _ticker; double _accumulator = 0; double _lastTime = 0; static const double fixedDt = 1 / 60; void tick(Duration elapsed) { double now = elapsed.inMicroseconds / Duration.microsecondsPerSecond; double frameDt = now - _lastTime; _lastTime = now; // 防止后台切回来时把物理帧一下补太多 frameDt = frameDt.clamp(0, 0.1); _accumulator += frameDt; while (_accumulator >= fixedDt) { update(fixedDt); // 固定步长更新物理 _accumulator -= fixedDt; } } }这样写的好处是物理表现和渲染帧率解耦,80Hz 屏幕和 60Hz 屏幕上球的绝对速度一致。而且碰撞检测也是在固定步长里做的,逻辑可复现,出问题能稳定复现排查。
球的反弹规则其实就三条:
- 碰到左/右壁,vx 取反。
- 碰到上壁,vy 取反。
- 碰到挡板,根据撞击位置重新计算速度方向。
这个模型简单,但已经是整个游戏的核心手感来源了。我调了最多时间的,就是挡板反弹的角度映射。
2.2 AABB碰撞检测:圆形球与矩形砖块
砖块和挡板都是矩形,球是圆形,所以核心碰撞检测是"圆 vs 矩形"。很多人第一反应是分别检测圆和四条边,其实有更优雅且性能更好的方法:把球心坐标 clamp 到矩形的范围内,得到一个距球心最近的矩形内点,如果球心到该点的距离小于球半径,就说明发生了碰撞。
double clampToRange(double value, double min, double max) { return value < min ? min : (value > max ? max : value); } bool circleRectCollision(Offset center, double radius, Rect rect) { double nearestX = clampToRange(center.dx, rect.left, rect.right); double nearestY = clampToRange(center.dy, rect.top, rect.bottom); double dx = center.dx - nearestX; double dy = center.dy - nearestY; return dx * dx + dy * dy <= radius * radius; }这段代码理解起来很直白:找矩形里离球心最近的点,量这个点到球心的距离,比半径小就是碰上了。
但检测到碰撞只是第一步,反弹方向才是重点。我的做法是:先算出最近点到球心的方向向量,把它归一化作为碰撞法线,然后让速度向量沿法线做镜面反射。
Offset reflect(Offset velocity, Offset normal) { double dot = velocity.dx * normal.dx + velocity.dy * normal.dy; return velocity - 2 * dot * normal; }这里有一个非常容易踩的坑:当球从砖块侧面撞进去时,如果单纯用"x 方向重叠多就反转 vx"这种粗略判断,球速快的时候会被夹在两面砖之间抖动,甚至出现一次碰撞后仍然处于重叠状态、下一帧再次触发碰撞的情况。用最近点法线 + 反射公式能大幅减少这种问题,因为法线方向代表了实际撞击面。
2.3 挡板反弹的"分段定角"策略
如果你直接让球撞到挡板就把 vy 取反,vx 保持不变,玩起来会非常无聊,球会一直在同一个角度来回飞,玩家很难控制球的落点。经典的打砖块通常采用分段反弹:挡板中心反弹时球竖直向上,越靠近挡板边缘,反弹角度越偏。
我的实现思路是,把球撞击挡板的位置映射成一个 [-1, 1] 的比例值,再用这个值把一个最大偏转角(我设为 60 度)映射到球的速度方向:
double hitRatio = (ball.center.dx - paddle.center.dx) / (paddle.width / 2); hitRatio = hitRatio.clamp(-1.0, 1.0); const double maxAngle = 60 * pi / 180; double angle = hitRatio * maxAngle; // 注意:屏幕坐标系 y 轴向下,向上反弹需要 cos 取负 ball.velocity = Offset(sin(angle), -cos(angle)) * ball.speed;这个方案手感比简单反弹好太多。玩家可以通过控制撞击点来决定球的走向,主动把球送到砖块密集的区域,游戏的可控性和策略性一下子就上来了。
实际调试时我发现,最大角度不能超过 60 度太多。角度太陡的话,球打掉边缘砖块之后会直接奔着侧墙飞,回防时间不够,玩家会觉得很"不公平"。60 度是比较经典的手感参数,我实测下来也最稳。
还有一个细节:球碰到挡板时不能只改方向不改坐标,必须把球的位置推出到挡板的上边缘之上,否则球会和挡板连续碰撞好几个逻辑帧,视觉上像被"吸"在挡板上一样。
2.4 防穿透与位置修正
打砖块里有个很经典的 bug 叫 tunneling,就是球速快到一定程度后,一帧之内球从矩形的一侧穿到了另一侧,碰撞检测完全错过。我的球速在后期关卡能到每秒 800 像素左右,在 60 逻辑帧下,每帧移动约 13 像素,而砖块厚度通常是 16 像素,虽然没穿透,但已经很接近了。
如果关卡设计里想让球速更快,有两个方案:
- 一是把物理步长调小,比如改成 120Hz 逻辑帧。
- 二是做连续碰撞检测,用球上一帧位置和当前位置连成线段,和矩形做相交检测。
我在这个项目里用了方案一,把固定 dt 从 1/60 调成 1/120,球的移动每次只有 6~7 像素,碰撞检测的可靠性大幅提升,性能开销并没有明显问题。方案二要算线段与矩形交点,复杂度会高不少,等真正需要子弹速度级别的游戏再上不迟。
另一个必须做的是碰撞后的位置修正。反射完速度向量之后,要把球的圆心位置沿着法线方向推出矩形,推到刚好接触的位置:
void resolveCollision(Ball ball, Rect rect, Offset normal) { ball.position = nearestPointOnRect + normal * (ball.radius + 0.01); }加 0.01 像素是为了防止浮点误差导致的下一次碰撞误判。这个小数看着不起眼,实际上能避免大量边缘闪烁问题。
3. 关卡系统的设计:从单局原型到多关体验
3.1 关卡数据用二维数组表达
打砖块的砖块排列天然适合用二维数组表达。我给每个关卡定义了一组独立的参数,包括砖块布局、球的初始速度、挡板宽度这些关键项:
class LevelData { final String name; final List<List<int>> bricks; // 0=空, 1=普通砖, 2=强化砖, -1=不可破坏 final double ballSpeed; final double paddleWidth; final int scorePerBrick; }关卡数据直接写成静态配置,放一个 levels.dart 文件里。一个简单的关卡是这样:
const LevelData level1 = LevelData( name: '开场热身', bricks: [ [1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 1], [1, 0, 2, 0, 0, 2, 0, 1], ], ballSpeed: 360, paddleWidth: 120, scorePerBrick: 10, );用二维数组的好处非常明显:配关时不用写代码,调布局改一行数组就行,而且可以设计各种形状。我配了几个经典图案:全满矩形、三角形、菱形、带缺口的城墙。砖块的颜色和血量我是通过值来区分的,绘制时再映射成不同颜色,逻辑层和表现层完全分离。
3.2 难度递增的曲线设计
打砖块的难度递增不只是"砖变多"这么简单。我设计关卡的时候同时调节四个维度,让玩家一直处于"有点挑战但还能打"的状态:
| 维度 | 前期关卡 | 中期关卡 | 后期关卡 |
|---|---|---|---|
| 球速 | 320~380 | 420~500 | 600~800 |
| 砖块血量 | 全部 1 点 | 混入 2 点 | 大量 2 点 + 少量 3 点 |
| 不可破坏砖 | 无 | 少量点缀 | 关键路径阻挡 |
| 挡板宽度 | 120 | 100 | 90 |
球速是最直接的难度来源,但无限加速会让玩家觉得游戏在"耍赖"。我的做法是球速提升的同时,把不可破坏砖块安排在砖阵的外围,这样玩家的击打目标更明确,策略性提升,难度曲线也就更平滑了。
血量设计也值得多说一句。强化砖我做成需要打两次,第一次打中颜色变浅,第二次才碎。这个设计在视觉上给了玩家进度反馈,比单纯加更多普通砖有意思得多。实测下来玩家对"两段式"砖块的反馈很好,因为它提供了一点点短期目标感。
3.3 关卡加载与重置流程
关卡流程我做了三层区分:初始化关卡、重置当前关卡、加载下一关。初始化是在游戏启动或者进入新关卡时执行,创建砖块列表并重置球和挡板;重置是指当前关卡失败重来,砖块布局不变,但球和挡板回到待发射状态;加载下一关则把关卡索引加一,再走初始化流程。
这个流程最好写成一个独立方法,不要在碰撞回调里直接改关卡索引。我一开始偷懒,在"砖块被清空"的回调里直接调了 nextLevel,结果同一帧里多个砖块同时触发回调,nextLevel 被连续执行了三次,直接跳过了两个关卡。后来改成事件标记,主循环每帧检查一次砖块数量,确认清零后才进入下一关,这个问题就消失了。
4. 实战落地:Flutter 工程在 OpenHarmony 设备上的运行全流程
4.1 环境准备与工具链
想在 OpenHarmony 上跑 Flutter 应用,工具链和普通 Flutter 开发不太一样,我最初在这里卡了不少时间。整体需要的环境有三块:
- DevEco Studio:OpenHarmony 应用开发的官方 IDE,负责创建鸿蒙工程、配置签名、编译和安装应用到设备或模拟器。
- 适配 OpenHarmony 的 Flutter SDK:需要在版本管理工具里单独拉一套,建议使用 fvm 做多版本隔离,避免和日常 Flutter 开发混用。
- OpenHarmony 模拟器或真机:模拟器要注意镜像架构,x86 模拟器在某些渲染特性和真机有差异,如果遇到渲染异常可以优先换真机验证。
我在机器上同时装着常规 Flutter 稳定版和鸿蒙适配版,切换全靠 fvm。这个习惯强烈建议养成,两个 SDK 的 API 存在差异,混用容易出现编译通过但运行崩溃的诡异问题。
启用 OpenHarmony 平台支持这一步,不同适配版本的命令略有差异,大致流程是在 Flutter 配置中启用 ohos 平台,然后创建或改造项目使其包含鸿蒙工程目录。新项目可以一步到位:
fvm use <ohos-adapted-flutter-version> flutter config --enable-ohos flutter create --platforms ohos .生成之后,工程目录里会多出 ohos 文件夹,这就是 OpenHarmony 的应用壳工程。再用 DevEco Studio 打开这个目录,完成签名配置后就可以连接设备编译运行了。
4.2 从 Flutter 工程到鸿蒙应用的接入要点
整个接入过程里最容易出问题的地方是 Flutter 引擎和鸿蒙工程的版本匹配。DevEco Studio 的 SDK 版本、Flutter 适配版的引擎版本、以及 ohos 平台模块三者必须对应。我一开始在 DevEco Studio 里用最新版 SDK 去跑一个较早 Flutter 适配包,结果编译时反复出现引擎初始化失败,日志指向 native 库不匹配,换对了版本组合后问题立刻消失。
版本对齐这件事没有什么捷径,我的做法是先看 Flutter 适配版仓库的 release notes,确认它支持的 OpenHarmony SDK 版本范围,再据此安装 DevEco Studio 和 SDK。
构建运行之后,游戏里的触控手势、定时器、画面刷新都工作正常。有一点要注意的是,OpenHarmony 设备上的默认文本缩放和屏幕圆角安全区可能和 Android 不一样,游戏里的挡板活动区域要主动避开系统安全区,否则全面屏设备上挡板会被底部手势条挡住。我用 MediaQuery 的 padding 处理了一下游戏画布的绘制区域,这个处理在平板上效果尤其明显。
4.3 渲染性能与内存优化注意事项
打砖块这类 2D 游戏在 OpenHarmony 上跑的时候,性能瓶颈往往不在处理器的计算能力,而在渲染管线的使用方式。Flutter 的 CustomPaint 如果写得不够小心,很容易出现整个游戏区域每帧都全量重绘的情况。
我做的第一个优化是给游戏画布包一层 RepaintBoundary,让 CustomPaint 独立于页面其他 Widget 重绘,避免挡板移动时把页面上的按钮、文本全部重画一遍。这个优化在低端设备上能省不少 GPU 开销。
第二个优化是严格控制游戏循环里的对象分配。Dart 是带垃圾回收的语言,如果每帧都 new 大量 Offset、Rect 对象,GC 一响游戏就卡顿。我把每帧会用到的临时对象尽量复用,碰撞检测里直接传已有的矩形引用,避免创建新列表。另外把偏移量和速度从业务模型里抽出来,用字段直接存储,而不是用 Dart 的不可变对象做函数式更新。
第三个优化是针对砖块绘制的。砖块这种静态图形其实不需要每帧重绘。我在 CustomPainter 的 paint 方法里加了一个 dirty 标记:只有砖块状态发生变化时,才重新绘制砖块层;球和挡板每帧只更新各自的绘制区域。这种局部更新策略在 OpenHarmony 上实测能将帧率波动降低不少,游戏的稳定感明显提升。
5. 常见问题与排查技巧实录
5.1 构建期报错速查表
开发过程中我整理了一份高频报错速查表,基本都是开发者会反复遇到的:
| 报错/警告 | 出现场景 | 处理方式 |
|---|---|---|
| unable to find suitable visual studio toolc | Windows 上构建原生插件或平台工程 | 在 Visual Studio 安装时勾选"使用 C++ 的桌面开发",或安装对应用户级 VS Build Tools |
| You are applying Flutter's main Gradle plugin imperatively... | Android 工程 Gradle 脚本配置方式过时 | 改用 plugins DSL 方式应用 Flutter Gradle 插件,删除 apply 脚本方式 |
| Execution failed for task ':app:compileFlutterBuildDebug' | 平台壳工程与 Flutter SDK 版本不匹配 | 用 fvm 检查当前项目锁定的 Flutter 版本,确认与壳工程适配版本一致 |
| hvigor compile error in ohos | DevEco Studio 构建鸿蒙工程失败 | 检查 SDK 版本号,清空 ohos/.hvigor 缓存后重新构建 |
这个"unable to find suitable visual studio toolc"特别坑。它看起来是 Flutter 的问题,实际上和 Flutter 关系不大,是 Windows 上构建原生 C++ 扩展时找不到 MSVC 编译器。我第一次遇到时绕了很久,后来装了 VS Build Tools,问题消失。如果你不做原生插件开发,只是用纯 Dart 文件,其实可以跳过原生构建,改用纯 Dart 方式运行调试,能省很多麻烦。
5.2 OpenHarmony 画面渲染异常排查
在 OpenHarmony 设备上跑 Flutter 应用,最容易遇到的是渲染类问题,典型症状有黑屏、画面不刷新、出现残影、以及横竖屏切换后布局错乱。
我遇到一次模拟器上黑屏问题,日志没有任何异常,应用也确实在运行,只是画面一直是黑的。排查到最后是模拟器的图形渲染模式问题,切换到支持 GPU 的渲染模式后画面正常。另外一次是真机上出现的画面闪烁,我用的是较老的 Flutter 适配版本,升级到修复了 vsync 问题的版本后闪烁消失。
这类问题的排查思路我总结成三步:先确认应用的 Dart 逻辑有没有跑,打印日志或者看帧率;再确认是引擎问题还是设备问题,换一台设备交叉验证;最后核对版本组合,优先使用官方定期发布的稳定适配组合。在社区里提问题的时候,附上设备型号、系统版本、Flutter 适配版本、日志片段,回复效率和准确度会高很多。
5.3 游戏卡顿、内存抖动排查
游戏运行一段时间后出现明显的周期性卡顿,多半和内存分配有关。Flutter 开发者工具里的 Performance 页面可以查看每帧的耗时分布,Memory 页面可以看到内存曲线的锯齿状波动,如果曲线每次骤降前都有卡顿,基本能断定是 GC 频繁触发。
我的排查方法是:帧耗时曲线如果出现规律性尖峰,先看是不是有对象在每帧被创建。打砖块项目里我抓到过一次,是在 CustomPainter 里为了渲染圆角矩形,每次 paint 都创建了新的 RRect 对象。后来把 RRect 缓存到砖块模型里,尖峰直接消失。
另外还要注意,不要在后台 isolate 里直接操作 UI 状态。Flutter 的 isolate 是独立内存空间的,如果你用 compute 处理碰撞计算再回传结果,传递大对象本身就有拷贝成本,小游戏场景得不偿失。我最终把碰撞检测放回了主 isolate,配合固定时间步长,性能完全够。
5.4 挡板跟手度优化
挡板操作是否跟手,直接决定游戏好不好玩。我在实现挡板移动时遇到过两个问题:一个是挡板位置更新滞后,一个是快速滑动时挡板"跳过"了手指位置。
滞后问题出在我在手势回调里做了很多多余的逻辑,比如把位置转成百分比再映射回坐标,中间还夹了一层状态通知。优化后手势回调只做一件事:把手指的横向坐标换算成挡板中心位置,直接更新模型字段,立刻触发重绘。
快速滑动跳位置的问题,是因为手势事件的采样频率跟不上手指移动速度。我的解决方法是不过度平滑,直接用最新位置更新挡板,不做插值。挡板本身就是高速移动的物体,插值反而会让人觉得"肉"。帧率够高的情况下,直接映射最新位置是最跟手的方案。
结语
这个项目从零到跑通 OpenHarmony 设备,前后花了我两周的业余时间,大部分时间不是花在写游戏逻辑上,而是花在版本适配和渲染排障上。回过头看,收益非常大,我不但把 Flutter 的绘制、动画、状态管理、碰撞算法完整走了一遍,也真正理解了 OpenHarmony 应用开发这条链路里最容易出问题的环节在哪里。
最后再分享一个我做游戏的习惯:把碰撞检测单独拆成纯函数,用单元测试覆盖所有边界情况,球从侧面撞、从角上撞、高速穿透、贴边滑动,每类情况写一个测试用例。调试的时候你会发现,花在写测试上的时间,会在排查诡异 bug 时十倍地省回来。