Flutter&Flame游戏实践#10 | 打砖块 - 金币与商店

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


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

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

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]

第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》【github 项目首页】 查看。


通过资源图片,可以看出其中有很多的砖块、挡板的样式。我们可以进一步提升游戏玩法的复杂程度:

本篇将完成打砖块游戏中的金币和商店系统,如下所示道具商店中有 挡板符文功能道具 三个种类的商品。点击商品时可以展示详情,进行购买。其中:

  • 挡板 : 不同的挡板具有不同的特殊功能,购买后永久持有。
  • 符文 :一次性持久道具,可以击碎关卡内对应样式的砖块。
  • 功能道具:一次性持久道具,具有特定功能的。
商品列表 商品详情

一、 关卡砖块设计

为拓展玩法,首先,我们对砖块的样式进行完善。这里提取了如下三类共 15 种砖块,在一个关卡内随机出现一种类型的五种砖块。结合道具中的碎石符文,可以击碎关卡内对应颜色的砖块:


1. 关卡内砖块随机样式

下图左侧是之前的单一砖块,右图是第一类 5种砖块的随机组合。其中每个关卡中砖块的展示与否是固定的,只是砖块的图片资源不同:

单一砖块

此时,我们需要对 Bricks 砖块构件进行优化。在功能需求中,要根据砖块类型批量删除,此时需要为 Bricks 增加标识;可以将 图片资源 视为标识,通过构造进行传入;刚好在加载时也可以作为砖块的图片资源:

dart 复制代码
---->[lib/bricks/06/heroes/bricks.dart]----
class Brick extends SpriteComponent with HasGameRef<BricksGame> {
  final int id;
  final String src;

  Brick(this.id, this.src);

  @override
  FutureOr<void> onLoad() {
    sprite = game.loader[src];
    add(RectangleHitbox());
    return super.onLoad();
  }

  @override
  void onRemove() {
    super.onRemove();
    game.world.checkSuccess();
  }
}

2. 随机类型随机砖块资源

Bricks 构件在 BricksManager 中创建时,可以通过随机数来随机得到图片资源。这里希望每个关卡中只出现一种类型的五种,而不是 15 种图片随机出现,这样太过杂乱。如下通过二维数组 kTypeBrickList 维护类型列表,每种类型中有五个图片资源:

dart 复制代码
---->[lib/bricks/06/heroes/bricks.dart]----
  static const List<String> kPureBricks = [
    "Colored_Blue-64x32.png",
    "Colored_Green-64x32.png",
    "Colored_Yellow-64x32.png",
    "Colored_Purple-64x32.png",
    "Colored_Orange-64x32.png",
  ];

  static const List<String> kWallBricks = [
    "Colored_Blue_Block-64x32.png",
    "Colored_Green_Block-64x32.png",
    "Colored_Yellow_Block-64x32.png",
    "Colored_Purple_Block-64x32.png",
    "Colored_Orange_Block-64x32.png",
  ];

  static const List<String> kTexturedBricks = [
    "Textured_Brick_03-64x32.png",
    "Textured_Brick_02-64x32.png",
    "Textured_Stone_02-64x32.png",
    "Textured_Stone_03-64x32.png",
    "Textured_Brick_04-64x32.png",
  ];

  static const List<List<String>> kTypeBrickList = [
    kPureBricks,
    kWallBricks,
    kTexturedBricks
  ];

这样每个关卡中,让随机出现某一类的砖块,如下所示:

纯色 砖块 石块

二、挡板的功能设计

目前一共有六种挡板,游戏中将为这六种挡板赋予特殊能力;自左到右依次是:

粉红幸运 : 关卡道具出现概率 +5%
蓝色雷霆 : 每击碎10个砖块,击碎横竖方向砖块
紫色秘宝 : 每击碎10个方块,获得随机道具
黄色宝盆 : 砖块击碎时,获取金币概率 +10%
红色嗜血 : 每击碎10个砖块,随机消除一类砖块
天蓝双星 :关卡开始后,获得 +1 球道具


1. 挡板类型

挡板一个有 6 种类型,每种类型有正常和延展的两种图片。这里通过枚举 PaddleType 进行维护:

