Flame是一款基于Flutter的2D游戏引擎,今天我将使用它制作一款简单的小游戏Flappy Bird
为游戏添加背景
游戏的的背景分为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)
,这是为了后续和管道保持同步的移动速度,最终会得到如下的效果
主角登场
接下来进行角色的制作,第一步我们需要一个扑腾着翅膀的小鸟,使用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);
为了后续更好的进行碰撞检测,这里使用了继承自SpriteAnimationComponent
的Player
class Player extends SpriteAnimationComponent with CollisionCallbacks {
Player({super.animation});
@override
FutureOr<void> onLoad() {
add(RectangleHitbox(size: size));
return super.onLoad();
}
}
Player
在onLoad
中为自己增加了一个矩形碰撞框
玩过游戏的都知道,正常情况下小鸟是自由下落的,要做到这一点只需要简单的重力模拟
_birdYVelocity += dt * _gravity;
final birdNewY = _birdComponent.position.y + _birdYVelocity * dt;
_birdComponent.position = Vector2(_birdComponent.position.x, birdNewY);
_gravity
规定了重力加速度的大小,_birdYVelocity
表示当前小鸟在Y轴上的速度,dt
则是模拟的时间间隔,这段代码会在Flame引擎每次update时调用,持续更新小鸟的速度和位置。
然后就是游戏的操作核心了,点击屏幕小鸟会跳起,这一步非常简单,只需要将小鸟的Y轴速度突然变大即可
@override
void onTap() {
super.onTap();
_birdYVelocity = -120;
}
在onTap
事件中,将_birdYVelocity
修改为-120,这样小鸟就会得到一个向上的速度,同时还会受到重力作用,产生一次小幅跳跃。
最后看起来还缺点什么,我们的小鸟并没有角度变化,现在需要的是在小鸟坠落时鸟头朝下,反之鸟头朝上,实现也是很简单的,让角度跟随速度变化即可
_birdComponent.anchor = Anchor.center;
final angle = clampDouble(_birdYVelocity / 180, -pi * 0.25, pi * 0.25);
_birdComponent.angle = angle;
这里将anchor设置为center,是为了在旋转时围绕小鸟的中心点,angle则使用clampDouble
进行了限制,否则你会得到一个疯狂旋转的小鸟
反派管道登场
管道的渲染
游戏选手已就位,该反派登场了,创建一个继承自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
是新增的属性,表示小鸟是否阵亡,如果碰撞到PipeComponent
,isDead
则被设置为true
。在游戏循环中,发现小鸟阵亡,则直接结束游戏
@override
void update(double dt) {
super.update(dt);
...
if (_birdComponent.isDead) {
gameOver();
}
}
通过管道的奖励
如何判定小鸟正常通过了管道呢?有一个简单的方法就是在管道缺口增加一个透明的碰撞体,发生碰撞则移除掉它,并且分数加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
上
添加一些音效
最后给游戏增加一些音效,我们分别在点击,小鸟撞击,死亡,获得分数增加对应音效
@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 可以获取完整代码,更多细节阅读代码就可以知道了哦~