likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 4

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

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

第一季:30 篇短文章,快速了解 Flame 基础。[已完结] 第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。


一、碰撞检测

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

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


1. 碰撞检测区域

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

准备可拖拽的直线(针),移动过程中检测它和小恐龙的碰撞情况。 两者碰撞时,将针和小恐龙的外框颜色置为蓝色示意。

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

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

  • [1] 混入 CollisionCallbacks
  • [2] 为构件添加 RectangleHitbox 支持矩形碰撞区。
  • [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 时调试信息的颜色。

---->[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 回调,处理针位置坐标的偏移量:

---->[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. 细致的碰撞区域

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

单一矩形检测区多矩形检测区
Flutter&Flame游戏实践#04 | Trex-碰撞与场景Flutter&Flame游戏实践#04 | Trex-碰撞与场景

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

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

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

parentSize : 父区域尺寸。 定义入参: 相对于父区域的宽高百分比。 position: 生成区域的偏移量。 anchor : 与父区域的对其锚点

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

---->[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 核心功能

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

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


1. 恐龙状态与碰撞区域

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

---->[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 添加其中即可。站起奔跑时同理:

---->[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) 时,才能切换到蹲下状态:

---->[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 时,表示抬起事件,当抬起 按键时,让小恐龙站起:

---->[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.移动端的交互优化

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

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

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

---->[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. 游戏场景的规划

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

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

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

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

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

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

2. 游戏运动场景 RunningScene

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

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

---->[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 展示提示文字:

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

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 上方:

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

---->[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:

---->[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

---->[点击或按键回调时]----
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 重新创建并添加。

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 次:

---->[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 中配置

dependencies:
  ...
  shared_preferences: ^2.2.2

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

---->[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 方法,当分数大于历史记录时,保存新的历史记录。这样即使退出游戏,下次进入时最高记录依旧生效:

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

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 就是非常好的体验了。

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

小游戏的逻辑处理对于桌面端来说是轻飘飘的,我这里可以达到 150 FPS ,不同的电脑会有所差异。在 Android 手机上也能稳定在 60 FPS。可以说目前该游戏没有性能上的问题。 想要在界面上展示 FPS 也非常简单,只要统计一秒钟渲染多少帧即可。如下所示,每帧渲染时都会触发 update 回调,这里每 500 ms 统计一次期间的帧数,除以真正流逝的时间 span 即可:

---->[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;
    }
  }
}

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

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

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