本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、 以有限模拟无限
对于游戏中的移动,往往角色的运动是无止境的。比如这里 在视觉上 小恐龙在一种向前跑;但实际上他只是在做原地踏步;真正运动的是 地面在运动
。
1. 有限的地面与无限的需求
有了第二集的铺垫,现在想让地面运动起来非常简单,只要在 GroundComponent 的 update
中不断减小 x ,向左进行位移即可。但是,地面会向左无限地运动,而图片本身的宽度是一定的,终究会移动到终点:
dart
----[lib/trex/04/heroes/ground_component.dart]----
double gameSpeed = 500;
@override
void update(double dt) {
super.update(dt);
x -= gameSpeed * dt;
}
现在的问题在于:
如何通过有限的图片,模拟出无限的运动效果。
2. 需求提炼分析
为了让大家更容易理解,现在现将场景简易化。如下所示,物体在地面上,地面由一个个砖块构成,每个砖块有对应的索引
,在下方进行标注。为了方便示意,砖块依次着色为红、绿、蓝、黄:
想要在局部区域模拟无限移动,只要在第 3 块刚移出区域时,动态添加第 4 块。当第一块完全移出区域时,移出调即可。以此类推:
这种先进入区域的块被先移出,就是非常典型的 队列数据结构
下面的 BrickComponent
表示一段砖块,在构造中传入索引值。通过 render 绘制带颜色的砖块;使用 TextComponent
展示当前的砖块索引:
dart
const List<Color> _kColors = [Colors.red, Colors.green, Colors.blue, Colors.yellow];
class BrickComponent extends PositionComponent {
final Vector2 brickSize = Vector2(300, 12);
int index;
BrickComponent({this.index = 0});
@override
Future<void> onLoad() async{
super.onLoad();
add(TextComponent(
text: '$index',
position: Vector2(150-6,14),
textRenderer: TextPaint(style: const TextStyle(fontSize: 14, color: Colors.blue)),
));
}
@override
void render(Canvas canvas) {
super.render(canvas);
Paint paint = Paint()..style = PaintingStyle.stroke;
canvas.drawRect(Rect.fromLTRB(0, 0, brickSize.x, brickSize.y),
Paint()..color = _kColors[index % _kColors.length]);
Path wallBottom = boxPath(0, Size(brickSize.x, brickSize.y));
canvas.drawPath(wallBottom, paint);
}
Path boxPath(double y, Size wallSize, {double step = 15}) {
Path path = Path()
..moveTo(0, y)
..lineTo(wallSize.width, y)
..relativeLineTo(0, wallSize.height)
..relativeLineTo(-wallSize.width, 0)
..relativeLineTo(0, -wallSize.height);
double step = 15;
for (double i = 0; i < wallSize.width - step; i += step) {
path
..moveTo(step + i, y)
..relativeLineTo(-step, wallSize.height);
}
return path;
}
}
3. 地面与砖块队列
按照上面分析的逻辑,在 GroundComponent
维护一个 Queue
对象 breaks 表示砖块队列。由于砖块的个数需要根据窗口尺寸进行变化,所以需要在 onGameResize
回调中处理初始砖块队列的任务。这里将核心逻辑封装在 createBreaksWhenNeed
方法中:
dart
---->[lib/world/10/heroes/ground_component.dart]----
class GroundComponent extends PositionComponent {
Queue<BrickComponent> bricks = Queue();
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
width = size.x;
height = groundHeight;
y = size.y - groundHeight - 40;
Queue<BrickComponent> addBricks = createBreaksWhenNeed(size.x, 300);
bricks.addAll(addBricks);
addAll(addBricks);
}
createBreaksWhenNeed 方法中接收两个参数,分别是屏幕宽度和砖块宽度。通过商可以很轻松地计算出当前尺寸需要展示的砖块总数 total
; 需要特别特别注意的一点:
onGameResize 会在视口尺寸变化时多次触发。
比如之前是左图,队列中已经有两块了,那么屏幕宽度变成容纳四块时,只需要在 后面额外添加两块 。 也就是说 createBreaksWhenNeed 返回的是需要额外添加的 addBricks
元素集。而不是在原有的基础上添加 total 块:
两块 | 四块 |
---|---|
dart
---->[lib/world/10/heroes/ground_component.dart]----
Queue<BrickComponent> createBreaksWhenNeed(double winWidth, double brickWidth) {
int total = (winWidth / brickWidth).ceil();
int current = bricks.length;
/// 窗口尺寸变化时需要额外添加的个数
int addCount = total - current;
int lastIndex = bricks.isEmpty ? -1 : bricks.last.index;
double lastX = bricks.isEmpty ? 0 : bricks.last.x;
Queue<BrickComponent> addBricks = Queue();
for (int i = 0; i < addCount; i++) {
int curIndex = lastIndex + 1 + i;
BrickComponent brick = BrickComponent(index: curIndex);
brick.x = lastX + brickWidth * i;
addBricks.add(brick);
}
return addBricks;
}
4. 动态添加与删除
有了前面的队列维护结构,接下来细看一下添加和移除砖块的时机,不难发现:
添加时机
: 当尾元素的右侧 x 坐标小于等于屏幕尺寸时。
移除时机
: 当首元素的右侧 x 坐标小于等于 0 时。
于是,只要在 update
回调中每帧检测更新砖块队列的坐标信息即可。如下,遍历更新 bricks 队列中的砖块的 x 值,让其向左移动。校验增删的逻辑交由 _handleBreaks
方法:
dart
---->[lib/world/10/heroes/ground_component.dart]----
@override
void update(double dt) {
super.update(dt);
double dx = _gameSpeed * dt;
for (final brick in bricks) {
brick.x -= dx;
}
_handleBreaks();
}
在 _updateBreaks
中 firstPosX
表示首元素的右侧横坐标,firstPosX < 0 表示首元素已经移到了框外,移除队首出队列,并且从场景中移除; lastPosX
表示尾元素的右侧横坐标,小于屏幕宽度时加入下一个砖块:
dart
---->[lib/world/10/heroes/ground_component.dart]----
void _updateBreaks() {
for (final line in bricks) {
line.x -= dx;
}
/// 当第一个砖块移出视图:时移除队首元素
double firstPosX = bricks.first.x + bricks.first.brickSize.x;
if (firstPosX <= 0) {
remove(bricks.removeFirst());
}
/// 当最后一个砖块移到:时移除队首元素
double lastPosX = bricks.last.x + bricks.last.brickSize.x;
if (lastPosX <= width) {
BrickComponent brick = BrickComponent(index: bricks.last.index + 1);
brick.x = lastPosX;
bricks.add(brick);
add(brick);
}
}
这样就实现了下图中无限移动的效果,但作为开发者而言,心里要清楚:这是通过队列实现的 动态添加和移除
。它既满足了在区域内的视图表现,也能保证队列中只有一定数量的元素,从而让无限的需求只消耗少量的空间。
二、 地面无限移动的代码实现
有了前面的理论基础,现在实现主线的地面运动应该是轻而易举啦。仔细观察图片可以看到,地面可以分为两类,左侧是平路,右侧是坡路。这里每段路可以只取一般的长度,这样有利于资源的使用。一块五毛钱能卖到的东西,没必要花两块钱。
1. 路块构件:RoadComponent
这里路块 RoadComponent
和上面的颜色砖块 BrickComponent
作用是一致的,用于提供路面构成的单体:
RoadComponent 继承自 SpriteComponent,由于不需要自定义绘制了反而比较简单。通过索引 index 来确定 sprite
图片精灵 ,因此如果今后想要拓展其他的地面图片,也很容易修改:
dart
---->[lib/trex/04/heroes/road_component.dart]----
class RoadComponent extends SpriteComponent with HasGameRef<TrexGame> {
final Vector2 roadSize = Vector2(1200, 24);
int index;
RoadComponent({this.index = 0});
late final _leftRoad = Sprite(game.spriteImage,
srcPosition: Vector2(2.0, 104.0), srcSize: roadSize);
late final _rightRoad = Sprite(game.spriteImage,
srcPosition: Vector2(game.spriteImage.width / 2, 104.0),
srcSize: roadSize);
void updateInfo(int value, double posX) {
index = value;
x = posX;
sprite = index % 2 == 0 ? _leftRoad : _rightRoad;
}
@override
Future<void> onLoad() async {
super.onLoad();
sprite = index % 2 == 0 ? _leftRoad : _rightRoad;
}
}
2. 地面构件:GroundComponent
这里的地面和上面小案例中的处理几乎一致,维护 RoadComponent
队列,在移动过程中动态添加和移除路段。这里就不更多赘述了:
dart
---->[lib/trex/04/heroes/ground_component.dart]----
Queue<RoadComponent> roads = Queue();
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
width = size.x;
y = size.y - groundHeight - 40;
Queue<RoadComponent> addRoads = createBreaksWhenNeed(size.x, 1200);
roads.addAll(addRoads);
addAll(addRoads);
}
@override
void update(double dt) {
super.update(dt);
double dx = gameSpeed * dt;
for (final road in roads) {
road.x -= dx;
}
_handleRoads();
}
三、云朵和障碍物的移动
我们之前是将障碍物放在了屏幕中间,所以地面的移动无法影响障碍物。实际上,障碍物和云朵也需要跟随地面运动。而且 障碍物
和 云朵
需要不断地生成和移除,这一点本质上和路段也很相似,也是一种无限地移动。
1. 云朵的移动
如下所示,在道路运动过程中,上方会出现若干个云朵,它们有一些特点:
[1]. 云朵可以移动,但速度相对于地面较慢。
[2]. 云朵添加到场景时,位置是随机的。
[3]. 场景中有若干个云朵,需要维护列表。
这里云朵的速度参考地面来计算得到,所以速度数据 moveSpeed
在地面和云朵中都需要。这就涉及了状态数据的共享,有个简单的处理方式:任何构件可以通过 with HasGameReference<TrexGame>
获取 TrexGame 对象,所以配置数据可以统一定义在 TrexGame 中:
这样在 update
每帧回调时修改 x
坐标,移动速度设为地面的 0.2 倍。这里有个细节,当云朵完全移出屏幕时,即横坐标满足 x + width < 0
; 可以通过 removeFromParent 把自己从场景中移除:
dart
---->[lib/trex/04/heroes/cloud_component.dart]----
class CloudComponent extends SpriteComponent with HasGameReference<TrexGame> {
final Vector2 cloudSize = Vector2(92.0, 28.0);
@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(166.0, 2.0),
srcSize: cloudSize,
);
}
@override
void update(double dt) {
super.update(dt);
if (isRemoving) return;
x -= game.moveSpeed * 0.2 * dt;
if (x + width < 0) {
removeFromParent();
}
}
}
2. 云朵集的管理
云朵在场景中有多个,实现需要明确云朵生成的策略。这里我设计的是(当然你也可以自己设计):
设 lastPosX = 最后一片云的右侧横坐标。
当 lastPosX < 屏幕宽度的一半时,生成一个云朵。
该云朵坐标横轴:
lastPosX + [300~500]
随机;纵轴:[30~70]
随机。
代码处理如下,通过一个 CloudManager
构建来统一维护 CloudComponent 的添加。update 回调中校验是否主要添加新的云朵;addCloud
处理云朵的创建逻辑。
dart
---->[lib/trex/04/heroes/cloud_component.dart]----
class CloudManager extends PositionComponent with HasGameReference<TrexGame> {
final Random random = Random();
@override
void update(double dt) {
super.update(dt);
if (children.isNotEmpty) {
final lastCloud = children.last as CloudComponent;
double lastPosX = lastCloud.x + lastCloud.cloudSize.x;
// 当 lastPosX < 屏幕宽度的一半时,生成一个云朵。
if (game.size.x / 2 - lastPosX > 0) {
addCloud();
}
} else {
addCloud();
}
}
void addCloud() {
CloudComponent cloud = CloudComponent();
// 该云朵坐标横轴: `lastPosX + [300~500]` 随机;纵轴:`[30~70]` 随机。
double offsetX = 300 + (500 - 300) * random.nextDouble();
cloud.y = 30 + (70 - 30) * random.nextDouble();
if (children.isEmpty) {
cloud.x = offsetX;
} else {
cloud.x = (children.last as PositionComponent).x + offsetX;
}
add(cloud);
}
}
这里云朵 CloudComponent 的移除时自身控制的,并通过一个管理者完成创建收集的任务。其实也可以像上面道路那样维护队列,手动管理云朵的添加和移除。同样道路也可以通过这种方式来维护,本质上殊途同归。
3. 障碍物的移动和管理
最后来看一下障碍物的移动,它们和云朵很类似,都是移出屏幕后自动消失;也需要不断地创建:
现在为 ObstacleComponent 覆写 update 回调,处理移动的逻辑。运动速度和地面一致:
dart
---->[lib/trex/04/heroes/obstacle_component.dart]----
class ObstacleComponent extends SpriteComponent with HasGameReference<TrexGame> {
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = size.y - 70.0 - 40;
}
@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(446.0, 2.0),
srcSize: Vector2(34.0, 70.0),
);
}
@override
void update(double dt) {
super.update(dt);
if (isRemoving) return;
x -= game.moveSpeed * dt;
if (x + width < 0) {
removeFromParent();
}
}
}
同样也通过 ObstacleManager 管理障碍物,这里添加的策略是:当最后一个障碍物达到屏幕右侧,在其后随机长度 gap 处添加一个新的障碍物。这样就可以保证障碍物源源不断地出现。
dart
---->[lib/trex/04/heroes/obstacle_component.dart]----
class ObstacleManager extends PositionComponent with HasGameReference<TrexGame> {
final Random random = Random();
@override
void update(double dt) {
super.update(dt);
if (children.isNotEmpty) {
final lastCloud = children.last as ObstacleComponent;
double lastPosX = lastCloud.x + lastCloud.size.x ;
if (game.size.x - lastPosX > 0) {
addObstacle();
}
} else {
addObstacle();
}
}
void addObstacle() {
ObstacleComponent cloud = ObstacleComponent();
double gap = 800 * (0.3 + 0.7 * random.nextDouble());
if (children.isEmpty) {
cloud.x = gap;
} else {
cloud.x = (children.last as PositionComponent).x + gap;
}
add(cloud);
}
}
4. 障碍物的类型
从图片中可以看出障碍物有不同的类型,有飞鸟、小仙人掌、大仙人掌、还可以仙人掌组合:
仙人掌是静态的,使用 SpriteComponent
即可展示;这里飞鸟是两个序列帧构成的动画,需要使用 SpriteAnimationComponent
来展示,如下所示,飞鸟一共有三种状态:
- 地面上: 相当于普通的障碍物,可以跳跃翻越。
- 半空中: 可以蹲下躲避。
- 高空中: 正常渡过,跳跃时碰撞会死。
下面是展示飞鸟序列帧的构件,传入的 type
表示飞鸟的类型。根据类型确定飞鸟的偏移高度,从而完成高低不同的展示需求:
dart
---->[lib/trex/04/heroes/obstacle_component.dart]----
class AnimaObstacleComponent extends SpriteAnimationComponent with HasGameReference<TrexGame> {
final int type;
AnimaObstacleComponent(this.type);
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
double offset = switch (type) {
0 => 80,
1 => 20,
_ => -50,
};
y = size.y - 180 + offset;
}
@override
Future<void> onLoad() async {
animation = SpriteAnimation.spriteList(
[Vector2(263.0, 9.0), Vector2(355.0, 9.0)]
.map((vector) => Sprite(
game.spriteImage,
srcSize: Vector2(90, 64),
srcPosition: vector,
)).toList(),
stepTime: 0.2,
);
}
@override
void update(double dt) {
super.update(dt);
if (isRemoving) return;
x -= game.moveSpeed * dt;
if (x + width < 0) {
removeFromParent();
}
}
}
仙人掌可以分为四类,加载不同区域的图片:小仙人掌、大仙人掌、两个人掌并排、三个仙人掌并排。如下所示:
代码实现中根据不同的 type 在 onLoad
时访问不同区域的精灵图即可。最后在 ObstacleManager 管理器中,可以随机生成障碍物及障碍物的类型。这里的策略是:20% 的概率生成飞鸟,80% 的概率生成仙人掌;每种类型随机出现。当然你也可以定义自己的规则,进行更精细的控制。
dart
---->[lib/trex/04/heroes/obstacle_component.dart]----
@override
Future<void> onLoad() async {
Vector2 position = switch (type) {
0 => Vector2(446.0, 2.0),
1 => Vector2(652.0, 2.0),
2 => Vector2(513.0, 2.0),
_ => Vector2(849.0, 2.0),
};
Vector2 size = switch (type) {
0 => Vector2(34.0, 70.0),
1 => Vector2(50.0, 100.0),
2 => Vector2(70.0, 70.0),
_ => Vector2(100.0, 100.0),
};
sprite = Sprite(
game.spriteImage,
srcPosition: position,
srcSize: size,
);
}
第三集中主要围绕如何通过有限的空间,来模拟无限的运动,完成了路面
、障碍物
、云朵
的运动效果。其核心的思想是:在运动过程中,将视口之外不可见的构件及时移除;通过逻辑判断及时添加将要出现在视口中的构件。除此之外,还了解了如何管理若干个同类型的构件以及随机数的使用。
到这里,视觉表现就基本完成了。下一篇将完成后续的开发任务,完成碰撞检测以及整体场景的衔接,比如开始、暂停、结束等。