本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、资源准备
巧妇难为无米之炊,对于游戏而言,资源素材是非常重要的一环。itch.io 是一个面向独立游戏开发者和游戏玩家的在线游戏分发平台。其中也有很多人分享,非常多优秀的 可免费商用 游戏素材包。感谢这些资源创作者的无私奉献,本接下来的游戏将使用下面的素材包:
screamingbrainstudios.itch.io/tinybreakou...
素材包开源协议: Creative Commons Zero v1.0 Universal (CCO 可商用)
1. 精灵图的制作
下载的资源包里有很多小图片,我们可以通过 精灵图制作工具 将小图片资源整合在一起。这里使用 TexturePacker 工具,将图片文件夹拖入其中即可。
工具可以导出一张 png
图片,和一个 json
文件。其中 json 文件记录着每个小图片的文件名、尺寸、位置信息。为了更便捷地访问精灵图,可以解析这个 json 文件,根据 文件名
访问对应的精灵图。
这里称每个小图片为一个帧 Frame,其中每帧必要的信息有三个:
- 名称: name
- 资源尺寸: sourceSize
- 资源位置坐标: srcPosition
如下定义一个 Frame 类型进行维护,并提供 fromMap 构造根据 map 数据创建 Frame 对象:
dart
--->[packages/flame_ext/lib/texture_loader.dart]----
class Frame {
final String name;
final Vector2 sourceSize;
final Vector2 srcPosition;
Frame({
required this.name,
required this.sourceSize,
required this.srcPosition,
});
factory Frame.fromMap(dynamic map) {
return Frame(
name: map['filename'],
sourceSize: Vector2(
map['frame']['w'].toDouble(),
map['frame']['h'].toDouble(),
),
srcPosition: Vector2(
map['frame']['x'].toDouble(),
map['frame']['y'].toDouble(),
));
}
}
2. 精灵图的解析
整张精灵图就是由若干个 Frame 构成的列表,如下定义 TextureLoader 类型负责维护精灵图的加载。 在 load
方法中传入 json 和图片资源,解析 json 为 Frame 列表添加元素;重载 []
运算符,便于根据文件名得到对应的 Sprite 对象:
dart
--->[packages/flame_ext/lib/texture_loader.dart]----
class TextureLoader {
final List<Frame> _frames = [];
late final Image _sprites;
Future<void> load(String jsonAsset, String imageAsset) async {
_frames.clear();
String data = await rootBundle.loadString(jsonAsset);
List<dynamic> textures = json.decode(data)['textures'];
for (int i = 0; i < textures.length; i++) {
dynamic texture = textures[i];
_frames.addAll((texture['frames'] as List).map(Frame.fromMap));
}
_sprites = await Flame.images.load(imageAsset);
}
/// 根据名称访问 _frames 中对应的精灵图
Sprite operator [](String name) {
Frame frame = _frames.singleWhere((e) => e.name == name);
return Sprite(
_sprites,
srcPosition: frame.srcPosition,
srcSize: frame.sourceSize,
);
}
}
上面的解析类完成之后,以后通过 TexturePacker 工具打包的精灵图,就可以很方便地访问。
3. 图片精灵的展示
如下,打砖块的游戏主类是 BreakBricksGame ,在 onLoad 回调中通过 TextureLoader#load
方法加载资源;加载完成后,通过 loader['Paddle_A_Blue_96x28.png'] 即可访问到对应文件名的精灵图。
dart
class BreakBricksGame extends FlameGame {
TextureLoader loader = TextureLoader();
@override
FutureOr<void> onLoad() async {
super.onLoad();
await loader.load(
'assets/images/break_bricks/break_bricks.json',
'break_bricks/break_bricks.png',
);
Sprite sprite = loader['Paddle_A_Blue_96x28.png'];
add(SpriteComponent(sprite: sprite));
}
}
打砖块的游戏中主要有三个角色,现将他们的单体呈现在屏幕上,如下所示:
- 底部的碰撞体: Paddle
- 小球: Ball
- 砖块列表: Brick
dart
---->[lib/break_bricks/01/heros/paddle.dart]----
class Paddle extends SpriteComponent with HasGameRef<BreakBricksGame>{
@override
FutureOr<void> onLoad() {
sprite = game.loader['Paddle_A_Blue_96x28.png'];
return super.onLoad();
}
}
---->[lib/break_bricks/01/heros/ball.dart]----
class Ball extends SpriteComponent with HasGameRef<BreakBricksGame>{
@override
FutureOr<void> onLoad() {
sprite = game.loader['Ball_Blue_Shiny-32x32.png'];
return super.onLoad();
}
}
---->[lib/break_bricks/01/heros/bricks.dart]----
class Brick extends SpriteComponent with HasGameRef<BreakBricksGame>{
@override
FutureOr<void> onLoad() {
sprite = game.loader['Colored_Blue-128x32.png'];
return super.onLoad();
}
}
二、挡板移动与小球碰撞
第一步,来实现一下最简单的小球的碰撞反弹。如下所示,小球会以一定的初速度运动,碰到四壁和挡板时,会反弹向另一边:
1. 挡板的移动
挡板可以通过鼠标拖拽或者 ←
、→
键控制移动,游戏主类 BricksGame 中:
- 混入 DragCallbacks 支持
拖拽
事件; - 混入 KeyboardEvents 支持
键盘
事件; - 混入 HasCollisionDetection 支持
碰撞检测
。
dart
class BricksGame extends FlameGame with DragCallbacks, KeyboardEvents, HasCollisionDetection {
覆写 onDragUpdate 方法,监听拖拽手势交互的事件,根据偏移量更新挡板的 x 坐标,使之在水平方向移动。另外 clamp
方法可以限制数字的最小和最大边界。挡板不能移出边界,所以需要限制最大横坐标是 width - paddle.width
:
dart
---->[lib/bricks/02/bricks_game.dart]----
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
double dx = event.localDelta.x;
double max = width - paddle.width;
paddle.x = (paddle.x + dx).clamp(0, max);
}
覆写 onKeyEvent 方法,监听键盘点击事件。其中回调 KeyEvent
对象有三种派生类型:
KeyDownEvent
: 键盘的按下事件。KeyUpEvent
: 键盘的抬起事件。KeyRepeatEvent
: 按键重复事件,也就是按下键盘不伸手,会多次触发的事件。
dart
---->[lib/bricks/02/bricks_game.dart]----
@override
KeyEventResult onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
if (event is KeyDownEvent || event is KeyRepeatEvent) {
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft: paddle.moveBy(-moveStep);
case LogicalKeyboardKey.arrowRight: paddle.moveBy(moveStep);
}
}
return KeyEventResult.handled;
}
如下所示,这里在按下或者长按时,触发挡板角色的 moveBy
方法进行移动。而不是直接修改 x 的值,因为 moveBy 方法使用 MoveToEffect ,可以在指定时间内平滑移动,从而避免突然位置变化带来的不佳体验。
dart
---->[lib/bricks/02/heroes/paddle.dart]----
void moveBy(double dx) {
double newX = position.x + dx;
if (newX < 0) {
x = 0;
return;
}
if (newX > game.width - width) {
x = game.width - width;
return;
}
add(MoveToEffect(
Vector2(newX, position.y),
EffectController(duration: 0.1),
));
}
2. 小球与挡板的碰撞反弹
在恐龙跳跃的小游戏中,介绍过简单的碰撞检测。其核心是为构件添加碰撞检测区域,混入 CollisionCallbacks
,在回调事件中监听碰撞时机。对于小球来说可以添加 CircleHitbox
设置圆形的碰撞检测区域:
下面代码中 v 表示小球的速度,并在 update 方法中通过根据 v*dt
更新小球坐标位置 position
, 从而实现小球的匀速运动。另外混入了 CollisionCallbacks
,可以覆写 onCollisionStart
回调监听到碰撞开始的事件:
dart
---->[lib/bricks/02/heroes/ball.dart]----
class Ball extends SpriteComponent with HasGameRef<BricksGame>, CollisionCallbacks {
@override
void onGameResize(Vector2 size) {
double initY = 80;
double initX = size.x / 2 - width / 2;
position = Vector2(initX, initY);
super.onGameResize(size);
}
@override
FutureOr<void> onLoad() {
sprite = game.loader['Ball_Blue_Shiny-32x32.png'];
add(CircleHitbox());
return super.onLoad();
}
/// 小球速度
Vector2 v = Vector2(0, 0);
@override
void update(double dt) {
super.update(dt);
position += v * dt;
}
void run() {
if (v.x == 0 && v.y == 0) {
v = Vector2(-250, 250);
}
}
@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
// TODO 碰撞逻辑处理
}
}
在小球碰撞到挡板时,这里先简单做个反弹的处理。如下所示,小球以 v
的速度撞向挡板,以 v'
的速度反弹。反弹后球速度的特点是: 水平速度 vx
不变, 竖直速度 vy
方向相反:
也就是说,小球碰撞到挡板时,只需要 vY = -vY 即可:
dart
void _handleHitPaddle(Vector2 position) {
v.y = -v.y;
}
3. 小球与四壁的碰撞反弹
游戏中需要限制小球的移动范围,这里可以添加一个游戏区域的构件,作为限制区间。比如下面的 Playground ,大小和窗口一致,并添加 RectangleHitbox 使四周支持碰撞检测:
dart
class Playground extends RectangleComponent with HasGameReference<BricksGame> {
Playground() : super(
paint: Paint()..color = const Color(0xff000000),
children: [RectangleHitbox()],
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
对于小球碰撞四壁,也可以进行类似的反弹。碰撞时可以获得接触点的坐标。根据坐标信息可以知道碰到的是哪个壁。水平方向的碰壁(左右),将 vX 反向;竖直方向的碰壁(上下),将 vY 反向。 如下通过 _handleHitPlayground
方法处理,其中 areaSize 是墙壁尺寸。
dart
---->[lib/bricks/02/heroes/ball.dart]----
void _handleHitPlayground(Vector2 position, Vector2 areaSize) {
if (position.y <= 0) {
v.y = -v.y; // 上壁
} else if (position.x <= 0) {
v.x = -v.x; // 左壁
} else if (position.x >= areaSize.x) {
v.x = -v.x; // 右壁
} else if (position.y >= areaSize.y) {
v.y = -v.y; // 下壁
}
}
在 onCollisionStart
回调中有两个参数,第一个是碰撞接触点的集合;第二个参数是碰撞到的另一个构件。这样就可以根据 PositionComponent 的类型知道,碰撞的是墙壁 Playground
还是挡板 Paddle
,从而进行不同的逻辑处理。
dart
---->[lib/bricks/02/heroes/ball.dart]----
@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
if (other is Playground) {
_handleHitPlayground(intersectionPoints.first, other.size);
} else if (other is Paddle) {
_handleHitPaddle(intersectionPoints.first);
}
super.onCollisionStart(intersectionPoints, other);
}
4. 四周拐角的碰撞检测优化
在开发过程中遇到一个很诡异的事,如下所示,当小球恰好弹到拐角。有概率会出边界,这是很严重的问题:
通过日志分析,在拐角处碰到两边时,只算是一次碰撞,上边界的反弹没有触发,所以球直接飞出:
修复这个问题可以给四角范围进行额外的判定。比如弹到边角时原路弹回:
dart
---->[lib/bricks/02/heroes/ball.dart]----
void _handleHitPlayground(Vector2 position, Vector2 areaSize) {
// 四周拐角
if (position.x < height && position.y < height
|| position.x < height && position.y > areaSize.y - height
|| position.x > areaSize.x - height && position.y > areaSize.y - height
|| position.x > areaSize.x - height && position.y < height
) {
v.y = -v.y;
v.x = -v.x;
return;
}
if (position.y <= 0) {
v.y = -v.y; // 上壁
} else if (position.x <= 0) {
v.x = -v.x; // 左壁
} else if (position.x >= areaSize.x) {
v.x = -v.x; // 右壁
} else if (position.y >= areaSize.y) {
v.y = -v.y; // 下壁
}
}
三、 砖块的管理与碰撞分析
到这里小球和四壁及挡板的碰撞就完成了。面看一下砖块构件列表的维护,以及小球和砖块的碰撞处理逻辑。资源中的砖块样式很多,这样先用短的蓝色砖块实现基础功能,后续再拓展更多功能。
1. 砖块管理器 BrickManager
和之前的云朵、障碍物类似,对于游戏中出现若干次的个体,一般通过管理器来维护。比如这里在 BrickManager
负责维护砖块列表,下图中是一个 2 行 10 列 的砖块阵:
BrickManager 在 onLoad 回调方法中,通过 _createBricks
创建 row 行 column 个砖块 (Brick) 。这里暂时将所有的砖块尺寸固定为 64*32
; 这样创建过程中,通过行号和列号 (i,j)
来控制砖块摆放的坐标。
dart
---->[lib/bricks/02/heroes/bricks.dart]----
class BrickManager extends PositionComponent {
final int column;
final int row;
BrickManager({
this.column = 10,
this.row = 2,
});
@override
FutureOr<void> onLoad() {
addAll(_createBricks());
width = 64.0 * column;
return super.onLoad();
}
List<Brick> _createBricks() {
List<Brick> bricks = [];
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
Brick brick = Brick();
brick.x = 64.0 * j;
brick.y = 32.0 * i;
bricks.add(brick);
}
}
return bricks;
}
}
2. 球与砖块的碰撞
小球在碰撞到砖块时,砖块需要移除;并且小球反弹:
其中最重要的任务是校验小球和砖块碰撞:小球撞到砖块的上下边界时,y 速度反向;小球撞到砖块的左右边界时,x 速度反向。碰撞回调中可以拿到砖块组件以及碰撞点:
这样以砖块左上顶点
作为原点,可以得到碰撞点相对于砖块左顶点的坐标 hitP
。根据这个坐标,可以感知碰到的方位。比如在 hitP.y 等于砖块高度时,说明碰撞发生在底部;hitP.y 等于 0 说明是顶部碰撞。左右同理:
dart
---->[lib/bricks/02/heroes/ball.dart]----
void _handleHitBrick(Vector2 hitPos, Brick brick) {
Vector2 hitP = hitPos - brick.position;
if (hitP.y <= 0) {
// 顶部碰撞
v.y = -v.y;
} else if (hitP.x <= 0) {
// 左部碰撞
v.x = -v.x;
} else if (hitP.y >= brick.height ) {
// 下部碰撞
v.y = -v.y;
} else if (hitP.x >= brick.width) {
// 右部碰撞
v.x = -v.x;
}
brick.removeFromParent();
}
碰撞完成后,将 break 从场景中移除,这样就可以完成最近的碰撞砖块,让小球反弹,并砖块消失的效果。
3. 砖块碰撞逻辑的优化
目前小球和砖块的碰撞检测逻辑有一个问题,当小球碰撞到两个砖块的的连接处。onCollisionStart
会先后触发两次,这样会导致小球的速度反向两次
从而无法反弹。
解决这个问题的思路是:
当碰撞到连接处时,只响应最开始碰到的砖块,忽略到第二次碰撞。
我想到的是将碰撞检测通过一个 bool 值 _lockedHitBrick
进行锁定,下面的 _lockCollisionTest
方法传入一个回调,表示碰撞的具体逻辑,当锁定时不做任何处理。触发完成后,通过 scheduleMicrotask
开启微任务将 _lockedHitBrick
置为 false。
dart
---->[lib/bricks/02/heroes/ball.dart]----
// 锁定砖块碰撞检测
bool _lockedHitBrick = false;
void _lockCollisionTest(VoidCallback callback){
if (_lockedHitBrick) return;
_lockedHitBrick = true;
callback();
scheduleMicrotask(() => _lockedHitBrick = false);
}
@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
//略同...
if (other is Brick) {
_lockCollisionTest(()=> _handleHitBrick(intersectionPoints.first, other));
}
}
这是一个非常细节的操作,可能很多朋友这里看不明白。这里稍微解释一下:
- 因为源码中碰撞检测是的遍历循环
同步逻辑
。微任务的回调是异步的,会在同步逻辑执行完成再触发。 - 所以第二个碰撞的回调会在微任务回调之前触发,此时
_lockedHitBrick
为 true ,将不会执行碰撞逻辑。 - 碰撞检测循环完成后,微任务回调触发,将
_lockedHitBrick
置为false,从而不影响之后的碰撞检测。
对异步不太明白的朋友可以阅读我的异步专栏: 《Flutter 知识进阶 - 异步编程》
到这里,我们就完成了基本的小球碰撞和砖块消失的功能。这是打砖块的核心逻辑,后面章节中会逐步优化,打造一个完善的打砖块游戏,敬请期待 ~