使用 Flutter 和 Flame 构建游戏

1. 简介

了解如何使用 Flutter 和 Flame 构建平台游戏!在受《涂鸦跳跃》启发的 Doodle Dash 游戏中,您可以扮演 Dash(Flutter 吉祥物)或她最好的朋友 Sparky(Firebase 吉祥物),并尝试通过在平台上跳跃来达到尽可能高的高度。

学习内容

  • 如何在 Flutter 中构建跨平台游戏。
  • 如何创建可重用的游戏组件,并将其作为 Flame 游戏循环的一部分进行渲染和更新。
  • 如何通过游戏物理学知识为角色(称为精灵)设计可控动作和动画。
  • 如何添加和管理碰撞检测。
  • 如何添加键盘和触控输入作为游戏控件。

前提条件

此 Codelab 假定您具备一定程度的 Flutter 经验。如果没有,您可以通过创建您的第一个 Flutter 应用 Codelab 来学习一些基础知识。

您将构建的内容

此 Codelab 将指导您构建一个名为 Doodle Dash 的游戏:这是一个以 Flutter 吉祥物 Dash 或 Firebase 吉祥物 Sparky 为角色的平台游戏(此 Codelab 的其余部分将以 Dash 为目标,但这些步骤同样适用于 Sparky)。您的游戏将具有以下特点:

  • 一个可以水平和垂直移动的精灵
  • 随机生成的平台
  • 将精灵向下拉的重力效应
  • 游戏菜单
  • 游戏内控制,如暂停和重新游戏
  • 能够保存得分

游戏内容

在 Doodle Dash 中,玩家可以控制 Dash 左右移动,在平台上跳跃。此外,玩家还可以在游戏过程中使用战力提升道具来短暂提升其能力。玩家选择初始难度级别(1 到 5),然后点击 Start 即可开始游戏。

d1e75aa0e05c526.gif

级别

游戏中有 5 个级别。每个级别(级别 1 之后)都会解锁新功能。

  • 级别 1(默认):此级别生成 NormalPlatformSpringBoard 平台。创建后,任何平台都有 20% 的几率成为移动平台。
  • 级别 2(得分 >= 20):添加仅支持一次跳跃的 BrokenPlatform
  • 级别 3(得分 >= 40):解锁 NooglerHat 战力提升道具。此特殊平台持续 5 秒,并将 Dash 的跳跃能力提高到其正常水平的 2.5 倍。在这 5 秒内,她还会戴上一顶很酷的 Google 员工帽。
  • 4 级(得分 >=80):解锁 Rocket 战力提升道具。此特殊平台具有火箭飞船的形态,可让 Dash 处于无敌状态。它还可以将 Dash 的跳跃能力提高到其正常水平的 3.5 倍。
  • 级别 5(得分 >= 100):解锁 Enemy 平台。如果 Dash 与敌人相撞,游戏就会自动结束。

按级别划分的平台类型

级别 1(默认)

NormalPlatform

SpringBoard

级别 2(得分 >= 20)

级别 3(得分 >= 40)

级别 4(得分 >= 80)

级别 5(得分 >= 100)

BrokenPlatform

NooglerHat

Rocket

Enemy

游戏失败

以下两种方式会导致游戏失败:

  • Dash 掉落到屏幕底部下方。
  • Dash 与敌人相撞(游戏达到级别 5 时会生成敌人)。

战力提升道具

战力提升道具可增强角色的游戏能力,例如增加其跳跃速度,或让其对敌人处于“无敌”状态,或两者兼而有之。Doodle Dash 有两种战力提升道具。一次只能激活一种战力提升道具。

  • Google 员工帽战力提升道具可以将 Dash 的跳跃高度提高至其正常水平的 2.5 倍。另外,Dash 在战力提升期间会戴着一顶 Google 员工帽。
  • 火箭飞船战力提升道具可以让 Dash 在面对敌人平台时处于无敌状态(与敌人碰撞没有影响),并将其跳跃高度提高到正常水平的 3.5 倍。Dash 会乘坐火箭飞行,直到重力克服其速度,让其降落在平台上。

2. 获取 Codelab 起始代码

a3c16fc17be25f6c.png从 GitHub 下载项目的初始版本:

  1. 在命令行中,将 GitHub 代码库克隆到 flutter-codelabs 目录:
git clone https://github.com/flutter/codelabs.git flutter-codelabs

此 Codelab 的代码位于 flutter-codelabs/flame-building-doodle-dash 目录中。该目录包含此 Codelab 中每个步骤的完整项目代码。

a3c16fc17be25f6c.png导入起始应用

  • flutter-codelabs/flame-building-doodle-dash/step_02 目录导入您的首选 IDE。

a3c16fc17be25f6c.png安装软件包:

  • 所有必需的软件包(例如 Flame)都已添加到项目 pubspec.yaml 文件中。如果您的 IDE 未自动安装依赖项,请打开命令行终端并从 Flutter 项目的根目录运行以下命令,以检索项目依赖项:
flutter pub get

设置您的 Flutter 开发环境

要完成此 Codelab,您需要具备以下条件:

3. 浏览代码

接下来,浏览下代码。

查看 lib/game/doodle_dash.dart 文件,其中包含扩展 FlameGame 的 DoodleDash 游戏。您向 FlameGame 实例注册组件。该实例是 Flame 中最基本的组件(类似于 Flutter Scaffold),其作用是在游戏过程中渲染和更新所有已注册的组件。可以将其视为游戏的中枢神经系统。

什么是组件?Flutter 应用由 Widgets 组成。同样,FlameGame 则是由 Components 组成。组件即构成游戏的所有构建块。(与 Flutter widget 相类似,组件也可以有子组件。)精灵角色、游戏背景、负责生成新游戏组件(例如敌人)的对象都是组件。事实上FlameGame 本身就是一个 Component;Flame 将其称为 Flame 组件系统。

组件继承自抽象 Component 类。实现 Component 的抽象方法以创建 FlameGame 类的机制。例如,您经常会看到整个 DoodleDash 中实现了以下方法:

  • onLoad:异步初始化组件(类似于 Flutter 的 initState 方法)
  • update:在游戏循环的每个 tick 中更新组件(类似于 Flutter 的 build 方法)

此外,add 方法还会向 Flame 引擎注册组件。

例如,lib/game/world.dart 文件包含 World 类,该类扩展 ParallaxComponent 以渲染游戏背景。该类接受图片资源列表,并按层对这些图片资源进行渲染,确保每个层以不同的速度移动,以增强真实感。DoodleDash 类包含一个 ParallaxComponent 实例,并通过 DoodleDash onLoad 方法将其添加到游戏中:

lib/game/world.dart

class World extends ParallaxComponent<DoodleDash> {
 @override
 Future<void> onLoad() async {
   parallax = await gameRef.loadParallax(
     [
       ParallaxImageData('game/background/06_Background_Solid.png'),
       ParallaxImageData('game/background/05_Background_Small_Stars.png'),
       ParallaxImageData('game/background/04_Background_Big_Stars.png'),
       ParallaxImageData('game/background/02_Background_Orbs.png'),
       ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
       ParallaxImageData('game/background/01_Background_Squiggles.png'),
     ],
     fill: LayerFill.width,
     repeat: ImageRepeat.repeat,
     baseVelocity: Vector2(0, -5),
     velocityMultiplierDelta: Vector2(0, 1.2),
   );
 }
}

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
   with HasKeyboardHandlerComponents, HasCollisionDetection {
 ...
 final World _world = World();
 ...

 @override
 Future<void> onLoad() async {
   await add(_world);
   ...
 }
}

状态管理

lib/game/managers 目录包含三个为 Doodle Dash 处理状态管理的文件:game_manager.dartobject_manager.dartlevel_manager.dart

GameManager 类(位于 game_manager.dart 中)跟踪整体游戏状态和记分。

ObjectManager 类(位于 object_manager.dart 中)管理生成和移除平台的位置及时间。您将在稍后为此类添加内容。

最后,LevelManager 类(位于 level_manager.dart 中)管理游戏的难度级别以及玩家升级时的所有相关游戏配置。游戏提供五个难度级别 — 玩家在达到其中一个得分里程碑时进入下一级别。当每次进入下一级别后,游戏难度都会增加,并且 Dash 必须跳跃更远的距离。在整个游戏过程中,重力始终是恒定的。因此,跳跃速度会逐渐增加,以便 Dash 能够达到更远的距离。

每当通过一个平台,玩家的得分就会增加。当玩家达到特定的点数阈值时,游戏会进入下一级别并解锁新的特殊平台,以增加游戏的趣味性和挑战性。

4. 在游戏中添加玩家

此步骤会在游戏中添加角色(在本例中为 Dash)。玩家控制角色,并且所有逻辑均包含在 Player 类中(位于 player.dart 文件中)。Player 类扩展 Flame 的 SpriteGroupComponent 类,该类包含一些抽象方法,您可以覆盖这些方法来实现自定义逻辑。这包括加载资源和精灵、确定玩家位置(水平和垂直位置)、配置碰撞检测以及接受用户输入。

加载资源

Dash 具有不同的精灵形态,代表不同版本的角色和战力提升。例如,以下图标显示了 Dash 和 Sparky 分别面向中心、左侧和右侧。

Flame 的 SpriteGroupComponent 允许您使用 sprites 属性管理多种精灵状态,正如 _loadCharacterSprites 方法中所示。

a3c16fc17be25f6c.pngPlayer 类中,将以下代码行添加到 onLoad 方法以加载精灵资源,并将 Player 的精灵状态设置为面向前方:

lib/game/sprites/player.dart

@override
Future<void> onLoad() async {
  await super.onLoad();

  await _loadCharacterSprites();                                      // Add this line
  current = PlayerState.center;                                       // Add this line
}

下面分析 _loadCharacterSprites 中用于加载精灵和资源的代码。可以直接在 onLoad 方法中实现此代码,但将其放在单独的方法中有助于组织源代码,并增加其可读性。此方法将映射分配给将每个角色状态与加载的精灵资源进行配对的 sprites 属性,如下所示:

lib/game/sprites/player.dart

Future<void> _loadCharacterSprites() async {
   final left = await gameRef.loadSprite('game/${character.name}_left.png');
   final right = await gameRef.loadSprite('game/${character.name}_right.png');
   final center =
       await gameRef.loadSprite('game/${character.name}_center.png');
   final rocket = await gameRef.loadSprite('game/rocket_4.png');
   final nooglerCenter =
       await gameRef.loadSprite('game/${character.name}_hat_center.png');
   final nooglerLeft =
       await gameRef.loadSprite('game/${character.name}_hat_left.png');
   final nooglerRight =
       await gameRef.loadSprite('game/${character.name}_hat_right.png');

   sprites = <PlayerState, Sprite>{
     PlayerState.left: left,
     PlayerState.right: right,
     PlayerState.center: center,
     PlayerState.rocket: rocket,
     PlayerState.nooglerCenter: nooglerCenter,
     PlayerState.nooglerLeft: nooglerLeft,
     PlayerState.nooglerRight: nooglerRight,
   };
 }

更新玩家组件

Flame 会在事件循环的每个 tick(或帧)中调用一次组件的 update 方法,以重新绘制每个发生变化的游戏组件(类似于 Flutter 的 build 方法)。接下来,在 Player 类的 update 方法中添加逻辑,以确定角色在屏幕上的位置。

a3c16fc17be25f6c.pngPlayerupdate 方法中,添加以下代码,以计算角色的当前速度和位置:

lib/game/sprites/player.dart

 void update(double dt) {
                                                             // Add lines from here...
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;                              // ... to here.

   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {                  // Add lines from here...
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }                                                                   // ... to here.

   // Core gameplay: Add gravity

   position += _velocity * dt;                                       // Add this line
   super.update(dt);
 }

在移动玩家之前,update 方法会检查以确保游戏未处于无法移动玩家的非游戏状态,例如初始状态(游戏首次加载时)或游戏结束状态。

如果游戏处于可游戏状态,则使用 new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick) 等式计算 Dash 的位置,如以下代码所示:

 position += _velocity * dt

在构建 Doodle Dash 时,另一个重要因素是确保包含无限的边界。这样一来,Dash 可以跳出屏幕的左侧边缘并从右侧边缘重新进入,反之亦然。

7068325e8b2f35fc.gif

其实现方法是检查 Dash 的位置是否超出了屏幕的左侧边缘或右侧边缘,如果超出某一侧边缘,则会将 Dash 重新放置到另一侧边缘。

重要事件

在初始设计中,Doodle Dash 适用于在网页和桌面设备上运行,因此该游戏需要支持键盘输入,以便玩家可以控制角色的移动。onKeyEvent 方法允许 Player 组件识别箭头键按下操作,以确定 Dash 应面向左侧移动,还是面向右侧移动。

Dash 向左侧移动时将面向左侧

Dash 向右侧移动时将面向右侧

接下来,实现 Dash 的水平移动能力(如 _hAxisInput 变量中所定义)。您还将让 Dash 面向其移动的方向。

a3c16fc17be25f6c.png修改 Player 类的 moveLeftmoveRight 方法,以定义 Dash 的当前方向:

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;

   current = PlayerState.left;                                      // Add this line

   _hAxisInput += movingLeftInput;                                  // Add this line

 }

 void moveRight() {
   _hAxisInput = 0;

   current = PlayerState.right;                                     // Add this line

   _hAxisInput += movingRightInput;                                 // Add this line

 }

a3c16fc17be25f6c.png修改 Player 类的 onKeyEvent 方法,以便在按下左箭头键或右箭头键时分别调用 moveLeftmoveRight 方法:

lib/game/sprites/player.dart

@override
 bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
   _hAxisInput = 0;

                                                             // Add lines from here...
   if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
     moveLeft();
   }

   if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
     moveRight();
   }                                                                   // ... to here.

   // During development, it's useful to "cheat"
   if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
     // jump();
   }

   return true;
 }

现在,Player 类已经可以正常运行了,并且可供 Doodle Dash 游戏使用。

a3c16fc17be25f6c.png在 DoodleDash 文件中,导入 sprites.dart,这会让 Player 类可供使用:

lib/game/doodle_dash.dart

import 'sprites/sprites.dart';                                       // Add this line

a3c16fc17be25f6c.pngDoodleDash 类中创建一个 Player 实例:

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
  DoodleDash({super.children});

  final World _world = World();
  LevelManager levelManager = LevelManager();
  GameManager gameManager = GameManager();
  int screenBufferSpace = 300;
  ObjectManager objectManager = ObjectManager();

  late Player player;                                                // Add this line
  ...
}

a3c16fc17be25f6c.png 接下来,根据玩家选择的难度级别初始化和配置 Player 跳跃速度,并将 Player 组件添加到 FlameGame。在 setCharacter 方法中填入以下代码:

lib/game/doodle_dash.dart

void setCharacter() {
  player = Player(                                           // Add lines from here...
     character: gameManager.character,
     jumpSpeed: levelManager.startingJumpSpeed,
   );
  add(player);                                                         // ... to here.
}

a3c16fc17be25f6c.png调用 initializeGameStart 开始位置的 setCharacter 方法。

lib/game/doodle_dash.dart

void initializeGameStart() {
    setCharacter();                                                   // Add this line

    ...
}

a3c16fc17be25f6c.png此外,在 initializeGameStart 中,对玩家调用 resetPosition,以便在每次游戏开始时将玩家的角色移回起始位置。

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...

    levelManager.reset();

    player.resetPosition();                                           // Add this line

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    ...
  }

a3c16fc17be25f6c.png 运行应用。开始游戏,Dash 出现在屏幕上!

ed15a9c6762595c9.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请使用以下链接中提供的代码恢复到正常状态。

5. 添加平台

此步骤会添加一些平台(供 Dash 着落和跳开),以及用于确定 Dash 跳跃是否成功的碰撞检测逻辑。

首先,查看 Platform 抽象类:

lib/game/sprites/platform.dart

abstract class Platform<T> extends SpriteGroupComponent<T>
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  bool isMoving = false;

  Platform({
    super.position,
  }) : super(
          size: Vector2.all(100),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    await add(hitbox);
  }
}

什么是碰撞框?

Doodle Dash 中引入的每一个平台组件均扩展了 Platform<T> 抽象类,该抽象类是一个带有碰撞框 (hitbox) 的 SpriteComponent碰撞框可允许精灵组件检测其是否与其他具有碰撞框的对象发生了碰撞。Flame 支持多种碰撞框形状,例如矩形、圆形和多边形。例如,Doodle Dash 为平台使用矩形碰撞框,并为 Dash 使用圆形碰撞框。Flame 负责处理用于计算碰撞的数学运算。

Platform 类为所有子类型添加了一个碰撞框和碰撞回调。

添加标准平台

Platform 类用于为游戏添加平台。普通平台的形态为 4 个随机选择的视觉元素之一:显示器、手机、终端或笔记本电脑。视觉元素的选择不会影响平台的行为。

NormalPlatform

a3c16fc17be25f6c.png通过添加 NormalPlatformState 枚举和 NormalPlatform 类来添加标准静态平台:

lib/game/sprites/platform.dart

enum NormalPlatformState { only }                            // Add lines from here...

class NormalPlatform extends Platform<NormalPlatformState> {
  NormalPlatform({super.position});

  final Map<String, Vector2> spriteOptions = {
    'platform_monitor': Vector2(115, 84),
    'platform_phone_center': Vector2(100, 55),
    'platform_terminal': Vector2(110, 83),
    'platform_laptop': Vector2(100, 63),
  };

  @override
  Future<void>? onLoad() async {
    var randSpriteIndex = Random().nextInt(spriteOptions.length);

    String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);

    sprites = {
      NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
    };

    current = NormalPlatformState.only;

    size = spriteOptions[randSprite]!;
    await super.onLoad();
  }
}                                                                      // ... to here.

接下来,生成供角色进行交互的平台。

ObjectManager 扩展了 Flame 的 Component 类,并在整个游戏中生成 Platform 对象。在 ObjectManagerupdateonMount 方法中实现生成平台的功能。

a3c16fc17be25f6c.pngObjectManager 类中创建一个名为 _semiRandomPlatform 的新方法,以便生成平台。您稍后将更新此方法以返回不同类型的平台,但目前,仅返回一个 NormalPlatform

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {             // Add lines from here...
    return NormalPlatform(position: position);
}                                                                      // ... to here.

a3c16fc17be25f6c.png重写 ObjectManagerupdate 方法,这次使用 _semiRandomPlatform 方法生成一个平台并将其添加到游戏中:

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void update(double dt) {
   final topOfLowestPlatform =
       _platforms.first.position.y + _tallestPlatformHeight;

   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;

   if (topOfLowestPlatform > screenBottom) {
     var newPlatY = _generateNextY();
     var newPlatX = _generateNextX(100);
     final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
     add(nextPlat);

     _platforms.add(nextPlat);

     gameRef.gameManager.increaseScore();

     _cleanupPlatforms();
     // Losing the game: Add call to _maybeAddEnemy()
     // Powerups: Add call to _maybeAddPowerup();
   }

   super.update(dt);
 }                                                                     // ... to here.

ObjectManageronMount 方法中执行同样的操作。最终目的就是,当游戏第一次运行时,_semiRandomPlatform 方法会生成一个起始平台并将其添加到游戏中。

a3c16fc17be25f6c.png添加 onMount 方法,代码如下:

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void onMount() {
   super.onMount();

   var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;

   var currentY =
       gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;

   for (var i = 0; i < 9; i++) {
     if (i != 0) {
       currentX = _generateNextX(100);
       currentY = _generateNextY();
     }
     _platforms.add(
       _semiRandomPlatform(
         Vector2(
           currentX,
           currentY,
         ),
       ),
     );

     add(_platforms[i]);
   }
 }                                                                     // ... to here.

例如,如以下代码所示,configure 方法让 Doodle Dash 游戏能够重新配置平台之间的最小和最大距离,并在难度级别增加时启用特殊平台:

lib/game/managers/object_manager.dart

 void configure(int nextLevel, Difficulty config) {
    minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
    maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;

    for (int i = 1; i <= nextLevel; i++) {
      enableLevelSpecialty(i);
    }
  }

DoodleDash 实例(位于 initializeGameStart 方法中)会创建一个已初始化的 ObjectManager,系统会根据难度级别对其进行配置,并将其添加到 Flame 游戏中:

lib/game/doodle_dash.dart

  void initializeGameStart() {
    gameManager.reset();

    if (children.contains(objectManager)) objectManager.removeFromParent();

    levelManager.reset();

    player.resetPosition();

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    add(objectManager);

    objectManager.configure(levelManager.level, levelManager.difficulty);
  }

ObjectManager 再次出现在 checkLevelUp 方法中。当玩家进入下一级别时,ObjectManager 会根据难度级别重新配置其平台生成参数。

lib/game/doodle_dash.dart

  void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);
    }
  }

a3c16fc17be25f6c.png 热重载 7f9a9e103c7b5e5.png(如果在网页上进行测试,则重新启动)以启用更改。(保存文件,然后使用 IDE 中的按钮,或者从命令行输入 r 以热重载。)开始游戏,屏幕上会显示 Dash 和一些平台:

7c6a6c6e630c42ce.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请使用以下链接中提供的代码恢复到正常状态。

6. 核心游戏内容

现在,您已经实现了单独的 PlayerPlatform widget。接下来,您可以开始将所有内容整合在一起。此步骤将实现核心功能、碰撞检测和相机移动。

重力

为了增加游戏的真实感,Dash 会受到重力的作用。重力会在 Dash 跳跃时向其向下拉。在当前的 Doodle Dash 版本中,重力保持为恒定的正值,始终将 Dash 向下拉。不过,您在以后可以选择改变重力来实现其他效果。

a3c16fc17be25f6c.pngPlayer 类中,添加一个值为 9 的 _gravity 属性:

lib/game/sprites/player.dart

class Player extends SpriteGroupComponent<PlayerState>
    with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {

  ...

  Character character;
  double jumpSpeed;
  final double _gravity = 9;                                         // Add this line

  @override
  Future<void> onLoad() async {
    ...
  }
  ...
}

a3c16fc17be25f6c.png修改 Playerupdate 方法以添加 _gravity 变量,以影响 Dash 的垂直速度:

lib/game/sprites/player.dart

 void update(double dt) {
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;
   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }

   _velocity.y += _gravity;                                          // Add this line

   position += _velocity * dt;
   super.update(dt);
 }

碰撞检测

Flame 支持开箱即用的碰撞检测。如需在您的 Flame 游戏中启用碰撞检测,请添加 HasCollisionDetection mixin。如果查看 DoodleDash 类,您会看到其中已经添加了此 mixin:

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
    ...
}

接下来,使用 CollisionCallbacks mixin 将碰撞检测添加到各个游戏组件。此 mixin 将为组件赋予对 onCollision 回调的访问权限。两个带有碰撞框的对象发生碰撞会触发 onCollision 回调,并传递到其相撞对象的引用,以便于实现对象反应方式的逻辑。

回顾上一步,Platform 抽象类已经具有 CollisionCallbacks mixin 和一个碰撞框。Player 类也有 CollisionCallbacks mixin,因此您只需将 CircleHitbox 添加到 Player 类。实际上,Dash 的碰撞框只是一个圆形,因为 Dash 要比矩形更圆。

a3c16fc17be25f6c.pngPlayer 类中,导入 sprites.dart 以便其能够访问各种 Platform 类:

lib/game/sprites/player.dart

import 'sprites.dart';

a3c16fc17be25f6c.pngPlayer 类的 onLoad 方法中添加一个 CircleHitbox

lib/game/sprites/player.dart

@override
Future<void> onLoad() async {
  await super.onLoad();

  await add(CircleHitbox());                                         // Add this line

  await _loadCharacterSprites();
  current = PlayerState.center;
}

Dash 需要一个跳跃方法,以便在与平台发生碰撞时完成跳跃。

a3c16fc17be25f6c.png 添加一个 jump 方法,该方法接受一个可选的 specialJumpSpeed 值:

lib/game/sprites/player.dart

void jump({double? specialJumpSpeed}) {
  _velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}

a3c16fc17be25f6c.png添加以下代码,覆盖 PlayeronCollision 方法:

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);
   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     }
   }
 }

每当 Dash 下落并与 NormalPlatform 的顶部发生碰撞时,此回调都将调用 Dash 的 jump 方法。isMovingDown && isCollidingVertically 语句将确保 Dash 在平台中向上移动,而不会触发跳跃。

相机移动

当 Dash 在游戏中向上移动时,相机应保持跟随,而当 Dash 下落时,相机应保持静止。

在 Flame 中,如果“世界”大于屏幕,使用相机的 worldBounds 添加边界,告知 Flame 应当显示世界的哪一部分。为了让相机在保持水平固定的同时向上移动,根据玩家的位置调整每次更新的顶部和底部世界边界,但保持左右边界不变。

a3c16fc17be25f6c.pngDoodleDash 类中,将以下代码添加到 update 方法中,确保相机在游戏过程中始终跟随 Dash:

lib/game/doodle_dash.dart

@override
  void update(double dt) {
    super.update(dt);

    if (gameManager.isIntro) {
      overlays.add('mainMenuOverlay');
      return;
    }

    if (gameManager.isPlaying) {
      checkLevelUp();

                                                            // Add lines from here...
      final Rect worldBounds = Rect.fromLTRB(
        0,
        camera.position.y - screenBufferSpace,
        camera.gameSize.x,
        camera.position.y + _world.size.y,
      );
      camera.worldBounds = worldBounds;

      if (player.isMovingDown) {
        camera.worldBounds = worldBounds;
      }

      var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
      if (!player.isMovingDown && isInTopHalfOfScreen) {
        camera.followComponent(player);
      }                                                               // ... to here.
    }
  }

接下来,每当重新开始游戏时,系统都必须将 Player 位置和相机边界重置为起始点。

a3c16fc17be25f6c.pnginitializeGameStart 方法中添加以下代码:

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...
    levelManager.reset();

                                                        // Add the lines from here...
    player.reset();
    camera.worldBounds = Rect.fromLTRB(
      0,
      -_world.size.y,
      camera.gameSize.x,
      _world.size.y +
          screenBufferSpace,
    );
    camera.followComponent(player);
                                                                      // ... to here.

   player.resetPosition();
    ...
  }

进入下一级别时增加跳跃速度

核心游戏内容中的最后一项就是要求 Dash 的跳跃速度随着难度级别的增加而增加,并且各平台之间的距离会越来越远。

a3c16fc17be25f6c.png添加对 setJumpSpeed 方法的调用,并提供与当前级别相关联的跳跃速度:

lib/game/doodle_dash.dart

