Flame 与 Flutter 简介

使用 Flutter 的 Flame 简介

关于此 Codelab

subject上次更新时间:5月 20, 2025
account_circleBrett Morgan 编写

1. 简介

Flame 是一款基于 Flutter 的 2D 游戏引擎。在此 Codelab 中,您将构建一款游戏,其灵感来自 70 年代的经典视频游戏之一,即 Steve Wozniak 的 Breakout。您将使用 Flame 的组件来绘制球拍、球和砖块。您将使用 Flame 的效果为蝙蝠的移动添加动画效果,并了解如何将 Flame 与 Flutter 的状态管理系统集成。

完成后,您的游戏应如下动画 GIF 所示,但速度会稍慢一些。

正在玩游戏的屏幕录制内容。游戏速度大幅加快。

学习内容

  • Flame 的基本工作原理,从 GameWidget 开始。
  • 如何使用游戏循环。
  • 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.8.0

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.28.1
  flutter_animate: ^4.5.2
  google_fonts: ^6.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

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. 将左上角配置为取景器的锚点。默认情况下,viewfinder 会使用该区域的中间作为 (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,因此可以推断出存在更多形状。CircleComponentRectangleComponent 一样,派生自 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。

要正确确定这些各种价值,需要进行一些迭代,这在业界也称为游戏测试。

最后一行会开启调试显示屏,以便在显示屏上添加更多信息,以协助调试。

现在,运行游戏时,界面应类似于以下显示内容。

屏幕截图:显示 brick_breaker 应用窗口,沙色矩形上方有一个蓝色圆圈。蓝色圆圈带有数字注释,表示其大小和屏幕上的位置

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

这会跟踪组件的碰撞框,并在每次游戏滴答时触发碰撞回调。

如需开始填充游戏的碰撞盒,请修改 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. 将球棒击向球

创建 bat

如需添加球棒以便在游戏中让球保持在游戏中,请执行以下操作:

  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,用于执行各种效果。

效果的构造函数参数包含对 game 获取器的引用。因此,您需要在此类中添加 HasGameReference mixin。此 mixin 会向此组件添加类型安全的 game 访问器,以访问组件树顶部的 BrickBreaker 实例。

  1. 如需将 Bat 提供给 BrickBreaker,请按如下所示更新 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(                                                  // Add from here...
     
Bat(
       
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(delay: 0.35));                         // Modify from 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 {                                                    // 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 游戏进行了类型安全的引用。

此代码引入的最重要的新概念是玩家如何实现胜利条件。胜利条件检查会查询世界中的砖块,并确认只剩一个砖块。这可能有点令人困惑,因为上一行会将此砖块从其父级中移除。

需要了解的重要一点是,组件移除是队列命令。它会在此代码运行后,但在游戏世界下一次滴答之前移除砖块。

如需让 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;
 
}
}

如果您以当前状态运行游戏,它会显示所有关键游戏机制。您可以关闭调试并宣布完成,但总觉得缺少点什么。

屏幕截图:显示了游戏区域中的球、球拍和大部分砖块的 brick_breaker。每个组件都有调试标签

欢迎界面、游戏结束界面和得分怎么样?Flutter 可以为游戏添加这些功能,接下来,您将着手实现这些功能。

9. 赢得比赛

添加播放状态

在此步骤中,您将 Flame 游戏嵌入到 Flutter 封装容器中,然后为欢迎屏幕、游戏结束屏幕和胜利屏幕添加 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

impimport '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 壳。

  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(
       
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 树构建方式。特定于 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 上下文。在此步骤中,您将 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 }

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 微件。

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(
       
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 的屏幕截图,显示了游戏前屏幕,提示用户点按屏幕即可玩游戏

brick_breaker 的屏幕截图,显示了游戏结束画面叠加在球拍和一些砖块上

11. 恭喜

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

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

后续操作

查看下列 Codelab…

深入阅读