Flutter Flame实战 - 制作一个Flappy Bird

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");
    }
}

接下来...

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353

推荐阅读更多精彩内容