void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);

      player.setJumpSpeed(levelManager.jumpSpeed);                   // Add this line
    }
  }

a3c16fc17be25f6c.png 热重载 7f9a9e103c7b5e5.png(或者,如果在网页环境中,则重新启动)以启用更改。(保存文件,然后使用 IDE 中的按钮,或者从命令行输入 r 以热重载。):

2bc7c856064d74ca.gif

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请使用以下链接中提供的代码恢复到正常状态。

7. 有关平台的更多操作

现在,ObjectManager 会生成可供 Dash 跳跃的平台。接下来,您可以再为其提供一些更加刺激的特殊平台。

继续添加 BrokenPlatformSpringBoard 类。顾名思义,BrokenPlatform 会在一次跳跃后破碎,而 SpringBoard 则会提供一个蹦床,让 Dash 能够跳得更高更快。

BrokenPlatform

SpringBoard

Player 类一样,所有这些平台类都依赖 enums 来表示其当前状态。

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }

平台的 current 状态发生变化也会改变游戏中的精灵形态。在 sprites 属性上定义 State 枚举与图像资源之间的映射,以关联分配给每个状态的精灵。

a3c16fc17be25f6c.png添加 BrokenPlatformState 枚举和 BrokenPlatform 类:

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }                // Add lines from here...

class BrokenPlatform extends Platform<BrokenPlatformState> {
  BrokenPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <BrokenPlatformState, Sprite>{
      BrokenPlatformState.cracked:
          await gameRef.loadSprite('game/platform_cracked_monitor.png'),
      BrokenPlatformState.broken:
          await gameRef.loadSprite('game/platform_monitor_broken.png'),
    };

    current = BrokenPlatformState.cracked;
    size = Vector2(115, 84);
  }

  void breakPlatform() {
    current = BrokenPlatformState.broken;
  }
}                                                                     // ... to here.

a3c16fc17be25f6c.png添加 SpringState 枚举和 SpringBoard 类:

lib/game/sprites/platform.dart

enum SpringState { down, up }                                // Add lines from here...

class SpringBoard extends Platform<SpringState> {
  SpringBoard({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <SpringState, Sprite>{
      SpringState.down:
          await gameRef.loadSprite('game/platform_trampoline_down.png'),
      SpringState.up:
          await gameRef.loadSprite('game/platform_trampoline_up.png'),
    };

    current = SpringState.up;

    size = Vector2(100, 45);
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isCollidingVertically) {
      current = SpringState.down;
    }
  }

  @override
  void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);

    current = SpringState.up;
  }
}                                                                      // ... to here.

接下来,在 ObjectManager 中启用特殊平台。为凸显特殊性,您并不希望在游戏中始终都能看到特殊平台,因此为它们设置有条件的生成几率:SpringBoard 的几率为 15%,BrokenPlatform 的几率为 10%。

a3c16fc17be25f6c.pngObjectManager_semiRandomPlatform 方法内部,在返回 NormalPlatform 的语句前面,添加以下代码,以根据条件返回一个特殊平台:

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {
   if (specialPlatforms['spring'] == true &&                 // Add lines from here...
       probGen.generateWithProbability(15)) {
     return SpringBoard(position: position);
   }

   if (specialPlatforms['broken'] == true &&
       probGen.generateWithProbability(10)) {
     return BrokenPlatform(position: position);
   }                                                                   // ... to here.

   return NormalPlatform(position: position);
}

在一定程度上,游戏乐趣在于升级时解锁新的挑战和功能。

您希望从级别 1 开始就启用跳板,但当 Dash 达到级别 2 之后,游戏就会解锁 BrokenPlatform,以增加游戏的难度。

a3c16fc17be25f6c.pngObjectManager 类中,修改 enableLevelSpecialty 方法(当前为桩),在其中添加一个 switch 语句,为级别 1 启用 SpringBoard 平台,并为级别 2 启用 BrokenPlatform 平台:

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {                                           // Add lines from here...
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
  }                                                                    // ... to here.
}

a3c16fc17be25f6c.png接下来,让平台能够在水平方向上左右移动。在 Platform 抽象类中**,**添加以下 _move 方法:

lib/game/sprites/platform.dart

void _move(double dt) {
    if (!isMoving) return;

    final double gameWidth = gameRef.size.x;

    if (position.x <= 0) {
      direction = 1;
    } else if (position.x >= gameWidth - size.x) {
      direction = -1;
    }

    _velocity.x = direction * speed;

    position += _velocity * dt;
}

当处于移动状态的平台到达游戏画面的边缘时,它会改变为向相反的方向移动。计算平台位置的方法与计算 Dash 位置的方法相同。_direction 与平台 speed 相乘即可得出速度。然后,将速度乘以 time-elapsed,并将所得距离与平台的当前 position 相加。

a3c16fc17be25f6c.png覆盖 Platform 类的 update 方法以调用 _move 方法:

lib/game/sprites/platform.dart

@override
void update(double dt) {
  _move(dt);
  super.update(dt);
}

a3c16fc17be25f6c.png若要触发处于移动状态的 Platform,请在 onLoad 方法中,以 20% 的几率随机将 isMoving 布尔值设置为 true

lib/game/sprites/platform.dart

@override
Future<void>? onLoad() async {
  await super.onLoad();

  await add(hitbox);

  final int rand = Random().nextInt(100);                            // Add this line
  if (rand > 80) isMoving = true;                                    // Add this line
}

a3c16fc17be25f6c.png最后,在 Player 中,修改 Player 类的 onCollision 方法,以识别与 SpringboardBrokenPlatform 的碰撞。请注意,SpringBoard 会以 2 倍的速度调用 jump,而 BrokenPlatform 仅在其状态为 .cracked 时才会调用 jump,在已经完成跳跃的 .broken 状态下则不会进行调用:

lib/game/sprites/player.dart

@override
 void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);

   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     } else if (other is SpringBoard) {                      // Add lines from here...
       jump(specialJumpSpeed: jumpSpeed * 2);
       return;
     } else if (other is BrokenPlatform &&
         other.current == BrokenPlatformState.cracked) {
       jump();
       other.breakPlatform();
       return;
     }                                                                 // ... to here.
   }
 }

a3c16fc17be25f6c.png 重新启动应用。开始游戏即可看到处于移动状态的平台、SpringBoardBrokenPlatform

d4949925e897f665.gif

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请使用以下链接中提供的代码恢复到正常状态。

8. 游戏失败

此步骤将为 Doodle Dash 游戏添加失败条件。以下两种方式会导致玩家失败:

  1. Dash 错过一个平台,并掉到屏幕底部以下。
  2. Dash 与 Enemy 平台发生碰撞。

在实现任一“游戏结束”条件之前,您需要添加将 DoodleDash 游戏状态设置为 gameOver 的逻辑。

