likes
comments
collection
share

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

作者站长头像
站长
· 阅读数 65

Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结] 第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。


一、关卡数据的设计

上一篇我们实现了打砖块游戏的基本玩法,目前砖块只是简单的堆砌,本章我们将着专注于游戏关卡内容的设计:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计


1. 砖块的显隐

首先看一下,如何决定砖块的显示和隐藏,如下所示,想要通过数据,将想要去除的砖块移除。从而达到关卡的可设计性:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

我们可以通过一个二维的点阵,来决定砖块展示的样式。如下所示, 1 代表展示砖块, 0 代表不展示砖块:

List<List<int>> tiles = [
  [1,1,1,1,1,1,1,1,1],
  [1,1,1,1,1,1,1,1,1],
  [0,1,0,0,1,0,0,1,0],
  [0,1,0,0,1,0,0,1,0],
  [1,1,1,1,1,1,1,1,1],
  [1,1,1,1,1,1,1,1,1],
];

这样,我们就不需要在 BrickManager 通过行列来添加砖块,可以解析 tiles 数据来完成:

---->[lib/bricks/05/heroes/bricks.dart]----
List<Brick> _createBricks() {
  List<Brick> bricks = [];
  for (int i = 0; i < tiles.length; i++) {
    List<int> rows = tiles[i];
    for (int j = 0; j < rows.length; j++) {
      if (rows[j] == 1) {
        Brick brick = Brick(j + tiles.length * i);
        brick.x = 64.0 * j;
        brick.y = 32.0 * i;
        bricks.add(brick);
      }
    }
  }
  return bricks;
}

2. 关卡数据的初步设计

对于关卡功能来说,目前希望每关卡可以指定砖块的排列方式、小球运行的速度两个参数。如下所示,定义一个 Level 类用于维护一个关卡中的数据:

class Level {
  final int id;
  final List<List<int>> tiles;
  final double ballSpeed;

  const Level({
    required this.id,
    required this.tiles,
    required this.ballSpeed,
  });
}

这样我们只要准备关卡的相关的数据,就可以实现不同的关卡。选关时只有加载对应关卡的数据,重新开始游戏即可。效果如下:

第一关第二关
Flutter&Flame游戏实践#08 | 打砖块 -关卡设计Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

目前简单起见,先将关卡的数据通过产量维护在内存中。如下所示是两个关卡的数据:

---->[lib/bricks/05/model/level.dart]----
const Map<int, Level> kLevels = {
  1:  Level(
    id: 1,
    tiles: [
      [1, 0, 0, 1, 0, 1, 0, 0, 1],
      [0, 1, 0, 1, 1, 1, 0, 1, 0],
      [1, 1, 1, 1, 1, 1, 1, 1, 1],
      [1, 1, 1, 1, 1, 1, 1, 1, 1],
      [0, 1, 0, 0, 1, 0, 0, 1, 0],
      [0, 1, 0, 0, 1, 0, 0, 1, 0],
      [1, 1, 1, 1, 1, 1, 1, 1, 1],
      [1, 1, 1, 1, 1, 1, 1, 1, 1],
      [0, 1, 0, 1, 1, 1, 0, 1, 0],
      [1, 0, 0, 1, 0, 1, 0, 0, 1],
    ],
    ballSpeed: 350,
  ),
  2:  Level(
    id: 2,
    tiles: [
      [1, 1, 1, 1, 0, 1, 1, 1, 1],
      [1, 1, 1, 1, 0, 1, 1, 1, 1],
      [1, 1, 1, 1, 0, 1, 1, 1, 1],
      [0, 1, 1, 0, 0, 1, 0, 0, 1],
      [0, 1, 1, 0, 0, 1, 0, 0, 1],
      [0, 1, 1, 0, 0, 1, 0, 0, 1],
      [0, 1, 1, 0, 0, 1, 0, 0, 1],
      [0, 1, 1, 0, 0, 1, 1, 1, 1],
      [0, 1, 1, 0, 0, 1, 1, 1, 1],
      [0, 1, 1, 0, 0, 1, 1, 1, 1],
    ],
    ballSpeed: 360,
  ),
};

3. 代码中对关卡的维护

游戏界面中的关卡数时游戏过程中的状态数据,目前先在 BricksGame 中维护,方便其他地方进行访问。 levelNum 的 set 方法可以更新 _levelNum 的值,并重新开始游戏;nextLever 方法将当前关卡数 +1,方面外界调用,进入下一关:

---->[lib/bricks/05/bricks_game.dart]----
int _levelNum = 1;

Level get level => kLevels[_levelNum]!;

set levelNum(int level) {
  if (_levelNum != level) {
    _levelNum = level.clamp(1, kLevels.length);
    // 设置关卡时,重置砖块管理器
    restart();
  }
}

void nextLever() => levelNum = _levelNum + 1;

砖块管理器根据 game 中的激活关卡 level 对象中的数据,构建砖块即可:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

游戏界面中的关卡文字也是同理。可以看出,对于 Flame 而言,游戏状态数据的管理,最简单直接的方式就是放入到游戏主类中。在下层的构件中,可以通过 game 实例来访问或修改数据:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计


此时当某一关卡的砖块被全部击碎,可以通过按钮触发 下一关卡

在代码中,点击事件只需要移除 GameSuccessMenu 弹出菜单,并触发 game.nextLever 即可切换到下一关卡。

void toNextLevel(){
  widget.game.am.play(SoundEffect.uiClose);
  widget.game.overlays.remove('GameSuccessMenu');
  widget.game.nextLever();
}

二、实现游戏选关

既然支持有关卡的,那么选择关卡的功能自然要安排上。上一篇在主页中添加了 选择关卡 的按钮,接下来实现一下右图所示的关卡选择功能。其中未通关的关卡需要锁定,禁止进入:

游戏主界面
Flutter&Flame游戏实践#08 | 打砖块 -关卡设计Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

1. 关卡界面 LevelPage

这里关卡界面也是作为一个浮层进行维护的,其中界面布局是 Flutter 原生组件。通过 LevelPage 进行展示:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

关卡选择界面是一个可滑动的网格布局,可以使用 GridView 组件来实现。其中封装 LevelItem 组件,负责展示单体的关卡按钮。组件需要的数据有 关卡名是否锁定 以及点击事件回调函数:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

--->[lib/bricks/05/overlays/lever_page/level_item.dart]----
class LevelItem extends StatelessWidget {
  final bool locked;
  final ValueChanged<int> onTapItem;
  final int level;

  const LevelItem({
    super.key,
    this.locked = true,
    required this.level,
    required this.onTapItem,
  });

在构建逻辑中,可以根据 locked 数据决定不同的界面表现。比如在构造时传入 locked 为 true 时,使用小锁的图标,否则使用文字:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

另外,锁定的关卡呈灰色,这里并没有单独使用一张灰色图片。而是使用 ColorFiltered 组件添加灰色的滤色效果。如果锁定的话直接返回,否则才嵌套 GestureDetector 处理点击事件:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

const ColorFilter greyscale = ColorFilter.matrix(<double>[
  0.2126, 0.7152, 0.0722, 0, 0,
  0.2126, 0.7152, 0.0722, 0, 0,
  0.2126, 0.7152, 0.0722, 0, 0,
  0,      0,      0,      1, 0,
]);

然后通过 GridView 构建滑动的网格布局,这里先放置 30 个关卡,激活前 10 个来测试展示效果。后面会结合实际数据来处理:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

点击时,触发 onSelectLevel 选择关卡进入。只要为 game.levelNum 设置值即可:

void onSelectLevel(int level) {
  game.overlays.remove("LevelPage");
  game.levelNum = level;
  game.overlays.remove("HomePage");
}

2.关卡数据的随机生成

每一个关卡理论上来说应该精心设计砖块的排布数据,但这里为了节约时间,通过写一个数据解析器,通过随机数来自动生成砖块排布的数据。每个关卡中:

  • [1]. 砖块宽高 9*10
  • [2]. 随机生成的砖块排布左右堆成。
  • [3]. 随机生成的最终结果以 json 的形式存储为关卡数据文件。在游戏初始化时加载数据。

: 关卡生成器代码是独立于游戏之外的存在,只是在开发过程中辅助创建数据。


如下所示,每一行通过 formLine 方法封装,由于左右对称。可以先生成一半的数据,然后把另一半反向放入列表尾部。

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

Random random = Random();

List<int> formLine(int count) {
  List<int> line = [];
  int half = count ~/ 2;
  for (int i = 0; i <= half; i++) {
    line.add(random.nextInt(2));
  }
  line.addAll(line.sublist(0,half).reversed);
  return line;
}

上面的数据就对应着游戏关卡中的一行:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

一共有 10 行数据,只要遍历生成 10 次 formLine 即可。这样就生成了随机的二维点阵:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

void main() {
  List<List<int>> tile = [];

  for (int i = 0; i < 10; i++) {
    tile.add(formLine(9));
  }
  print(tile.join('\n'));
}

将其作用于界面上,效果如下。这样我们就随机生成了一个关卡中的砖块布局:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

然后可以进一步封装一个 gen 方法生成关卡数据。比如现在关卡的小球速度依次 + 10 :

Level gen(int index) {
  List<List<int>> tiles = [];
  for (int i = 0; i < 10; i++) {
    tiles.add(formLine(9));
  }
  return Level(
    id: index,
    tiles: tiles,
    ballSpeed: 340 + 10.0 * index,
  );
}

3.随机关卡数据的存储和加载

下面代码中随机 30 关的数据,并将其通过 json.encode 编码为字符串实现序列化,存储到资源文件中:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

void main() {
  List<Level> levels = [];
  for (int i = 0; i < 10; i++) {
    levels.add(gen(i+1));
  }
  String data = json.encode(levels);
  String filePath = path.join(Directory.current.path,'assets','data','bricks_levels.json');
  File(filePath).writeAsString(data);
}

对象想要序列化成 json ,需要实现 toJson 的方法,根据成员创建 map 对象:

class Level {
  /// 略同...
  
  Map<String, dynamic> toJson() => {
        'id': id,
        'tiles': tiles,
        'ballSpeed': ballSpeed,
      };
}

现在游戏中就可以抛弃掉之前临时打工的 kLevels 关卡映射。现在所有的关卡数据都集中在 bricks_levels.json 中。所以我们只需要在游戏主类中读取问价,反序列化解析成 Level 列表即可。之前当前 level 获取方式,可以修改为 _levels 列表根据激活关卡的索引获取:

---->[lib/bricks/05/bricks_game.dart]----
List<Level> _levels = [];
Level get level => _levels[_levelNum-1];

Future<void> loadLevels() async {
  String path = 'assets/data/bricks_levels.json';
  String data = await rootBundle.loadString(path);
  List<dynamic> list = json.decode(data) as List;
  _levels = list.map(Level.fromMap).toList();
}

现在就可以真正地进行选关挑战了。目前关卡的解锁功能还没有实现,接下来一起来完成吧 ~

第3关第10关
Flutter&Flame游戏实践#08 | 打砖块 -关卡设计Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

三、游戏中持久化数据维护

为了不让游戏退出后,解锁的关卡被重置,需要将解锁的最大关卡数被持久化存储。之前在小恐龙跳跃中已经介绍过使用 shared_preferences 数据持久化存储的方案。另外,像金币数、钻石数、音效的开启等配置项,也需要进行数据的持久化。


1. 游戏配置参数 GameConfig

这里先将游戏的配置参数统一交由 GameConfig 类进行维护,其中承载着目前需要的数据信息:

---->[lib/bricks/05/config/game_config.dart]----
class GameConfig {
  /// 最大解锁关卡数
  final int maxUnLockLevel;
  // 绿水晶个数
  final int blueCrystal;
  // 金币个数
  final int coin;
  // 是否开启音效
  final bool enableSoundEffect;
  // 是否开启背景音乐
  final bool enableBgMusic;

  GameConfig({
    required this.maxUnLockLevel,
    required this.blueCrystal,
    required this.coin,
    required this.enableSoundEffect,
    required this.enableBgMusic,
  });
}

为 GameConfig 提供三个辅助方法,便于调用:

