【基于Flutter&Flame 的飞机大战开发笔记】重构敌机

3,061 阅读5分钟

前言

Component实现碰撞检测,添加了碰撞的反馈效果后,整个效果就暂时闭环了。本文会着重敌机Component的重构工作。借此机会将其余类型的敌机Component添加进来

笔者将这一系列文章收录到以下专栏,欢迎有兴趣的同学阅读:

基于Flutter&Flame 的飞机大战开发笔记

抽象

之前的类Enemy1是一种类型的敌机Component,它具备了敌机在飞机大战中的基本功能:

  • 无碰撞情况下,从屏幕最上方匀速移动到屏幕最下方,最终从Component树中移除。
  • 具备碰撞检测的能力,与战机Component/子弹Component发生碰撞时,会有生命值减少情况。
  • 生命值为0时,产生销毁/击毁效果。

其实这里还缺了一个与战机Component/子弹Component发生碰撞时,减少生命值的效果。结合上述几点,我们需要添加多种敌机Component到屏幕上。所以需要将敌机Component的特性进一步抽象出来。

结合上述的特性,定义了抽象类Enemy

SpriteAnimationComponent

先来说说不同状态的动画帧播放方案。前文我们利用SpriteAnimationComponent的播放能力,在敌机Component被击毁时设置playing = true。但此时敌机Component至少有3种状态,分别是正常、被攻击、销毁,可以定义一个枚举

enum EnemyState {
  idle,
  hit,
  down,
}

这里可以用SpriteAnimationGroupComponent代替SpriteAnimationComponent,通过设置参数current来切换当前的状态,从而切换对应的动画效果。大概瞄一下源码吧

// sprite_animation_group_component.dart
class SpriteAnimationGroupComponent<T> extends PositionComponent
    with HasPaint
    implements SizeProvider {
  /// Key with the current playing animation
  T? current;

  /// Map with the mapping each state to the flag removeOnFinish
  final Map<T, bool> removeOnFinish;

  /// Map with the available states for this animation group
  Map<T, SpriteAnimation>? animations;

这里的范型T可以设置成上面定义的EnemyStateanimations不同状态对应的SpriteAnimation。还有一个removeOnFinish,可以理解是哪个状态播放完成后,Component自动移除

// sprite_animation_group_component.dart
SpriteAnimation? get animation => animations?[current];

@mustCallSuper
@override
void render(Canvas canvas) {
  animation?.getSprite().render(
        canvas,
        size: size,
        overridePaint: paint,
      );
}

@mustCallSuper
@override
void update(double dt) {
  animation?.update(dt);
  if ((removeOnFinish[current] ?? false) && (animation?.done() ?? false)) {
    removeFromParent();
  }
}

render方法会获取对应状态的SpriteAnimation来渲染update检测动画是否完成,该状态是否需要自动移除。ps:render方法为每帧绘制的回调。

状态应用

构造方法中,初始的状态为idle,设置down状态播放完成后自动移除

// class Enemy
Enemy(
    {required Vector2 initPosition,
    required Vector2 size,
    required this.life,
    required this.speed})
    : super(
          position: initPosition,
          size: size,
          current: EnemyState.idle,
          removeOnFinish: {EnemyState.down: true}) {
  animations = <EnemyState, SpriteAnimation>{};
}

定义三个抽象方法,用于加载不同状态下的SpriteAnimation,由于hit状态并非所有敌机Component都有,所以这里定义为可空。

// class Enemy
Future<SpriteAnimation> idleSpriteAnimation();

Future<SpriteAnimation?> hitSpriteAnimation();

Future<SpriteAnimation> downSpriteAnimation();

onLoad中加载。注意这里hit状态播放完成后,需要将状态重置到idle状态

// abstract class Enemy
@override
Future<void> onLoad() async {
  animations?[EnemyState.idle] = await idleSpriteAnimation();
  final hit = await hitSpriteAnimation();
  hit?.onComplete = () {
    _enemyState = EnemyState.idle;
  };
  if (hit != null) animations?[EnemyState.hit] = hit;
  animations?[EnemyState.down] = await downSpriteAnimation();
  。。。

在碰撞检测中

  • 如果已经是down状态了,就无需触发等待动画播放完自动移除。
  • 如果碰撞目标为Player/Bullect1,则需要处理,生命值未到达0前状态更改为hit,否则为downhit播放完需要变更回idle,与上述逻辑对应上。
// class Enemy
@override
void onCollisionStart(
    Set<Vector2> intersectionPoints, PositionComponent other) {
  super.onCollisionStart(intersectionPoints, other);
  if (current == EnemyState.down) return;
  if (other is Player || other is Bullet1) {
    if (current == EnemyState.idle) {
      if (life > 1) {
        _enemyState = EnemyState.hit;
        life--;
      } else {
        _enemyState = EnemyState.down;
        life = 0;
      }
    。。。

状态变成前,需要重置将要变更状态的SpriteAnimation。这是为了保证每次变更都是从第一帧开始,不会造成画面异常。

// class Enemy
set _enemyState(EnemyState state) {
  if (state == EnemyState.hit) {
    animations?[state]?.reset();
  }
  current = state;
}

Component的移动

之前是通过s = v * t,在update方法回调中更新position的方式实现移动的。这里改成使用MoveEffect实现

// class Enemy
add(MoveEffect.to(
    Vector2(position.x, gameRef.size.y), EffectController(speed: speed),
    onComplete: () {
  removeFromParent();
}));

传入speed,会使用SpeedEffectController,默认是线性移动的。

// effect_contorller.dart
final isLinear = curve == Curves.linear;
if (isLinear) {
  items.add(
    duration != null
        ? LinearEffectController(duration)
        : SpeedEffectController(LinearEffectController(0), speed: speed!),
  );
}

这部分可参考官方示例: flame/aseprite_example.dart at main · flame-engine/flame (github.com)

新一代敌机Component

简单说一下重构后的敌机Component,这里以第二个类型类Enemy2为例,因为它的生命值高可以触发hit状态。

// class Enemy2
@override
Future<SpriteAnimation?> hitSpriteAnimation() async {
  List<Sprite> sprites = [];
  sprites.add(await Sprite.load('enemy/enemy2_hit.png'));
  sprites.add(await Sprite.load('enemy/enemy2.png'));
  final spriteAnimation =
      SpriteAnimation.spriteList(sprites, stepTime: 0.15, loop: false);
  return spriteAnimation;
}

@override
RectangleHitbox rectangleHitbox() {
  return RectangleHitbox(
      size: Vector2(size.x, size.y * 0.9), position: Vector2(0, 0));
}
  • 以重写hit状态的SpriteAnimation加载为例,这里有一帧的被击中效果。
  • 还需要输出一个RectangleHitbox,由于不同素材的尺寸有误差,所以这里单独作碰撞箱的修正。

大部分逻辑都在父类Enemy实现,这里基本只需要实现抽象方法即可。

敌机生成器适配

还记得之前有一个敌机生成器EnemyCreator,用于定时创建敌机Component吗?由于添加了不同类型的敌机,所以它的定时触发方法_createEnemy要作相应修改。在此之前,我们需要定义每款敌机的属性,1、2、3分别代表了类Enemy1、Enemy2、Enemy3,即小中大类型。属性值就见文知意吧。

// class EnemyCreator
final enemyAttrMapping = {
  1: EnemyAttr(size: Vector2(45, 45), life: 1, speed: 50.0),
  2: EnemyAttr(size: Vector2(50, 60), life: 2, speed: 30.0),
  3: EnemyAttr(size: Vector2(100, 150), life: 4, speed: 20.0)
};

_createEnemy中,我们通过区间控制每个类型的生成概率

void _createEnemy() {
  final width = gameRef.size.x;
  double x = _random.nextDouble() * width;
  final double random = _random.nextDouble();
  final EnemyAttr attr;
  final Enemy enemy;
  if (random < 0.5) {
    // load Enemy1
  } else if (random >= 0.5 && random < 0.8) {
    // load Enemy2
  } else {
    // load Enemy3
  }
  add(enemy);
}

至此,敌机Component的重构就告一段落了,后续还会有一些小改动。先来看看目前的效果吧

Record_2022-07-11-16-39-13_13914082904e1b7ce2b619733dc8fcfe_.gif

总结

敌机Component的重构就完成了,定时生成的规则可能有点粗糙,这个后续可能会考虑优化。关于敌机的属性,目前是写死的,后续可以考虑做成本地配置。