a3c16fc17be25f6c.pngDoodleDash 类中**,**添加一个 onLose 方法,以便在游戏结束时调用。该方法会设置游戏状态,从屏幕中移除玩家,并激活**Game Over**菜单/叠加层。

lib/game/sprites/doodle_dash.dart

 void onLose() {                                             // Add lines from here...
    gameManager.state = GameState.gameOver;
    player.removeFromParent();
    overlays.add('gameOverOverlay');
  }                                                                    // ... to here.

Game Over 菜单:

6a79b43f4a1f780d.png

a3c16fc17be25f6c.pngDoodleDashupdate 方法顶部,添加以下代码,以便在游戏状态为 GameOver 时停止游戏更新:

lib/game/sprites/doodle_dash.dart

@override
 void update(double dt) {
   super.update(dt);

   if (gameManager.isGameOver) {                             // Add lines from here...
     return;
   }                                                                   // ... to here.
   ...
}

a3c16fc17be25f6c.png此外,在 update 方法中,当玩家掉落到屏幕底部以下时调用 onLose

lib/game/sprites/doodle_dash.dart

@override
 void update(double dt) {
   ...

   if (gameManager.isPlaying) {
     checkLevelUp();

     final Rect worldBounds = Rect.fromLTRB(
       0,
       camera.position.y - screenBufferSpace,
       camera.gameSize.x,
       camera.position.y + _world.size.y,
     );
     camera.worldBounds = worldBounds;
     if (player.isMovingDown) {
       camera.worldBounds = worldBounds;
     }

     var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
     if (!player.isMovingDown && isInTopHalfOfScreen) {
       camera.followComponent(player);
     }

                                                             // Add lines from here...
     if (player.position.y >
         camera.position.y +
             _world.size.y +
             player.size.y +
             screenBufferSpace) {
       onLose();
     }                                                                 // ... to here.
   }
 }

敌人具有各种不同的形状和大小;在 Doodle Dash 中,敌人的形态为垃圾桶或错误文件夹图标。玩家应避免与任一敌人发生碰撞,因为这会导致游戏立即结束。

Enemy

a3c16fc17be25f6c.png添加 EnemyPlatformState 枚举和 EnemyPlatform 类以创建敌人平台类型:

lib/game/sprites/platform.dart

enum EnemyPlatformState { only }                             // Add lines from here...

class EnemyPlatform extends Platform<EnemyPlatformState> {
  EnemyPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    var randBool = Random().nextBool();
    var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';

    sprites = <EnemyPlatformState, Sprite>{
      EnemyPlatformState.only:
          await gameRef.loadSprite('game/$enemySprite.png'),
    };

    current = EnemyPlatformState.only;

    return super.onLoad();
  }
}                                                                      // ... to here.

EnemyPlatform 类扩展了 Platform 超类型。ObjectManager 会生成和管理敌人平台,就像生成和管理所有其他平台一样。

a3c16fc17be25f6c.pngObjectManager 中,添加以下代码以生成和管理敌人平台:

lib/game/managers/object_manager.dart

final List<EnemyPlatform> _enemies = [];                    // Add lines from here...
void _maybeAddEnemy() {
  if (specialPlatforms['enemy'] != true) {
    return;
  }
  if (probGen.generateWithProbability(20)) {
    var enemy = EnemyPlatform(
      position: Vector2(_generateNextX(100), _generateNextY()),
    );
    add(enemy);
    _enemies.add(enemy);
    _cleanupEnemies();
  }
}

void _cleanupEnemies() {
  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;

  while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
    remove(_enemies.first);
    _enemies.removeAt(0);
  }
}                                                                      // ... to here.

ObjectManager 保存敌人对象列表 _enemies_maybeAddEnemy 以 20% 的几率生成敌人,并将对象添加到敌人列表中。_cleanupEnemies() 方法移除不再可见的过时 EnemyPlatform 对象。

a3c16fc17be25f6c.pngObjectManagerupdate 方法中调用 _maybeAddEnemy() 以生成敌人平台:

lib/game/managers/object_manager.dart

@override
void update(double dt) {
  final topOfLowestPlatform =
      _platforms.first.position.y + _tallestPlatformHeight;

  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;
  if (topOfLowestPlatform > screenBottom) {
    var newPlatY = _generateNextY();
    var newPlatX = _generateNextX(100);
    final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
    add(nextPlat);

    _platforms.add(nextPlat);
    gameRef.gameManager.increaseScore();

    _cleanupPlatforms();
    _maybeAddEnemy();                                                 // Add this line
  }

  super.update(dt);
}

a3c16fc17be25f6c.pngPlayeronCollision 方法中添加内容,以检查是否与 EnemyPlatform 发生碰撞。如果发生碰撞,则调用 onLose() 方法。

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform) {                           // Add lines from here...
      gameRef.onLose();
      return;
    }                                                                 // ... to here.

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }
  }

a3c16fc17be25f6c.png最后,修改 ObjectManagerenableLevelSpecialty 方法,在 switch 语句中添加级别 5:

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
    case 5:                                                  // Add lines from here...
      enableSpecialty('enemy');
      break;                                                           // ... to here.
  }
}

a3c16fc17be25f6c.png 您已经增加了游戏的挑战性。接下来,请热重载 7f9a9e103c7b5e5.png 以启用更改。(保存文件,然后使用 IDE 中的按钮,或者从命令行输入 r 以热重载。):

请注意那些破碎的文件夹敌人。它们很狡猾。它们与背景融为一体!

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请使用以下链接中提供的代码恢复到正常状态。

9. 战力提升道具

此步骤将添加一些增强的游戏功能,以便在整个游戏过程中提升 Dash 的战力。Doodle Dash 有两种战力提升道具:Google 员工帽和火箭飞船。您可以将这些战力提升道具视为另一种特殊平台。当 Dash 在游戏中跳跃时,如果碰撞并拥有 Google 员工帽或火箭飞船道具,其速度会加快。

NooglerHat

Rocket

当玩家得分 >= 40 后,即在级别 3 后,游戏会生成 Google 员工帽。当与 Google 员工帽发生碰撞时,Dash 会戴上 Google 员工帽,其速度会提升到正常水平的 2.5 倍。这将持续 5 秒。

当玩家得分 >= 80 后,即在级别 4 后,游戏会生成火箭。当 Dash 与火箭发生碰撞时,其精灵将变为火箭飞船的形态,并且其速度会提升到正常水平的 3.5 倍,直到其降落在某个平台上。另一项效果是,当拥有火箭道具时,Dash 面对敌人将处于无敌状态。

NooglerHat 和 Rocket 精灵扩展了 PowerUp 抽象类。与 Platform 抽象类一样,PowerUp 抽象类(如下所示)也包括大小调整和碰撞框。

lib/game/sprites/powerup.dart

