Flutter&Flame游戏实践#09 | 打砖块 - 道具设计

04-08 1022阅读


theme: cyanosis

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


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

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

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

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


目前打砖块的玩法功能比较单一,而且砖块较多,打起来比较费劲。本篇将通过加入道具来拓展玩法,增强趣味性的同时,也可以加快游戏副本通关时间。游戏道具主要分关卡内的道具,和持久道具。本章将着重实现如下的五个关卡道具:

jvideo


一、道具的维护:Prop

在某个版本中,游戏的道具类型是固定的,所以可以通过枚举来维护道具 (Prop):

| +1球 | 3s 无敌 | 3s 射击 | 10s 延展 | 生命 +1 | | --- | --- |--- |--- |--- | | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计| Flutter&Flame游戏实践#09 | 打砖块 - 道具设计|Flutter&Flame游戏实践#09 | 打砖块 - 道具设计


1. 道具构建: PropComponent

如下通过 Prop 枚举维护道具的类型,并在构造中指定资源名称和道具获取后持续的秒数:

```dart ---->[lib/bricks/06/heroes/prop/prop.dart]---- enum Prop { addBall('propaddaball.png',-1), shoot('propshoot.png',3), life('proplife.png',-1), invincible('propinvincible.png',3), expand('prop_length.png',10), ;

final String src; final double time;

const Prop(this.src,this.time); } ```

道具最终也是通过构件被添加到世界中,这里定义 PropComponent

```dart ---->[lib/bricks/06/heroes/prop/prop.dart]---- class PropComponent extends SpriteComponent with HasGameRef { final Prop prop;

PropComponent(this.prop);

@override FutureOr onLoad() { sprite = game.loader[prop.src]; return super.onLoad(); }

} ```


2. 道具管理器:PropManager

