本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、认识物理运动
上一篇我们已经将图片资源展示到屏幕上,本篇将了解一下游戏中 帧的持续渲染
而产生动画效果。现在先抛开 trex , 来研究一下如何完成对物体运动的模拟。从中即可以掌握一些 Flame 的基础知识,同时这将有助于我们后续功能的实现。
物理运动是通过在编程中模拟物理学规律,让角色产生的运动效果:
一维 | 二维 |
---|---|
1. 地板自定义绘制: GroundComponent
Flame 中 Component 的 render 回调 方法,其中可以回调 Canvas
对象,我们可以操作 Canvas 进行绘制,比如这里在 GroundComponent
中,通过路径 Path 操作,绘制一个带有斜线的地面:
dart
---->[lib/world/01/heroes/ground_component.dart]----
class GroundComponent extends PositionComponent {
final double groundHeight = 12;
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
width = size.x;
y = size.y - groundHeight - 60;
}
@override
void render(Canvas canvas) {
super.render(canvas);
Paint paint = Paint()..style = PaintingStyle.stroke;
Path path = Path()..lineTo(width, 0);
double step = 15;
for (double i = 0; i < width; i += step) {
path
..moveTo(step + i, 0)
..relativeLineTo(-step, groundHeight);
}
canvas.drawPath(path, paint);
}
}
2. 放置物体:BoxComponent
这个方块通过 BoxComponent
构件维护,如下定义了一些描述尺寸和坐标的变量,将它放在地面上方。方块的绘制非常简单,通过 canvas.drawRect
一个矩形即可:
dart
---->[lib/world/01/heroes/box_component.dart]----
class BoxComponent extends PositionComponent {
final double initX = 60; // 初始位置 x
final double initY = 60; // 初始位置 y
final double groundHeight = 12; // 地面高度
final Vector2 boxSize = Vector2(40, 40); // 方块尺寸
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
x = initX;
y = size.y - groundHeight - initY - boxSize.y;
}
Paint paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = const Color(0xff133C9A);
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRect(Rect.fromLTRB(0, 0, boxSize.x, boxSize.y), paint);
}
}
3. 物理量的定义
在现实世界中,具有 速度
的物体随着 时间
流逝,不断产生 位移
。物体匀速运动过程中,物理量有:
名称 | 变量 | 单位 | 介绍 |
---|---|---|---|
初始位置 | initX | 逻辑像素 px | 方块原始位置 |
位移 | sX | 逻辑像素 px | 方块相比于原始位置的水平 |
速度 | vX | px/s | 水平方向速度的大小 |
时间 | t | s | 物体运动的时间 |
每帧偏移量 | dX | 逻辑像素 px | 每帧时间内的偏移量 |
物体的位置 x
随时间的变化将满足:
dart
dX = vX * t;
sX += dX;
x = initX + sX;
二、模拟匀速运动
游戏中,Flame 游戏引擎会一直进行渲染界面,即使界面是静止的,也会不停刷新,这称之为 游戏循环 GameLoop
。 一般的游戏引擎都是这种性质,因为游戏一般都是持续的界面交互,这种不停更新是合理的:
其优势在于数据变化后,不用考虑如何通知界面更新。因为会不断地自动触发更新渲染界面。
在 Flame 中,可以通过 update
回调来监听每帧触发的时机。其中 dt 是两帧之间的时间间隔,单位是秒(s)。 Flame 的游戏循环本质上是通过 Flutter 中的 Ticker ,所以一般来说每帧间的时间间隔是 16 ms 左右。这样游戏的每秒帧率可以达到 60fps 。
level 1 :匀速运动
想让方块水平移动,就是要在每帧之间让 位移 sx 根据 速度 vX 进行变化,然后将物体的 x 坐标
赋值为初始位置加上位移。这个逻辑在 update
回调方法中进行处理:
dart
---->[lib/world/02/heroes/box_component.dart]----
double vX = 100; // 水平速度
double sX = 0; // 水平总位移
@override
void update(double dt) {
super.update(dt);
double ds = vX * dt;
sX += ds;
x = initX + sX;
}
level 2 :运动范围的限定
上面的小块虽然可以运动,但由于速度会一直增加位移,导致方块滑到非常远的地方。现在想让给移动 增加边界
,当运动到指定位置就停下。如下所示,背景在右侧 700 + 40 像素的位置画个墙:
想在边界处停下,本质上就是让位移无法再增加,在 update
中添加处理逻辑:只需要在位移大于额定位移时,位移 sX 保持不变即可:
dart
---->[lib/world/03/heroes/box_component.dart]----
final double maxWallX = 700;
@override
void update(double dt) {
super.update(dt);
double ds = vX * dt;
sX += ds;
if (sX > maxWallX - initX) {
sX = maxWallX - initX;
}
x = initX + sX;
}
void run() {
vX = 100;
}
此时案例中碰到墙就无法恢复了,可以做个小优化。提供 run
方法,当点击屏幕时,重置位移并设置速度。这样就可以自己控制物体运动的时机。在游戏主类中的 onTapDown
回调中触发方法:
dart
---->[lib/world/03/heroes/box_component.dart]----
void run() {
sX = 0;
vX = 100;
}
level 3 :速度的反弹
为了更好地理解 速度
和 位移
,这里继续添加新的小功能。在起始位置也放置一个墙,让方块在达到左右边界时反向运动。这样就可以让其在边界中永远运动:
处理逻辑也很简单,比如达到最大位移时, 将速度置为反向。这就表示在之后每帧之间的位移增量将是 负数
。总偏移量 sX
累加时就会越来越小。所以 x 坐标变小,在界面上的效果就是向左运动。达到左边界时同理:
dart
----[lib/world/04/heroes/box_component.dart]----
@override
void update(double dt) {
super.update(dt);
double ds = vX * dt;
sX += ds;
// 达到最大位移
if (sX > maxWallX - initX) {
sX = maxWallX - initX;
vX = -vX;
}
// 达到最小位移
if (sX < 0) {
sX = 0;
vX = -vX;
}
x = initX + sX;
}
三、模拟匀变速运动
在现实世界中,物体在力的作用下产生 加速度
,使 速度
随着 时间
增加。 更快的速度会使在相同的时间内产生更大的 位移
。 下图的运动中,速度每秒会增加 100 px/s
。在打点计时器的表现中,每 0.2 s 记录一下物体中心点的坐标,进行渲染。可以很明显地看出:
随着速度的增加,在同时间间隔内位移会增加
1. 加速度的模拟
加速度的模拟代码实现起来是很简单的,只要定义一个 aX
变量表示水平方向的加速度,在每帧计算时通过 vX += aX * dt
对速度进行该变即可。
dart
----[lib/world/05/heroes/box_component.dart]----
// 略同...
double aX = 0; // 水平加速度
final double maxWallX = 700;
void run() {
sX = 0;
aX = 100;
vX = 150;
}
@override
void update(double dt) {
super.update(dt);
vX += aX * dt;
double ds = vX * dt;
// 略同...
2. 打点计时器
一个自由下落的物理,我们很难仅靠脑子想像,来定量地描述其中的物理学规律。所以需要额外的手段来 记录
物体运动过程详细的物理量变化。
相信大家对中学物理中的 打点计时器 都不陌生,它可以以固定的频率震动,在一条运动的纸带上留下墨点。这样就可以根据墨点位置计算唯一,推算运动学规律。
这是我非常喜欢的一个物理实验,它展现出人类记录自然、探索自然的智慧。这里简单实现一个打点计时器,它可以更直观地帮助我们理解运动过程。而不是仅限于表象: "哦,它动了" 。
打点计时器通过类 TickerTap
完成记录点的工作,其中 frequency
表示每秒打点的频率;_points
表示收录的墨点。定义一个 onTick
方法,记录时间以及校验是否达到间隔秒数;达到的话,收录到点集中:
dart
---->[lib/world/06/heroes/dot_component.dart]----
class TickerTap {
/// 打点计数器的频率: 1s 打多少个点
double frequency = 0;
TickerTap({this.frequency = 5});
final List<Offset> _points = [];
List<Offset> get points => _points;
void clear() {
_points.clear();
}
double _totalTime = 0;
double _snapshot = 0;
void onTick(Offset point, double dt) {
_totalTime += dt;
if ((_totalTime - _snapshot) > 1 / frequency) {
_snapshot = _totalTime;
_points.add(point);
}
}
}
然后定义一个 DotComponent
构建负责渲染 TickerTap
记录的点集;在 render
回调方法中遍历绘制小圆:
dart
---->[lib/world/06/heroes/dot_component.dart]----
class DotComponent extends PositionComponent {
final TickerTap dotRecord;
DotComponent(this.dotRecord);
@override
void onGameResize(Vector2 size) {
y = size.y - 111;
super.onGameResize(size);
}
Paint paint = Paint()
..style = PaintingStyle.fill
..color = const Color(0xff133C9A);
@override
void render(Canvas canvas) {
for (Offset points in dotRecord.points) {
canvas.drawCircle(points, 4, paint);
}
}
}
物体的运动发生在 BoxComponent
中,打点计时器也需要在该类中展开工作。通过构造函数传入打点计时器,在 update
回调中触发 onTick
方法,通知打点计时器物体位置的变化。另外在左右边缘碰撞时,将点置空,避免点集的无限累积。
这样 BoxComponent 作为数据的生产者 ,DotComponent 作为数据的消费者 ,TickerTap 作为数据的维护者。这三个角色就为我们描绘出了如下的画画:
dart
---->[lib/world/06/heroes/box_component.dart]----
class BoxComponent extends PositionComponent {
final TickerTap dotRecord;
BoxComponent({required this.dotRecord});
// 略同...
@override
void update(double dt) {
// 略同...
if (sX > maxWallX - initX) {
sX = maxWallX - initX;
vX = -vX;
dotRecord.clear();
}
// 达到最小位移
if (sX < 0) {
sX = 0;
vX = -vX;
dotRecord.clear();
}
x = initX + sX;
dotRecord.onTick(Offset(x+boxSize.x/2 ,0+boxSize.y/2), dt);
}
三、二维的物理运动
上面只是对物体在水平方向上的一维运动模拟。当我们加入竖直方向上的速度和位移量之后,就可以让物体在竖直方向上产生运动效果。一维运动通过一条线段
的两端限制移动区域; 那么二维运动需要一块 矩形面
限制物体运动区域。如下所示,在顶部建一面墙:
1. 竖直方向运动参数
如下,定义竖直 Y
方向上的总位移 sY
、竖直速度 vY
、竖直加速度 aY
。竖直方向最大高度 maxWallY
。
tips: 屏幕坐标系向上为负方向, 这里 maxWallY = -200 ;表示在上方 200 逻辑像素。
dart
---->[lib/world/07/heroes/box_component.dart]----
double vX = 0; // 水平速度
double vY = 0; // 竖直速度
double sX = 0; // 水平总位移
double sY = 0; // 竖直总位移
double aX = 0; // 水平加速度
double aY = 0; // 竖直加速度
final double maxWallY = -200; // 竖直方向最大高度
void run() {
sX = 0;
aX = 100;
vX = 150;
vY = -60;
}
启动时为 vY
赋值为 -60
,说明物体向上运动,每秒在竖直方向前进 60 逻辑像素;在 update
每帧回调时,处理到达边界的反向运动:
dart
@override
void update(double dt) {
super.update(dt);
vX += aX * dt;
vY += aY * dt;
sX += vX * dt;
sY += vY * dt;
// 达到最大位移
if (sX > maxWallX - initX) {
sX = maxWallX - initX;
vX = -vX;
}
// 达到最小位移
if (sX < 0) {
sX = 0;
vX = -vX;
}
// 达到顶部高度
if (sY < maxWallY + boxSize.y) {
sY = maxWallY + boxSize.y;
vY = -vY;
}
// 达到底部高度
if (sY > 0) {
sY = 0;
vY = -vY;
dotRecord.clear();
}
x = initX + sX;
y = initY + sY;
if (vX != 0 && vY != 0) {
dotRecord.onTick(Offset(x + boxSize.x / 2, sY + boxSize.y / 2), dt);
}
}
4. 平抛运动:认识构件的组合
我们知道,构件加入到 Game 场景(蓝框)中时,默认位置在屏幕的 左上角
。计算摆放完地面后,摆放方块时还需要根据地面的高度再计算一下。这是比较麻烦的,特别是当地面上有非常多的构件时,想要改变地面高度,其他地方也要同步修改。
我们希望以地面高度的区域(红色)为参考,让物体默认放在 地面左上角
。这样地面的位置改变可以连带其内容一起变化。Flame 中的 Component 本身也是一个树形结构,可以容纳若干的 Component。比如这里在 GroundComponent
中添加 BoxComponent
和 DotComponent
,那么方块和点集将会以上面红框左上角为原点:
此时方块的位置不用根据屏幕尺寸进行计算,将会方便很多。只要定义方块相对于地面左上角的初始偏移量 initX
和 initY
即可;另外 Component 构件也可以通过 TapCallbacks
响应点击事件,这样只有 点击方块区域 才会触发事件:
dart
--->[lib/world/08/heroes/box_component.dart]----
class BoxComponent extends PositionComponent with TapCallbacks{
final double initX = 60; // 初始位置 x
late double initY = -boxSize.y; // 初始位置 y
final double initSY = -200; // 初始 y 偏移量位置
final double groundHeight = 12; // 地面高度
final Vector2 boxSize = Vector2(40, 40); // 方块尺寸
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
width = boxSize.x;
height = boxSize.y;
sY = initSY;
}
@override
void onTapDown(TapDownEvent event) {
run();
}
现在要实现如下的平抛运动,有了上面的基础,实现起来就比较容易了:
[1]. 物体在开始时已经具有位移: 将
sY
在开始赋值为-200
。[2]. 平抛是一个具有
水平向右初速度
和竖直加速度
的运动。
ini
--->[lib/world/08/heroes/box_component.dart]----
void run() {
sY = initSY;
sX = 0;
vX = 300;
vY = 0;
aY = 100;
}
这里在到达地面时,希望停下而不是继续反弹,只需要将速度和加速度置为 0 即可。这样我们就实现了平抛运动,另外斜抛运动就是在开始时,竖直方向 vY 非零,大家可以自己试验一下。
dart
--->[lib/world/08/heroes/box_component.dart]----
@override
void update(double dt) {
super.update(dt);
vX += aX * dt;
vY += aY * dt;
sX += vX * dt;
sY += vY * dt;
// 达到底部高度
if (sY > 0) {
sY = 0;
vX = 0;
vY = 0;
aY = 0;
}
x = initX + sX;
y = initY + sY;
dotRecord.onTick(Offset(x + boxSize.x / 2, y + boxSize.y / 2), dt);
}
3. 竖直上抛运动与跳跃
最后书归正传,来看一下 竖直上抛运动 。 一个物体有向上的速度,再施加向下的加速,就能模拟出跳起到降落的动作,而且运动的过程符合物理世界的特点:速度越来越慢,直到反方向越来越快下坠:
ini
--->[lib/world/09/heroes/box_component.dart]----
void run() {
aY = 100;
sY = 0;
vY = -200;
}
我们可以通过这个竖直上抛运动来让小恐龙跳跃,效果如下:
代码实现如下,这里小恐龙只有向上跳跃的需求,使用只定义 vY
、aY
、sY
即可。另外在跳跃时对小恐龙的状态进行切换,限制跳跃中不能再跳跃;只有 jumping 状态才需要进行位移,落地时将状态置为 running 。这样不同时期就可以展示不同状态的精灵:
dart
---->[lib/trex/03/heroes/player.dart]----
double vY = 0; // 竖直速度
double aY = 300; // 竖直加速度
double sY = 0; // 竖直位移
void jump() {
if (current == PlayerState.jumping) {
return;
}
vY = -280 ;
sY = 0;
current = PlayerState.jumping;
}
@override
void update(double dt) {
super.update(dt);
if (current == PlayerState.jumping) {
vY += aY * dt;
sY += vY * dt;
if (sY > 0) {
sY = 0;
current = PlayerState.running;
}
y = initY + sY;
}
}
到这里第二集就结束啦,本集研究了一下如何通过编程的手段模拟物理量的变化;从中也学习了 Component 的一些知识,比如通过 render
回调自定义绘制、update
回调监听每帧的更新。
本集相当于一个小插曲,让我们对 Flame 有更多的认知。下一集,将进入 Trex 游戏的核心逻辑。