Flutter&Flame游戏实践#11 | 打砖块 - 功能背包
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
一、游戏背包
上一篇我们实现了金币的获取,以及商店商品的购买。购买的商品明应该放入背包,并在挂卡中使用。本篇将实现游戏背包的功能,并实现道具在关卡中效果的具体逻辑。
使用符文 | 使用道具 |
---|---|
![]() | ![]() |
1. 背包界面的展示形式
如下所示,背包通过弹框的形式展示。分为 挡板
、符文
和 道具
三个 Tab 。其中已购买的挡板可以在背包中进行切换,在主页面可以查看符文和道具的个数,但只能在关卡中使用:
挡板 | 符文 |
---|---|
![]() | ![]() |
2. 背包商品数据
首先在购买商品时,需要将商品加入背包,并且持久化存储。这里将购买的商品封装为 BuyGoods
对象,维护已购买的商品以及对应的数量:并提供 fromMap
方法根据映射关系构建对象,以及 toJson
方法将对象序列化成字符串:
---->[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<String, dynamic> toJson() => {
'goods': goods,
'count': count,
};
}
3. 商品的购买与背包数据维护
严格来说,像商品、购买的商品数据,应该通过数据库进行存储,或者通过网络提交到服务器。当前数据并不多单,这里先以首先功能为主,所以也将这些数据存入配置文件中。后续有机会,也可以将数据的存储迁移到数据库或服务器网络。
如下所示,在 GameConfig
中增加 buyGoods
对象表示已购买的道具商品:
然后再配置信息管理器中,通过 saveGoodsToPackage
方法,将购买的商品加入背包数据里。其中如果背包包含该商品,则让数量 +1 ,否则将商品添加到背包中:
---->[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 方法获取已购买的挡板商品列表:
---->[lib/bricks/06/bricks_game.dart#BricksGame]----
List<PaddleType> get buyPaddles => configManager.config.activePaddles
.map((e) => PaddleType.values[e])
.toList();
List<String> get buyPaddleSrcs => buyPaddles
.map((e) =>e.src)
.toList();
List<Goods> get buyPaddleGoods =>
goodsManager.goods(GoodsType.paddle)
.where((e) => buyPaddleSrcs.contains(e.src)).toList();
此时购买商品后就可以在 shared_preferences.json 中查看到购买商品的信息,此时你也可以通过编辑器修改配置信息,来达到 "作弊"
目的。这也是为什么配置文件不是很好的原因。
二、背包界面构建
商品的数据准备完毕之后,接下来将完成对背包界面的构建:
购买商品 | 背包展示商品 |
---|---|
![]() | ![]() |
1. 背包界面:PackagePage
添加一个 PackagePage
浮层界面表示背包菜单页,通过 PackagePage
组件展示:
在代码中通过 game.overlays
添加和移除 PackagePage
即可打开/关闭背包界面:
---->[打开背包界面]----
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
方法根据激活索引得到对应购买类型的商品列表:
---->[lib/bricks/06/overlays/package_page/package_content.dart]----
class PackageContent extends StatefulWidget {
final BricksGame game;
const PackageContent({super.key, required this.game});
@override
State<PackageContent> createState() => _PackageContentState();
}
class _PackageContentState extends State<PackageContent> {
int _activeIndex = 0;
List<BuyGoods> get buyGoods {
if (_activeIndex == 0) {
return widget.game.buyPaddleGoods
.map<BuyGoods>((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
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
将根据新索引,计算出当前索引对应的商品,从而保证界面上展示商品的准确性:
---->[lib/bricks/06/overlays/package_page/package_content.dart]----
void _onTabChange(int index) {
setState(() {
_activeIndex = index;
});
}
在主页面中,背包内可以切换激活挡板,游戏关卡内不希望切换面板;只有关卡中才可以使用道具商品。此时可以通过 overlays.isActive
来校验是否存在主界面,另外在 BricksGame
中添加一个 useGoods
方法,用于关卡内使用道具的逻辑操作,将在后续实现:
---->[lib/bricks/06/overlays/package_page/package_content.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 并持久化存储:
---->[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
方法移除砖块即可 :
---->[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
方法处理挡板特效,并重置计数器:
---->[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 增加了行列数据,在构造时传入:
void handlePaddleEffect(Brick brick) {
if(game.paddleType==PaddleType.blue){
List<Brick> bricks = brickManager.children
.whereType<Brick>()
.where((b) => b.column==brick.column)
.toList();
for(Brick brick in bricks){
onBrickWillRemove(brick);
}
}
if(game.paddleType==PaddleType.red){
List<Brick> bricks = brickManager.children
.whereType<Brick>()
.where((b) => b.row==brick.row)
.toList();
for(Brick brick in bricks){
onBrickWillRemove(brick);
}
}
}
紫色秘宝 每击碎10个方块,获得随机道具。在 handlePaddleEffect 中继续校验,当挡板是紫色时,在 100 毫秒后随机掉落一个 PropComponent
道具:
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 添加一个小球:
发射两颗球 | |
---|---|
![]() | ![]() |
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
即可:
void useGoods(Goods goods) async{
/// 略同...
if(goods.type==GoodsType.function){
if(goods.src=='prop_show_5s.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();
}
}
}
到这里,挡板、符文和道具的功能就已经实现完毕,购买的商品也可以加入背包。目前打砖块的玩法和代码实现就告一段落了,有其他想法的朋友可以继续拓展玩法。下一篇,将对打砖块项目进行优化,为游戏交互过程中增加一些特效,使其在视觉上体验更好一些。
转载自:https://juejin.cn/post/7356511140829331465