Flutter&Flame游戏实践#03 | Trex-无限移动

本文为稀土掘金技术社区首发签约文章,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();
}

_updateBreaksfirstPosX 表示首元素的右侧横坐标,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,
  );
}

第三集中主要围绕如何通过有限的空间,来模拟无限的运动,完成了路面障碍物云朵的运动效果。其核心的思想是:在运动过程中,将视口之外不可见的构件及时移除;通过逻辑判断及时添加将要出现在视口中的构件。除此之外,还了解了如何管理若干个同类型的构件以及随机数的使用。

到这里,视觉表现就基本完成了。下一篇将完成后续的开发任务,完成碰撞检测以及整体场景的衔接,比如开始、暂停、结束等。

相关推荐
想取一个与众不同的名字好难13 分钟前
android studio导入OpenCv并改造成.kts版本
android·ide·android studio
Jewel1051 小时前
Flutter代码混淆
android·flutter·ios
Yawesh_best2 小时前
MySQL(5)【数据类型 —— 字符串类型】
android·mysql·adb
曾经的三心草5 小时前
Mysql之约束与事件
android·数据库·mysql·事件·约束
guoruijun_2012_48 小时前
fastadmin多个表crud连表操作步骤
android·java·开发语言
Winston Wood8 小时前
一文了解Android中的AudioFlinger
android·音频
一头小火烧10 小时前
flutter打包签名问题
flutter
sunly_10 小时前
Flutter:异步多线程结合
flutter
AiFlutter10 小时前
Flutter网络通信-封装Dio
flutter
B.-10 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio