Flutter Flame实战 - 制作一个Flappy Bird

2,229 阅读6分钟

Flame是一款基于Flutter的2D游戏引擎,今天我将使用它制作一款简单的小游戏Flappy Bird

flappy_bird_preview.gif

为游戏添加背景

游戏的的背景分为2个部分,远景和近处的平台,我们可以使用ParallaxComponent来进行展示

final bgComponent = await loadParallaxComponent(
    [ParallaxImageData("background-day.png")],
    baseVelocity: Vector2(5, 0), images: images);
add(bgComponent);

_pipeLayer = PositionComponent();
add(_pipeLayer);

final bottomBgComponent = await loadParallaxComponent(
    [ParallaxImageData("base.png")],
    baseVelocity: Vector2(gameSpeed, 0),
    images: images,
    alignment: Alignment.bottomLeft,
    repeat: ImageRepeat.repeatX,
    fill: LayerFill.none);
add(bottomBgComponent);

第一个bgComponent为远景,中间的_pipeLayer是为了后续的管道占位,bottomBgComponent 则是下面的平台。bgComponent作为远景,缓慢移动,速度为Vector2(5, 0)bottomBgComponent则是使用了规定的游戏速度Vector2(gameSpeed, 0),这是为了后续和管道保持同步的移动速度,最终会得到如下的效果

flappy_bird_bg.gif

主角登场

接下来进行角色的制作,第一步我们需要一个扑腾着翅膀的小鸟,使用SpriteAnimationComponent可以很方便的得到它

List<Sprite> redBirdSprites = [
  await Sprite.load("redbird-downflap.png", images: images),
  await Sprite.load("redbird-midflap.png", images: images),
  await Sprite.load("redbird-upflap.png", images: images)
];
final anim = SpriteAnimation.spriteList(redBirdSprites, stepTime: 0.2);
_birdComponent = Player(animation: anim);
add(_birdComponent);

为了后续更好的进行碰撞检测,这里使用了继承自SpriteAnimationComponentPlayer

class Player extends SpriteAnimationComponent with CollisionCallbacks {
  Player({super.animation});

  @override
  FutureOr<void> onLoad() {
    add(RectangleHitbox(size: size));
    return super.onLoad();
  }
}

PlayeronLoad中为自己增加了一个矩形碰撞框

flappy_bird_bird_anim.gif

玩过游戏的都知道,正常情况下小鸟是自由下落的,要做到这一点只需要简单的重力模拟

_birdYVelocity += dt * _gravity;
final birdNewY = _birdComponent.position.y + _birdYVelocity * dt;
_birdComponent.position = Vector2(_birdComponent.position.x, birdNewY);

_gravity规定了重力加速度的大小,_birdYVelocity表示当前小鸟在Y轴上的速度,dt则是模拟的时间间隔,这段代码会在Flame引擎每次update时调用,持续更新小鸟的速度和位置。

flappy_bird_bird_gravity.gif

然后就是游戏的操作核心了,点击屏幕小鸟会跳起,这一步非常简单,只需要将小鸟的Y轴速度突然变大即可

@override
void onTap() {
    super.onTap();
    _birdYVelocity = -120;
}

onTap事件中,将_birdYVelocity修改为-120,这样小鸟就会得到一个向上的速度,同时还会受到重力作用,产生一次小幅跳跃。

flappy_bird_bird_jump.gif

最后看起来还缺点什么,我们的小鸟并没有角度变化,现在需要的是在小鸟坠落时鸟头朝下,反之鸟头朝上,实现也是很简单的,让角度跟随速度变化即可

_birdComponent.anchor = Anchor.center;
final angle = clampDouble(_birdYVelocity / 180, -pi * 0.25, pi * 0.25);
_birdComponent.angle = angle;

这里将anchor设置为center,是为了在旋转时围绕小鸟的中心点,angle则使用clampDouble进行了限制,否则你会得到一个疯狂旋转的小鸟

flappy_bird_bird_jump_full.gif

反派管道登场

管道的渲染

游戏选手已就位,该反派登场了,创建一个继承自PositionComponent的管道组件PipeComponent

class PipeComponent extends PositionComponent with CollisionCallbacks {
  final bool isUpsideDown;
  final Images? images;
  PipeComponent({this.isUpsideDown = false, this.images, super.size});
  @override
  FutureOr<void> onLoad() async {
    final nineBox = NineTileBox(
        await Sprite.load("pipe-green.png", images: images))
      ..setGrid(leftWidth: 10, rightWidth: 10, topHeight: 60, bottomHeight: 60);
    final spriteCom = NineTileBoxComponent(nineTileBox: nineBox, size: size);
    if (isUpsideDown) {
      spriteCom.flipVerticallyAroundCenter();
    }
    spriteCom.anchor = Anchor.topLeft;

    add(spriteCom);

    add(RectangleHitbox(size: size));
    return super.onLoad();
  }
}

由于游戏素材图片管道长度有限,这里使用了NineTileBoxComponent而不是SpriteComponent来进行管道的展示,NineTileBoxComponent可以让管道无限长而不拉伸。为了让管道可以在顶部,通过flipVerticallyAroundCenter来对顶部管道进行翻转,最后和Player一样,添加一个矩形碰撞框RectangleHitbox

管道的创建

每一组管道包含顶部和底部两个,首先随机出来缺口的位置

const pipeSpace = 220.0; // the space of two pipe group
const minPipeHeight = 120.0; // pipe min height
const gapHeight = 90.0; // the gap length of two pipe 
const baseHeight = 112.0; // the bottom platform height
const gapMaxRandomRange = 300; // gap position max random range

final gapCenterPos = min(gapMaxRandomRange,
            size.y - minPipeHeight * 2 - baseHeight - gapHeight) *
        Random().nextDouble() +
    minPipeHeight +
    gapHeight * 0.5;

