theme: cyanosis
本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter\&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
\ 第二季:从休闲游戏实践,进阶 Flutter\&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、游戏背包
上一篇我们实现了金币的获取,以及商店商品的购买。购买的商品明应该放入背包,并在挂卡中使用。本篇将实现游戏背包的功能,并实现道具在关卡中效果的具体逻辑。
| 使用符文 | 使用道具 | | --- | --- | | | |
1. 背包界面的展示形式
如下所示,背包通过弹框的形式展示。分为 挡板
、符文
和 道具
三个 Tab 。其中已购买的挡板可以在背包中进行切换,在主页面可以查看符文和道具的个数,但只能在关卡中使用:
| 挡板 | 符文 | | --- | --- | | | |
2. 背包商品数据
首先在购买商品时,需要将商品加入背包,并且持久化存储。这里将购买的商品封装为 BuyGoods
对象,维护已购买的商品以及对应的数量:并提供 fromMap
方法根据映射关系构建对象,以及 toJson
方法将对象序列化成字符串:
```dart ---->[lib/bricks/06/config/buy_goods.dart]---- class BuyGoods { final Goods goods; final int count;
BuyGoods({ required this.goods, required this.count, });
factory BuyGoods.fromMap(dynamic map) { return BuyGoods( count: map['count'] ?? 0, goods: Goods.fromMap(map['goods']), ); }
Map toJson() => { 'goods': goods, 'count': count, }; }
```
3. 商品的购买与背包数据维护
严格来说,像商品、购买的商品数据,应该通过数据库进行存储,或者通过网络提交到服务器。当前数据并不多单,这里先以首先功能为主,所以也将这些数据存入配置文件中。后续有机会,也可以将数据的存储迁移到数据库或服务器网络。
如下所示,在 GameConfig
中增加 buyGoods
对象表示已购买的道具商品:
然后再配置信息管理器中,通过 saveGoodsToPackage
方法,将购买的商品加入背包数据里。其中如果背包包含该商品,则让数量 +1 ,否则将商品添加到背包中:
dart ---->[lib/bricks/06/config/game_config.dart#GameConfigManager]---- Future<void> saveGoodsToPackage(Goods goods) { List<BuyGoods> result = List.of(config.buyGoods); List<BuyGoods> buyGoods = config.buyGoods.where((e) => e.goods.src == goods.src).toList(); if(buyGoods.isEmpty){ result.add(BuyGoods(goods: goods, count: 1)); }else{ BuyGoods buy = buyGoods.first; BuyGoods newBuyGoods = BuyGoods(goods: buy.goods,count:buy.count+1); result.removeWhere((e) => e.goods.src == goods.src); result.insert(0, newBuyGoods); } config = config.copyWith(buyGoods: result); return saveConfig(); }
然后再购买商品的时,调用 saveGoodsToPackage
保存商品:
由于之前已经通过 activePaddles
记录了购买的挡板,这里 buyGoods
就不再额外记录了。在 BricksGame
中提供一个 buyPaddleGoods
的 get 方法获取已购买的挡板商品列表:
```dart ---->[lib/bricks/06/bricks_game.dart#BricksGame]---- List get buyPaddles => configManager.config.activePaddles .map((e) => PaddleType.values[e]) .toList();
List get buyPaddleSrcs => buyPaddles .map((e) =>e.src) .toList();
List get buyPaddleGoods => goodsManager.goods(GoodsType.paddle) .where((e) => buyPaddleSrcs.contains(e.src)).toList(); ```
此时购买商品后就可以在 shared_preferences.json 中查看到购买商品的信息,此时你也可以通过编辑器修改配置信息,来达到 "作弊"
目的。这也是为什么配置文件不是很好的原因。
二、背包界面构建
商品的数据准备完毕之后,接下来将完成对背包界面的构建:
| 购买商品 | 背包展示商品 | | --- | --- | | | |
1. 背包界面:PackagePage
添加一个 PackagePage
浮层界面表示背包菜单页,通过 PackagePage
组件展示:
在代码中通过 game.overlays
添加和移除 PackagePage
即可打开/关闭背包界面:
```dart ---->[打开背包界面]---- game.am.play(SoundEffect.uiOpen); game.overlays.add('PackagePage');
---->[关闭背包界面]---- game.am.play(SoundEffect.uiClose); game.overlays.remove('PackagePage'); ```
背包的面板图片背景,使用之前封装的 NineImageWidget
进行宫格缩放。其中布局结构并不复杂,顶部标题和下部内容区域竖直排列;关闭按钮通过 Stack 组件叠放在右上角:
2. 背包主题体内容:PackageContent
背包的主体内容通过 PackageContent
组件单独维护,其中有点击 Tab 更改内容数据的需求,所以 PackageContent 选用 StatefulWidget。状态类中定义整型的 _activeIndex
表示激活 Tab 的索引,通过 buyGoods
方法根据激活索引得到对应购买类型的商品列表:
```dart ---->[lib/bricks/06/overlays/packagepage/packagecontent.dart]---- class PackageContent extends StatefulWidget { final BricksGame game;
const PackageContent({super.key, required this.game});
@override State createState() => _PackageContentState(); }
class _PackageContentState extends State { int _activeIndex = 0;
List get buyGoods { if (_activeIndex == 0) { return widget.game.buyPaddleGoods .map ((e) => BuyGoods(count: 1, goods: e)) .toList(); } else { return widget.game.configManager.config.buyGoods .where((e) => e.goods.type.index == _activeIndex) .toList(); } } } ```
购买的商品列表通过 Wrap
组件包裹展示。每个商品通过 PackageGoodsCell
组件完成构建逻辑。具体界面构建的细节比较简单,就不一一展开了。可以查看源码 package_content.dart
dart Widget goodsContent() { if (buyGoods.isEmpty) { return const Expanded( child: Center( child: Text('暂无道具', style: TextStyle(color: Colors.white)), ), ); } return Wrap( spacing: 8, runSpacing: 8, children: buyGoods .map((e) => PackageGoodsCell( buyGoods: e, game: widget.game, onSelect: _onSelectGoods, )) .toList(), ); }
3. 背包中的交互事件
点击 Tab 时,更新激活索引并重新构建。这样 buyGoods
将根据新索引,计算出当前索引对应的商品,从而保证界面上展示商品的准确性:
dart ---->[lib/bricks/06/overlays/package_page/package_content.dart]---- void _onTabChange(int index) { setState(() { _activeIndex = index; }); }
在主页面中,背包内可以切换激活挡板,游戏关卡内不希望切换面板;只有关卡中才可以使用道具商品。此时可以通过 overlays.isActive
来校验是否存在主界面,另外在 BricksGame
中添加一个 useGoods
方法,用于关卡内使用道具的逻辑操作,将在后续实现:
```dart ---->[lib/bricks/06/overlays/packagepage/packagecontent.dart]---- void _onSelectGoods(Goods goods) { if(widget.game.hasHomePage){ if (goods.type == GoodsType.paddle) { widget.game.switchPaddle(goods.src); setState(() {}); } }else{ if (goods.type != GoodsType.paddle) { widget.game.useGoods(goods); } } }
---->[lib/bricks/06/bricks_game.dart#BricksGame]---- bool get hasHomePage => overlays.isActive('HomePage');
void useGoods(Goods goods) { } ```
三、商品功能的实现
这样背包展示购买的商品功能就完成了。下面完成最后一步:实现商品的实际能力。包括 碎石符文
、挡板功能
、功能道具
三个方面。
1. 使用符文消除一类砖块
在关卡界面中点击背包,在符文中可以选择对应砖块的符文,使用后,消除关卡中所有对应砖块。如下所示,击碎所有的普通蓝色砖块:
| 关卡内打开背包 | 使用符文消除蓝色砖块 | | --- | --- | | | |
使用符文道具时需要做两件事:
- 减少道具数 ;使用道具也维护在
GameConfigManager
中,和添加道具类似,useGoodsInPackage 方法将对应道具数量 -1 并持久化存储:
dart ---->[lib/bricks/06/config/game_config.dart#GameConfigManager]---- Future<void> useGoodsInPackage(Goods goods) { List<BuyGoods> result = List.of(config.buyGoods); BuyGoods buyGoods = config.buyGoods.singleWhere((e) => e.goods.src == goods.src); if(buyGoods.count==1){ result.removeWhere((e) => e.goods.src == goods.src); }else{ BuyGoods newBuyGoods = BuyGoods(goods: buyGoods.goods,count:buyGoods.count-1); result.removeWhere((e) => e.goods.src == goods.src); result.insert(0, newBuyGoods); } config = config.copyWith(buyGoods: result); return saveConfig(); }
- 触发击碎效果 : 击碎对应符文的砖块非常简单,只要从
brickManager
中查找资源名称和符文一致的砖块列表,遍历触发onBrickWillRemove
方法移除砖块即可 :
dart ---->[lib/bricks/06/bricks_game.dart#BricksGame]---- void useGoods(Goods goods) { //减少道具数 configManager.useGoodsInPackage(goods); if (goods.type == GoodsType.rune) { //击碎砖块 List<Brick> bricks = world.brickManager.children .whereType<Brick>() .where((b) => b.src == goods.src) .toList(); for(Brick brick in bricks){ world.onBrickWillRemove(brick); } } }
2. 挡板的特殊效果实现
这里在回顾一下六个挡板的特殊能力:
粉红幸运
: 关卡道具出现概率 +5%蓝色雷霆
: 每击碎第10个砖块,击碎当前列所有砖块紫色秘宝
: 每击碎10个方块,获得随机道具黄色宝盆
: 砖块击碎时,获取金币概率 +10%红色嗜血
: 每击碎第10个砖块,击碎当前行所有砖块。天蓝双星
:每次发射两颗球。
首先看两个简单的增加概率的挡板 粉红幸运 和 黄色宝盆。 增加道具出现的概率,只需要在 PropManager
中,将道具出现的概率增加 0.05
即可:
同理,为黄色挡板时,金币出现的概率在 PlayWorld#createCoin
方法中,将金币出现的概率 +0.1 :
蓝色雷霆 和 红色嗜血 类似,每击碎 10 个砖块触发效果。蓝色消除当前列,红色消除当前行:
| 蓝色雷霆 | 红色嗜血 | | --- | --- | | | |
对于每 10 个砖块的消除时机,可以在 PlayWorld
中增加 _breakCount
计数器。砖块击碎会统一走 onBrickWillRemove
方法,所以可以在其中计数。每到达 10 个触发 handlePaddleEffect
方法处理挡板特效,并重置计数器:
```dart ---->[lib/bricks/06/bricks_game.dart#PlayWorld]---- int _breakCount = 0;
void onBrickWillRemove(Brick brick) { breakCount++; if(breakCount==10){ handlePaddleEffect(brick); _breakCount = 0; } brick.removeFromParent(); propManager.fallOrNot(brick.id); createCoin(brick.absolutePosition + brick.size / 2); game.am.play(SoundEffect.uiSelect); } ```
击碎横向和纵向的砖块,主要是从砖块管理器中取出对应的砖块列表,逐一击碎。这里为了方便根据行列查找砖块,为 Brick 增加了行列数据,在构造时传入:
```dart void handlePaddleEffect(Brick brick) { if(game.paddleType==PaddleType.blue){ List bricks = brickManager.children .whereType () .where((b) => b.column==brick.column) .toList(); for(Brick brick in bricks){ onBrickWillRemove(brick); } }
if(game.paddleType==PaddleType.red){ List bricks = brickManager.children .whereType () .where((b) => b.row==brick.row) .toList(); for(Brick brick in bricks){ onBrickWillRemove(brick); } } } ```
紫色秘宝 每击碎10个方块,获得随机道具。在 handlePaddleEffect 中继续校验,当挡板是紫色时,在 100 毫秒后随机掉落一个 PropComponent
道具:
dart if(game.paddleType==PaddleType.purple){ Vector2 position = brick.absolutePosition + brick.size / 2; await Future.delayed(const Duration(milliseconds: 100)); int index= game.random.nextInt(Prop.values.length); PropComponent prop = PropComponent(Prop.values[index]); prop.position = position; add(prop); prop.fall(); }
最后 天蓝双星 每次可以发射两颗球。小球的发射是 play 方法的运行,所以在其中校验当挡板类型是 PaddleType.azure
时,延迟 200 ms 添加一个小球:
| 发射两颗球 | | | --- | --- | | | |
dart void play() { if (game.status == GameStatus.ready) { List<Ball> balls = children.whereType<Ball>().toList(); if (balls.isNotEmpty) { balls.first.run(); if (game.paddleType == PaddleType.azure) { Future.delayed(const Duration(milliseconds: 200)).then((value) { addBall(autoPlay: true); }); } game.status = GameStatus.playing; } } }
到这里,六个挡板的特殊能力就完成了,下面看一下两个功能道具的代码实现。
3. 道具功能
如下所示,两个道具分别用于查看关卡内道具,以及随机掉落一个道具:
| 查看关卡道具 | 随机掉落道具 | | --- | --- | | | |
道具功效的处理逻辑在 useGoods
方法中,根据选择商品的 src 决定是哪个道具。展示关卡内的功能道具,只要改变道具管理器的 priority
就行了,三秒后将 priority
归 0 即可隐藏道具。掉落随机道具,只需要在 world
中添加坠落的随机 PropComponent
即可:
```dart void useGoods(Goods goods) async{ /// 略同... if(goods.type==GoodsType.function){ if(goods.src=='propshow5s.png'){ world.propManager.priority = 10; await Future.delayed(const Duration(seconds: 3)); world.propManager.priority = 0; }
if(goods.src=='prop_random.png'){
int index = random.nextInt(Prop.values.length);
PropComponent prop = PropComponent(Prop.values[index]);
prop.position = Vector2(kViewPort.width/2, 300);
world.add(prop);
prop.fall();
}
} } ```
到这里,挡板、符文和道具的功能就已经实现完毕,购买的商品也可以加入背包。目前打砖块的玩法和代码实现就告一段落了,有其他想法的朋友可以继续拓展玩法。下一篇,将对打砖块项目进行优化,为游戏交互过程中增加一些特效,使其在视觉上体验更好一些。