去年年底我们团队准备做一个小游戏矩阵,用来验证跨端技术栈的底子,我顺手就把 SkyTank 这个项目立了起来。简单说,SkyTank 是一款竖屏弹幕射击玩法的战机大战小游戏,技术栈选的是 Flutter,目标平台是 Android、iOS、Web,外加当时刚拿到样机的 HarmonyOS 6.0 设备。前后花了三周多的时间,把自机操控、敌机生成、子弹碰撞、Boss 战、计分排行这些核心玩法全部跑通,并且用同一个代码库出到了鸿蒙 6.0 上。这篇就当作一份完整的跨端小游戏开发实战笔记,既包含玩法拆解,也把 Flutter 适配鸿蒙时踩过的坑一并记录下来。如果你正在考虑用 Flutter 做轻量级游戏,或者手头有鸿蒙 6.0 设备的适配需求,这篇应该能帮你省不少时间。
1. 为什么用 Flutter 做战机大战,而不是 Unity 或原生
1.1 项目定位决定了技术选型
很多人一听"小游戏",第一反应是 Unity、Cocos,或者直接用 C++ 撸一套引擎。但 SkyTank 的实际定位很明确:它不需要 3D 渲染,不需要物理引擎,不需要大世界资源流式加载,核心是 2D 精灵、碰撞检测、游戏循环和触控反馈。这种体量用 Flutter 完全撑得住,而且有两个 Unity 比不了的优势:一是同一套 Dart 代码可以覆盖移动端、桌面端、Web 端以及鸿蒙端,不用维护多套工程;二是 Flutter 的 UI 表现力很强,计分面板、设置页、排行弹窗这些游戏外系统,写起来比在 Unity 里拼 UI 快得多。
另外我还考虑了团队的实际人力。我们是一个双人小团队,一个负责玩法逻辑,一个负责游戏外的系统层。Flutter 的组件化思维和 Dart 的类型系统,让我们能舒服地把玩家、敌机、子弹、特效拆成独立组件,并行开发不打架。这也是我最终放弃 Unity 的原因之一:为了一个 2D 弹幕游戏引入一整套重型引擎,学习成本和打包链路的复杂度都不划算。
1.2 游戏引擎选型:Flame 还是纯 CustomPainter
在 Flutter 生态里做游戏,绕不开一个选择:用 Flame 框架还是纯手工用 CustomPainter 画每一帧。我第一天做了个简单 Benchmark,结论是纯 CustomPainter 完全能跑子弹流程图,但一旦要处理碰撞回调、动画帧序列、组件生命周期管理,纯手写的工作量会翻倍,而且 bug 率极高。
最终选了 Flame,原因很直接:Flame 提供了游戏循环(GameLoop)、组件树、碰撞检测回调、精灵动画、音频管理这些基础能力,相当于把游戏开发的"公共底座"做好了。你要做的只是继承 FlameGame,往里面挂业务组件。SkyTank 的战斗场景,我用了 Flame 的 HasCollisionDetection mixin,配合多边形 Hitbox,敌机和子弹的碰撞判定基本开箱即用。后面我会把核心代码贴出来,讲清楚每个对象的职责边界,方便你迁移到自己的项目里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 游戏整体设计与核心玩法拆解
2.1 玩法循环与对象建模
SkyTank 的玩法是一个标准的竖屏弹幕射击循环:玩家控制战机在屏幕下方左右移动,自动射击或点击射击,敌机从屏幕上方分批出现,朝玩家方向移动或发射子弹,击毁敌机获得分数,每隔一段时间出现一个 Boss。Boss 有独立血条和弹幕模式,打完后进入下一波循环。
对象建模是整个项目最核心的一步。我没用传统面向对象那套大而全的继承树,而是为每种战斗实体定义最小接口,然后组合成组件。项目里主要对象有这几类:
- Player:玩家战机,包含移动速度、射击间隔、生命值、无敌帧、爆炸动画引用。
- Enemy:普通敌机,包含血量、速度、移动模式(直线、正弦穿插)、掉落道具概率。
- Boss:关卡 Boss,包含阶段切换逻辑、多套弹幕模式、独立的血条渲染。
- Bullet:子弹,区分玩家子弹和敌机子弹,避免误伤。
- StarfieldLayer:背景滚动星空层,营造速度感。
这些对象在 Flame 中全部派生自 PositionComponent,然后挂到 FlameGame 的组件树上。更新逻辑统一走 update(dt),渲染逻辑由 Flame 自动调用 render(Canvas),不需要手动管理绘制时机。
2.2 游戏状态管理与场景切换
战斗场景只是整个游戏的一部分。SkyTank 的游戏状态至少包括:主菜单、战斗关卡、结算页、设置页。我没引入 Riverpod 这类状态管理库,而是用 FlameGame 内部的状态枚举加简单的场景管理器处理切换。
原因是我发现游戏内的状态切换和普通 App 页面切换有本质区别:页面切换关心的是 Widget 的 build 和 dispose,而游戏切换关心的是组件树要不要清理、音频要不要停、计时器要不要重置。用 Widget 层面的状态管理库强行套游戏场景,容易出现"页面还在但游戏组件已销毁"的竞态问题。SkyTank 的做法是:FlameGame 持有 currentScene 枚举,切换时调用 game.overlay.add 或 remove 来管理 Flutter 覆盖层,战斗组件则由单独的场景节点负责挂载和释放。这样主菜单、结算页这些 UI 用 Flutter Widget 写,战斗逻辑留在 Flame 组件树里