通过pipe的最小高度,缺口的高度,底部平台的高度可以计算出缺口位置随机的范围,同时通过gapMaxRandomRange限制随机的范围上限,避免缺口位置变化的太离谱。接下来通过缺口位置计算管道的位置,并创建出对应的管道

PipeComponent topPipe =
    PipeComponent(images: images, isUpsideDown: true, size: pipeFullSize)
      ..position = Vector2(
          lastPipePos, (gapCenterPos - gapHeight * 0.5) - pipeFullSize.y);
_pipeLayer.add(topPipe);
_pipes.add(topPipe);

PipeComponent bottomPipe =
    PipeComponent(images: images, isUpsideDown: false, size: pipeFullSize)
      ..size = pipeFullSize
      ..position = Vector2(lastPipePos, gapCenterPos + gapHeight * 0.5);
_pipeLayer.add(bottomPipe);
_pipes.add(bottomPipe);

lastPipePos是管道的x坐标位置,通过最后一个管道x坐标位置(不存在则为屏幕宽度)加上pipeSpace计算可得

var lastPipePos = _pipes.lastOrNull?.position.x ?? size.x - pipeSpace;
lastPipePos += pipeSpace;

管道的更新

管道需要按照规定的速度向左匀速移动,实现起来很简单

updatePipes(double dt) {
    for (final pipe in _pipes) {
      pipe.position =
          Vector2(pipe.position.x - dt * gameSpeed, pipe.position.y);
    }
}

不过除此之外还有些杂事需要处理,比如离开屏幕后自动销毁

_pipes.removeWhere((element) {
  final remove = element.position.x < -100;
  if (remove) {
    element.removeFromParent();
  }
  return remove;
});

最后一个管道出现后需要创建下一个

if ((_pipes.lastOrNull?.position.x ?? 0) < size.x) {
  createPipe();
}

管道的碰撞检测

最后需要让管道发挥他的反派作用了,如果小鸟碰到管道,需要让游戏立即结束,在Player的碰撞回调中,进行如下判断

@override
void onCollisionStart(
  Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PipeComponent) {
      isDead = true;
    }
}

isDead是新增的属性,表示小鸟是否阵亡,如果碰撞到PipeComponentisDead则被设置为true。在游戏循环中,发现小鸟阵亡,则直接结束游戏

@override
void update(double dt) {
    super.update(dt);
    ...
    if (_birdComponent.isDead) {
      gameOver();
    }
}

flappy_bird_pipe.gif

通过管道的奖励

如何判定小鸟正常通过了管道呢?有一个简单的方法就是在管道缺口增加一个透明的碰撞体,发生碰撞则移除掉它,并且分数加1,新建一个BonusZone组件来做这件事情

class BonusZone extends PositionComponent with CollisionCallbacks {
  BonusZone({super.size});

  @override
  FutureOr<void> onLoad() {
    add(RectangleHitbox(size: size));
    return super.onLoad();
  }

  @override
  void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);
    if (other is Player) {
      other.score++;
      removeFromParent();
    }
  }
}

onLoad中为自己添加碰撞框,与Player碰撞结束时,移除自身,并且给Player分数加1。BonusZone需要被放置在缺口处,代码如下

..

PipeComponent bottomPipe =
    PipeComponent(images: images, isUpsideDown: false, size: pipeFullSize)
      ..size = pipeFullSize
      ..position = Vector2(lastPipePos, gapCenterPos + gapHeight * 0.5);
_pipeLayer.add(bottomPipe);
_pipes.add(bottomPipe);

final bonusZone = BonusZone(size: Vector2(pipeFullSize.x, gapHeight))
  ..position = Vector2(lastPipePos, gapCenterPos - gapHeight * 0.5);
add(bonusZone);
_bonusZones.add(bonusZone);

...

显示当前的分数

游戏素材中每一个数字是一张图片,也就是说需要将不同数字的图片组合起来显示,我们可以使用ImageComposition来进行图片的拼接

final scoreStr = _birdComponent.score.toString();
final numCount = scoreStr.length;
double offset = 0;
final imgComposition = ImageComposition();
for (int i = 0; i < numCount; ++i) {
  int num = int.parse(scoreStr[i]);
  imgComposition.add(
      _numSprites[num], Vector2(offset, _numSprites[num].size.y));
  offset += _numSprites[num].size.x;
}
final img = await imgComposition.compose();
_scoreComponent.sprite = Sprite(img);

_numSprites是加载好的数字图片列表,索引则代表其显示的数字,从数字最高位开始拼接出一个新图片,最后显示在_scoreComponent

flappy_bird_num.gif

添加一些音效

最后给游戏增加一些音效,我们分别在点击,小鸟撞击,死亡,获得分数增加对应音效

@override
void onTap() {
    super.onTap();
    FlameAudio.play("swoosh.wav");
    _birdYVelocity = -120;
}
![image](https://note.youdao.com/yws/res/1/WEBRESOURCE136045f72f1f0dc0fdaef9919b55d3f1)
...

@override
void onCollisionStart(
  Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PipeComponent) {
      FlameAudio.play("hit.wav");
      isDead = true;
    }
}

...

@override
void update(double dt) {
    super.update(dt);
    updateBird(dt);
    updatePipes(dt);
    updateScoreLabel();
    if (_birdComponent.isDead) {
      FlameAudio.play("die.wav");
      gameOver();
    }
}

...

@override
void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);
    if (other is Player) {
      other.score++;
      removeFromParent();
      FlameAudio.play("point.wav");
    }
}

接下来...

访问 github.com/BuildMyGame… 可以获取完整代码,更多细节阅读代码就可以知道了哦~