【Flutter&Flame 游戏实践 - 贰】Trex | 物理运动

本文为稀土掘金技术社区首发签约文章,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 中添加 BoxComponentDotComponent,那么方块和点集将会以上面红框左上角为原点:

此时方块的位置不用根据屏幕尺寸进行计算,将会方便很多。只要定义方块相对于地面左上角的初始偏移量 initXinitY 即可;另外 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;
}

我们可以通过这个竖直上抛运动来让小恐龙跳跃,效果如下:

代码实现如下,这里小恐龙只有向上跳跃的需求,使用只定义 vYaYsY 即可。另外在跳跃时对小恐龙的状态进行切换,限制跳跃中不能再跳跃;只有 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 游戏的核心逻辑。

相关推荐
福柯柯4 分钟前
Android ContentProvider的使用
android·contenprovider
不想迷路的小男孩5 分钟前
Android Studio 中Palette跟Component Tree面板消失怎么恢复正常
android·ide·android studio
餐桌上的王子6 分钟前
Android 构建可管理生命周期的应用(一)
android
菠萝加点糖10 分钟前
Android Camera2 + OpenGL离屏渲染示例
android·opengl·camera
用户20187928316720 分钟前
🌟 童话:四大Context徽章诞生记
android
yzpyzp29 分钟前
Android studio在点击运行按钮时执行过程中输出的compileDebugKotlin 这个任务是由gradle执行的吗
android·gradle·android studio
aningxiaoxixi39 分钟前
安卓之service
android
TeleostNaCl1 小时前
Android 应用开发 | 一种限制拷贝速率解决因 IO 过高导致系统卡顿的方法
android·经验分享
用户2018792831672 小时前
📜 童话:FileProvider之魔法快递公司的秘密
android
吴Wu涛涛涛涛涛Tao2 小时前
一步到位:用 Very Good CLI × Bloc × go_router 打好 Flutter 工程地基
flutter·ios