Flutter Flame实战 - 制作一个Flappy Bird

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

为游戏添加背景

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

dart 复制代码
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),这是为了后续和管道保持同步的移动速度,最终会得到如下的效果

主角登场

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

dart 复制代码
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

scala 复制代码
class Player extends SpriteAnimationComponent with CollisionCallbacks {
  Player({super.animation});

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

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

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

dart 复制代码
_birdYVelocity += dt * _gravity;
final birdNewY = _birdComponent.position.y + _birdYVelocity * dt;
_birdComponent.position = Vector2(_birdComponent.position.x, birdNewY);

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

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

dart 复制代码
@override
void onTap() {
    super.onTap();
    _birdYVelocity = -120;
}

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

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

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

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

反派管道登场

管道的渲染

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

dart 复制代码
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

管道的创建

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

dart 复制代码
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限制随机的范围上限,避免缺口位置变化的太离谱。接下来通过缺口位置计算管道的位置,并创建出对应的管道

dart 复制代码
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计算可得

dart 复制代码
var lastPipePos = _pipes.lastOrNull?.position.x ?? size.x - pipeSpace;
lastPipePos += pipeSpace;

管道的更新

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

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

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

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

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

dart 复制代码
if ((_pipes.lastOrNull?.position.x ?? 0) < size.x) {
  createPipe();
}

管道的碰撞检测

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

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

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

dart 复制代码
@override
void update(double dt) {
    super.update(dt);
    ...
    if (_birdComponent.isDead) {
      gameOver();
    }
}

通过管道的奖励

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

dart 复制代码
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需要被放置在缺口处,代码如下

dart 复制代码
..

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来进行图片的拼接

dart 复制代码
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

添加一些音效

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

dart 复制代码
@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... 可以获取完整代码,更多细节阅读代码就可以知道了哦~

相关推荐
红红大虾16 小时前
Defold引擎中关于CollectionProxy的使用
前端·游戏开发
火柴就是我18 小时前
flutter 之真手势冲突处理
android·flutter
Speed12319 小时前
`mockito` 的核心“打桩”规则
flutter·dart
法的空间19 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
恋猫de小郭19 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
玲珑Felone20 小时前
从flutter源码看其渲染机制
android·flutter
红红大虾2 天前
Defold引擎中关于CollectionFactory的使用
游戏开发
ALLIN2 天前
Flutter 三种方式实现页面切换后保持原页面状态
flutter
Dabei2 天前
Flutter 国际化
flutter
Dabei2 天前
Flutter MQTT 通信文档
flutter