适用场景:从普通 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 顺序猜状态。第二,createdAtsavedAt 用 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,内部维护 coinsslots,对外暴露购买、拖拽、保存、恢复几个动作。日志不要只写自然语言,要带上事件名、索引、等级和金币变化,方便 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 改变合成概率或金币产出,否则同一个存档在不同渠道会表现不一致。这个阶段的目标是让程序可重复构建、可重复验证,而不是提前做复杂商业化开关。

验证方式:先规则测试,再真机手势,再构建包

验证不要只靠手玩一遍。最小检查清单如下:

  1. 执行 flutter analyze,确认没有未处理的空安全警告。
  2. 执行 flutter test,规则测试必须覆盖移动、合成、交换和边界。
  3. 执行 flutter run -d <device>,在 Android 模拟器和至少一台真机上拖拽 20 次。
  4. 退出应用再重新进入,确认金币、网格、等级和离线收益正常恢复。
  5. 执行 flutter build apk --debug,确认 Android 构建链路可用。
  6. 查看控制台日志,确认 game_loadeditem_movebuy_blocked 等事件能说明问题。

如果要进一步工程化,可以把 flutter analyzeflutter test 放进 CI,再把 debug APK 作为构建产物保存。发布前不要急着上广告、内购或远程配置,先让核心循环稳定:买入、拖动、合成、产出、保存、恢复。

避坑点:小游戏项目最常见的问题

第一个坑是 UI 状态和领域状态互相污染。不要在 Widget 里直接改 slots[index],所有状态变化都走控制器方法。第二个坑是拖拽时索引失效,如果拖拽过程中同时触发购买或重置,来源索引可能指向新对象,正式项目要在移动前检查 item id。第三个坑是离线收益无限累加,必须配置上限并记录日志。第四个坑是只在模拟器验证,真机长按、边缘返回、应用切后台和低端机帧率都可能暴露不同问题。第五个坑是图片资源过早复杂化,初版可以用颜色、等级文本和少量本地 asset,等逻辑稳定后再补美术。

发布前还要做一次低端机回归:连续拖拽、快速购买、切后台、断电重启和清理进程后恢复。每个异常都应能在日志里找到对应事件,而不是只靠玩家描述复现。

复盘清单:下一步怎么进阶

完成这个版本后,复盘时不要只问“能不能玩”。更有用的问题是:规则是否可测试,存档是否可迁移,日志是否能定位一次异常合成,Android 构建命令是否稳定,UI 是否在小屏幕上仍然可拖拽。下一步可以沿三个方向推进:一是增加任务和图鉴,把养成目标做清楚;二是引入动画和音效,但保持规则层不变;三是增加自动化测试,用 Widget test 覆盖购买按钮和拖拽目标。到这个阶段,项目已经不再是一个演示页面,而是一个可以继续迭代的 Flutter Android 小游戏骨架。