abstract class PowerUp extends SpriteComponent
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  double get jumpSpeedMultiplier;

  PowerUp({
    super.position,
  }) : super(
          size: Vector2.all(50),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    await add(hitbox);
  }
}

a3c16fc17be25f6c.png 创建一个扩展 PowerUp 抽象类的 Rocket 类。当与火箭发生碰撞时,Dash 的速度将提升到正常水平的 3.5 倍。

lib/game/sprites/powerup.dart

class Rocket extends PowerUp {                               // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 3.5;

  Rocket({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/rocket_1.png');
    size = Vector2(50, 70);
  }
}                                                                      // ... to here.

a3c16fc17be25f6c.png 创建一个扩展 PowerUp 抽象类的 NooglerHat 类。当与 NooglerHat 发生碰撞时,Dash 的速度会提升到正常水平的 2.5 倍。加速状态将持续 5 秒。

lib/game/sprites/powerup.dart

class NooglerHat extends PowerUp {                          // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 2.5;

  NooglerHat({
    super.position,
  });

  final int activeLengthInMS = 5000;

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/noogler_hat.png');
    size = Vector2(75, 50);
  }
}                                                                      // ... to here.

现在,您已经实现了 NooglerHatRocket 战力提升道具,更新 ObjectManager 以在游戏中生成这些道具。

a3c16fc17be25f6c.png 修改 ObjectManger 类以添加一个列表来保存生成的战力提升道具,并添加 _maybePowerup_cleanupPowerups 这两个新方法,分别用于生成和移除新的战力提升道具平台。

lib/game/managers/object_manager.dart

final List<PowerUp> _powerups = [];                          // Add lines from here...

 void _maybeAddPowerup() {
   if (specialPlatforms['noogler'] == true &&
       probGen.generateWithProbability(20)) {
     var nooglerHat = NooglerHat(
       position: Vector2(_generateNextX(75), _generateNextY()),
     );
     add(nooglerHat);
     _powerups.add(nooglerHat);
   } else if (specialPlatforms['rocket'] == true &&
       probGen.generateWithProbability(15)) {
     var rocket = Rocket(
       position: Vector2(_generateNextX(50), _generateNextY()),
     );
     add(rocket);
     _powerups.add(rocket);
   }

   _cleanupPowerups();
 }

 void _cleanupPowerups() {
   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;
   while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
     if (_powerups.first.parent != null) {
       remove(_powerups.first);
     }
     _powerups.removeAt(0);
   }
 }                                                                     // ... to here.

_maybeAddPowerup 方法会以 20% 的几率生成 Google 员工帽,或以 15% 的几率生成火箭。系统会调用 _cleanupPowerups 方法以移除屏幕底部边界下方的战力提升道具。

a3c16fc17be25f6c.png 修改 ObjectManager update 方法以在游戏循环的每个 tick 调用 _maybePowerup

lib/game/managers/object_manager.dart

@override
  void update(double dt) {
    final topOfLowestPlatform =
        _platforms.first.position.y + _tallestPlatformHeight;

    final screenBottom = gameRef.player.position.y +
        (gameRef.size.x / 2) +
        gameRef.screenBufferSpace;

    if (topOfLowestPlatform > screenBottom) {
      var newPlatY = _generateNextY();
      var newPlatX = _generateNextX(100);
      final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
      add(nextPlat);

      _platforms.add(nextPlat);

      gameRef.gameManager.increaseScore();

      _cleanupPlatforms();
      _maybeAddEnemy();
      _maybeAddPowerup();                                            // Add this line
    }

    super.update(dt);
  }

a3c16fc17be25f6c.png修改 enableLevelSpecialty 方法,在 switch 语句中添加两种新情况:一种是在级别 3 时启用 NooglerHat,另一种是在级别 4 时启用 Rocket

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
    switch (level) {
      case 1:
        enableSpecialty('spring');
        break;
      case 2:
        enableSpecialty('broken');
        break;
      case 3:                                               // Add lines from here...
        enableSpecialty('noogler');
        break;
      case 4:
        enableSpecialty('rocket');
        break;                                                        // ... to here.
      case 5:
        enableSpecialty('enemy');
        break;
    }
  }

a3c16fc17be25f6c.png 将以下布尔值 getter 添加到 Player 类。如果拥有处于活动状态的战力提升道具,Dash 会呈现为各种不同的状态。这些 getter 可以更加轻松地检查哪个战力提升道具处于活动状态。

lib/game/sprites/player.dart

 bool get hasPowerup =>                                      // Add lines from here...
     current == PlayerState.rocket ||
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;

 bool get isInvincible => current == PlayerState.rocket;

 bool get isWearingHat =>
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;                             // ... to here.

a3c16fc17be25f6c.png修改 PlayeronCollision 方法,以使用 NooglerHatRocket 对碰撞做出响应。此代码还将确保 Dash 仅在未拥有战力提升道具的情况下才会激活新的战力提升道具。

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform && !isInvincible) {
      gameRef.onLose();
      return;
    }

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }

    if (!hasPowerup && other is Rocket) {                    // Add lines from here...
      current = PlayerState.rocket;
      other.removeFromParent();
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    } else if (!hasPowerup && other is NooglerHat) {
      if (current == PlayerState.center) current = PlayerState.nooglerCenter;
      if (current == PlayerState.left) current = PlayerState.nooglerLeft;
      if (current == PlayerState.right) current = PlayerState.nooglerRight;
      other.removeFromParent();
      _removePowerupAfterTime(other.activeLengthInMS);
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    }                                                                  // ... to here.
  }

如果 Dash 与火箭发生碰撞,则 PlayerState 会更改为 Rocket,并让 Dash 能够以 3.5 倍的 jumpSpeedMultiplier 进行跳跃。

如果 Dash 与 Google 员工帽发生碰撞,根据当前的 PlayerState 方向(.center.left.right),PlayerState 会更改为相应的Google 员工帽 PlayerState,同时 Dash 会戴上 Google 员工帽,其速度将提升到 jumpSpeedMultiplier 的 2.5 倍。_removePowerupAfterTime 方法会在 5 秒后移除战力提升道具,并将 PlayerState 从战力提升状态更改回 center

调用 other.removeFromParent 会从屏幕上移除 Google 员工帽或火箭精灵平台,以反映 Dash 已获得战力提升道具。

ede04fdfe074f471.gif

a3c16fc17be25f6c.png修改 Player 类的 moveLeftmoveRight 方法以考虑 NooglerHat 精灵。您不需要考虑 Rocket 战力提升道具,因为无论行进方向如何,该精灵都面向相同的方向。

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerLeft;
   } else if (!hasPowerup) {                                           // ... to here.
     current = PlayerState.left;
   }                                                                  // Add this line
   _hAxisInput += movingLeftInput;
 }

 void moveRight() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerRight;
   } else if (!hasPowerup) {                                            //... to here.
     current = PlayerState.right;
   }                                                                  // Add this line
   _hAxisInput += movingRightInput;
 }

