本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
目前打砖块的玩法功能比较单一,而且砖块较多,打起来比较费劲。本篇将通过加入道具来拓展玩法,增强趣味性的同时,也可以加快游戏副本通关时间。游戏道具主要分关卡内的道具,和持久道具。本章将着重实现如下的五个关卡道具:
一、道具的维护:Prop
在某个版本中,游戏的道具类型是固定的,所以可以通过枚举来维护道具 (Prop):
+1球 | 3s 无敌 | 3s 射击 | 10s 延展 | 生命 +1 |
---|---|---|---|---|
1. 道具构建: PropComponent
如下通过 Prop 枚举维护道具的类型,并在构造中指定资源名称和道具获取后持续的秒数:
dart
---->[lib/bricks/06/heroes/prop/prop.dart]----
enum Prop {
addBall('prop_add_a_ball.png',-1),
shoot('prop_shoot.png',3),
life('prop_life.png',-1),
invincible('prop_invincible.png',3),
expand('prop_length.png',10),
;
final String src;
final double time;
const Prop(this.src,this.time);
}
道具最终也是通过构件被添加到世界中,这里定义 PropComponent
dart
---->[lib/bricks/06/heroes/prop/prop.dart]----
class PropComponent extends SpriteComponent with HasGameRef<BricksGame> {
final Prop prop;
PropComponent(this.prop);
@override
FutureOr<void> onLoad() {
sprite = game.loader[prop.src];
return super.onLoad();
}
}
2. 道具管理器:PropManager
道具有非常多个,所以也需要通过一个管理器来统一维护。首先思考一下,砖块和道具的关系:
- [1]. 击碎砖块时,有概率获得随机道具。
- [2]. 击碎砖块时,随机道具下落。
从代码层面,我们有两种处理方式。其一、在砖块被 击碎时
,一定概率爆出随机道具;其二,在进入 关卡开始时
,一定概率为每个砖块附加道具。这里取用后者,将道具藏在砖块下方:
注: 开发阶段,为了方便调试,道具放在砖块上方
- | - |
---|---|
首先想一想,如何实现概率。比如说 25%
的概率,我们可以随机生成 0~1 的数字,如果它比 0.25 小时即为命中。为了方便使用概率,可以在 BricksGame
中维护随机数和 probability
概率方法。
ini
---->[lib/bricks/06/bricks_game.dart]----
final Random _random = Random();
Random get random => _random;
// value: 概率 0~1
bool probability(double value) {
double rad = random.nextDouble();
return rad > value;
}
然后 PropManager 在 onLoad 加载时,需要得到所有的砖块,然后概率为其添加道具。
- 砖块管理器在 PlayWorld 中,我们可以通过 game 对象拿到世界,在拿到 BrickManager 对象。
- 砖块管理器通过查询 Brick 类型的子组件列表,就可以拿到所有的砖块。
- 遍历砖块,以 25% 的概率,在砖块的中心坐标添加道具。
由于 PropManager 依赖 BrickManager 构件,所以 PropManager 对象需要在 BrickManager 之后添加到世界中。
这里对于生命道具做了一个小处理,在一个关卡中最多只会出现一次。propPool
是道具池,命中之后从道具池中随机抽取道具。如果抽中了生命道具,则道具池将声明道具移除。
dart
---->[lib/bricks/06/heroes/prop/prop_manager.dart]----
class PropManager extends PositionComponent with HasGameRef<BricksGame> {
@override
FutureOr<void> onLoad() {
final BrickManager brickManager = game.world.brickManager;
List<Brick> bricks = brickManager.children.whereType<Brick>().toList();
List<Prop> propPool = Prop.values.toList();
for (Brick brick in bricks) {
/// 0.25 的概率出现道具
bool hit = game.probability(0.25);
if (hit) {
int index = game.random.nextInt(propPool.length);
Prop active = propPool[index];
PropComponent prop = PropComponent(active);
prop
..anchor = Anchor.center
..position = brick.center;
add(prop);
if (active == Prop.life) {
propPool.remove(Prop.life);
}
}
}
return super.onLoad();
}
}
3. 砖块的击碎与道具掉落
下面来思考一下,如何在砖块击碎时让道具坠落。这一需求中,需要建立 道具 Prop
和 砖块 Brick
之间的联系。在小球撞击砖块之后,我们需要根据砖块,开查找到对应的道具,并触发其坠落。
两个类之间如何建立联系呢? 其实方式非常多。比如让 Brick 持有 Prop 对象,或让 Prop 持有 Brick对象。但这样会使两个类的耦合性增强,而且他们之间也没有持有对方的必要性。
我们可以在 PropManager
中维护一下砖块 id 和 道具之间的映射关系 propMap
,在加入道具时以砖块 id 为 key, 道具为值添加一条记录。
这样在小球碰撞到砖块时,触发 fallOrNot
方法,根据砖块 id,从 propMap
中查找对应的道具。如果存在,触发其 fall
方法坠落。坠落后,就可以移除掉记录:
ini
---->[lib/bricks/06/heroes/prop/prop_manager.dart]----
final Map<int, PropComponent> propMap = {};
void fallOrNot(int breakId) {
PropComponent? prop = propMap[breakId];
if (prop != null) {
prop.fall();
propMap.remove(breakId);
}
}
道具的坠落是在 y 方向上向下平移,我们可以在 update
中通过 fallSpeed
的速度来增加位移。通过 absolutePosition
可以得到构建的绝对位置,当绝对位置大于视口宽度时,通过 removeFromParent
可以将道具从世界中移除。这样 fall
坠落方法中,只需要为 fallSpeed
赋值即可,比如这里是 200
逻辑像素每秒:
dart
---->[lib/bricks/06/heroes/prop/prop.dart]----
class PropComponent extends SpriteComponent with HasGameRef<BricksGame> {
final Prop prop;
PropComponent(this.prop);
@override
FutureOr<void> onLoad() {
fallSpeed = 0;
sprite = game.loader[prop.src];
return super.onLoad();
}
@override
void update(double dt) {
if (fallSpeed == 0 || isRemoving) return;
y += dt * fallSpeed;
if (absolutePosition.y > kViewPort.height) {
removeFromParent();
}
super.update(dt);
}
double fallSpeed = 0;
void fall() {
fallSpeed = 200;
}
}
二、获得道具的处理: +1 球 和 +1 生命
在道具下落的过程中,和挡板碰撞时,表示获取到道具。如下所示,当 +1 球
道具拾取成功时,会在世界中添加一个小球,并立刻弹射:
获取道具 | 小球死亡 |
---|---|
1.碰撞检测的处理
首先需要处理 道具 PropComponent
和 挡板 Paddle
构件间的碰撞检测。在 PropComponent
中增加 RectangleHitbox
矩形碰撞检测边界:
dart
---->[lib/bricks/06/heroes/prop/prop.dart]----
class PropComponent extends SpriteComponent with HasGameRef<BricksGame> {
///略同...
// 添加矩形碰撞盒
add(RectangleHitbox());
return super.onLoad();
}
然后挡板混入 CollisionCallbacks
, 覆写 onCollisionStart
方法处理碰撞事件。当碰撞物的类型是 PropComponent
时,可以将道具移除,并触发道具获取的逻辑 onGetProp
。
dart
---->[lib/bricks/06/heroes/paddle.dart]----
class Paddle extends SpriteComponent with HasGameRef<BricksGame>,CollisionCallbacks {
@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if(other is PropComponent){
onGetProp(other.prop);
other.removeFromParent();
}
}
比如当道具是 Prop.addBall
时,在世界中添加一个自动启动的球。该逻辑封装为 PlayWorld#addBall
:
dart
void onGetProp(Prop prop){
if(prop == Prop.addBall){
game.world.addBall(autoPlay: true);
}
}
2. 为世界添加多个小球
之前我们将小球作为 PlayWorld 的成员变量,但现在场景中可能出现多个球,需要优化一下处理逻辑。如下所示,通过 addBall
方法,在 paddle 上方添加一个小球。此时开始的 onLoad 方法可以通过 addBall
添加小球:
dart
---->[lib/bricks/06/bricks_game.dart]----
void addBall({bool autoPlay = false}) {
Ball ball = Ball();
ball.anchor=Anchor.bottomCenter;
add(ball);
ball.position = paddle.center-Vector2(0,paddle.height/2+4);
if (autoPlay) {
ball.run();
}
}
由于小球构件不是以成员变量维护在世界中,此时可以通过 children.whereType<Ball>()
获取小球列表。 play
方法状通过这种形式得到世界中的小球对象:
dart
void play() {
if (game.status == GameStatus.ready) {
List<Ball> balls = children.whereType<Ball>().toList();
if(balls.isNotEmpty){
balls.first.run();
game.status = GameStatus.playing;
}
}
}
3. 小球死亡逻辑的优化
之前,小球落到底部视为死亡,但现在可能有若干个小球。需要游戏场景中没有小球时,才可以视为死亡一次,生命值减 1。如下代码中,小球落到底部时,只需要通过 removeFromParent
从世界中移除即可:
dart
---->[lib/bricks/06/heroes/ball.dart]----
void _handleHitPlayground(Vector2 position, Vector2 areaSize) {
if (position.y >= areaSize.y - height) {
removeFromParent();
return;
}
另外,在 onRemove
回调中监听到需求移除完成的时机,其中检测一下世界中的小球是否为空。如果为空,才会视为死亡。触发 PlayWorld#died
方法:
dart
@override
void onRemove() {
bool noBall = game.world.children.whereType<Ball>().isEmpty;
if (noBall) {
game.world.died();
}
super.onRemove();
}
4. +1 生命道具
到这里,+1 小球的道具就已经完成了,同理可以完成 +1 生命
的道具功能。如下所示,当接住了增加生命值的道具,当前关卡内可以增加一条生命:
掉落 +1 生命道具 | 获得道具 |
---|---|
处理的逻辑也很简单,在 onGetProp
方法中,校验道具类型为 Prop.life
时,触发 PlayWorld#addLife
方法:
dart
---->[lib/bricks/06/heroes/paddle.dart]----
void onGetProp(Prop prop){
if(prop==Prop.addBall){
game.world.addBall(autoPlay: true);
}
if(prop==Prop.life){
game.world.addLife();
}
}
---->[lib/bricks/06/heroes/paddle.dart]----
void addLife(){
_life += 1;
titleBar.updateLifeCount(_life);
}
三、有时间期限的道具
上面的 +1 生命和 +1 小球,都是回合内生效的道具。如下的无敌道具,在得到之后,可以进入 3 s 的 无敌状态
。 无敌状态时,会击碎所过路径上的砖块,且碰到砖块不反弹:
击落道具 | 无敌道具效果 |
---|---|
1. 具有时间期限的道具展示:PropDisplay
当获得有时间期限的道具之后,需要在如下所示的区域中。展示道具图标以及剩余的秒数:
展示道具生命的任务,通过如下的 PropDisplay 构件负责。其中传入 Prop
类型,在 onLoad
回调中添加道具对应的图片和生命秒数:
dart
---->[lib/bricks/06/heroes/prop/prop_display.dart]----
class PropDisplay extends PositionComponent with HasGameRef<BricksGame> {
final Prop prop;
double _life = prop.time;
PropDisplay(this.prop);
late TextComponent time = TextComponent(
text: "$_life s",
anchor: Anchor.center,
textRenderer:
TextPaint(style: const TextStyle(color: Colors.white, fontSize: 12)),
);
void addOne() {
_life += prop.time;
}
@override
FutureOr<void> onLoad() {
SpriteComponent sprite = SpriteComponent(sprite: game.loader[prop.src]);
add(sprite);
add(time);
time.x = sprite.width / 2;
time.y = -time.height / 2;
size = sprite.size;
return super.onLoad();
}
在 update 方法中处理生命秒数减少的逻辑,当生命小于 0 时,从世界中移除:
dart
@override
void update(double dt) {
if(isRemoving) return;
_life -= dt;
time.text = '${_life.toStringAsFixed(1)} s';
if (_life < 0) {
removeFromParent();
}
super.update(dt);
}
}
2. 添加道具展示
获得道具的时机是 onGetProp
,其中其他三种道具有时间期限,需要 PropDisplay 进行展示,这里在 PlayWorld 中封装一个 addPropDisplay
方法进行处理:
dart
---->[lib/bricks/06/heroes/paddle.dart]----
void onGetProp(Prop prop){
if(prop==Prop.addBall){
game.world.addBall(autoPlay: true);
return;
}
if(prop==Prop.life){
game.world.addLife();
return;
}
game.world.addPropDisplay(prop);
}
在添加道具时,有一些细节需要处理。道具栏中可能存在多个道具,另外道具在生命期间内,也可能重复获取。所以需要进行方案设计,这里添加一个道具时流程如下:
- [1]. 道具栏没有道具展示时,添加对应的 PropDisplay。
- [2]. 道具栏已经存当前道具时,对应的 PropDisplay 增加秒数。
- [3]. 道具栏有其他道具时,在最后的道具后面添加对应的 PropDisplay。
代码实现如下:
dart
---->[lib/bricks/06/bricks_game.dart]----
void addPropDisplay(Prop pro) {
/// 没有道具展示时,添加 PropDisplay
if(displays.isEmpty) {
PropDisplay display = PropDisplay(pro);
display.position = Vector2(360, 86);
add(display);
return;
}
/// 表示已经存在展示的道具
List<PropDisplay> targets = displays.where((e) => e.prop == pro).toList();
if (targets.isNotEmpty) {
/// 已存当前道具效力, + 生命时间
displays.first.addOne();
return;
} else {
/// 有没有,则在之后加一个
PropDisplay display = PropDisplay(pro);
display.position = displays.last.position+Vector2(displays.last.width+8,0);
add(display);
}
}
2. 让道具发挥效力:无敌道具
无敌道具生效期间时,击碎砖块时不进行反弹,小球沿路径击碎所有的砖块。代码中可以通过如下方式校验,无敌道具是否生效:
校验世界中,是否存在类型为
Prop.invincible
的 PropDisplay 构件。
dart
---->[lib/bricks/06/bricks_game.dart]----
/// 是否处于 无敌状态
bool get isInvincible =>
displays
.where((e) => e.prop == Prop.invincible)
.isNotEmpty;
List<PropDisplay> get displays => children.whereType<PropDisplay>().toList();
然后修改在小球碰撞到砖块时的逻辑,当 isInvincible
时,表示无敌道具生效。此时直接移除砖块,不处理需求的碰撞反弹即可:
dart
---->[lib/bricks/06/heroes/ball.dart]----
else if (other is Brick) {
if (game.world.isInvincible) {
other.removeFromParent();
game.world.propManager.fallOrNot(other.id);
game.am.play(SoundEffect.uiSelect);
return;
}
_lockCollisionTest(
() => _handleHitBrick(intersectionPoints.first, other));
3. 延展道具
挡板延展道具,会让挡板变长 6s,将有更大的碰撞范围:
道具掉落 | 挡板延展 |
---|---|
实现起来非常简单,在 Paddle
中增加两个方法 expand
和 expandEnd
分别让 sprite 图片设置为长和短的挡板即可。碰撞区域会自动变化:
dart
--->[lib/bricks/06/heroes/paddle.dart]---
class Paddle extends SpriteComponent with HasGameRef<BricksGame> , CollisionCallbacks {
void expand(){
sprite = game.loader['Paddle_A_Blue_192x28.png'];
}
void expandEnd(){
sprite = game.loader['Paddle_A_Blue_96x28.png'];
}
挡板碰撞时,调用 expand 方法延展;延展道具失效的契机可以监听 PropDisplay
移除时是否是 expand
道具,失效时触发 expandEnd
取消延展:
scss
--->[lib/bricks/06/heroes/paddle.dart]---
void onGetProp(Prop prop){
/// 略同...
if(prop==Prop.expand){
expand();
}
game.world.addPropDisplay(prop);
}
--->[lib/bricks/06/heroes/prop/prop_display.dart]---
@override
void onRemove() {
super.onRemove();
if(prop==Prop.expand){
game.world.paddle.expandEnd();
}
}
四、加入设计功能
如下所示,在接到射击道具时,挡板会处于射击状态,可以持续 3 s发射子弹来击碎砖块:
道具掉落 | 射击道具 |
---|---|
1. 子弹构件 - Bullet
首先准备一下子弹的单体 Bullet,这里绘制一个圆角矩形进行展示。当然你也可以展示子弹图片:
dart
--->[lib/bricks/06/heroes/bullet.dart]---
class Bullet extends PositionComponent with HasGameRef<BricksGame>, CollisionCallbacks {
double speed = -400;
@override
FutureOr<void> onLoad() {
size = Vector2(6, 14);
add(RectangleHitbox());
return super.onLoad();
}
@override
void render(Canvas canvas) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromPoints(Offset.zero, const Offset(6, 14)),
const Radius.circular(2),
),
Paint()..color = Colors.white);
super.render(canvas);
}
子弹自诞生之初就具有向上的速度,在 update
回调中根据时间处理子弹在竖直方向上的偏移量。另外,子弹混入 CollisionCallbacks
支持碰撞检测。当时砖块时,击碎砖块并移除自身,如果是墙壁时,移除自身:
dart
@override
void update(double dt) {
if (speed == 0 || isRemoving) return;
y += dt * speed;
super.update(dt);
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is Brick) {
other.removeFromParent();
game.world.propManager.fallOrNot(other.id);
removeFromParent();
}
if (other is BrickWall) {
removeFromParent();
}
}
}
2. 子弹管理器构件 - BulletManager
挡板会持续 3 s 发射子弹,说明子弹的个数有很多。可以通过子弹管理器 BulletManager 来维护,处理添加子弹 addBullet
和开始射击 startShoot
的功能:
addBullet 会在挡板的两侧分别创建一个子弹;startShoot
会添加子弹,并延迟 400ms ,当仍处于射击状态,则继续发射子弹:
dart
--->[lib/bricks/06/heroes/bullet.dart]---
class BulletManager extends PositionComponent with HasGameRef<BricksGame> {
void startShoot() async {
addBullet();
await Future.delayed(const Duration(milliseconds: 400));
if (game.world.isShoot) {
startShoot();
}
}
void addBullet() {
Paddle paddle = game.world.paddle;
Bullet bullet1 = Bullet();
bullet1.anchor = Anchor.bottomCenter;
add(bullet1);
bullet1.position = paddle.center -
Vector2(-(paddle.width / 2 - 20), paddle.height / 2 + 4);
Bullet bullet2 = Bullet();
bullet2.anchor = Anchor.bottomCenter;
add(bullet2);
bullet2.position =
paddle.center - Vector2((paddle.width / 2 - 20), paddle.height / 2 + 4);
}
}
是否处于射击状态,也可以通过是否存在 Prop.shoot 类型的 PropDisplay 判断;最后在接到射击道具时,开启射击即可:
dart
--->[lib/bricks/06/bricks_game.dart]---
/// 是否处于 射击状态
bool get isShoot =>
displays.where((e) => e.prop == Prop.shoot).isNotEmpty;
--->[lib/bricks/06/heroes/paddle.dart]---
void onGetProp(Prop prop){
/// 略同...
if(prop==Prop.shoot){
game.world.bulletManager.startShoot();
}
game.world.addPropDisplay(prop);
}
本集通过实现五个道具的功能,进一步完善了打砖块游戏的玩法。从中也锻炼了对 Flame 的使用,现在你应该能体会到,完成一个功能需求,就是通过构件和数据,通过代码来实现逻辑。大家可以先自己尝试一下,完成击碎砖块时 30% 概率掉落金币。下一集,将介绍和金币相关的商店和背包: