Flutter&Flame游戏实践#04 | Trex-碰撞与场景

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]

第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》【github 项目首页】 查看。


一、碰撞检测

碰撞检测在游戏开发中是非常非常重要的一环,无论是子弹命中角色,还是主角碰到陷阱,都需要检测碰撞情况来触发核心的业务逻辑。


1. 碰撞检测区域

在正式处理 Trex 游戏的碰撞需求之前,这里仍是准备了一些开胃小菜,辅助大家更容易理解 Flame 中的碰撞检测。第一道菜如下所示:

准备可拖拽的直线(针),移动过程中检测它和小恐龙的碰撞情况。

两者碰撞时,将针和小恐龙的外框颜色置为蓝色示意。

Line 构件可以通过 render 回调绘制线;想要一个构件响应碰撞事件,可以:

  • 1\] 混入 `CollisionCallbacks`。

  • 3\] 覆写 **onCollisionStart** 响应碰撞开始事件;**onCollisionEnd** 响应碰撞结束事件。

---->[lib/world/11/heroes/line.dart]----
class Line extends PositionComponent with CollisionCallbacks {
Line() : super(position: Vector2(300, 100), size: Vector2(120, 2));

final Paint _paint = Paint() ..color = Colors.black
..style = PaintingStyle.stroke..strokeWidth = 1;

@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawLine(Offset.zero, Offset(width, 0), _paint);
}

@override
Future<void> onLoad() async {
add(RectangleHitbox());
}

@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
// 碰撞开始时将线的颜色置为蓝色
_paint.color = Colors.blue;
}

@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
// 碰撞开始时将线的颜色置为黑色
_paint.color = Colors.black;
}
}

复制代码
*** ** * ** ***

小恐龙构件也是类似,`RectangleHitbox` 本质上也是一种 Component 构件,可以设置 `debugMode` 展示区域调试信息;`debugColor` 时调试信息的颜色。

```dart
---->[lib/world/11/heroes/player.dart]----
class PlayerComponent extends SpriteComponent with HasGameRef<GameWorld>, CollisionCallbacks {
  PlayerComponent();

  @override
  Future<void> onLoad() async {
    super.onLoad();
    sprite = Sprite(
      game.spriteImage,
      srcPosition: Vector2(1514.0, 4.0),
      srcSize: Vector2(88.0, 90.0),
    );
    position = Vector2(100, 50);
    // 添加矩形碰撞区
    RectangleHitbox rHitBox = RectangleHitbox();
    rHitBox..debugMode = true..debugColor = Colors.orange;
    add(rHitBox);
  }

  @override
  void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    children.first.debugColor = Colors.blue;
  }

  @override
  void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);
    children.first.debugColor = Colors.orange;
  }
}

最后也是最主要的一点, 游戏主类 GameWorld 需要混入 HasCollisionDetection 启用碰撞检测。拖动针 移动的功能,可以通过混入 PanDetector 覆写 onPanUpdate 回调,处理针位置坐标的偏移量:

dart 复制代码
---->[lib/world/11/game_world.dart]----
class GameWorld extends FlameGame with PanDetector, HasCollisionDetection {
  late Line line;
  late final Image spriteImage;
  PlayerComponent player = PlayerComponent();
  
  @override
  Future<void> onLoad() async {
    spriteImage = await Flame.images.load('trex/trex.png');
    line = Line();
    add(player);
    add(line);
  }

  @override
  Color backgroundColor() => const Color(0xffffffff);

  @override
  void onPanUpdate(DragUpdateInfo info) {
    line.position += info.delta.global;
  }
}

2. 细致的碰撞区域

如果仅是橙色矩形框(下左图) 作为碰撞区域,在游戏中如下红色的 空白矩形区域 碰到障碍物时,也会被视为碰撞。其实我们并不想这样,所以可以添加多个小矩形检测区域(下右图)

单一矩形检测区 多矩形检测区

下面是将检测区域变为三个小矩形的效果,通过探针碰撞可以很清晰地看出:左上角、右下角、左下角的空白区域将忽略碰撞, 这样可以更精准地校验碰撞的有效性。

在代码中只需要添加多个 RectangleHitbox 即可;如下所示,可以通过 RectangleHitbox.relative 构造,方便以父区域为基准,创建一个矩形区域。其中:

parentSize : 父区域尺寸。

定义入参: 相对于父区域的宽高百分比。

position: 生成区域的偏移量。

anchor : 与父区域的对其锚点

比如头部的小矩形,以精灵图的整个矩形框为基准,宽是整体区域的 45%; 高时整体区域的 35% ; 在右上角对其、并有略微向左的偏移量:

dart 复制代码
---->[lib/world/12/heroes/player.dart]----
List<RectangleHitbox> createHitBoxes() {
  return [
    RectangleHitbox.relative(
      Vector2(0.45, 0.35),
      position: Vector2(4, 0),
      anchor: const Anchor(-1,0),
      parentSize: size,
    ),
    RectangleHitbox.relative(
      Vector2(0.66, 0.45),
      position: Vector2(4, 32),
      parentSize: size,
    ),
    RectangleHitbox.relative(
      Vector2(0.3, 0.15),
      position: Vector2(24, height-16),
      parentSize: size,
    )
  ];
}

然后我们依次对需要碰撞的精灵设计碰撞区域,达到如下的效果。这里角色的区域设定就不一一介绍了,

具体代码详见: lib/world/12


二、实现 Trex 核心功能

现在万事俱备只欠东风,只要在游戏过程中校验小恐龙和障碍物的碰撞,在碰撞时结束游戏即可。下面是录屏效果,其中展示出游戏过程中小恐龙和障碍物的碰撞边界:


1. 恐龙状态与碰撞区域

这里代码中有一个小难点:小恐龙具有趴下的状态,此时碰撞区域和 running 不同。所以 Player 组件需要根据小恐龙状态 动态改变碰撞区域,如下所示将两个区域定义为成员变量:

dart 复制代码
---->[lib/trex/05/heroes/player.dart]----
/// 蹲下碰撞区域
late final List<RectangleHitbox> _downHitBoxes = [
  RectangleHitbox.relative(
    Vector2(0.96, 0.42),
    position: Vector2(4, 36),
    parentSize: size,
  ),
  RectangleHitbox.relative(
    Vector2(0.3, 0.15),
    position: Vector2(24, height - 16),
    parentSize: size,
  )
];

/// 其他碰撞区域
late final List<RectangleHitbox> _customHitBoxes = [
  RectangleHitbox.relative(
    Vector2(0.45, 0.35),
    position: Vector2(4, 0),
    anchor: const Anchor(-1, 0),
    parentSize: size,
  ),
  RectangleHitbox.relative(
    Vector2(0.66, 0.3),
    position: Vector2(4, 32),
    parentSize: size,
  ),
  RectangleHitbox.relative(
    Vector2(0.35, 0.28),
    position: Vector2(22, height - 30),
    parentSize: size,
  )
];

下面定义 switchHitBoxByState 方法根据小恐龙的新旧状态处理碰撞区域:当新状态是蹲下时,旧状态不是蹲下,此时移除之前的碰撞区域,将 _downHitBoxes 添加其中即可。站起奔跑时同理:

dart 复制代码
---->[lib/trex/05/heroes/player.dart]----
void switchHitBoxByState(PlayerState? newState, PlayerState? oldState) {
  if (newState == PlayerState.down && oldState != PlayerState.down) {
    // 新状态是蹲下,修改碰撞区域
    removeWhere((component) => component is RectangleHitbox);
    addAll(_downHitBoxes);
  }
  
  if (oldState == PlayerState.down && newState != PlayerState.down || oldState == null) {
    // 旧状态是蹲下,修改碰撞区域
    removeWhere((component) => component is RectangleHitbox);
    addAll(_customHitBoxes);
  }
}

2. 小恐龙变化的逻辑

在 Player 中定义设置 state 的方法,切换蹲下和起立的状态。是否可以蹲下或起立,需要进行校验,比如只有正在奔跑 (running) 时,才能切换到蹲下状态:

dart 复制代码
---->[lib/trex/05/heroes/player.dart]----
set state(PlayerState newState) {
  PlayerState? old = current;
  // 死亡或跳跃中不允许修改状态
  if(old == PlayerState.crashed ||old == PlayerState.jumping) return;
  // 蹲下
  if (old == PlayerState.running && newState == PlayerState.down) {
    current = PlayerState.down;
    switchHitBoxByState(current, old);
  }
  // 起立
  if (old != PlayerState.running && newState == PlayerState.running) {
    current = PlayerState.running;
    if(old==PlayerState.down){
      switchHitBoxByState(current, old);
    }
  }
  // 起跳
  if (old != PlayerState.jumping && newState == PlayerState.jumping) {
    current = PlayerState.jumping;
    vY = -770;
    sY = 0;
  }
}

按下 按键时 (arrowDown),小恐龙蹲下;键盘事件为 RawKeyUpEvent 时,表示抬起事件,当抬起 按键时,让小恐龙站起:

dart 复制代码
---->[lib/trex/05/trex_game.dart]----
@override
KeyEventResult onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
  if (event is RawKeyUpEvent) {
    if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
      player.state = PlayerState.running;
    }
  }
  if (keysPressed.contains(LogicalKeyboardKey.arrowDown)) {
    player.state = PlayerState.down;
  }
  if (keysPressed.contains(LogicalKeyboardKey.space)) {
    player.state = PlayerState.jumping;
  }
  return KeyEventResult.handled;
}

3.移动端的交互优化

到这里,最核心的 跳跃躲避障碍 的功能就实现了。但对于移动端来说,一般不会连接键盘,所以跳跃和蹲下的交互需要设计一些。如果交互比较复杂,可以弄些按钮的控件来触发,这里只有两个动作,可以将屏幕分成两半:

onTapDown 的回调参数 TapDownEvent 对象中包含 localPosition 的点位信息,可以用其进行校验:横坐标值小于屏幕宽度一半处理跳跃,反之蹲下。 onTapUp 回调用于监听手指抬起事件:

dart 复制代码
---->[lib/trex/05/trex_game.dart]----
@override
void onTapDown(TapDownEvent event) {
  if (player.current == PlayerState.waiting) {
    moveSpeed = 320;
    player.state = PlayerState.running;
  }
  if (player.current == PlayerState.running) {
    if (event.localPosition.x < size.x / 2) {
      player.state = PlayerState.jumping;
    } else {
      player.state = PlayerState.down;
    }
  }
}

@override
void onTapUp(TapUpEvent event) {
  super.onTapUp(event);
  if (event.localPosition.x < size.x / 2) {
  } else {
    player.state = PlayerState.running;
  }
}

三、游戏场景优化

下面来规整一下项目,完善游戏整体流程。比如:

游戏状态维护:等待、运行中、游戏结束。

游戏重新开始功能。


1. 游戏场景的规划

其实之前写的交互只是游戏的场景之一。如下所示,根据游戏的不同状态,还需要给出两个场景 等待场景结束场景

等待场景 结束场景

之前代码中把所有构件都塞到 TrexGame 中,在完整流程中需要根据游戏状态展示不同场景。如果仍然全部塞入TrexGame 中,会让代码显得非常杂乱。这里引入 场景 Scene 的概念来维护不同场景的构件:

代码中只需要根据游戏状态,显示不同的场景组合即可。比如游戏开始时是 waiting 状态,只展示 WaitingScene 构件;点击启动后移除 WaitingScene ,创建并添加 RunningScene 构件即可;同理游戏结束时添加 GameOverScene

dart 复制代码
---->[lib/trex/06/main.dart]----
enum GameState { waiting, running, gameOver }

2. 游戏运动场景 RunningScene

这里将之前游戏主类中的构件提取到 RunningScene 中,视为游戏运行的主场景,其中提供 state 的获取和设置方法,以供外界操作 Player 的状态:

dart 复制代码
---->[lib/trex/06/scene/running_scene.dart]----
class RunningScene extends PositionComponent with HasGameReference<TrexGame> {
  final Player player = Player();
  final ScoreComponent score = ScoreComponent();
  final GroundComponent ground = GroundComponent();
  final CloudManager cloudManager = CloudManager();
  final ObstacleManager obstacleManager = ObstacleManager();

  @override
  FutureOr<void> onLoad() {
    add(cloudManager);
    add(ground);
    add(obstacleManager);
    add(player);
    add(score);
    return super.onLoad();
  }

  set state(PlayerState newState) {
    player.state = newState;
  }

  PlayerState get state => player.current ?? PlayerState.waiting;
}

3. 等待和结束场景

WaitingScene 构件比较简单:通过 SpriteComponent 呈现小恐龙等待状态图片; 以及 TextComponent 展示提示文字:

dart 复制代码
class WaitingScene extends PositionComponent with HasGameReference<TrexGame> {
  @override
  void onGameResize(Vector2 size) {
    super.onGameResize(size);
    y = (size.y - text.height + sprite.height) / 2;
    x = (size.x - text.width) / 2;
    width = size.x;
    height = size.y;
  }

  late TextComponent text = TextComponent(
    text: '按任意键或点击屏幕开始',
    textRenderer: TextPaint(style: const TextStyle(fontSize: 24, color: Colors.black)),
  );

  late SpriteComponent sprite = SpriteComponent(
    sprite: Sprite(
      game.spriteImage,
      srcPosition: Vector2(76.0, 6.0),
      srcSize: Vector2(88.0, 90.0),
  ));

  @override
  FutureOr<void> onLoad() {
    add(text);
    add(sprite);
    sprite.y -= sprite.height + 10;
    return super.onLoad();
  }
}

GameOverScene 中展示展示两个精灵图片,覆盖在 RunningScene 上方:

dart 复制代码
---->[lib/trex/06/scene/game_over_scene.dart]----
class GameOverScene extends PositionComponent with HasGameReference<TrexGame> {
  @override
  void onGameResize(Vector2 size) {
    super.onGameResize(size);
    y = (size.y - spriteText.height - spriteButton.height) / 2;
    x = (size.x - spriteText.width) / 2;
  }

  late SpriteComponent spriteText = SpriteComponent(
      sprite: Sprite(
        game.spriteImage,
        srcPosition: Vector2(954.0, 30.0),
        srcSize: Vector2(384.0, 32.0),
      ));

  late SpriteComponent spriteButton = SpriteComponent(
      sprite: Sprite(
    game.spriteImage,
    srcPosition: Vector2(4.0, 4.0),
    srcSize: Vector2(72.0, 62.0),
  ));

  @override
  FutureOr<void> onLoad() {
    add(spriteButton);
    add(spriteText);
    spriteButton.y += spriteText.height + 20;
    spriteButton.x += (spriteText.width-spriteButton.width)/2;
    return super.onLoad();
  }
}

4. 游戏主类对场景的维护

现在 TrexGame 在 onLoad 回调中只需要加入等待场景 waitingScene:

dart 复制代码
---->[lib/trex/06/trex_game.dart]----
class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection {
  double moveSpeed = 0;
  double kInitSpeed = 320;
  
  late final Image spriteImage;

  GameState state = GameState.waiting;
  late RunningScene runningScene;
  final WaitingScene waitingScene = WaitingScene();
  final GameOverScene gameOverScene = GameOverScene();

  @override
  Future<void> onLoad() async {
    spriteImage = await Flame.images.load('trex/trex.png');
    add(waitingScene);
  }

如果游戏状态是 GameState.waiting,点击屏幕或任意按键时进入游戏。具体逻辑是:移除 WaitingScene 并通过 startRunningGame 方法动态添加 RunningScene

dart 复制代码
---->[点击或按键回调时]----
if (state == GameState.waiting) {
  removeWhere((component) => component is WaitingScene);
  startRunningGame();
  return;
}

void startRunningGame(){
  runningScene = RunningScene();
  add(runningScene);
  moveSpeed = kInitSpeed;
  runningScene.state = PlayerState.running;
  state = GameState.running;
}

游戏结束时,触发 gameOver 方法,添加 gameOverScene 场景;游戏结束之后,再次点击需要重新开始。这里的逻辑处理是:将 GameOverScene 和 RunningScene 从游戏中移除,然后通过 startRunningGame 重新创建并添加。

dart 复制代码
void gameOver() {
  moveSpeed = 0;
  runningScene.state = PlayerState.crashed;
  state = GameState.gameOver;
  add(gameOverScene);
}

---->[点击回调时]----
if (state == GameState.gameOver) {
  removeWhere((component) => component is GameOverScene || component is RunningScene);
  startRunningGame();
  return;
}

四、游戏功能优化

上面 等待 -> 运行 -> 结束 -> 重新开始 功能实现完毕,整个游戏的基本交互逻辑就融会贯通了。在此基础上,我们可以继续完善或者拓展新的功能:

游戏分数功能,持久化记录最高记录。

根据分数的增加,加快地面运动速度。

展示游戏帧刷新速率 FPS 。


1.游戏得分的设计

游戏的得分在 ScoreComponent 构件中进行展示,计分方式大家也可以自己设计。这里取运动的总距离 _distance~/5 作为得分;每 1000 分,地面的移动速度增加 20 px/s ,最高增加 10 次:

dart 复制代码
---->[lib/trex/06/heroes/score_component.dart]----
int _score = 0;
int _highScore = 0;
double _distance = 0;
final double acceleration = 20;

@override
void update(double dt) {
  super.update(dt);
  if (game.state == GameState.running) {
    _distance += dt * game.moveSpeed;
    score = _distance ~/ 5;
    // 通过分数确定等级,提高速度
    int level = _score ~/ 1000;
    if (level <= 10) {
      game.moveSpeed = game.kInitSpeed + level * acceleration;
    }
  }
}

2. 通过 shared_preferences 保存最高记录

shared_preferences 是一个全平台的基于 xml 配置文件的数据持久化手段;实现需要在 pubspec.yaml 中配置

yaml 复制代码
dependencies:
  ...
  shared_preferences: ^2.2.2

可以在游戏主类中提供全局的访问点 sp, 在 onLoad 回调中异步初始化 SharedPreferences 对象;在 gameOver 方法中获取到 scoreComponent 触发 saveHistory 保存历史记录:

dart 复制代码
---->[lib/trex/06/trex_game.dart]----
class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection {
  late SharedPreferences sp;

  @override
  Future<void> onLoad() async {
    sp = await SharedPreferences.getInstance();
    // 略同...
  }
  
  void gameOver() {
    // 略同...
    runningScene.scoreComponent.saveHistory();
  }

然后在 ScoreComponent 中定义 highestScore 表示最高记录,在 onLoad 回调中读取本地存储的最高记录;并提供 saveHistory 方法,当分数大于历史记录时,保存新的历史记录。这样即使退出游戏,下次进入时最高记录依旧生效:

dart 复制代码
const String kHighestScoreKey = 'highestScore';

class ScoreComponent extends PositionComponent with HasGameReference<TrexGame> {
  int highestScore = 0;

  @override
  Future<void> onLoad() async {
    highestScore = game.sp.getInt(kHighestScoreKey)??0;
    // 略同...
  }
  
  void saveHistory() async{
    if (score > highestScore) {
      highestScore = score;
      await game.sp.setInt(kHighestScoreKey, highestScore);
    }
  }

3. 展示每秒刷新频率 FPS

游戏中每秒刷新频率 FPS 是性能体验很重要的标准,刷新率越高说明游戏性能体验越好。一般电影是 24fps ,仅对于人类视觉而言就可以很流畅;但游戏是交互性的产品,需要及时反馈,所以流畅的游戏帧率要求高一些,一般游戏在是 40fps 左右就可以称之为流畅,达到 60 fps 就是非常好的体验了。

小游戏的逻辑处理对于桌面端来说是轻飘飘的,我这里可以达到 150 FPS ,不同的电脑会有所差异。在 Android 手机上也能稳定在 60 FPS。可以说目前该游戏没有性能上的问题。

想要在界面上展示 FPS 也非常简单,只要统计一秒钟渲染多少帧即可。如下所示,每帧渲染时都会触发 update 回调,这里每 500 ms 统计一次期间的帧数,除以真正流逝的时间 span 即可:

dart 复制代码
---->[lib/trex/06/heroes/fps_text.dart]----
class FpsText extends PositionComponent {
  @override
  void onGameResize(Vector2 size) {
    super.onGameResize(size);
    x = 10;
    y = 8;
  }

  late TextComponent text = TextComponent(
    textRenderer: TextPaint(style: const TextStyle(fontSize: 14, color: Colors.grey)),
  );

  @override
  Future<void> onLoad() async => add(text);

  int _timeRecord = 0;
  int _frameCount = 0;

  @override
  void update(double dt) {
    super.update(dt);
    int now = DateTime.now().millisecondsSinceEpoch;
    _frameCount++;
    int span = now - _timeRecord;
    if (span > 500) {
      _timeRecord = now;
      text.text = 'FPS: ${(_frameCount / span * 1000).toInt()}';
      _frameCount = 0;
    }
  }
}

本集的重点在于对角色碰撞的处理,以及通过划分场景,让游戏的交互逻辑融会贯通。玩家可以在游戏结束后重新开始,从而生生不息。

经历了四集,到这里恐龙跳跃 Trex 1.0.0 版基本功能就实现完毕了。你可以将它打包成全平台的应用程序,分享给大小朋友玩耍了。通过这个过程,想必大家对 Flame 已经有了一定的认知,接下来我们将继续通过其他小游戏学习,敬请期待~

相关推荐
MiyamuraMiyako20 分钟前
从 0 到发布:Gradle 插件双平台(MavenCentral + Plugin Portal)发布记录与避坑
android
NRatel1 小时前
Unity 游戏提升 Android TargetVersion 相关记录
android·游戏·unity·提升版本
叽哥3 小时前
Kotlin学习第 1 课:Kotlin 入门准备:搭建学习环境与认知基础
android·java·kotlin
风往哪边走4 小时前
创建自定义语音录制View
android·前端
用户2018792831674 小时前
事件分发之“官僚主义”?或“绕圈”的艺术
android
用户2018792831674 小时前
Android事件分发为何喜欢“兜圈子”?不做个“敞亮人”!
android
Kapaseker5 小时前
你一定会喜欢的 Compose 形变动画
android
QuZhengRong6 小时前
【数据库】Navicat 导入 Excel 数据乱码问题的解决方法
android·数据库·excel
zhangphil7 小时前
Android Coil3视频封面抽取封面帧存Disk缓存,Kotlin(2)
android·kotlin
程序员码歌13 小时前
【零代码AI编程实战】AI灯塔导航-总结篇
android·前端·后端