Flame 与 Flutter 简介

1. 简介

Flame 是一个基于 Flutter 的 2D 游戏引擎。在此 Codelab 中,您将根据 70 年代的经典视频游戏之一 Steve Wozniak 的 Breakout,构建一款游戏。您将使用 Flame 的组件来绘制球棒、球和积木。您将利用 Flame 的“效果”功能为蝙蝠的运动添加动画效果,并了解如何将 Flame 与 Flutter 的状态管理系统相集成。

完成后,您的游戏应看起来像这张 GIF 动画,只是速度有点慢。

正在录制的游戏屏幕录像。游戏速度明显加快。

学习内容

  • GameWidget 开始,Flame 的基本工作原理。
  • 如何使用游戏循环。
  • Flame 的 Component 的工作原理。它们类似于 Flutter 的 Widget
  • 如何处理冲突。
  • 如何使用 EffectComponent 添加动画效果。
  • 如何在 Flame 游戏上叠加 Flutter Widget
  • 如何将 Flame 与 Flutter 的状态管理集成。

构建内容

在此 Codelab 中,您将使用 Flutter 和 Flame 构建一个 2D 游戏。完成后,您的游戏应满足以下要求

  • 可在 Flutter 支持的所有六个平台上运行:Android、iOS、Linux、macOS、Windows 和 Web
  • 使用 Flame 的游戏循环保持至少 60 fps。
  • 使用 google_fonts 软件包和 flutter_animate 等 Flutter 功能重现 80 年代街机游戏的风格。

2. 设置您的 Flutter 环境

编辑者

为简化此 Codelab,它假定 Visual Studio Code (VS Code) 是您的开发环境。VS Code 是免费的,适用于所有主要平台。在此 Codelab 中,我们使用 VS Code,因为相关说明默认为 VS Code 专用快捷键。这些任务变得更简单明了:“点击此按钮”或者“按此键执行 X 操作”而不是“在您的编辑器中执行适当的操作以执行 X”。

您可以使用您喜欢的任何编辑器:Android Studio、其他 IntelliJ IDE、Emacs、Vim 或 Notepad++。它们都使用 Flutter。

包含一些 Flutter 代码的 VS Code 的屏幕截图

选择目标开发平台

Flutter 适用于多个平台。您的应用可以在以下任何操作系统上运行:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • 网络

通常的做法是选择一个操作系统作为目标操作系统。这是您的应用在开发过程中所运行的操作系统。

描绘了通过数据线将笔记本电脑和手机连接到笔记本电脑的绘图。该笔记本电脑上标有

例如:假设您使用一台 Windows 笔记本电脑开发您的 Flutter 应用。然后,选择 Android 作为您的开发目标。要预览应用,请使用 USB 线将 Android 设备连接到 Windows 笔记本电脑,然后开发中的应用在连接的 Android 设备或 Android 模拟器上运行。您可以选择 Windows 作为目标开发平台,它会将开发中的应用作为 Windows 应用与编辑器一起运行。

您可能会想要选择 Web 作为目标开发平台。这在开发过程中有一个缺点:您会失去 Flutter 的有状态热重载功能。Flutter 目前无法热重载 Web 应用。

请先做出选择,然后再继续。之后,您始终可以在其他操作系统上运行您的应用。选择目标开发平台会使后续步骤更加顺利。

安装 Flutter

有关安装 Flutter SDK 的最新说明,请参阅 docs.flutter.dev

Flutter 网站上的说明涵盖了 SDK 的安装、与目标开发平台相关的工具和编辑器插件。对于此 Codelab,请安装以下软件:

  1. Flutter SDK
  2. 随带 Flutter 插件的 Visual Studio Code
  3. 适用于所选开发目标的编译器软件。(您需要 Visual Studio 以 Windows 为目标,或者以 Xcode 为目标 macOS 或 iOS)

在下一节中,您将创建您的第一个 Flutter 项目。

如果您需要排查任何问题,以下来自 StackOverflow 的一些问题和解答可能有助于您排查问题。

常见问题解答

3. 创建项目

创建您的第一个 Flutter 项目

这需要打开 VS Code,并在您选择的目录中创建 Flutter 应用模板。

  1. 启动 Visual Studio Code。
  2. 打开命令面板(F1Ctrl+Shift+PShift+Cmd+P),然后输入“flutter new”。出现该窗口时,选择 Flutter: New Project 命令。

VS Code 的屏幕截图,其中显示了

  1. 选择 Empty Application(清空应用)。选择要在其中创建项目的目录。该目录应该是任何不需要提升权限或路径中含有空格的目录。例如您的主目录或 C:\src\

VS Code 屏幕截图,其中包含在新应用流程中选中的“Empty Application”(空白应用)

  1. 将您的项目命名为 brick_breaker。此 Codelab 的其余部分假定您将应用命名为 brick_breaker

VS Code 的屏幕截图,其中显示了

现在,Flutter 会创建项目文件夹,然后在 VS Code 中打开该文件夹。现在,您将使用应用的基本基架来覆盖两个文件的内容。

复制并粘贴初始应用

这会将此 Codelab 中提供的示例代码添加到您的应用中。

  1. 在 VS Code 的左侧窗格中,点击 Explorer 并打开 pubspec.yaml 文件。

VS Code 的部分屏幕截图,其中箭头突出显示 pubspec.yaml 文件的位置

  1. 将此文件的内容替换为以下内容。

pubspec.yaml

name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

pubspec.yaml 文件指定与您的应用相关的基本信息,例如其当前版本、依赖项以及其随附的资源。

  1. 打开 lib/ 目录中的 main.dart 文件。

VS Code 的部分屏幕截图,其中包含一个显示 main.dart 文件位置的箭头

  1. 将此文件的内容替换为以下内容。

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. 运行此代码,验证一切是否正常。它应该会显示一个新窗口,并使用空白的黑色背景。世界上最差的视频游戏现在能够以 60fps 的帧速率渲染!

显示完全黑色的 brick_breaker 应用窗口的屏幕截图。

4. 创建游戏

扩大游戏规模

以二维 (2D) 模式玩的游戏需要一个游戏区域。您将构建一个特定尺寸的区域,然后使用这些尺寸确定游戏的其他方面的大小。

您可以通过多种方式在游戏区域布置坐标。按照一种惯例,您可以从屏幕中心测量方向,原点 (0,0) 位于屏幕中心,正值沿着 x 轴向右移动项目,沿 y 轴向上移动。此标准适用于目前的大多数游戏,尤其是涉及三维的游戏。

原始 Breakout 游戏制作时的惯例是在左上角设置原点。x 正方向保持不变,但 y 被翻转了。x 正方向为向右,y 向下。为了忠于那个时代,这款游戏将原点设在左上角。

在名为 lib/src 的新目录中创建一个名为 config.dart 的文件。此文件将在后续步骤中获得更多常量。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

此游戏的宽度为 820 像素,高度为 1600 像素。游戏区域会缩放以适应其显示窗口,但添加到屏幕的所有组件都符合此高度和宽度。

创建 PlayArea

在 Breakout 游戏中,球会从游戏区域的墙壁上弹跳。为应对冲突,您需要先使用 PlayArea 组件。

  1. 在名为 lib/src/components 的新目录中创建一个名为 play_area.dart 的文件。
  2. 将以下内容添加到此文件中。

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
      : super(
          paint: Paint()..color = const Color(0xfff2e8cf),
        );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Flutter 有 Widget,Flame 有 Component。在 Flutter 应用中,由创建 widget 组成的树构成,Flame 游戏由维护组件树组成。

Flutter 和 Flame 之间存在一个有趣的区别。Flutter 的 widget 树是一种临时描述,旨在用于更新持久且可变的 RenderObject 层。Flame 的组件是持久可变的,并且希望开发者将这些组件用作模拟系统的一部分。

Flame 的组件针对表达游戏机制进行了优化。此 Codelab 将从下一步中介绍游戏循环开始。

  1. 如需控制杂乱,请添加一个包含此项目中所有组件的文件。在 lib/src/components 中创建一个 components.dart 文件,并添加以下内容。

lib/src/components/components.dart

export 'play_area.dart';

export 指令与 import 相反。它用于声明在将此文件导入到另一个文件时公开的功能。随着您在下面的步骤中添加新组件,此文件会增加更多条目。

创建 Flame 游戏

如需消除上一步中的红色波浪线,请为 Flame 的 FlameGame 派生一个新的子类。

  1. lib/src 中创建名为 brick_breaker.dart 的文件,并添加以下代码。

lib/src/brick_breaker.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

此文件用于协调游戏的操作。在构建游戏实例期间,此代码会将游戏配置为使用固定分辨率进行渲染。游戏会调整大小以填满包含它的屏幕,并根据需要添加信箱模式

您可以公开游戏的宽度和高度,以便子组件(如 PlayArea)能够自行设置为适当的尺寸。

onLoad 替换的方法中,您的代码会执行两项操作。

  1. 将左上角配置为取景器的锚点。默认情况下,取景器使用该区域的中间作为 (0,0) 的锚点。
  2. PlayArea 添加到 world。世界代表着游戏世界。它通过 CameraComponent 视图转换投影其所有子级。

将游戏搬到屏幕上

如需查看您在此步骤中做出的所有更改,请使用以下更改更新您的 lib/main.dart 文件。

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

import 'src/brick_breaker.dart';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

完成这些更改后,重启游戏。游戏应如下图所示。

一个屏幕截图,显示了 brick_breaker 应用窗口,应用窗口中间有一个沙色矩形

在下一步中,您将向世界添加一个球,并让它动起来!

5. 看球

创建球组件

要将一个移动的球放到屏幕上,还需要创建另一个组件并将其添加到游戏世界中。

  1. 按如下方式修改 lib/src/config.dart 文件的内容。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

在此 Codelab 中,将命名常量定义为派生值的设计模式将多次返回。这样,您就可以修改顶级 gameWidthgameHeight,以探索游戏外观和风格的变化。

  1. lib/src/components 中名为 ball.dart 的文件中创建 Ball 组件。

lib/src/components/ball.dart

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

class Ball extends CircleComponent {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill);

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }
}

之前,您使用 RectangleComponent 定义了 PlayArea,因此它也是存在更多形状的原因。与 RectangleComponent 一样,CircleComponent 派生自 PositionedComponent,因此您可以将球放置在屏幕上。更重要的是,其位置可以更新。

此组件引入了 velocity 的概念,即位置随时间发生的变化。速度是一个 Vector2 对象,因为速度是指速度和方向。如需更新位置,请替换游戏引擎为每个帧调用的 update 方法。dt 是上一帧与这一帧之间的时长。这样一来,您就可以适应不同帧速率(60hz 或 120hz)或因计算量过大而导致的长帧等因素。

请密切关注 position += velocity * dt 更新。这就是实现随时间更新离散运动模拟的方式。

  1. 如需在组件列表中添加 Ball 组件,请按如下方式修改 lib/src/components/components.dart 文件。

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

向全世界添加球

您获得了一个球。我们将这个设备放置在现实世界中,然后将其设置为在游乐区内移动。

按如下方式修改 lib/src/brick_breaker.dart 文件。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math; // Add this import

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(                                             // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    debugMode = true;                                           // To here.
  }
}

此更改会将 Ball 组件添加到 world 中。为了将球的 position 设置为显示区域的中心,代码会先将游戏大小减半,因为 Vector2 包含运算符过载(*/),以按标量值缩放 Vector2

设置球的 velocity 涉及更复杂的操作。这样做的目的是以合理的速度将球按随机方向在屏幕上向下移动。调用 normalized 方法会创建一个 Vector2 对象,该对象的方向与原始 Vector2 相同,但会缩小到 1 的距离。这样,无论球向哪个方向移动,球的速度都始终相同。然后,系统会将球的速度放大到游戏高度的 1/4。

要正确设定这些不同的价值,需要反复改进,在业内也称为游戏测试。

最后一行代码会打开调试显示屏,从而向显示屏添加额外信息以帮助进行调试。

现在,游戏运行后,您应该会看到类似下图所示的内容。

屏幕截图:一个打砖块应用窗口,在沙色矩形的顶部有一个蓝色圆圈。蓝色圆圈标注有数字,表示其大小和在屏幕上的位置

PlayArea 组件和 Ball 组件都包含调试信息,但背景遮罩会剪裁 PlayArea 的数字。之所以显示所有调试信息,是因为您为整个组件树启用了 debugMode。您也可以只对选定的组件开启调试功能(如果这样更有用)。

如果您多次重新开始游戏,您可能会注意到球无法像预期的那样与墙壁互动。要实现这种效果,您需要添加碰撞检测,您将在下一步中执行此操作。

6. 四处弹跳

添加碰撞检测

碰撞检测添加了一项行为,让您的游戏可以在两个对象彼此接触时识别该行为。

如需在游戏中添加碰撞检测,请将 HasCollisionDetection mixin 添加到 BrickBreaker 游戏中,如以下代码所示。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    debugMode = true;
  }
}

这样可以跟踪组件的碰撞框,并在每个游戏 tick 中触发碰撞回调。

如需开始填充游戏的碰撞框,请修改 PlayArea 组件,如下所示。

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
      : super(
          paint: Paint()..color = const Color(0xfff2e8cf),
          children: [RectangleHitbox()],                        // Add this parameter
        );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

RectangleHitbox 组件添加为 RectangleComponent 的子级后,系统会构建一个用于检测碰撞的碰撞框,该碰撞框与父级组件的尺寸相匹配。RectangleHitbox 有一个名为 relative 的工厂构造函数,适用于需要比父组件更小或更大的碰撞框。

弹跳球

到目前为止,添加碰撞检测对游戏内容没有任何影响。一旦您修改了 Ball 组件,它的确会发生变化。在球与 PlayArea 发生碰撞时,必须更改球的行为。

按如下方式修改 Ball 组件。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);                        // Add this parameter

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override                                                     // Add from here...
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

此示例通过添加 onCollisionStart 回调进行了重大更改。在前面的示例中添加到 BrickBreaker 的碰撞检测系统会调用此回调。

首先,代码测试 Ball 是否与 PlayArea 发生碰撞。目前这似乎是多余的,因为游戏世界中没有其他组件。但在下一步中,当你向这个世界添加一只蝙蝠时,这种情况将会发生变化。然后,它还添加了一个 else 条件,用于处理球与非球棒碰撞的物体。温馨提示,提醒您实现其余逻辑(如果您愿意的话)。

当球与底壁相撞时,它只会从球面上消失,但仍然位于非常大的视野范围内。您将在后续步骤中利用 Flame 的效果功能来处理此工件。

现在,让球与游戏的墙壁相撞,为玩家提供一支球拍肯定会有所帮助...

7. 拿球拍

打造球拍

要在比赛中添加球棒来让球持续进行,请执行以下操作:

  1. 如下所示,在 lib/src/config.dart 文件中插入一些常量。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

batHeightbatWidth 常量不言而喻。另一方面,batStep 常量需要一些解释。若要在此游戏中与球互动,玩家可以使用鼠标或手指(取决于平台)或者使用键盘拖动球棒。batStep 常量用于配置每次按下向左键或向右键时击球键的步数。

  1. 按如下方式定义 Bat 组件类。

lib/src/components/bat.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(
          anchor: Anchor.center,
          children: [RectangleHitbox()],
        );

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
        RRect.fromRectAndRadius(
          Offset.zero & size.toSize(),
          cornerRadius,
        ),
        _paint);
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    add(MoveToEffect(
      Vector2((position.x + dx).clamp(0, game.width), position.y),
      EffectController(duration: 0.1),
    ));
  }
}

该组件引入了一些新功能。

首先,Bat 组件是一个 PositionComponent,而不是 RectangleComponentCircleComponent。这意味着此代码需要在屏幕上渲染 Bat。为此,它会替换 render 回调。

仔细观察 canvas.drawRRect(绘制圆角矩形)调用,您可能会问自己,“矩形在哪里?”Offset.zero & size.toSize() 对创建 Rectdart:ui Offset 类利用了 operator & 重载。这一简写形式可能一开始可能会让您感到困惑,但您会经常在较低级别的 Flutter 和 Flame 代码中看到它。

其次,可以使用手指或鼠标拖动此 Bat 组件,具体取决于平台。如需实现此功能,请添加 DragCallbacks mixin 并替换 onDragUpdate 事件。

最后,Bat 组件需要响应键盘控制。moveBy 函数允许其他代码指示这只蝙蝠向左或向右移动特定数量的虚拟像素。此函数引入了 Flame 游戏引擎的一项新功能:Effect。添加 MoveToEffect 对象作为此组件的子对象后,玩家会看到球棒以动画形式移动到新位置。Flame 中有一系列 Effect 可用于执行各种效果。

Effect 的构造函数参数包含对 game getter 的引用。因此,您需要在此类中添加 HasGameReference mixin。此 mixin 将为该组件添加了类型安全的 game 访问器,以访问组件树顶部的 BrickBreaker 实例。

  1. 如需使 BrickBreaker 能够使用 Bat,请按如下所示更新 lib/src/components/components.dart 文件。

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

让球棒踏上世界

如需向游戏世界添加 Bat 组件,请按如下所示更新 BrickBreaker

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(                                              // Add from here...
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));          // To here

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here
}

添加 KeyboardEvents mixin 和被替换的 onKeyEvent 方法可处理键盘输入。回顾您之前添加的代码,将球棒移动相应的步长。

所添加的其余代码块以适当的比例将球棒以适当的比例添加到游戏世界中。在这个文件中公开所有这些设置,可以简化您调整球棒和球的相对尺寸,从而获得适合比赛的感觉。

如果此时再玩游戏,您会发现,除了您在 Ball 的碰撞检测代码中留下的调试日志记录之外,您可以移动球棒拦截球,但是没有得到可见的响应。

是时候解决此问题了。按如下方式修改 Ball 组件。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(                                       // Modify from here...
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

这些代码更改解决了两个单独的问题。

首先,它修复了小球在触及屏幕底部时弹出的问题。若要解决此问题,请将 removeFromParent 调用替换为 RemoveEffectRemoveEffect 会在让球离开可见游戏区域后,将球从游戏世界中移除。

其次,这些更改解决了球拍与球之间碰撞的处理。该处理代码非常有利于玩家。只要球员用球拍触碰球,球就会回到屏幕顶部。如果这感觉让人难以接受,而您希望呈现更逼真的效果,则可以更改处理方式,使其更适合您期望的游戏体验。

有必要指出 velocity 更新的复杂性。它不只是反转速度的 y 分量,就像对墙壁碰撞所做的那样。此外,它还会根据球拍和球在接触时的相对位置来更新 x 组件。这使得玩家能够更好地控制球的行为,但玩家无法以任何其他方式(通过玩游戏)传达给玩家。

现在,您有了可以用来击打球的球棒,最好用一些砖块来击打球!

8. 打破墙壁

创建积木

要在游戏中添加积木,请执行以下操作:

  1. 如下所示,在 lib/src/config.dart 文件中插入一些常量。

lib/src/config.dart

import 'package:flutter/material.dart';                         // Add this import

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1)))
    / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. 按如下方式插入 Brick 组件。

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

现在,您应该已经熟悉此代码的大部分内容。此代码使用 RectangleComponent,同时在组件树的顶部具有碰撞检测和对 BrickBreaker 游戏的类型安全引用。

此代码引入的最重要的新概念是玩家如何实现获胜条件。获胜条件检查向全世界查询积木,并确认仅剩一块积木。这可能有点令人困惑,因为前面的代码行将这个积木从其父项中移除。

需要了解的要点是,组件移除是一个排队的命令。它会在此代码运行之后、游戏世界的下一个 tick 之前移除积木。

如需使 BrickBreaker 可以访问 Brick 组件,请按如下方式修改 lib/src/components/components.dart

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

向世界添加积木

更新 Ball 组件,如下所示。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';                                            // Add this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,                           // Add this parameter
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {                                // Modify from here...
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);          // To here.
    }
  }
}

这引入了唯一的新方面,即难度调节系数,可在每次砖块碰撞后增加球的速度。您需要对此可调参数进行测试,以找到适合您游戏的相应难度曲线。

按如下方式修改 BrickBreaker 游戏。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }
}

如果您按目前的方式运行游戏,它会显示所有关键游戏机制。您可以关闭调试并调用它,但感觉缺少了一些内容。

屏幕截图:打砖块,打破了球,球棒和游戏区域大部分砖块。每个组件都有调试标签

看看欢迎屏幕、游戏结束屏幕,甚至比分,怎么样?Flutter 可以将这些功能添加到游戏中,而这正是您接下来要关注的重点。

9. 赢得游戏

添加播放状态

在此步骤中,您将在 Flutter 封装容器中嵌入 Flame 游戏,然后为欢迎界面、游戏结束界面和胜出界面添加 Flutter 叠加层。

首先,您需要修改游戏和组件文件以实现某种游戏状态,该状态反映了是否显示叠加层。如果显示,则显示哪个叠加层。

  1. 按如下方式修改 BrickBreaker 游戏。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }              // Add this enumeration

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {   // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;                                    // Add from here...
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }                                                             // To here.

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;                              // Add from here...
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;                              // To here.

    world.add(Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    world.addAll([                                              // Drop the await
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }                                                             // Drop the debugMode

  @override                                                     // Add from here...
  void onTap() {
    super.onTap();
    startGame();
  }                                                             // To here.

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:                            // Add from here...
      case LogicalKeyboardKey.enter:
        startGame();                                            // To here.
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);          // Add this override
}

此代码很大程度上改变了 BrickBreaker 游戏。添加 playState 枚举需要完成大量工作。此字段可捕获玩家进入游戏、玩游戏以及输赢游戏的位置。在文件顶部,定义枚举,然后使用匹配的 getter 和 setter 将其实例化为隐藏状态。利用这些 getter 和 setter,您可以在游戏的各个部分触发游戏状态转换时修改叠加层。

接下来,您将 onLoad 中的代码拆分为 onLoad 和一个新的 startGame 方法。在此次变更之前,您只能通过重启游戏来开始新游戏。有了这些新增的游戏内容,玩家现在可以直接开始新的游戏,无需如此严厉的措施。

要允许玩家开始新游戏,您为游戏配置了两个新的处理程序。您添加了一个点按处理程序并扩展了键盘处理程序,让用户能够以多种模式启动新游戏。对游戏状态建模时,有必要更新组件,以便在玩家获胜或失败时触发播放状态转换。

  1. 按如下方式修改 Ball 组件。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;
  final double difficultyModifier;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            }));                                                // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

这项细微更改会向 RemoveEffect 添加 onComplete 回调,该回调会触发 gameOver 播放状态。如果玩家允许球从屏幕底部逃脱出来,应该符合相应设置。

  1. 按如下方式修改 Brick 组件。

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

另一方面,如果玩家能将所有砖块击碎,就表示他们成功赢得了游戏。屏幕。干得漂亮,玩家,干得漂亮!

添加 Flutter 封装容器

如需提供位置来嵌入游戏并添加游戏状态叠加层,请添加 Flutter shell。

  1. lib/src 下创建一个 widgets 目录。
  2. 添加 game_app.dart 文件并在该文件中插入以下内容。

lib/src/widgets/game_app.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../brick_breaker.dart';
import '../config.dart';