道具有非常多个,所以也需要通过一个管理器来统一维护。首先思考一下,砖块和道具的关系:

  • [1]. 击碎砖块时,有概率获得随机道具。
  • [2]. 击碎砖块时,随机道具下落。

    从代码层面,我们有两种处理方式。其一、在砖块被 击碎时,一定概率爆出随机道具;其二,在进入 关卡开始时,一定概率为每个砖块附加道具。这里取用后者,将道具藏在砖块下方: 注: 开发阶段,为了方便调试,道具放在砖块上方

    | - | - | | --- | --- | | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 |

    首先想一想,如何实现概率。比如说 25% 的概率,我们可以随机生成 0~1 的数字,如果它比 0.25 小时即为命中。为了方便使用概率,可以在 BricksGame 中维护随机数和 probability 概率方法。

    ``` ---->[lib/bricks/06/bricks_game.dart]---- final Random _random = Random(); Random get random => _random;

    // value: 概率 0~1 bool probability(double value) { double rad = random.nextDouble(); return rad > value; } ```

    然后 PropManager 在 onLoad 加载时,需要得到所有的砖块,然后概率为其添加道具。

    • 砖块管理器在 PlayWorld 中,我们可以通过 game 对象拿到世界,在拿到 BrickManager 对象。
    • 砖块管理器通过查询 Brick 类型的子组件列表,就可以拿到所有的砖块。
    • 遍历砖块,以 25% 的概率,在砖块的中心坐标添加道具。

      由于 PropManager 依赖 BrickManager 构件,所以 PropManager 对象需要在 BrickManager 之后添加到世界中。

      Flutter&Flame游戏实践#09 | 打砖块 - 道具设计

      这里对于生命道具做了一个小处理,在一个关卡中最多只会出现一次。propPool 是道具池,命中之后从道具池中随机抽取道具。如果抽中了生命道具,则道具池将声明道具移除。

      ```dart ---->[lib/bricks/06/heroes/prop/prop_manager.dart]---- class PropManager extends PositionComponent with HasGameRef {

      @override FutureOr onLoad() { final BrickManager brickManager = game.world.brickManager; List bricks = brickManager.children.whereType ().toList(); List propPool = Prop.values.toList(); for (Brick brick in bricks) { /// 0.25 的概率出现道具 bool hit = game.probability(0.25); if (hit) { int index = game.random.nextInt(propPool.length); Prop active = propPool[index]; PropComponent prop = PropComponent(active); prop ..anchor = Anchor.center ..position = brick.center; add(prop); if (active == Prop.life) { propPool.remove(Prop.life); } } } return super.onLoad(); } } ```


      3. 砖块的击碎与道具掉落

      下面来思考一下,如何在砖块击碎时让道具坠落。这一需求中,需要建立 道具 Prop 和 砖块 Brick 之间的联系。在小球撞击砖块之后,我们需要根据砖块,开查找到对应的道具,并触发其坠落。

      两个类之间如何建立联系呢? 其实方式非常多。比如让 Brick 持有 Prop 对象,或让 Prop 持有 Brick对象。但这样会使两个类的耦合性增强,而且他们之间也没有持有对方的必要性。

      我们可以在 PropManager 中维护一下砖块 id 和 道具之间的映射关系 propMap,在加入道具时以砖块 id 为 key, 道具为值添加一条记录。

      Flutter&Flame游戏实践#09 | 打砖块 - 道具设计

      这样在小球碰撞到砖块时,触发 fallOrNot方法,根据砖块 id,从 propMap 中查找对应的道具。如果存在,触发其 fall 方法坠落。坠落后,就可以移除掉记录:

      ``` ---->[lib/bricks/06/heroes/prop/prop_manager.dart]---- final Map propMap = {};

      void fallOrNot(int breakId) { PropComponent? prop = propMap[breakId]; if (prop != null) { prop.fall(); propMap.remove(breakId); } } ```


      道具的坠落是在 y 方向上向下平移,我们可以在 update 中通过 fallSpeed 的速度来增加位移。通过 absolutePosition 可以得到构建的绝对位置,当绝对位置大于视口宽度时,通过 removeFromParent 可以将道具从世界中移除。这样 fall 坠落方法中,只需要为 fallSpeed 赋值即可,比如这里是 200 逻辑像素每秒:

      ```dart ---->[lib/bricks/06/heroes/prop/prop.dart]---- class PropComponent extends SpriteComponent with HasGameRef { final Prop prop;

      PropComponent(this.prop);

      @override FutureOr onLoad() { fallSpeed = 0; sprite = game.loader[prop.src]; 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); }

      double fallSpeed = 0;

      void fall() { fallSpeed = 200; } } ```


      二、获得道具的处理: +1 球 和 +1 生命

      在道具下落的过程中,和挡板碰撞时,表示获取到道具。如下所示,当 +1 球 道具拾取成功时,会在世界中添加一个小球,并立刻弹射:

      | 获取道具 | 小球死亡 | | --- | --- | | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 |


      1.碰撞检测的处理

      首先需要处理 道具 PropComponent 和 挡板 Paddle 构件间的碰撞检测。在 PropComponent 中增加 RectangleHitbox 矩形碰撞检测边界:

      ```dart ---->[lib/bricks/06/heroes/prop/prop.dart]---- class PropComponent extends SpriteComponent with HasGameRef { ///略同...

      // 添加矩形碰撞盒
      add(RectangleHitbox());
      return super.onLoad();

      } ```

      然后挡板混入 CollisionCallbacks , 覆写 onCollisionStart 方法处理碰撞事件。当碰撞物的类型是 PropComponent 时,可以将道具移除,并触发道具获取的逻辑 onGetProp。

      ```dart ---->[lib/bricks/06/heroes/paddle.dart]---- class Paddle extends SpriteComponent with HasGameRef ,CollisionCallbacks {

      @override void onCollisionStart(Set intersectionPoints, PositionComponent other) { super.onCollisionStart(intersectionPoints, other); if(other is PropComponent){ onGetProp(other.prop); other.removeFromParent(); } } ```

      比如当道具是 Prop.addBall 时,在世界中添加一个自动启动的球。该逻辑封装为 PlayWorld#addBall:

      dart void onGetProp(Prop prop){ if(prop == Prop.addBall){ game.world.addBall(autoPlay: true); } }


      2. 为世界添加多个小球

      之前我们将小球作为 PlayWorld 的成员变量,但现在场景中可能出现多个球,需要优化一下处理逻辑。如下所示,通过 addBall 方法,在 paddle 上方添加一个小球。此时开始的 onLoad 方法可以通过 addBall 添加小球:

      Flutter&Flame游戏实践#09 | 打砖块 - 道具设计

      dart ---->[lib/bricks/06/bricks_game.dart]---- void addBall({bool autoPlay = false}) { Ball ball = Ball(); ball.anchor=Anchor.bottomCenter; add(ball); ball.position = paddle.center-Vector2(0,paddle.height/2+4); if (autoPlay) { ball.run(); } }


      由于小球构件不是以成员变量维护在世界中,此时可以通过 children.whereType() 获取小球列表。 play 方法状通过这种形式得到世界中的小球对象:

      dart void play() { if (game.status == GameStatus.ready) { List balls = children.whereType().toList(); if(balls.isNotEmpty){ balls.first.run(); game.status = GameStatus.playing; } } }


      3. 小球死亡逻辑的优化

      之前,小球落到底部视为死亡,但现在可能有若干个小球。需要游戏场景中没有小球时,才可以视为死亡一次,生命值减 1。如下代码中,小球落到底部时,只需要通过 removeFromParent 从世界中移除即可:

      dart ---->[lib/bricks/06/heroes/ball.dart]---- void _handleHitPlayground(Vector2 position, Vector2 areaSize) { if (position.y >= areaSize.y - height) { removeFromParent(); return; }

      另外,在 onRemove 回调中监听到需求移除完成的时机,其中检测一下世界中的小球是否为空。如果为空,才会视为死亡。触发 PlayWorld#died 方法:

      dart @override void onRemove() { bool noBall = game.world.children.whereType().isEmpty; if (noBall) { game.world.died(); } super.onRemove(); }


      4. +1 生命道具

      到这里,+1 小球的道具就已经完成了,同理可以完成 +1 生命 的道具功能。如下所示,当接住了增加生命值的道具,当前关卡内可以增加一条生命:

      | 掉落 +1 生命道具 | 获得道具 | | --- | --- | | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 |

      处理的逻辑也很简单,在 onGetProp 方法中,校验道具类型为 Prop.life 时,触发 PlayWorld#addLife 方法:

      ```dart ---->[lib/bricks/06/heroes/paddle.dart]---- void onGetProp(Prop prop){ if(prop==Prop.addBall){ game.world.addBall(autoPlay: true); } if(prop==Prop.life){ game.world.addLife(); } }

      ---->[lib/bricks/06/heroes/paddle.dart]---- void addLife(){ life += 1; titleBar.updateLifeCount(life); } ```


      三、有时间期限的道具

      上面的 +1 生命和 +1 小球,都是回合内生效的道具。如下的无敌道具,在得到之后,可以进入 3 s 的 无敌状态。 无敌状态时,会击碎所过路径上的砖块,且碰到砖块不反弹:

      | 击落道具 | 无敌道具效果 | | --- | --- | | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 |


      1. 具有时间期限的道具展示:PropDisplay

      当获得有时间期限的道具之后,需要在如下所示的区域中。展示道具图标以及剩余的秒数:

      Flutter&Flame游戏实践#09 | 打砖块 - 道具设计

      展示道具生命的任务,通过如下的 PropDisplay 构件负责。其中传入 Prop 类型,在 onLoad 回调中添加道具对应的图片和生命秒数:

      ```dart ---->[lib/bricks/06/heroes/prop/prop_display.dart]---- class PropDisplay extends PositionComponent with HasGameRef { final Prop prop; double _life = prop.time;

      PropDisplay(this.prop);

      late TextComponent time = TextComponent( text: "$_life s", anchor: Anchor.center, textRenderer: TextPaint(style: const TextStyle(color: Colors.white, fontSize: 12)), );

      void addOne() { _life += prop.time; }

      @override FutureOr onLoad() { SpriteComponent sprite = SpriteComponent(sprite: game.loader[prop.src]); add(sprite); add(time); time.x = sprite.width / 2; time.y = -time.height / 2; size = sprite.size; return super.onLoad(); } ```

      在 update 方法中处理生命秒数减少的逻辑,当生命小于 0 时,从世界中移除:

      dart @override void update(double dt) { if(isRemoving) return; _life -= dt; time.text = '${_life.toStringAsFixed(1)} s'; if (_life


      2. 添加道具展示

      获得道具的时机是 onGetProp,其中其他三种道具有时间期限,需要 PropDisplay 进行展示,这里在 PlayWorld 中封装一个 addPropDisplay 方法进行处理:

      dart ---->[lib/bricks/06/heroes/paddle.dart]---- void onGetProp(Prop prop){ if(prop==Prop.addBall){ game.world.addBall(autoPlay: true); return; } if(prop==Prop.life){ game.world.addLife(); return; } game.world.addPropDisplay(prop); }

      在添加道具时,有一些细节需要处理。道具栏中可能存在多个道具,另外道具在生命期间内,也可能重复获取。所以需要进行方案设计,这里添加一个道具时流程如下:

      • [1]. 道具栏没有道具展示时,添加对应的 PropDisplay。
      • [2]. 道具栏已经存当前道具时,对应的 PropDisplay 增加秒数。
      • [3]. 道具栏有其他道具时,在最后的道具后面添加对应的 PropDisplay。

        Flutter&Flame游戏实践#09 | 打砖块 - 道具设计

        代码实现如下:

        dart ---->[lib/bricks/06/bricks_game.dart]---- void addPropDisplay(Prop pro) { /// 没有道具展示时,添加 PropDisplay if(displays.isEmpty) { PropDisplay display = PropDisplay(pro); display.position = Vector2(360, 86); add(display); return; } /// 表示已经存在展示的道具 List targets = displays.where((e) => e.prop == pro).toList(); if (targets.isNotEmpty) { /// 已存当前道具效力, + 生命时间 displays.first.addOne(); return; } else { /// 有没有,则在之后加一个 PropDisplay display = PropDisplay(pro); display.position = displays.last.position+Vector2(displays.last.width+8,0); add(display); } }


        2. 让道具发挥效力:无敌道具

        无敌道具生效期间时,击碎砖块时不进行反弹,小球沿路径击碎所有的砖块。代码中可以通过如下方式校验,无敌道具是否生效:

        校验世界中,是否存在类型为 Prop.invincible 的 PropDisplay 构件。

        ```dart ---->[lib/bricks/06/bricks_game.dart]---- /// 是否处于 无敌状态 bool get isInvincible => displays .where((e) => e.prop == Prop.invincible) .isNotEmpty;

        List get displays => children.whereType ().toList(); ```


        然后修改在小球碰撞到砖块时的逻辑,当 isInvincible 时,表示无敌道具生效。此时直接移除砖块,不处理需求的碰撞反弹即可:

        dart ---->[lib/bricks/06/heroes/ball.dart]---- else if (other is Brick) { if (game.world.isInvincible) { other.removeFromParent(); game.world.propManager.fallOrNot(other.id); game.am.play(SoundEffect.uiSelect); return; } _lockCollisionTest( () => _handleHitBrick(intersectionPoints.first, other));


        3. 延展道具

        挡板延展道具,会让挡板变长 6s,将有更大的碰撞范围:

        | 道具掉落 | 挡板延展 | | --- | --- | | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 |

        实现起来非常简单,在 Paddle 中增加两个方法 expand 和 expandEnd 分别让 sprite 图片设置为长和短的挡板即可。碰撞区域会自动变化:

        ```dart --->[lib/bricks/06/heroes/paddle.dart]--- class Paddle extends SpriteComponent with HasGameRef , CollisionCallbacks {

        void expand(){ sprite = game.loader['PaddleABlue_192x28.png']; }

        void expandEnd(){ sprite = game.loader['PaddleABlue_96x28.png']; } ```

        挡板碰撞时,调用 expand 方法延展;延展道具失效的契机可以监听 PropDisplay 移除时是否是 expand 道具,失效时触发 expandEnd 取消延展:

        ``` --->[lib/bricks/06/heroes/paddle.dart]--- void onGetProp(Prop prop){ /// 略同... if(prop==Prop.expand){ expand(); } game.world.addPropDisplay(prop); }

        --->[lib/bricks/06/heroes/prop/prop_display.dart]--- @override void onRemove() { super.onRemove(); if(prop==Prop.expand){ game.world.paddle.expandEnd(); } }

        ```

        四、加入设计功能

        如下所示,在接到射击道具时,挡板会处于射击状态,可以持续 3 s发射子弹来击碎砖块:

        | 道具掉落 | 射击道具 | | --- | --- | | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 | Flutter&Flame游戏实践#09 | 打砖块 - 道具设计 |


        1. 子弹构件 - Bullet

        首先准备一下子弹的单体 Bullet,这里绘制一个圆角矩形进行展示。当然你也可以展示子弹图片:

        ```dart --->[lib/bricks/06/heroes/bullet.dart]--- class Bullet extends PositionComponent with HasGameRef , CollisionCallbacks {

        double speed = -400; @override FutureOr onLoad() { size = Vector2(6, 14); add(RectangleHitbox()); return super.onLoad(); }

        @override void render(Canvas canvas) { canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromPoints(Offset.zero, const Offset(6, 14)), const Radius.circular(2), ), Paint()..color = Colors.white); super.render(canvas); } ```

        子弹自诞生之初就具有向上的速度,在 update 回调中根据时间处理子弹在竖直方向上的偏移量。另外,子弹混入 CollisionCallbacks 支持碰撞检测。当时砖块时,击碎砖块并移除自身,如果是墙壁时,移除自身:

        ```dart @override void update(double dt) { if (speed == 0 || isRemoving) return; y += dt * speed; super.update(dt); }

        @override void onCollisionStart( Set intersectionPoints, PositionComponent other) { super.onCollisionStart(intersectionPoints, other); if (other is Brick) { other.removeFromParent(); game.world.propManager.fallOrNot(other.id); removeFromParent(); } if (other is BrickWall) { removeFromParent(); } } } ```


        2. 子弹管理器构件 - BulletManager

        挡板会持续 3 s 发射子弹,说明子弹的个数有很多。可以通过子弹管理器 BulletManager 来维护,处理添加子弹 addBullet 和开始射击 startShoot 的功能:

        addBullet 会在挡板的两侧分别创建一个子弹;startShoot 会添加子弹,并延迟 400ms ,当仍处于射击状态,则继续发射子弹:

        ```dart --->[lib/bricks/06/heroes/bullet.dart]--- class BulletManager extends PositionComponent with HasGameRef {

        void startShoot() async { addBullet(); await Future.delayed(const Duration(milliseconds: 400)); if (game.world.isShoot) { startShoot(); } }

        void addBullet() { Paddle paddle = game.world.paddle; Bullet bullet1 = Bullet(); bullet1.anchor = Anchor.bottomCenter; add(bullet1); bullet1.position = paddle.center - Vector2(-(paddle.width / 2 - 20), paddle.height / 2 + 4);

        Bullet bullet2 = Bullet();
        bullet2.anchor = Anchor.bottomCenter;
        add(bullet2);
        bullet2.position =
            paddle.center - Vector2((paddle.width / 2 - 20), paddle.height / 2 + 4);

        } } ```


        是否处于射击状态,也可以通过是否存在 Prop.shoot 类型的 PropDisplay 判断;最后在接到射击道具时,开启射击即可:

        ```dart --->[lib/bricks/06/bricks_game.dart]--- /// 是否处于 射击状态 bool get isShoot => displays.where((e) => e.prop == Prop.shoot).isNotEmpty;

        --->[lib/bricks/06/heroes/paddle.dart]--- void onGetProp(Prop prop){ /// 略同... if(prop==Prop.shoot){ game.world.bulletManager.startShoot(); } game.world.addPropDisplay(prop); } ```


        本集通过实现五个道具的功能,进一步完善了打砖块游戏的玩法。从中也锻炼了对 Flame 的使用,现在你应该能体会到,完成一个功能需求,就是通过构件和数据,通过代码来实现逻辑。大家可以先自己尝试一下,完成击碎砖块时 30% 概率掉落金币。下一集,将介绍和金币相关的商店和背包:

        Flutter&Flame游戏实践#09 | 打砖块 - 道具设计

VPS购买请点击我

文章版权声明:除非注明,否则均为主机测评原创文章,转载或复制请以超链接形式并注明出处。

目录[+]