likes
comments
collection
share

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

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

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

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

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


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


一、道具的维护: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 枚举维护道具的类型,并在构造中指定资源名称和道具获取后持续的秒数:

---->[lib/bricks/06/heroes/prop/prop.dart]----
enum Prop {
  addBall('prop_add_a_ball.png',-1),
  shoot('prop_shoot.png',3),
  life('prop_life.png',-1),
  invincible('prop_invincible.png',3),
  expand('prop_length.png',10),
  ;

  final String src;
  final double time;

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

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

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

  PropComponent(this.prop);

  @override
  FutureOr<void> 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 是道具池,命中之后从道具池中随机抽取道具。如果抽中了生命道具,则道具池将声明道具移除。

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

  @override
  FutureOr<void> onLoad() {
    final BrickManager brickManager = game.world.brickManager;
    List<Brick> bricks = brickManager.children.whereType<Brick>().toList();
    List<Prop> 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<int, PropComponent> 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 逻辑像素每秒:

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

  PropComponent(this.prop);

  @override
  FutureOr<void> 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 矩形碰撞检测边界:

---->[lib/bricks/06/heroes/prop/prop.dart]----
class PropComponent extends SpriteComponent with HasGameRef<BricksGame> {
    ///略同...
    
    // 添加矩形碰撞盒
    add(RectangleHitbox());
    return super.onLoad();
  }

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

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

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

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

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

2. 为世界添加多个小球

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

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

---->[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<Ball>() 获取小球列表。 play 方法状通过这种形式得到世界中的小球对象:

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

3. 小球死亡逻辑的优化

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

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

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

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

4. +1 生命道具

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

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

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

---->[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 回调中添加道具对应的图片和生命秒数:

---->[lib/bricks/06/heroes/prop/prop_display.dart]----
class PropDisplay extends PositionComponent with HasGameRef<BricksGame> {
  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<void> 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 时,从世界中移除:

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

2. 添加道具展示

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

---->[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 | 打砖块 - 道具设计

代码实现如下:

---->[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<PropDisplay> 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 构件。

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

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

---->[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 中增加两个方法 expandexpandEnd 分别让 sprite 图片设置为长和短的挡板即可。碰撞区域会自动变化:

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

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

  void expandEnd(){
    sprite = game.loader['Paddle_A_Blue_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,这里绘制一个圆角矩形进行展示。当然你也可以展示子弹图片:

--->[lib/bricks/06/heroes/bullet.dart]---
class Bullet extends PositionComponent with HasGameRef<BricksGame>, CollisionCallbacks {
 
  double speed = -400;
  @override
  FutureOr<void> 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 支持碰撞检测。当时砖块时,击碎砖块并移除自身,如果是墙壁时,移除自身:

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

  
  @override
  void onCollisionStart(
      Set<Vector2> 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 ,当仍处于射击状态,则继续发射子弹:

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

  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 判断;最后在接到射击道具时,开启射击即可:

--->[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 | 打砖块 - 道具设计