dart 复制代码
---->[lib/bricks/06/heroes/paddle.dart]----
enum PaddleType{
  pink('Paddle_C_Red_96x28.png','Paddle_C_Red_192x28.png'),
  blue('Paddle_C_Blue_96x28.png','Paddle_C_Blue_192x28.png'),
  purple('Paddle_B_Purple_96x28.png','Paddle_B_Purple_192x28.png'),
  yellow('Paddle_B_Yellow_96x28.png','Paddle_B_Yellow_192x28.png'),
  red('Paddle_A_Red_96x28.png','Paddle_A_Red_192x28.png'),
  azure('Paddle_A_Blue_96x28.png','Paddle_A_Blue_192x28.png'),
  ;
  final String src;
  final String expandSrc;

  const PaddleType(this.src,this.expandSrc);
}

2. 应用配置信息的维护

挡板一旦购买,会永久生效。所以 是否已经购买、当前激活挡板类型,这两个数据需要进行持久化存储。之前游戏数据配置通过 GameConfig 类记录,可以在其中添加两个数据用于记录挡板信息:

dart 复制代码
---->[lib/bricks/06/config/game_config.dart#GameConfig]----
class GameConfig {
  // 略同...
    
  // 挡板类型
  final PaddleType paddleType;
  // 已购买的挡板索引列表
  final List<int> activePaddles;
  
  factory GameConfig.fromMap(dynamic map) {
    return GameConfig(
     // 略同...
      activePaddles: map['activePaddles'].map<int>((e)=>e as int).toList() ?? [5],
      paddleType: PaddleType.values[map['paddleType'] ?? 5],
    );

之前数据持久化的工作在 GameConfigManager 处理,通过 xml 配置文件存储数据。这里在其中提供 switchPaddleTypebuyPaddleSuccess 方法,分别处理切换挡板类型,和购买成功后的数据存储:

dart 复制代码
---->[lib/bricks/06/config/game_config.dart#GameConfigManager]----
/// 切换挡板样式
Future<void> switchPaddleType(PaddleType type) {
  config = config.copyWith(paddleType: type);
  return saveConfig();
}

/// 购买挡板
Future<void> buyPaddleSuccess(PaddleType type) {
  List<int> actives = [type.index, ...config.activePaddles];
  config = config.copyWith(activePaddles: actives);
  return saveConfig();
}

3. 挡板构建的代码处理

为了方便访问挡板相关的配置信息,在 BricksGame 中提供了 paddleTypebuyPaddles 两个 get 方法,分别获取 当前挡板已购买的挡板列表

dart 复制代码
class BricksGame extends FlameGame<PlayWorld> with KeyboardEvents, HasCollisionDetection {
  /// 略同...

  // 当前挡板
  PaddleType get paddleType => configManager.config.paddleType;
  
  // 已购买的挡板列表
  List<PaddleType> get buyPaddles => configManager.config.activePaddles
      .map((e) => PaddleType.values[e])
      .toList();
      
  // 切换挡板
  void switchPaddle(String src) {
    PaddleType type = PaddleType.values.singleWhere((e) => e.src == src);
    configManager.switchPaddleType(type);
    world.paddle.switchType(src);
  }

此时 Paddle 中的图片资源,取用 game.paddleType 即可。切换挡板类型,对于 Paddle 来说就是更新图片资源:

dart 复制代码
class Paddle extends SpriteComponent with HasGameRef<BricksGame> , CollisionCallbacks {

  void expand(){
    String src = game.paddleType.expandSrc;
    sprite = game.loader[src];
  }

  void expandEnd(){
    String src = game.paddleType.src;
    sprite = game.loader[src];
  }

  void switchType(String src) {
    sprite = game.loader[src];
  }

三、道具商店界面构建

道具商店中的界面如下,包括顶部标题、挡板商铺、碎石符文、功能道具四个区域。其中列出的售卖品称之为商品(Goods) , 待售商品在点击时,可以弹出详情介绍:

挡板 符文

1. 商品的封装和管理

三类商品在视图中的表现有所差异,比如挡板只能购买一次,而且已购买的挡板可以切换激活状态。这里通过 GoodsType 枚举表示商品类型,定义 Goods 类承载商品的通用数据:

dart 复制代码
---->[lib/bricks/06/overlays/shop_page/goods.dart]----
enum GoodsType {
  paddle, /// 挡板
  rune, /// 符文
  function, // 功能道具
}

class Goods {
  final String title; // 标题
  final String desc; // 描述
  final String src; // 资源图片
  final GoodsType type; // 类型
  final int? coin; // 所需金币
  final int? crystal; // 所需水晶
···

商品的具体数据列表,这里通过 json 字符串进行维护,其中是商品信息的列表数据。在 loadGoods 方法中解析 json 文件,为 _goods 列表赋值;并提供 goods 方法根据类型查询对应类型的商品列表:

dart 复制代码
---->[lib/bricks/06/overlays/shop_page/goods_mamager.dart]----
class GoodsManager {
  List<Goods> _goods = [];
  Goods? showMenuGoods;


  List<Goods> goods(GoodsType type) =>
      _goods.where((e) => e.type == type).toList();

  Future<void> loadGoods() async {
    String goodsStr = await rootBundle.loadString('assets/data/goods.json');
    List data = jsonDecode(goodsStr) as List;
    _goods = data.map(Goods.fromMap).toList();
  }
}

这样,商品的数据层就准备完毕了,下面看一下视图构建。


2. 商品容器的构建

如下所示,每个商品对应一个容器,上面是商品的图片,下面是商品的售价或信息。我们将这个容器单独封装为 GoodsCell 组件。

条目容器背景使用图片,另外我们希不同的尺寸,图片以九宫缩放。可以使用之前的封装的 NineImageWidget , 另外容器中的具体内容、底部信息、点击回调事件,通过构造方法由使用者提供:

dart 复制代码
class GoodsCell extends StatelessWidget {
  final BricksGame game;
  final Goods goods;
  final Size size;
  final Widget child;
  final Widget bottom;

  final ValueChanged<Goods> onSelectGoods;

  const GoodsCell({
    super.key,
    required this.goods,
    required this.game,
    required this.size,
    required this.onSelectGoods,
    required this.child,
    required this.bottom,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => onSelectGoods(goods),
      child: SizedBox(
        height: size.height,
        width: size.width,
        child: NineImageWidget(
          expandZone: const Rect.fromLTWH(4, 4, 4, 4),
          image: game.loader['Cell01.png'].image,
          child: _buildContent(),
        ),
      ),
    );
  }
  
  Widget _buildContent(){
    const EdgeInsets pd = EdgeInsets.symmetric(vertical: 4);
    return Center(
      child: Column(
        children: [
          Expanded(child: Center(child: child)),
          const Divider(height: 1),
          Padding(padding: pd, child: bottom),
        ],
      ),
    );
  }
}

3. 挡板商品列表视图构建

挡板商品在展示时,底部有三种状态:

  • 如果是当前在使用的挡板,展示 正在使用
  • 如果已经购买过挡板,展示 已购买
  • 如果没有购买过挡板,展示 需要购买的金币
已购买 正在使用 未购买

这里通过 PaddleGoodsItem 展示挡板商品,其中传入 active是否是当前使用的挡板;以及 hasPaddle 表示是否已经购买:

dart 复制代码
---->[lib/bricks/06/overlays/shop_page/goods_item/paddle_goods_item.dart]----
class PaddleGoodsItem extends StatelessWidget {
  final bool active;
  final bool hasPaddle;
  final BricksGame game;
  final Goods goods;
  final ValueChanged<Goods> onSelectGoods;

  const PaddleGoodsItem({
    super.key,
    required this.game,
    required this.goods,
    required this.onSelectGoods,
    required this.active,
    required this.hasPaddle,
  });

视图构建逻辑在 build 方法中,使用上面的 GoodsCell 组件提供整体的布局结构。bottom 对应的组件,单独封装为 buildBottom 处理。对于已经购买过的挡板,点击时不希望弹出菜单框,这时可以通过 Tooltip 组件展示具体功能:

dart 复制代码
@override
Widget build(BuildContext context) {
  Widget child = GoodsCell(
    size: const Size(90, 90),
    goods: goods,
    game: game,
    onSelectGoods: onSelectGoods,
    bottom: buildBottom(hasPaddle, active),
    child: SizedBox(
      width: 64,
      height: 64 * 28 / 96,
      child: SpriteWidget(sprite: game.loader[goods.src]),
    ),
  );
  if (hasPaddle) {
    child = Tooltip(message: goods.desc, child: child);
  }
  return child;
}

buildBottom 方法中,根据 hasactive 决定底部的构建逻辑即可:

dart 复制代码
Widget buildBottom(bool has, bool active) {
  const TextStyle white = TextStyle(color: Colors.white);
  const TextStyle blue = TextStyle(color: Colors.blue);
  if (active) return const Text("正在使用", style: blue);
  if (has) return const Text("已购买", style: white);
  String coin = 'assets/images/break_bricks/coin.png';
  return Wrap(
    spacing: 2,
    crossAxisAlignment: WrapCrossAlignment.center,
    children: [
      Image.asset(coin, width: 14, height: 14),
      Text(goods.coin.toString(), style: white),
    ],
  );
}

挡板的单体构建完毕,接下来需要通过商品的数据,遍历创建多个商品。如下,通过 Wrap 组件包裹商品列表条目,使用 _buildItem 方法根据 Goods 数据构建条目:

dart 复制代码
class PaddleShop extends StatefulWidget {
  final BricksGame game;

  const PaddleShop({super.key, required this.game});

  @override
  State<PaddleShop> createState() => _PaddleShopState();
}

class _PaddleShopState extends State<PaddleShop> {
  @override
  Widget build(BuildContext context) {
    const TextStyle style =  TextStyle(fontSize: 16, color: Colors.white);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        const Padding(
          padding: EdgeInsets.symmetric(horizontal: 24.0, vertical: 12),
          child: Text("挡板商铺", style: style),
        ),
        Center(
          child: Wrap(
            spacing: 12,
            runSpacing: 12,
            crossAxisAlignment: WrapCrossAlignment.center,
            alignment: WrapAlignment.center,
            runAlignment: WrapAlignment.center,
            children: widget.game.goodsManager
                .goods(GoodsType.paddle)
                .map(_buildItem)
                .toList(),
          ),
        ),
      ],
    );
  }

在使用 PaddleGoodsItem 时,我们需要明确当前商品是否已购买,或是当前激活项。如下所示:

  • 根据 game.buyPaddles 获取图片资源列表,校验当前商品图片资源是否在其中,可以得到是否购买。
  • 根据 game.paddleType 的图片资源,是否和商品图片资源限定。确定是否为当前激活项。
  • 在回调事件中,如果已经购买且非激活项,调用 switchPaddle 切换挡板类型。
  • 在回调事件中,如果未购买,弹出 GoodsInfoMenu 展示商品信息弹框。
dart 复制代码
Widget _buildItem(Goods goods) {
List<String> srcList = widget.game.buyPaddles.map((e) => e.src).toList();
bool hasPaddle = srcList.contains(goods.src);
bool active = widget.game.paddleType.src == goods.src;
return PaddleGoodsItem(
  hasPaddle: hasPaddle,
  active: active,
  goods: goods,
  onSelectGoods: (goods) {
    if (active) return;
    if (hasPaddle && !active) {
      widget.game.switchPaddle(goods.src);
      setState(() {});
    } else {
      widget.game.goodsManager.showMenuGoods = goods;
      widget.game.overlays.add("GoodsInfoMenu");
    }
  },
  game: widget.game,
);
}

符文和功能道具的商品处理方式和上面类似,就不过多赘述了,详情可见源码。


四、金币的获取与购买商品

目前作为一个简单的打砖块游戏,金币获取的唯一方式是击碎砖块时,概率掉落。挡板接到金币时,金币数 +1 。如下图所示:

金币掉落 金币拾取

1. 金币掉落代码处理

和道具掉落类似,当击碎砖块时,随机概率产生金币并掉落。只不过道具是在开局时生成的,这里将金币在砖块击碎时随机掉落。首先准备一个会下落的 CoinComponent 表示金币构件:

dart 复制代码
---->[lib/bricks/06/heroes/prop/prop.dart]----
class CoinComponent extends SpriteComponent with HasGameRef<BricksGame> {

  CoinComponent({super.position});
  double fallSpeed = 150;

  @override
  FutureOr<void> onLoad() {
    sprite = game.loader['coin.png'];
    anchor = Anchor.center;
    size = Vector2(24, 24);
    add(RectangleHitbox());
    return super.onLoad();
  }

  @override
  void update(double dt) {
    if (fallSpeed == 0 || isRemoving) return;
    y += dt * fallSpeed;
    if (absolutePosition.y > kViewPort.height) {
      removeFromParent();
    }
    super.update(dt);
  }

}

砖块将要消失有多处需要处理,比如子弹、小球已经后面的道具都可以销毁砖块。在 PlayWorld 中封装 onBrickWillRemove 方法,方便统一处理砖块将要消失的逻辑,其中:

  • [1]. 将 Brick 从世界中移除
  • [2]. 如果有关卡内道具,则掉落。
  • [3]. 触发 createCoin 方法,20% 概率掉落金币。
  • [4]. 播放砖块击碎音效。
dart 复制代码
---->[lib/bricks/06/bricks_game.dart#PlayWorld]----
void onBrickWillRemove(Brick brick) {
  brick.removeFromParent();
  propManager.fallOrNot(brick.id);
  createCoin(brick.absolutePosition + brick.size / 2);
  game.am.play(SoundEffect.uiSelect);
}

void createCoin(Vector2 position) {
  if (game.probability(0.20)) {
    CoinComponent coin = CoinComponent(position: position);
    add(coin);
  }
}

此时在小球碰撞砖块,以及子弹碰撞砖块时,触发 PlayWorld#onBrickWillRemove 方法即可:


2. 金币拾取和增加

金币的拾取和道具一样,通过检测挡板和金币的碰撞事件即可。获取金币后,将金币从世界中移除,并触发 game#getCoin 方法处理增加金币的业务逻辑:

BricksGame 中的 getCoin 方法,触发 configManager.addCoin 增加一个金币,并将数据持久化。然后触发顶部的金币构件更新数字:

dart 复制代码
---->[lib/bricks/06/bricks_game.dart#BricksGame]----
void getCoin() {
    configManager.addCoin();
    world.titleBar.coin.updateCoin();
}

3. 金币购买商品

本篇最后看一下通过金币购买商品的逻辑。视图交互如下,当单击商品弹出菜单时,选择购买会获得商品,并且减少金币数:

购买挡板 购买道具

这里在 BricksGame 中提供一个 buy 方法,用于处理购买商品。减去对应的金币数,并进行持久化存储。如果购买的是挡板,还需要进行挡板购买项的维护工作,在 GameConfigManager#buyPaddleSuccess 方法中处理,增加对应的挡板索引,并持久化记录:

dart 复制代码
---->[lib/bricks/06/bricks_game.dart#BricksGame]----
void buy(Goods goods) {
  if(goods.type==GoodsType.paddle){
    String src = goods.src;
    PaddleType type = PaddleType.values.singleWhere((e) => e.src==src);
    configManager.buyPaddleSuccess(type);
  }
  configManager.addCoin(count: -(goods.coin??0));
  world.titleBar.coin.updateCoin();
}

---->[lib/bricks/06/config/game_config.dart]----
/// 购买挡板
Future<void> buyPaddleSuccess(PaddleType type) {
  List<int> actives = [type.index, ...config.activePaddles];
  config = config.copyWith(activePaddles: actives);
  return saveConfig();
}

到这里,金币的获取以及消费的流程就完成了。本篇还对打砖块的玩法做了进一步的升级,使其更具有可玩性。那购买的商品道具如何使用呢?下一篇将继续完善商品中道具功能,并支持背包盛纳道具,敬请期待 ~

相关推荐
诸神黄昏EX1 小时前
Android 分区相关介绍
android
大白要努力!2 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee2 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood2 小时前
Perfetto学习大全
android·性能优化·perfetto
旭日猎鹰3 小时前
Flutter踩坑记录(三)-- 更改入口执行文件
flutter
旭日猎鹰3 小时前
Flutter踩坑记录(一)debug运行生成的项目,不能手动点击运行
flutter
️ 邪神3 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】自定义View
flutter·ios·鸿蒙·reactnative·anroid
Dnelic-5 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen7 小时前
MTK Android12 user版本MtkLogger
android·framework
比格丽巴格丽抱15 小时前
flutter项目苹果编译运行打包上线
flutter·ios