去年年底我们团队准备做一个小游戏矩阵,用来验证跨端技术栈的底子,我顺手就把 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 组件树里,职责分离,互不干扰。
主菜单到战斗场景的切换我做了一个 FadeTransition,通过 GameWidget 的 overlay 机制实现,过渡动画不影响游戏循环,实测在低端 Android 上也不会掉帧。
2.3 碰撞检测的设计细节
弹幕射击游戏到处是子弹和敌机,碰撞检测的频率非常高。Flame 的 HasCollisionDetection 提供的是空间划分的初步优化,但默认情况下每个组件每帧都要做遍历。SkyTank 里实测子弹数量超过 200 个时,全遍历的 CPU 开销会上来,尤其是旧款手机。
我的优化策略是分两个碰撞层级:
- 第一层:玩家子弹 vs 敌机,玩家战机 vs 敌机子弹/敌机本体,这两组用 Hitbox 加 CollisionCallbacks,由 Flame 的碰撞系统处理。
- 第二层:玩家子弹与 Boss 弹幕之间的碰撞不做任何检测,因为玩家子弹本质是被 Boss 弹幕遮挡,物理上不需要互相作用。
另外我给每个碰撞回调里加了一个 deactivated 标记,组件 removeFromParent 之后立刻把标记置为 true,回调里先判断标记再执行业务逻辑,避免 Flutter 的组件树遍历时碰到已移除节点引发异常。这个细节帮我少了很多难查的异步崩溃。
3. 鸿蒙 6.0 跨端环境搭建与工程准备
3.1 Flutter SDK 安装与国内镜像配置
先讲通用环境,这部分 Android 和 iOS 开发者都比较熟,但鸿蒙适配需要额外多配一个 OpenHarmony 的 Flutter SDK,所以环境准备会比普通 Flutter 项目多一步。整个过程按顺序来:
- 下载稳定版 Flutter SDK,建议 3.24 以上版本,因为对 OpenHarmony 的适配分支兼容性更好。
- 解压后把 bin 目录加入 PATH,执行 flutter doctor 检查基础依赖。
- 如果你的网络访问 pub.dev 不稳定,建议配国内镜像环境变量,否则后面拉依赖会非常痛苦。
bash复制export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
export PATH=$HOME/flutter/bin:$PATH
需要注意,国内镜像和官方源之间切换时,最好删掉根目录的 .pub-cache 再重新拉取,不然偶尔会出现依赖包的哈希校验失败。我自己遇到过一次,排查了半天,最后发现是镜像缓存了损坏的压缩包。
鸿蒙 6.0 使用的 Flutter 分支是社区在 Gitee 上维护的 OpenHarmony SIG 仓库。克隆下来后同样加入 PATH,但注意不要和标准 Flutter SDK 混用,建议用两个终端配置切换,或者干脆把鸿蒙分支的工程单独用一个构建脚本指定 SDK 路径。
3.2 创建游戏工程并引入 Flame 依赖
新项目直接用 flutter create 创建,然后在 pubspec.yaml 里引入 Flame、音频插件和本地数据存储插件。SkyTank 需要记录历史最高分,我用了 sqflite 的鸿蒙适配方案,但说实话对于单一数值,用 SharedPreferences 级别就够了,不需要让一整个数据库进场。这里又踩了一个坑:Flutter 的 showLicensePage 在鸿蒙端默认主题色和 Android 不一致,后来我统一在 MaterialApp.theme 里显式指定了 licensePage 的样式,才保证两边一致。
yaml复制dependencies:
flutter:
sdk: flutter
flame: ^1.16.0
flame_audio: ^2.1.0
shared_preferences: ^2.2.2
sqflite: ^2.3.0
Flame 的版本不要盲目追新,小游戏项目关键是稳。SkyTank 用到的主要是 Flame 的 GameLoop、组件系统、碰碰检测、精灵图集加载,这几个功能在 1.16 版本上已经非常稳定,再新的版本改动反而需要重新测试兼容性。
3.3 HarmonyOS 6.0 真机调试前置条件
真机调试鸿蒙 6.0 需要准备以下环境:
- 一台 HarmonyOS 6.0 及以上版本的测试设备,开启开发者模式并打开 USB 调试。
- 安装 DevEco Studio,用它的 SDK Manager 安装 HarmonyOS SDK。鸿蒙 6.0 的 SDK 路径和 API 版本和 5.0 不完全一样,建议按真机型号对应安装。
- Flutter 鸿蒙分支 SDK,通过环境变量把命令指向它,然后执行 flutter doctor 检查 ohos 工具链是否可用。
bash复制git clone https://gitee.com/openharmony-sig/flutter_flutter.git -b 3.27.0-ohos
export PATH=$HOME/ohos_flutter/bin:$PATH
flutter config --enable-ohos
flutter doctor -v
配置完成后,在项目根目录执行 flutter create --platforms ohos . 会自动生成 ohos 目录,里面是一个完整的 HarmonyOS 工程。用 DevEco Studio 打开这个目录后,还需要处理签名:鸿蒙 6.0 不允许直接跑未签名包,首次调试时用 DevEco Studio 的自动签名功能或手动配置 profile 文件。
这块是我花时间最长的一个环节,因为网上资料大多针对 4.0/5.0,6.0 的签名流程入口有些变化。核心思路不变:登录开发者账号、创建项目、选择设备类型、生成证书和 profile、导入 DevEco。一旦完成,后续 flutter build hap 就能直接产出安装包。
4. 核心玩法实现:从自机到 Boss 战的完整拆解
4.1 玩家战机的移动与射击手感调优
玩家控制是弹幕游戏最看重的部分,手感不好会直接毁掉游戏。SkyTank 的玩家移动采用 Touch 拖动和键盘方向键两种输入:移动端检测屏幕拖动偏移,将偏移量累加到战机坐标;桌面端和 Web 端监听键盘状态,通过按键状态集合计算移动方向。
在 update 循环里,移动逻辑需要乘以 dt 来保证不同刷新率设备上的速度一致。这里最容易犯的错是直接写死每帧位移,导致 120Hz 设备上战机快如闪电。正确做法是定义一个像素每秒的速度值,再乘 dt:
dart复制class Player extends PositionComponent with CollisionCallbacks, HasGameReference<SkyTankGame> {
static const double moveSpeed = 420;
Vector2 _moveDirection = Vector2.zero();
@override
void update(double dt) {
position += _moveDirection * moveSpeed * dt;
position.clamp(Vector2(0, 0), game.size - size);
}
}
射击手感主要看三件事:射击间隔、子弹初速度、音效反馈。SkyTank 默认射击间隔是 0.18 秒,子弹初速度 680 像素每秒,这样在手机屏幕上能形成恰到好处的弹幕密度。音效方面用 flame_audio 加载了短促的激光音效,并加了 200ms 的冷却,避免连续触发导致音效重叠太吵。
4.2 敌机生成器与子弹对象池
敌机不能一次性全部生成,需要按波次节奏出怪。SkyTank 的敌机生成器是一个 SpawnComponent,内部维护波次列表,每波包含敌机类型、数量、间隔、移动路径。这里用到 Timer 组件管理每波敌机的出现间隔,避免在 update 里手写累加器导致逻辑分散。
子弹是弹幕游戏里创建最频繁的对象,每秒钟可能产生几十个,如果不做复用会频繁触发 GC 导致掉帧。对象池的基本思路是维护一个可用列表和一个活跃列表,需要子弹时从池里取,子弹从屏幕上移除后重置属性并归还池子:
dart复制class BulletPool {
final List<PlayerBullet> _available = [];
PlayerBullet obtain() {
if (_available.isEmpty) {
return PlayerBullet();
}
return _available.removeLast();
}
void recycle(PlayerBullet bullet) {
bullet.reset();
bullet.removeFromParent();
_available.add(bullet);
}
}
对象池的坑在于"重置",如果 reset 方法漏了某个属性,重现出来的子弹可能带着上一次的伤害值或异常速度。我在调试时抓到过一次:子弹复用时没有重置 onCollision 的 deactivated 标记,导致第二次碰撞判定全部失效。后来我把 reset 方法写成强制全量赋值,不用字段默认值,彻底根治。
4.3 背景滚动与战斗特效实现
弹幕游戏需要强烈的速度感,背景高速滚动是最经济的手段。SkyTank 做了三层滚动背景:远景星云层、中景行星层、近景高速星星层,每层速度不同,形成视差效果。每层是一个独立 PositionComponent,update 时让位置往上偏移,超出屏幕后通过取模回到底部:
dart复制class StarfieldLayer extends PositionComponent with HasGameReference<SkyTankGame> {
final double scrollSpeed;
final Color starColor;
@override
void update(double dt) {
y += scrollSpeed * dt;
if (y > game.size.y) {
y -= game.size.y * 2;
}
}
}
战斗特效主要指的是战机爆炸、中弹闪白、激光尾迹这几类高频反馈。SkyTank 没有引入庞大的粒子系统库,而是用 Flame 的 ParticleSystemComponent 配合自定义小扇形粒子来模拟爆炸碎片,总粒子数量控制在 60 个以内。火花尾迹用一条逐渐变短的透明度渐变的 Sprite,比粒子方案更省性能,视觉上也更干净。
移动端开发有个基本经验:手机 GPU 对过度绘制的容忍度远低于 PC,所以每个特效组件都设了生命周期上限,比如爆炸特效 0.5 秒后自动销毁,避免特效堆积导致渲染压力。
4.4 计分、UI 覆盖层与本地存储
战斗 UI 包括实时得分、血量、Boss 血条三块。Flame 支持在游戏画布上叠加 Flutter widget,但我更推荐把战斗 HUD 用纯 Flame 文本组件渲染,因为 Flutter widget 叠层每帧都要走 Widget 构建,耗时偏高。SkyTank 的得分文本是一个 TextComponent,每 10 分刷新一次显示内容,Boss 血条则画在两个嵌套 RectangleComponent 上,用 width 表示剩余血量比例。
历史最高分的持久化使用了 shared_preferences,在游戏结束页面读取本地缓存并在得分刷新时才写盘,减少 I/O 频率。后续如果要做排行榜或云存档,再迁移到 sqflite 加后端同步方案。
有一点需要注意:shared_preferences 在鸿蒙端和 Android 端的行为略有差异,写入后读取的时机如果太近,偶尔返回旧值。我处理的办法是在返回主菜单前强制读一次并缓存到内存变量,后续页面展示都用内存值。
4.5 Boss 战的多阶段弹幕设计
Boss 战是 SkyTank 玩法上的一个高潮点。我设计了三种基础弹幕模式:扇形散射、螺旋环、激光预警。每种模式都封装成一个独立函数,接收出弹点位置和当前角度,返回一组子弹初始状态。Boss 组件内部维护当前阶段和阶段切换阈值,血量低于 70%、40% 时分别解锁新模式。
螺旋环的实现思路很简单:每次发射时用一个 progress 变量保存当前角度,angle = progress * 0.5,然后围绕 Boss 一周均匀生成 36 发子弹。随着 progress 增加,子弹整体旋转起来,就能形成螺旋效果。
dart复制void spiralShot(double dt) {
_progress += dt * 2;
for (int i = 0; i < 36; i++) {
final rad = _progress * 0.5 + i * (2 * pi / 36);
final dir = Vector2(cos(rad), sin(rad));
spawnEnemyBullet(boss.position, dir, 220);
}
}
Boss 的碰撞体比它看起来的样子要小一圈,这是有意为之:玩家的体验优先级高于物理真实性,贴图大但判定小,会让玩家觉得更有成就感。如果采用完全贴合贴图的碰撞区域,玩家会频繁因擦边而扣血,挫败感很强。
5. 跨端打包与 HarmonyOS 6.0 实战适配
5.1 Android、iOS、Web 三端打包要点
Android 和 iOS 的打包路径比较常规,但有两个细节值得记录。一是 Android 的 minSdkVersion 建议设置为 21 以上,否则部分 Flutter 新版本插件的原生代码会报错。二是 iOS 上架资源方面的经验,建议把 Game Center 或社交分享功能放到 1.0 之后再加,先保证核心玩法稳定过审,避免被 4.3 这类重复内容审核条款盯上。
Web 端是 SkyTank 跨端能力的一个亮点。Flutter Web 默认渲染模式是 CanvasKit,游戏跑在浏览器里效果尚可,但首次加载需要下载 wasm 文件,体积偏大。如果你对加载速度敏感,可以改成 HTML renderer,不过弹幕量大的场景下 CanvasKit 的流畅度明显更好,我最终保留 CanvasKit,同时做了一次资源 gzip 压缩,首包从 5.8M 压到 2.7M。
桌面端(Windows/macOS/Linux)我没有作为主要发布目标,但 Flutter 代码天然支持,键盘操作在桌面端适配反而比手机更顺手。鸿蒙 6.0 的适配步骤和 Android 高度相似,只是签名和 SDK 有所差异。
5.2 HarmonyOS 6.0 适配:从创建 ohos 工程到打包 HAP
鸿蒙 6.0 的适配是整个项目里最具新鲜感的部分。流程上,打开鸿蒙工程目录后,做三件事:
- 在 ohos 工程的 module.json5 里声明所需权限,比如网络访问权限用于后续版本更新检查。
- 用 DevEco Studio 配置签名,流程是 Project Structure -> Signing Configs -> Automatically generate signature。
- 执行 flutter build hap --debug 构建调试包,再用 hdc install 安装到真机。
bash复制flutter build hap --debug
hdc install build/ohos/debug/ohos-debug-signed.hap
第一次跑 HAP 包时,我在真机上遇到黑屏,火速查日志发现是 Flutter 引擎跑起来了,但 Surface 没有成功绑定。这个问题的原因是 DevEco Studio 生成的 module 配置里,默认页面和 Flutter 入口不一致。解决方式是检查 Ability 的 onWindowStageCreate 回调,确保调用了 Flutter 的 loadContent 方法,并把页面路径配到 Flutter 侧。
鸿蒙 6.0 对 Flutter 的适配还有一个注意点:大量第三方插件原生层没有适配,例如某些广告 SDK、支付 SDK。SkyTank 用到的基础插件都支持鸿蒙,但如果你的项目里有地图、定位、推送这类高耦合原生能力的插件,建议先做一次完整清单盘点,否则到集成阶段才发现某个核心插件无法编译,会很被动。
5.3 平台差异处理:触摸、鼠标、键盘与物理按键
跨端游戏最容易被忽略的是输入差异。手机玩家用手指滑动,桌面玩家用鼠标和键盘,Web 玩家甚至可能用触控板。Flame 提供了基础的触控 API,但键盘状态需要自己监听。
SkyTank 的做法是在主 game 里统一注册 HardwareKeyboard 监听,把按键状态记录到局部变量,update 循环再消费。鼠标操作不需要额外监听,Flame 会把鼠标点击识别成触控 tap,直接复用手势逻辑。鸿蒙 6.0 真机上,触控事件和 Flutter 标准行为一致,没有额外适配成本,这也是 Flutter 跨端框架带来的核心价值之一。
不同平台之间的 UI 比例也要做适配。鸿蒙 6.0 的一些平板设备屏幕比例接近 16:10,和手机的 19.5:9 差异很大。SkyTank 用 camera.viewport 设置逻辑分辨率,保证游戏世界在不同屏幕上都保持等比缩放,两侧多出来的空间用背景色填充,不让 UI 元素变形。
6. 常见问题与排查技巧实录
6.1 弹幕数量一多就卡顿
这是弹幕游戏绕不开的优化问题。SkyTank 实测在老旧 Android 手机上,同时活跃子弹超过 350 个时帧率跌到 40 以下。排查后三个优化点立竿见影:
- 启用对象池,减少 GC 导致的卡顿。
- 把子弹渲染改为更简单的圆形或矩形,不用带透明通道的复杂 png 贴图。
- 移除屏幕外子弹,超出 safe area 一定距离就直接回收,不等它完全消失到屏幕边缘外。
优化之后,450 个活跃子弹也能稳定在 55 帧以上。开发者始终要记住一点:游戏掉帧的根因,很多时候不是渲染能力,而是频繁创建和销毁对象,触发 Dart 堆里的垃圾回收。
6.2 碰撞检测漏检问题
Flame 的碰撞检测在子弹速度很高时会出现"隧穿",也就是子弹一帧穿过敌机却没触发碰撞。SkyTank 的子弹初速度是 680 像素每秒,在 120Hz 设备上单帧位移只有约 5.7 像素,不会隧穿,但在 30Hz 设备上单帧位移达到约 22.7 像素,加上子弹自身宽度较小,就有一定概率漏判。
解决思路是给高速子弹增大 Hitbox 的尺寸,而不是让物理引擎去做连续碰撞检测。我把玩家子弹的 Hitbox 半径从 4 调到了 8,漏检问题立刻消失,玩家体感上也没有觉得子弹变粗。这个思路在有 Unity 物理经验的人眼里可能不太严谨,但在 2D 弹幕游戏里,简单粗暴调整碰撞盒子往往比上物理引擎更实用。
6.3 鸿蒙真机白屏与签名问题
白屏问题上文已经提到过一次,再补充一个排查技巧:鸿蒙设备上的 Flutter 日志不完全打印到 logcat,需要同时关注 hdc 控制台的异常输出和 DevEco Studio 的日志面板,两边的报错合并对比才能定位根因。
签名问题则表现为安装时报 504 或签名校验失败。首次配置签名时,要确保 DevEco Studio 里的 project 名和 bundle name 与 Flutter 工程生成 ohos 目录时默认值一致。如果中途改过工程名,签名会被强制失效,必须重新生成签名文件再更新 profile。
6.4 本地存储和插件兼容性排查
鸿蒙端对 Flutter 插件的兼容性整体不错,但 shared_preferences、sqflite 这类插件在新版本上偶尔会有行为差异。我的排查思路是尽量把存储逻辑收敛到一个独立类里,通过抽象接口隔离平台差异。后续如果某个插件在鸿蒙上出了分支问题,只需要替换适配实现类,不影响游戏逻辑代码。
还有一个容易被忽略的小坑:Flutter 的 showLicensePage 页面的主题颜色在鸿蒙 6.0 上可能没有跟随全局 Theme 变化,需要显式设置。如果你用到了开源字体或第三方组件,务必在真机上过一遍 license 页面,避免出现深色模式下半白半黑这种尴尬界面。
SkyTank 项目开发到这一步,最大的收获不是把游戏跑上了多少个平台,而是验证了一个判断:Flutter 做轻量级跨端游戏是一条走得通的路,尤其在鸿蒙 6.0 这类新平台上,Flutter 社区已经提供了足够成熟的基础设施。如果你也想做类似的小游戏,我的建议是先锁死玩法边界,再动手写代码,把对象池和碰撞检测的坑提前避开,后面的开发会顺畅很多。