class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                              child: Text(
                                'TAP TO PLAY',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.gameOver.name: (context, game) => Center(
                              child: Text(
                                'G A M E   O V E R',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.won.name: (context, game) => Center(
                              child: Text(
                                'Y O U   W O N ! ! !',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

此文件中的大多数内容都遵循标准的 Flutter widget 树 build。特定于 Flame 的部分包括使用 GameWidget.controlled 构建和管理 BrickBreaker 游戏实例以及用于 GameWidget 的新 overlayBuilderMap 参数。

overlayBuilderMap 的键必须与 BrickBreaker 中的 playState setter 添加或移除的叠加层保持一致。试图设置不在此地图中的叠加层会导致各种不愉快的面孔。

  1. 如需在屏幕上获取这项新功能,请将 lib/main.dart 文件替换为以下内容。

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
  runApp(const GameApp());
}

如果您在 iOS、Linux、Windows 或 Web 上运行此代码,游戏中会显示预期输出。以 macOS 或 Android 为目标平台时,您需要完成最后一项调整,才能显示 google_fonts

启用字体访问权限

为 Android 设备添加互联网权限

对于 Android 设备,您必须添加互联网权限。按如下方式修改您的 AndroidManifest.xml

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

修改 macOS 的授权文件

对于 macOS,您有两个文件需要编辑。

  1. 修改 DebugProfile.entitlements 文件以匹配以下代码。

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. 修改 Release.entitlements 文件以匹配以下代码

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>

按原样运行此脚本应该会在所有平台上显示一个欢迎屏幕以及游戏结束或胜出屏幕。这些界面可能比较简单,如果能给出分数就更好了。因此,猜一猜您下一步会执行什么操作!

10. 保存得分

为游戏添加得分

在此步骤中,您将向周围的 Flutter 上下文公开游戏得分。在此步骤中,您将向相关的 Flutter 状态管理公开 Flame 游戏中的状态。这样一来,游戏代码就可以在玩家每次打破砖块时更新得分。

  1. 按如下方式修改 BrickBreaker 游戏。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final ValueNotifier<int> score = ValueNotifier(0);            // Add this line
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;
    score.value = 0;                                            // Add this line

    world.add(Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }

  @override
  void onTap() {
    super.onTap();
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

通过向游戏添加 score,您可以将游戏的状态与 Flutter 状态管理联系起来。

  1. 修改 Brick 类,以在玩家打砖块时为得分添加一个点。

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

打造精美的游戏

现在,您可以在 Flutter 中保留得分了,是时候整合 widget 来使其看起来美观了。

  1. lib/src/widgets 中创建 score_card.dart 并添加以下内容。

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({
    super.key,
    required this.score,
  });

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. lib/src/widgets 中创建 overlay_screen.dart 并添加以下代码。

这利用 flutter_animate 软件包的强大功能为叠加层屏幕添加一些动态效果和样式,让叠加层更加完善。

lib/src/widgets/overlay_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({
    super.key,
    required this.title,
    required this.subtitle,
  });

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.headlineLarge,
          ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
          const SizedBox(height: 16),
          Text(
            subtitle,
            style: Theme.of(context).textTheme.headlineSmall,
          )
              .animate(onPlay: (controller) => controller.repeat())
              .fadeIn(duration: 1.seconds)
              .then()
              .fadeOut(duration: 1.seconds),
        ],
      ),
    );
  }
}

如需深入了解 flutter_animate 的强大功能,请查看在 Flutter 中构建新一代界面 Codelab。

此代码在 GameApp 组件中发生了很大变化。首先,为使 ScoreCard 能够访问 score,请将其从 StatelessWidget 转换为 StatefulWidget。添加记分卡需要添加 Column,以便将得分堆叠在游戏上方。

其次,为了增强欢迎、游戏结束和胜出体验,您添加了新的 OverlayScreen widget。

lib/src/widgets/game_app.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

一切就绪后,您现在应该能够在六个 Flutter 目标平台上的任何一个上运行这款游戏。游戏应如下所示。

brick_breaker 屏幕截图,其中显示了游戏开始前屏幕,邀请用户点按屏幕来玩游戏

“打砖块”的屏幕截图,显示游戏打破屏幕,画面覆盖在球棒和一些积木上

11. 恭喜

恭喜,您已成功使用 Flutter 和 Flame 构建了一款游戏!

您使用 Flame 2D 游戏引擎构建了一款游戏,并将其嵌入到了 Flutter 封装容器中。您使用了 Flame 的效果来为组件添加动画和移除组件。您使用了 Google Fonts 和 Flutter Animate 软件包,让整个游戏看起来设计良好。

后续操作

查看下列 Codelab…

深入阅读