在拥有 Rocket 战力提升道具时,Dash 面对敌人将处于无敌状态,因此在这段时间内不会结束游戏。

a3c16fc17be25f6c.png修改 onCollision 回调,以便在因与 EnemyPlatform 发生碰撞而触发游戏结束之前检查 Dahs 是否处于 isInvincible 状态:

lib/game/sprites/player.dart

   if (other is EnemyPlatform && !isInvincible) {                 // Modify this line
     gameRef.onLose();
     return;
   }

a3c16fc17be25f6c.png 重新启动应用并体验游戏,以查看战力提升道具的实际效果。

e1fece51429dae55.gif

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请使用以下链接中提供的代码恢复到正常状态。

10. 叠加层

可以将 Flame 游戏封装在一个 widget 中,以便轻松将其与 Flutter 应用中的其他 widget 相集成。您还可以将 Flutter widget 显示为 Flame 游戏之上的叠加层。这可以方便地管理不依赖于游戏循环的非游戏组件,例如菜单、暂停屏幕、按钮和滑块。

在游戏中的得分显示以及 Doodle Dash 中的所有菜单都是常规 Flutter widget,而不是 Flame 组件。所有 widget 的代码均位于 lib/game/widgets,例如 Game Over 菜单只是一个包含其他 widget(例如 TextElevatedButton)的列,如以下代码所示:

lib/game/widgets/game_over_overlay.dart

class GameOverOverlay extends StatelessWidget {
 const GameOverOverlay(this.game, {super.key});

 final Game game;

 @override
 Widget build(BuildContext context) {
   return Material(
     color: Theme.of(context).colorScheme.background,
     child: Center(
       child: Padding(
         padding: const EdgeInsets.all(48.0),
         child: Column(
           mainAxisAlignment: MainAxisAlignment.center,
           crossAxisAlignment: CrossAxisAlignment.center,
           children: [
             Text(
               'Game Over',
               style: Theme.of(context).textTheme.displayMedium!.copyWith(),
             ),
             const WhiteSpace(height: 50),
             ScoreDisplay(
               game: game,
               isLight: true,
             ),
             const WhiteSpace(
               height: 50,
             ),
             ElevatedButton(
               onPressed: () {
                 (game as DoodleDash).resetGame();
               },
               style: ButtonStyle(
                 minimumSize: MaterialStateProperty.all(
                   const Size(200, 75),
                 ),
                 textStyle: MaterialStateProperty.all(
                     Theme.of(context).textTheme.titleLarge),
               ),
               child: const Text('Play Again'),
             ),
           ],
         ),
       ),
     ),
   );
 }
}

如需在 Flame 游戏中将 widget 用作叠加层,请在 GameWidget 上定义一个 overlayBuilderMap 属性,其中 key 表示叠加层(作为 String),并通过 widget 函数的 value 返回一个 widget,如以下代码所示:

lib/main.dart

GameWidget(
  game: game,
  overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
    'gameOverlay': (context, game) => GameOverlay(game),
    'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
    'gameOverOverlay': (context, game) => GameOverOverlay(game),
  },
)

添加后,即可在游戏中的任何位置使用叠加层。使用 overlays.add 显示叠加层,并使用 overlays.remove 隐藏叠加层,如以下代码所示:

lib/game/doodle_dash.dart

void resetGame() {
   startGame();
   overlays.remove('gameOverOverlay');
 }

 void onLose() {
   gameManager.state = GameState.gameOver;
   player.removeFromParent();
   overlays.add('gameOverOverlay');
 }

11. 移动平台支持

Doodle Dash 基于 Flutter 和 Flame 构建,因此已经可以在 Flutter 支持的各种平台上运行。但是,到目前为止,Doodle Dash 仅支持基于键盘的输入。对于没有键盘的设备(如手机),可以轻松地将屏幕触控按钮添加到叠加层。

a3c16fc17be25f6c.png 将布尔值状态变量添加到 GameOverlay,后者用于确定游戏是否在移动平台上运行:

lib/game/widgets/game_overlay.dart

class GameOverlayState extends State<GameOverlay> {
 bool isPaused = false;

                                                                      // Add this line
 final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);

 @override
 Widget build(BuildContext context) {
   ...
 }
}

现在,当游戏在移动设备上运行时,叠加层中会显示左右方向按钮。与第 4 步中的“按键事件”逻辑相类似,点击左方向按钮会将 Dash 向左移动。点击右方向按钮会将 Dash 向右移动。

a3c16fc17be25f6c.pngGameOverlaybuild 方法中,添加一个 isMobile 部分,该部分遵循与第 4 步相同的行为:点击左方向按钮会调用 moveLeft,点击右方向按钮会调用 moveRight。释放任一按钮会调用 resetDirection,并导致 Dash 在水平方向上待定。

lib/game/widgets/game_overlay.dart

@override
 Widget build(BuildContext context) {
   return Material(
     color: Colors.transparent,
     child: Stack(
       children: [
         Positioned(... child: ScoreDisplay(...)),
         Positioned(... child: ElevatedButton(...)),
         if (isMobile)                                       // Add lines from here...
           Positioned(
             bottom: MediaQuery.of(context).size.height / 4,
             child: SizedBox(
               width: MediaQuery.of(context).size.width,
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                 children: [
                   Padding(
                     padding: const EdgeInsets.only(left: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveLeft();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_left, size: 64),
                       ),
                     ),
                   ),
                   Padding(
                     padding: const EdgeInsets.only(right: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveRight();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_right, size: 64),
                       ),
                     ),
                   ),
                 ],
               ),
             ),
           ),                                                          // ... to here.
         if (isPaused)
           ...
       ],
     ),
   );
 }

大功告成!现在,Doodle Dash 应用会自动检测其运行的平台类型,并相应地切换其输入。

a3c16fc17be25f6c.png 在 iOS 或 Android 上运行应用以查看方向按钮的实际效果。

7b0cac5fb69bc89.gif

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请使用以下链接中提供的代码恢复到正常状态。

12. 后续步骤

恭喜!

您已完成此 Codelab 课程,并学习了如何使用 Flame 游戏引擎在 Flutter 中创建游戏。

所学内容:

  • 如何使用 Flame 软件包创建平台游戏,包括:
  • 添加角色
  • 添加多种平台类型
  • 实现碰撞检测
  • 添加重力组件
  • 定义相机移动
  • 创建敌人
  • 创建战力提升道具
  • 如何检测游戏运行所在的平台以及…
  • 如何使用该信息在键盘与触控输入控件之间切换

资源

我们希望您已经了解了关于如何在 Flutter 中创建游戏的更多知识!

您可能还会发现以下资源很有帮助,甚至启发灵感: