【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 游戏的核心逻辑。

相关推荐
lqj_本人5 小时前
鸿蒙next选择 Flutter 开发跨平台应用的原因
flutter·华为·harmonyos
帅得不敢出门7 小时前
安卓设备adb执行AT指令控制电话卡
android·adb·sim卡·at指令·电话卡
lqj_本人8 小时前
Flutter&鸿蒙next 状态管理框架对比分析
flutter·华为·harmonyos
我又来搬代码了9 小时前
【Android】使用productFlavors构建多个变体
android
德育处主任10 小时前
Mac和安卓手机互传文件(ADB)
android·macos
芦半山10 小时前
Android“引用们”的底层原理
android·java
迃-幵11 小时前
力扣:225 用队列实现栈
android·javascript·leetcode
大风起兮云飞扬丶11 小时前
Android——从相机/相册获取图片
android
Rverdoser11 小时前
Android Studio 多工程公用module引用
android·ide·android studio
aaajj12 小时前
[Android]从FLAG_SECURE禁止截屏看surface
android