  • fromMap 方法: 通过 json 解析成的 Map 创建 GameConfig 实例,
  • toJson 方法 : 便于将 GameConfig 编码成字符串,用于持久化存储。
  • copyWith 方法: 便于通过已有的 GameConfig 对象,修改若干属性,创建新的对象。
factory GameConfig.fromMap(dynamic map) {
  return GameConfig(
    maxUnLockLevel: map['maxUnLockLevel'] ?? 1,
    blueCrystal: map['blueCrystal'] ?? 0,
    coin: map['coin'] ?? 0,
    enableSoundEffect: map['enableSoundEffect'] ?? true,
    enableBgMusic: map['enableBgMusic'] ?? true,
  );
}

Map<String, dynamic> toJson() => {
      'maxUnLockLevel': maxUnLockLevel,
      'blueCrystal': blueCrystal,
      'coin': coin,
      'enableSoundEffect': enableSoundEffect,
      'enableBgMusic': enableBgMusic,
    };
    
GameConfig copyWith({
  int? maxUnLockLevel,
  int? blueCrystal,
  int? coin,
  bool? enableSoundEffect,
  bool? enableBgMusic,
}) =>
    GameConfig(
      maxUnLockLevel: maxUnLockLevel ?? this.maxUnLockLevel,
      blueCrystal: blueCrystal ?? this.blueCrystal,
      coin: coin ?? this.coin,
      enableSoundEffect: enableSoundEffect ?? this.enableSoundEffect,
      enableBgMusic: enableBgMusic ?? this.enableBgMusic,
    );

2.配置信息管理类

这里将配置信息的加载和存储通过 GameConfigManager 类进行维护,其中:

  • 持有 SharedPreferences 对象,并在构造方法中赋值。
  • 持有当前游戏的配置信息 config 对象,该对象通过 loadConfig 方法进行初始化。
  • 通过 saveConfig 方法,将 config 对象序列化成字符串,使用 sp 持久化存储.
---->[lib/bricks/05/config/game_config.dart]----
class GameConfigManager {
  static const _kConfigKey = 'bricks-game-config-key';
  final SharedPreferences sp;
  late GameConfig config;
  
  GameConfigManager(this.sp);
  
  void loadConfig() {
    String data = sp.getString(_kConfigKey) ?? "{}";
    config = GameConfig.fromMap(jsonDecode(data));
  }
  
  Future<void> saveConfig() => sp.setString(_kConfigKey, jsonEncode(config));
}

另外基于 saveConfig 方法,可以提供一些修改某个配置项的方法,方便调用:

/// 解锁下一关
Future<void> unlockNextLevel() {
  config = config.copyWith(maxUnLockLevel: config.maxUnLockLevel + 1);
  return saveConfig();
}
/// 增加绿水晶
Future<void> addBlueCrystal({int count = 1}) {
  config = config.copyWith(blueCrystal: config.blueCrystal + count);
  return saveConfig();
}
/// 增加金币
Future<void> addCoin({int count = 1}) {
  config = config.copyWith(coin: config.coin + count);
  return saveConfig();
}
/// 修改背景音乐是否激活
Future<void> changeEnableBgMusic(bool enable) {
  config = config.copyWith(enableBgMusic: enable);
  return saveConfig();
}
/// 修改音效是否激活
Future<void> changeEnableSoundEffect(bool enable) {
  config = config.copyWith(enableSoundEffect: enable);
  return saveConfig();
}

3. 配置管理器的使用

这里让游戏主类持有 GameConfigManager 对象,方便访问,并在 onLoad 回调在进行创建。另外,声音的配置之前是在 AudioManager 中维护的,此时可以让其依赖 GameConfigManager ,获取配置信息:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

---->[lib/bricks/05/bricks_game.dart]----
GameConfig get config => configManager.config;

late GameConfigManager configManager;

@override
FutureOr<void> onLoad() async {
  sp = await SharedPreferences.getInstance();
  configManager = GameConfigManager(sp);
  configManager.loadConfig(sp);
  am = AudioManager(configManager);
  /// 略同...
}

这样在相关的时机调用 GameConfigManager 方法,更新 config 配置对象即可,同时也会持久化存储到本地的 xml 中。比如当砖块全部被击碎时,可以触发 unlockNextLevel 解锁下一关,以及 addBlueCrystal 增加一个绿水晶。如下是存储的配置文件具体信息:

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

void checkSuccess() {
  if (brickManager.children.isEmpty) {
    game.am.play(SoundEffect.uiClose);
    game.overlays.add('GameSuccessMenu');
    game.configManager.unlockNextLevel();
    game.configManager.addBlueCrystal();
    game.status = GameStatus.gameOver;
  }
}

四。本章小结

本章我们实现了打砖块游戏的选关功能,进一步拓展了游戏玩法。并通过随机生成的方式制作出 30 个关卡。另外,也完成了一些配置数据的持久化存储的功能,这样配置信息就不会随着游戏结束而重置。

Flutter&Flame游戏实践#08 | 打砖块 -关卡设计

接下来,我们将进一步拓展游戏玩法,设计一些道具进一步增加游戏的可玩性。

转载自:https://juejin.cn/post/7350934543400861733
评论
请登录