适用场景:从普通 Flutter 页面推进到一个可玩的小游戏
如果你的 Flutter 项目已经完成了环境搭建、页面路由、多环境配置和基础状态管理,下一步很适合做一个小而完整的游戏项目。养成合成类小游戏的范围可控:它不需要复杂物理引擎,也不依赖服务器实时对战,但会逼着项目同时处理网格状态、拖拽交互、资源配置、本地存档、离线收益、日志和 Android 构建。相比只写一个列表页,这类项目更接近真实移动端工程,因为玩家每一次拖动、合成、退出和重新进入都会暴露状态一致性问题。
本文搭建的是一个 Android Flutter 实践项目:merge_garden。玩法是 4x4 网格里放置不同等级的种子,同等级道具拖到一起会合成更高等级,道具会持续产生金币,金币可以购买新的一级种子。这个项目不追求美术复杂度,而是把程序结构写清楚:UI 层只负责渲染和手势,领域层负责合成规则,存储层负责本地 JSON 存档,日志层记录关键状态变化。你可以把它作为站内 Flutter Android 多环境配置实战 之后的进阶练习:前一篇解决构建环境,这一篇解决一个真实功能闭环。
项目结构:先把游戏状态和页面拆开
创建项目后先不要急着堆 Widget。小游戏最容易失控的地方是把格子、金币、计时器和合成判断全部塞进一个 StatefulWidget。建议从一开始就分层,哪怕项目很小也保留清晰边界:
merge_garden/
lib/
main.dart
app.dart
game/
merge_game_page.dart
game_controller.dart
game_models.dart
merge_rules.dart
save_store.dart
game_logger.dart
test/
merge_rules_test.dart
初始化命令如下:
flutter create merge_garden
cd merge_garden
flutter pub add shared_preferences
flutter pub add dev:very_good_analysis
flutter test
flutter run -d emulator-5554
依赖配置保持克制。shared_preferences 足够保存一个小型 JSON 存档;状态管理可以先用 ChangeNotifier,等项目出现商店、任务、图鉴、广告、云存档再升级到 Riverpod 或 Bloc。这样做的技术取舍是:当前阶段优先让规则可测、存档可读、日志可排查,而不是一开始引入过重架构。
数据模型:网格不要直接存 Widget
合成游戏的核心是状态,不是图片。格子里应该存领域对象,而不是存某个 Widget 实例。先定义道具和游戏快照:
class MergeItem {
const MergeItem({required this.id, required this.level, required this.createdAt});
final String id;
final int level;
final DateTime createdAt;
int get coinPerMinute => level * 2;
Map<String, Object?> toJson() => {
'id': id,
'level': level,
'createdAt': createdAt.toIso8601String(),
};
factory MergeItem.fromJson(Map<String, Object?> json) => MergeItem(
id: json['id']! as String,
level: json['level']! as int,
createdAt: DateTime.parse(json['createdAt']! as String),
);
}
class GameSnapshot {
const GameSnapshot({required this.coins, required this.slots, required this.savedAt});
final int coins;
final List<MergeItem?> slots;
final DateTime savedAt;
}
这里有三个避坑点。第一,slots 使用固定长度列表,对应 16 个格子,拖拽时只交换索引,不根据 UI 顺序猜状态。第二,createdAt 和 savedAt 用 ISO 字符串保存,方便后续计算离线收益。第三,coinPerMinute 是从等级推导出来的规则,不要把产出值重复存进 JSON,否则后面调整平衡性时老存档会出现一堆不一致字段。
合成规则:用纯 Dart 函数先写清楚
合成规则必须能脱离 UI 独立测试。最小规则是:目标格为空时移动;两个道具等级相同且未到最高等级时合成;等级不同则交换。可以放在 merge_rules.dart:
enum MergeResultType { moved, merged, swapped, blocked }
class MergeResult {
const MergeResult(this.type, this.slots, {this.rewardCoins = 0});
final MergeResultType type;
final List<MergeItem?> slots;
final int rewardCoins;
}
MergeResult applyMove({
required List<MergeItem?> slots,
required int from,
required int to,
required String Function() nextId,
int maxLevel = 6,
}) {
if (from == to || from < 0 || to < 0 || from >= slots.length || to >= slots.length) {
return MergeResult(MergeResultType.blocked, List.of(slots));
}
final next = List<MergeItem?>.of(slots);
final source = next[from];
final target = next[to];
if (source == null) return MergeResult(MergeResultType.blocked, next);
if (target == null) {
next[to] = source;
next[from] = null;
return MergeResult(MergeResultType.moved, next);
}
if (source.level == target.level && source.level < maxLevel) {
next[to] = MergeItem(id: nextId(), level: source.level + 1, createdAt: DateTime.now());
next[from] = null;
return MergeResult(MergeResultType.merged, next, rewardCoins: source.level * 5);
}
next[to] = source;
next[from] = target;
return MergeResult(MergeResultType.swapped, next);
}
配套测试要先写。命令是 flutter test test/merge_rules_test.dart,测试至少覆盖移动、合成、交换、越界、空源格和最高等级不能继续合成。这个步骤很关键,因为拖拽 UI 调试起来很费时间,如果规则本身没有测试,后续任何动画问题都会被误判成状态问题。
控制器:金币、购买、离线收益和日志
GameController 负责把规则串起来。它继承 ChangeNotifier,内部维护 coins 和 slots,对外暴露购买、拖拽、保存、恢复几个动作。日志不要只写自然语言,要带上事件名、索引、等级和金币变化,方便 Android 线上排查。
class GameController extends ChangeNotifier {
GameController({required SaveStore store, required GameLogger logger})
: _store = store,
_logger = logger;
final SaveStore _store;
final GameLogger _logger;
int coins = 50;
List<MergeItem?> slots = List<MergeItem?>.filled(16, null);
Future<void> load() async {
final snapshot = await _store.load();
if (snapshot == null) return;
slots = snapshot.slots;
coins = snapshot.coins + _offlineCoins(snapshot);
_logger.info('game_loaded', {'coins': coins});
notifyListeners();
}
Future<void> buySeed() async {
const price = 10;
final emptyIndex = slots.indexWhere((item) => item == null);
if (coins < price || emptyIndex == -1) {
_logger.info('buy_blocked', {'coins': coins, 'emptyIndex': emptyIndex});
return;
}
coins -= price;
slots[emptyIndex] = MergeItem(id: DateTime.now().microsecondsSinceEpoch.toString(), level: 1, createdAt: DateTime.now());
await save();
notifyListeners();
}
Future<void> moveItem(int from, int to) async {
final before = coins;
final result = applyMove(slots: slots, from: from, to: to, nextId: () => DateTime.now().microsecondsSinceEpoch.toString());
slots = result.slots;
coins += result.rewardCoins;
_logger.info('item_move', {'from': from, 'to': to, 'type': result.type.name, 'coinDelta': coins - before});
await save();
notifyListeners();
}
}
离线收益不要一开始就做得太刺激。可以按 savedAt 到当前时间的分钟差计算,并设置上限,例如最多累计 8 小时。否则用户改系统时间或长期不打开应用,会让金币经济失衡。后续如果接入服务器,再把本地计算降级为预估展示,真实收益由后端确认。
UI 搭建:GridView、Draggable 和 DragTarget
页面层只订阅控制器状态。每个格子是一个固定比例的 DragTarget<int>,内部如果有道具再包一层 Draggable<int>。拖拽数据只传来源索引,不传整个对象,避免 UI 拖拽期间拿到旧引用。
class MergeGrid extends StatelessWidget {
const MergeGrid({super.key, required this.controller});
final GameController controller;
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.slots.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemBuilder: (context, index) {
final item = controller.slots[index];
return DragTarget<int>(
onAcceptWithDetails: (details) => controller.moveItem(details.data, index),
builder: (context, candidate, rejected) {
final cell = DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: candidate.isEmpty ? const Color(0xffeef4f0) : const Color(0xffd8f3dc),
),
child: Center(child: item == null ? const SizedBox.shrink() : Text('Lv.${item.level}')),
);
if (item == null) return cell;
return Draggable<int>(
data: index,
feedback: Material(child: SizedBox(width: 72, height: 72, child: Center(child: Text('Lv.${item.level}')))),
childWhenDragging: Opacity(opacity: .35, child: cell),
child: cell,
);
},
);
},
);
}
}
Android 上要特别注意触摸目标。格子不能太小,GridView 外层不要再套无约束滚动容器。按钮区建议固定在底部,包含金币数、购买按钮、保存按钮和重置按钮。小游戏页面不是营销落地页,首屏应该直接进入可玩的棋盘,不要先放一堆介绍文案。
本地存档配置:shared_preferences 保存 JSON
SaveStore 只负责序列化和反序列化,不夹杂合成规则:
class SaveStore {
static const _key = 'merge_garden_snapshot_v1';
Future<void> save(GameSnapshot snapshot) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, jsonEncode({
'coins': snapshot.coins,
'savedAt': snapshot.savedAt.toIso8601String(),
'slots': snapshot.slots.map((item) => item?.toJson()).toList(),
}));
}
Future<GameSnapshot?> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null) return null;
final json = jsonDecode(raw) as Map<String, Object?>;
final slots = (json['slots']! as List).map((item) => item == null ? null : MergeItem.fromJson(item as Map<String, Object?>)).toList();
return GameSnapshot(coins: json['coins']! as int, slots: slots, savedAt: DateTime.parse(json['savedAt']! as String));
}
}
这里的版本号 _v1 很重要。后续你增加任务系统、图鉴或稀有道具时,不要直接覆盖老结构,可以新增 _v2 并写迁移函数。日志中也要记录存档读取失败,例如 save_decode_failed,但不要把完整存档 JSON 原样上传到日志系统,里面可能包含用户进度或测试账号信息。
Android 工程配置:先保证构建和资源路径稳定
小游戏后续一定会加入图片、音效、启动图标和签名配置,所以初版就要把 Android 工程边界留好。资源放在 ssets/images/items/ 和 ssets/audio/,在 pubspec.yaml 明确声明,不要在代码里拼接不存在的路径。调试阶段可以只使用文本等级,但目录先建好,后续替换成 Image.asset('assets/images/items/seed_lv.png') 时不会影响规则层。Android 最低版本、应用 id、版本号和签名文件也要在发布前固定,避免测试包和正式包共用同一个调试配置。
建议增加一份本地运行配置说明:lutter run --dart-define=GAME_LOG_LEVEL=debug 打开详细日志,lutter build apk --release --dart-define=GAME_LOG_LEVEL=info 构建发布包。代码里读取配置时只控制日志等级、调试入口和实验功能,不要让 dart-define 改变合成概率或金币产出,否则同一个存档在不同渠道会表现不一致。这个阶段的目标是让程序可重复构建、可重复验证,而不是提前做复杂商业化开关。
验证方式:先规则测试,再真机手势,再构建包
验证不要只靠手玩一遍。最小检查清单如下:
- 执行
flutter analyze,确认没有未处理的空安全警告。 - 执行
flutter test,规则测试必须覆盖移动、合成、交换和边界。 - 执行
flutter run -d <device>,在 Android 模拟器和至少一台真机上拖拽 20 次。 - 退出应用再重新进入,确认金币、网格、等级和离线收益正常恢复。
- 执行
flutter build apk --debug,确认 Android 构建链路可用。 - 查看控制台日志,确认
game_loaded、item_move、buy_blocked等事件能说明问题。
如果要进一步工程化,可以把 flutter analyze 和 flutter test 放进 CI,再把 debug APK 作为构建产物保存。发布前不要急着上广告、内购或远程配置,先让核心循环稳定:买入、拖动、合成、产出、保存、恢复。
避坑点:小游戏项目最常见的问题
第一个坑是 UI 状态和领域状态互相污染。不要在 Widget 里直接改 slots[index],所有状态变化都走控制器方法。第二个坑是拖拽时索引失效,如果拖拽过程中同时触发购买或重置,来源索引可能指向新对象,正式项目要在移动前检查 item id。第三个坑是离线收益无限累加,必须配置上限并记录日志。第四个坑是只在模拟器验证,真机长按、边缘返回、应用切后台和低端机帧率都可能暴露不同问题。第五个坑是图片资源过早复杂化,初版可以用颜色、等级文本和少量本地 asset,等逻辑稳定后再补美术。
发布前还要做一次低端机回归:连续拖拽、快速购买、切后台、断电重启和清理进程后恢复。每个异常都应能在日志里找到对应事件,而不是只靠玩家描述复现。
复盘清单:下一步怎么进阶
完成这个版本后,复盘时不要只问“能不能玩”。更有用的问题是:规则是否可测试,存档是否可迁移,日志是否能定位一次异常合成,Android 构建命令是否稳定,UI 是否在小屏幕上仍然可拖拽。下一步可以沿三个方向推进:一是增加任务和图鉴,把养成目标做清楚;二是引入动画和音效,但保持规则层不变;三是增加自动化测试,用 Widget test 覆盖购买按钮和拖拽目标。到这个阶段,项目已经不再是一个演示页面,而是一个可以继续迭代的 Flutter Android 小游戏骨架。