使用 Flutter 和 Flame 构建 2D 物理游戏

使用 Flutter 和 Flame 构建 2D 物理游戏

关于此 Codelab

subject上次更新时间:6月 23, 2025
account_circleBrett Morgan 编写

1. 准备工作

Flame 是一款基于 Flutter 的 2D 游戏引擎。在此 Codelab 中,您将构建一款使用 Forge2D(类似于 Box2D)的 2D 物理模拟游戏。您可以使用 Flame 的组件在屏幕上绘制模拟的物理现实,供用户玩耍。完成后,您的游戏应如下动画 GIF 所示:

这款 2D 物理游戏的游戏动画

前提条件

学习内容

  • Forge2D 基础知识的运作方式,从不同类型的物理实体开始。
  • 如何在 2D 中设置物理模拟。

所需条件

适用于您所选目标开发平台的编译器软件。此 Codelab 适用于 Flutter 支持的所有六个平台。您需要使用 Visual Studio 以 Windows 为目标平台,使用 Xcode 以 macOS 或 iOS 为目标平台,使用 Android Studio 以 Android 为目标平台。

2. 创建项目

创建 Flutter 项目

您可以通过多种方式创建 Flutter 项目。为简洁起见,本部分将使用命令行。

若要开始,请按照以下步骤操作:

  1. 在命令行中,创建一个 Flutter 项目:
    $ flutter create --empty forge2d_game
    Creating project forge2d_game...
    Resolving dependencies in forge2d_game... (4.7s)
    Got dependencies in forge2d_game.
    Wrote 128 files.
    
    All done!
    You can find general documentation for Flutter at: https://docs.flutter.dev/
    Detailed API documentation is available at: https://api.flutter.dev/
    If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev
    
    In order to run your empty application, type:
    
      $ cd forge2d_game
      $ flutter run
    
    Your empty application code is in forge2d_game/lib/main.dart.
    
  2. 修改项目的依赖项以添加 Flame 和 Forge2D:
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

flame 软件包对您来说很熟悉,但其他三个软件包可能需要一些说明。characters 软件包用于以符合 UTF8 标准的方式操控路径。flame_forge2d 软件包以与 Flame 良好配合的方式公开 Forge2D 功能。最后,xml 软件包会在各种位置用于使用和修改 XML 内容。

打开该项目,然后将 lib/main.dart 文件的内容替换为以下内容:

lib/main.dart

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

void main() {
 
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}

这会使用用于实例化 FlameGame 实例的 GameWidget 启动应用。此 Codelab 中没有任何 Flutter 代码会使用游戏实例的状态来显示正在运行的游戏的相关信息,因此这种简化的引导加载程序可以正常运行。

可选:完成仅限 macOS 的支线任务

本项目中的屏幕截图是游戏作为 macOS 桌面应用时的截图。为避免应用的标题栏影响整体体验,您可以修改 macOS 运行程序的项目配置以省略标题栏。

具体步骤如下:

  1. 创建一个 bin/modify_macos_config.dart 文件,并添加以下内容:

bin/modify_macos_config.dart

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('macos/Runner/Base.lproj/MainMenu.xib');
 
var document = XmlDocument.parse(file.readAsStringSync());
 
document.xpath('//document/objects/window').first
   
..setAttribute('titlebarAppearsTransparent', 'YES')
   
..setAttribute('titleVisibility', 'hidden');
 
document
     
.xpath('//document/objects/window/windowStyleMask')
     
.first
     
.setAttribute('fullSizeContentView', 'YES');
 
file.writeAsStringSync(document.toString());
}

此文件不在 lib 目录中,因为它不是游戏的运行时代码库的一部分。这是一个用于修改项目的命令行工具。

  1. 在项目基础目录中,按如下方式运行该工具:
dart bin/modify_macos_config.dart

如果一切按计划进行,该程序将不会在命令行上生成任何输出。不过,它会修改 macos/Runner/Base.lproj/MainMenu.xib 配置文件,以便在运行游戏时不显示标题栏,并让 Flame 游戏占据整个窗口。

运行游戏以验证一切正常。系统应显示一个新窗口,其中只有空白的黑色背景。

背景为黑色且前景中没有任何内容的应用窗口

3. 添加图片素材资源

添加图片

任何游戏都需要美术资源,才能以有趣的方式绘制屏幕。此 Codelab 将使用 Kenney.nl 中的物理资产包。这些资源采用 Creative Commons CC0 许可,但我仍强烈建议您向 Kenney 团队捐款,以便他们能够继续开展出色的工作。我有啊,

您需要修改 pubspec.yaml 配置文件,才能使用 Kenney 的资源。将其修改为以下代码:

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame 希望图片资源位于 assets/images 中,但也可以采用其他配置。如需了解详情,请参阅 Flame 的图片文档。现在,您已配置好路径,接下来需要将其添加到项目本身。为此,一种方法是使用命令行,如下所示:

mkdir -p assets/images

mkdir 命令应该不会输出任何内容,但新目录应该会显示在编辑器或文件资源管理器中。

展开您下载的 kenney_physics-assets.zip 文件,您应该会看到如下内容:

已展开的 kenney_physics-assets 文件包的文件列表,其中突出显示了 PNG/Backgrounds 目录

PNG/Backgrounds 目录中,将 colored_desert.pngcolored_grass.pngcolored_land.pngcolored_shroom.png 文件复制到项目的 assets/images 目录。

还有精灵图片表。它们由 PNG 图片和 XML 文件组成,用于描述精灵图表图片中可能包含较小图片的位置。雪碧图是一种减少加载时间的技术,它只加载一个文件,而不是数十个甚至数百个单独的图片文件。

已展开的 kenney_physics-assets 文件包的文件列表,其中突出显示了“Spritesheet”目录

spritesheet_aliens.pngspritesheet_elements.pngspritesheet_tiles.png 复制到项目的 assets/images 目录。同时,将 spritesheet_aliens.xmlspritesheet_elements.xmlspritesheet_tiles.xml 文件复制到项目的 assets 目录。您的项目应如下所示。

forge2d_game 项目目录的文件列表,其中突出显示了 assets 目录

绘制背景

现在,您的项目已添加图片素材资源,接下来该将其显示在界面上。屏幕上显示一张图片。后续步骤中将提供更多信息。

在名为 lib/components 的新目录中创建一个名为 background.dart 的文件,并添加以下内容。

lib/components/background.dart

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

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
 
Background({required super.sprite})
   
: super(anchor: Anchor.center, position: Vector2(0, 0));

 
@override
 
void onMount() {
   
super.onMount();

   
size = Vector2.all(
     
max(
       
game.camera.visibleWorldRect.width,
       
game.camera.visibleWorldRect.height,
     
),
   
);
 
}
}

此组件是一种专用 SpriteComponent。它负责显示 Kenney.nl 的四张背景图片之一。此代码中有一些简化假设。第一点是图片是方形的,Kenney 提供的所有四张背景图片都是方形的。第二点是,可见世界的大小永远不会发生变化,否则此组件需要处理游戏大小调整事件。第三个假设是,位置 (0,0) 将位于屏幕中心。这些假设需要对游戏的 CameraComponent 进行特定配置。

再次在 lib/components 目录中创建一个新文件,这次命名为 game.dart

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));

   
return super.onLoad();
 
}
}

这里有很多事情要做。从 MyPhysicsGame 类开始。与上一个 Codelab 不同,此代码会扩展 Forge2DGame,而不是 FlameGameForge2DGame 本身通过一些有趣的调整扩展了 FlameGame。第一个是,默认情况下,zoom 设置为 10。此 zoom 设置与 Box2D 风格物理模拟引擎能很好地处理的实用值范围有关。该引擎使用 MKS 系统编写,其中假定单位为米、千克和秒。在 0.1 米到几十米的范围内,您不会看到对象的明显数学误差。如果直接输入像素尺寸,而没有进行一定程度的缩减,则会使 Forge2D 超出其有效范围。一个实用的总结是,考虑模拟从苏打罐到公交车的各种对象。

通过将 CameraComponent 的分辨率固定为 800 x 600 虚拟像素,此处满足了在“Background”组件中做出的假设。这意味着游戏区域的宽度为 80 个单位,高度为 60 个单位,中心点为 (0,0)。这对显示的分辨率没有影响,但会影响我们在游戏场景中放置对象的位置。

除了 camera 构造函数参数之外,还有另一个名为 gravity 的更符合物理特性的参数。重力设置为 Vector2x0y1010 与普遍接受的每秒 9.81 米的重力值非常接近。将重力设置为正 10 表明,在此系统中,Y 轴的方向为向下。这与 Box2D 通常不同,但与 Flame 的通常配置方式一致。

接下来是 onLoad 方法。此方法是异步的,这很合适,因为它负责从磁盘加载图片素材资源。对 images.load 的调用会返回 Future<Image>,并作为副作用将加载的图片缓存在 Game 对象中。这些 Future 会聚集在一起,并使用 Futures.wait 静态方法作为单个单元进行等待。然后,系统会将返回的图片列表与模式匹配,以获得各个名称。

然后,将精灵图集图片馈送到一系列 XmlSpriteSheet 对象,这些对象负责检索精灵图集中包含的各个命名精灵。XmlSpriteSheet 类在 flame_kenney_xml 软件包中定义。

完成以上所有操作后,您只需对 lib/main.dart 进行一些细微的修改,即可在屏幕上显示图片。

lib/main.dart

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

import 'components/game.dart';                                    // Add this import

void main() {
 
runApp(GameWidget.controlled(gameFactory: MyPhysicsGame.new));  // Modify this line
}

进行此更改后,您现在可以再次运行游戏,在屏幕上查看背景。请注意,CameraComponent.withFixedResolution() 相机实例会根据需要添加信箱模式,以使游戏的宽高比为 800 x 600。

一款应用,其中有连绵起伏的绿色山丘和奇怪的抽象树木。

4. 添加地面

可供构建的基础

如果我们使用重力,就需要在游戏中的物体掉落到屏幕底部之前捕获它们。当然,除非掉出屏幕是游戏设计的一部分。在 lib/components 目录中创建一个新的 ground.dart 文件,并向其中添加以下内容:

lib/components/ground.dart

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

const groundSize = 7.0;

class Ground extends BodyComponent {
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

Ground 组件派生自 BodyComponent。在 Forge2D 中,物体非常重要,它们是二维物理模拟的一部分。此组件的 BodyDef 被指定为具有 BodyType.static

在 Forge2D 中,物体有三种不同的类型。静态物体不会移动。它们实际上既具有零质量(不受重力影响),又具有无限质量(无论其他物体有多重,被撞击后都不会移动)。因此,静态物体非常适合地面表面,因为它不会移动。

另外两种类型的物体是运动学物体和动力学物体。动态物体是完全模拟的物体,它们会对重力和碰撞到的物体做出反应。在此 Codelab 的其余部分中,您会看到许多动态 Body。运动学物体介于静态和动态之间。它们会移动,但不会对重力或其他物体撞击做出反应。这很有用,但超出了此 Codelab 的讨论范围。

正文本身并没有太大的作用。身体需要有相关的形状才能有实质。在本例中,此正文有一个关联的形状,即设置为 BoxXYPolygonShape。此类盒子的轴与世界坐标系对齐,这与设置为 BoxXYPolygonShape 不同,后者可以围绕旋转点旋转。这很有用,但也超出了本 Codelab 的讨论范围。形状和正文通过固定装置连接在一起,这对于向系统添加 friction 等内容非常有用。

默认情况下,身体会以一种有助于调试的方式渲染其附加的形状,但无法提供出色的游戏体验。将 super 参数 renderBody 设置为 false 可停用此调试渲染。子 SpriteComponent 负责为此正文提供游戏内渲染。

如需将 Ground 组件添加到游戏,请按如下方式修改 game.dart 文件。

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {                               // Add from here...
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}                                                        // To here.
}

此修改通过在 List 上下文中使用 for 循环,并将生成的 Ground 组件列表传递给 worldaddAll 方法,将一系列 Ground 组件添加到世界中。

现在,运行游戏会显示背景和地面。

带有背景和地面层的应用窗口。

5. 添加砖块

构建墙

地面给了我们一个静态物体的示例。现在,是时候创建第一个动态组件了。Forge2D 中的动态组件是玩家体验的基石,它们是会移动并与周围世界互动的内容。在此步骤中,您将引入砖块,系统会随机选择这些砖块,并将其显示在屏幕上的一组砖块中。您会看到它们在移动过程中会跌倒和互相碰撞。

砖块将由元素精灵图表制作而成。如果您查看 assets/spritesheet_elements.xml 中的精灵图片描述,就会发现我们遇到了一个有趣的问题。这些名称似乎没有什么帮助。最好能按材料类型、尺寸和损坏程度选择砖块。幸运的是,一位乐于助人的精灵花了一些时间找出文件命名中的模式,并创建了一个工具,以便大家更轻松地处理此问题。在 bin 目录中创建一个新文件 generate_brick_file_names.dart,并添加以下内容:

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('assets/spritesheet_elements.xml');
 
final rects = <String, Rect>{};
 
final document = XmlDocument.parse(file.readAsStringSync());
 
for (final node in document.xpath('//TextureAtlas/SubTexture')) {
   
final name = node.getAttribute('name')!;
   
rects[name] = Rect(
     
x: int.parse(node.getAttribute('x')!),
     
y: int.parse(node.getAttribute('y')!),
     
width: int.parse(node.getAttribute('width')!),
     
height: int.parse(node.getAttribute('height')!),
   
);
 
}
 
print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
 
final int x;
 
final int y;
 
final int width;
 
final int height;
 
const Rect({
   
required this.x,
   
required this.y,
   
required this.width,
   
required this.height,
 
});

 
Size get size => Size(width, height);

 
@override
 
List<Object?> get props => [x, y, width, height];

 
@override
 
bool get stringify => true;
}

class Size extends Equatable {
 
final int width;
 
final int height;
 
const Size(this.width, this.height);

 
@override
 
List<Object?> get props => [width, height];

 
@override
 
bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
 
final groups = <Size, List<String>>{};
 
for (final entry in rects.entries) {
   
groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
 
}
 
final buff = StringBuffer();
 
buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
 
for (final entry in groups.entries) {
   
final size = entry.key;
   
final entries = entry.value;
   
entries.sort();
   
for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
     
var filtered = entries.where((element) => element.contains(type));
     
if (filtered.length == 5) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
     
} else if (filtered.length == 10) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
     
} else if (filtered.length == 15) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
     
}
   
}
 
}
 
buff.writeln('''
  };
}''');
 
return buff.toString();
}

您的编辑器应该会向您发出有关缺少依赖项的警告或错误。使用以下命令进行添加:

flutter pub add equatable

现在,您应该可以按如下方式运行此程序:

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

此工具非常有用,它会解析精灵贴片描述文件并将其转换为 Dart 代码,我们可以使用这些代码为您要显示在屏幕上的每个砖块选择正确的图片文件。非常实用!

创建包含以下内容的 brick.dart 文件:

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
 
explosive(density: 1, friction: 0.5),
 
glass(density: 0.5, friction: 0.2),
 
metal(density: 1, friction: 0.4),
 
stone(density: 2, friction: 1),
 
wood(density: 0.25, friction: 0.6);

 
final double density;
 
final double friction;

 
const BrickType({required this.density, required this.friction});
 
static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
 
size70x70(ui.Size(70, 70)),
 
size140x70(ui.Size(140, 70)),
 
size220x70(ui.Size(220, 70)),
 
size70x140(ui.Size(70, 140)),
 
size140x140(ui.Size(140, 140)),
 
size220x140(ui.Size(220, 140)),
 
size140x220(ui.Size(140, 220)),
 
size70x220(ui.Size(70, 220));

 
final ui.Size size;

 
const BrickSize(this.size);

 
static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
 
return switch ((type, size)) {
   
(BrickType.explosive, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementExplosive009.png',
     
BrickDamage.some: 'elementExplosive012.png',
     
BrickDamage.lots: 'elementExplosive050.png',
   
},
   
(BrickType.glass, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementGlass010.png',
     
BrickDamage.some: 'elementGlass013.png',
     
BrickDamage.lots: 'elementGlass048.png',
   
},
   
(BrickType.metal, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementMetal009.png',
     
BrickDamage.some: 'elementMetal012.png',
     
BrickDamage.lots: 'elementMetal050.png',
   
},
   
(BrickType.stone, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementStone009.png',
     
BrickDamage.some: 'elementStone012.png',
     
BrickDamage.lots: 'elementStone047.png',
   
},
   
(BrickType.wood, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementWood011.png',
     
BrickDamage.some: 'elementWood014.png',
     
BrickDamage.lots: 'elementWood054.png',
   
},
   
(BrickType.explosive, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementExplosive011.png',
     
BrickDamage.some: 'elementExplosive014.png',
     
BrickDamage.lots: 'elementExplosive049.png',
   
},
   
(BrickType.glass, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementGlass011.png',
     
BrickDamage.some: 'elementGlass012.png',
     
BrickDamage.lots: 'elementGlass046.png',
   
},
   
(BrickType.metal, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementMetal011.png',
     
BrickDamage.some: 'elementMetal014.png',
     
BrickDamage.lots: 'elementMetal049.png',
   
},
   
(BrickType.stone, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementStone011.png',
     
BrickDamage.some: 'elementStone014.png',
     
BrickDamage.lots: 'elementStone046.png',
   
},
   
(BrickType.wood, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementWood010.png',
     
BrickDamage.some: 'elementWood013.png',
     
BrickDamage.lots: 'elementWood045.png',
   
},
   
(BrickType.explosive, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementExplosive013.png',
     
BrickDamage.some: 'elementExplosive016.png',
     
BrickDamage.lots: 'elementExplosive051.png',
   
},
   
(BrickType.glass, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementGlass014.png',
     
BrickDamage.some: 'elementGlass017.png',
     
BrickDamage.lots: 'elementGlass049.png',
   
},
   
(BrickType.metal, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementMetal013.png',
     
BrickDamage.some: 'elementMetal016.png',
     
BrickDamage.lots: 'elementMetal051.png',
   
},
   
(BrickType.stone, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementStone013.png',
     
BrickDamage.some: 'elementStone016.png',
     
BrickDamage.lots: 'elementStone048.png',
   
},
   
(BrickType.wood, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementWood012.png',
     
BrickDamage.some: 'elementWood015.png',
     
BrickDamage.lots: 'elementWood047.png',
   
},
   
(BrickType.explosive, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementExplosive017.png',
     
BrickDamage.some: 'elementExplosive022.png',
     
BrickDamage.lots: 'elementExplosive052.png',
   
},
   
(BrickType.glass, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementGlass018.png',
     
BrickDamage.some: 'elementGlass023.png',
     
BrickDamage.lots: 'elementGlass050.png',
   
},
   
(BrickType.metal, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementMetal017.png',
     
BrickDamage.some: 'elementMetal022.png',
     
BrickDamage.lots: 'elementMetal052.png',
   
},
   
(BrickType.stone, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementStone017.png',
     
BrickDamage.some: 'elementStone022.png',
     
BrickDamage.lots: 'elementStone049.png',
   
},
   
(BrickType.wood, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementWood016.png',
     
BrickDamage.some: 'elementWood021.png',
     
BrickDamage.lots: 'elementWood048.png',
   
},
   
(BrickType.explosive, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementExplosive018.png',
     
BrickDamage.some: 'elementExplosive023.png',
     
BrickDamage.lots: 'elementExplosive053.png',
   
},
   
(BrickType.glass, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementGlass019.png',
     
BrickDamage.some: 'elementGlass024.png',
     
BrickDamage.lots: 'elementGlass051.png',
   
},
   
(BrickType.metal, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementMetal018.png',
     
BrickDamage.some: 'elementMetal023.png',
     
BrickDamage.lots: 'elementMetal053.png',
   
},
   
(BrickType.stone, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementStone018.png',
     
BrickDamage.some: 'elementStone023.png',
     
BrickDamage.lots: 'elementStone050.png',
   
},
   
(BrickType.wood, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementWood017.png',
     
BrickDamage.some: 'elementWood022.png',
     
BrickDamage.lots: 'elementWood049.png',
   
},
   
(BrickType.explosive, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementExplosive019.png',
     
BrickDamage.some: 'elementExplosive024.png',
     
BrickDamage.lots: 'elementExplosive054.png',
   
},
   
(BrickType.glass, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementGlass020.png',
     
BrickDamage.some: 'elementGlass025.png',
     
BrickDamage.lots: 'elementGlass052.png',
   
},
   
(BrickType.metal, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementMetal019.png',
     
BrickDamage.some: 'elementMetal024.png',
     
BrickDamage.lots: 'elementMetal054.png',
   
},
   
(BrickType.stone, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementStone019.png',
     
BrickDamage.some: 'elementStone024.png',
     
BrickDamage.lots: 'elementStone051.png',
   
},
   
(BrickType.wood, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementWood018.png',
     
BrickDamage.some: 'elementWood023.png',
     
BrickDamage.lots: 'elementWood050.png',
   
},
   
(BrickType.explosive, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementExplosive020.png',
     
BrickDamage.some: 'elementExplosive025.png',
     
BrickDamage.lots: 'elementExplosive055.png',
   
},
   
(BrickType.glass, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementGlass021.png',
     
BrickDamage.some: 'elementGlass026.png',
     
BrickDamage.lots: 'elementGlass053.png',
   
},
   
(BrickType.metal, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementMetal020.png',
     
BrickDamage.some: 'elementMetal025.png',
     
BrickDamage.lots: 'elementMetal055.png',
   
},
   
(BrickType.stone, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementStone020.png',
     
BrickDamage.some: 'elementStone025.png',
     
BrickDamage.lots: 'elementStone052.png',
   
},
   
(BrickType.wood, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementWood019.png',
     
BrickDamage.some: 'elementWood024.png',
     
BrickDamage.lots: 'elementWood051.png',
   
},
   
(BrickType.explosive, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementExplosive021.png',
     
BrickDamage.some: 'elementExplosive026.png',
     
BrickDamage.lots: 'elementExplosive056.png',
   
},
   
(BrickType.glass, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementGlass022.png',
     
BrickDamage.some: 'elementGlass027.png',
     
BrickDamage.lots: 'elementGlass054.png',
   
},
   
(BrickType.metal, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementMetal021.png',
     
BrickDamage.some: 'elementMetal026.png',
     
BrickDamage.lots: 'elementMetal056.png',
   
},
   
(BrickType.stone, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementStone021.png',
     
BrickDamage.some: 'elementStone026.png',
     
BrickDamage.lots: 'elementStone053.png',
   
},
   
(BrickType.wood, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementWood020.png',
     
BrickDamage.some: 'elementWood025.png',
     
BrickDamage.lots: 'elementWood052.png',
   
},
 
};
}

class Brick extends BodyComponent {
 
Brick({
   
required this.type,
   
required this.size,
   
required BrickDamage damage,
   
required Vector2 position,
   
required Map<BrickDamage, Sprite> sprites,
 
}) : _damage = damage,
       
_sprites = sprites,
       
super(
         
renderBody: false,
         
bodyDef: BodyDef()
           
..position = position
           
..type = BodyType.dynamic,
         
fixtureDefs: [
           
FixtureDef(
               
PolygonShape()..setAsBoxXY(
                 
size.size.width / 20 * brickScale,
                 
size.size.height / 20 * brickScale,
               
),
             
)
             
..restitution = 0.4
             
..density = type.density
             
..friction = type.friction,
         
],
       
);

 
late final SpriteComponent _spriteComponent;

 
final BrickType type;
 
final BrickSize size;
 
final Map<BrickDamage, Sprite> _sprites;

 
BrickDamage _damage;
 
BrickDamage get damage => _damage;
 
set damage(BrickDamage value) {
   
_damage = value;
   
_spriteComponent.sprite = _sprites[value];
 
}

 
@override
 
Future<void> onLoad() {
   
_spriteComponent = SpriteComponent(
     
anchor: Anchor.center,
     
scale: Vector2.all(1),
     
sprite: _sprites[_damage],
     
size: size.size.toVector2() / 10 * brickScale,
     
position: Vector2(0, 0),
   
);
   
add(_spriteComponent);
   
return super.onLoad();
 
}
}

现在,您可以了解之前生成的 Dart 代码是如何集成到此代码库中的,以便快速根据材质、尺寸和状态选择砖块图片。撇开 enum,看看 Brick 组件本身,您应该会发现其中的大部分代码与上一步中的 Ground 组件非常相似。这里有一个可变状态,用于允许砖块损坏,但使用此状态将留给读者自行练习。

现在,我们将在屏幕上显示砖块。按如下方式修改 game.dart 文件:

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());                                // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();                                // Add from here...

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}                                                        // To here.
}

此代码与您用于添加 Ground 组件的代码略有不同。这一次,Brick 会随时间的推移被添加到随机集群中。这分为两部分,第一部分是添加 BrickawaitFuture.delayed 方法,它是 sleep() 调用的异步等效项。不过,要想实现这一点,还需要完成第二部分,即 onLoad 方法中的 addBricks 调用未进行 await 处理。如果进行了 await 处理,则 onLoad 方法将在所有砖块显示在屏幕上之前无法完成。将对 addBricks 的调用封装在 unawaited 调用中,可以让 lint 工具满意,并让未来的程序员清楚地了解我们的意图。不等待此方法返回是故意为之。

运行游戏,您会看到砖块出现、互相碰撞并散落在地面。

应用窗口,背景是绿色山丘、地面层和落在地面上的积木。

6. 添加玩家

将外星人扔向砖块

观看砖块倒塌的前几次很有趣,但我猜想,如果我们为玩家提供一个可用于与世界互动的化身,这款游戏会更有趣。不如让他们扔一只外星人来砸砖头?

lib/components 目录中创建一个新的 player.dart 文件,并向其中添加以下内容:

lib/components/player.dart

import 'dart:math';

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

const playerSize = 5.0;

enum PlayerColor {
 
pink,
 
blue,
 
green,
 
yellow;

 
static PlayerColor get randomColor =>
     
PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

 
String get fileName =>
     
'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
 
Player(Vector2 position, Sprite sprite)
   
: _sprite = sprite,
     
super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static
         
..angularDamping = 0.1
         
..linearDamping = 0.1,
       
fixtureDefs: [
         
FixtureDef(CircleShape()..radius = playerSize / 2)
           
..restitution = 0.4
           
..density = 0.75
           
..friction = 0.5,
       
],
     
);

 
final Sprite _sprite;

 
@override
 
Future<void> onLoad() {
   
addAll([
     
CustomPainterComponent(
       
painter: _DragPainter(this),
       
anchor: Anchor.center,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
     
SpriteComponent(
       
anchor: Anchor.center,
       
sprite: _sprite,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
   
]);
   
return super.onLoad();
 
}

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

   
if (!body.isAwake) {
     
removeFromParent();
   
}

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}

 
Vector2 _dragStart = Vector2.zero();
 
Vector2 _dragDelta = Vector2.zero();
 
Vector2 get dragDelta => _dragDelta;

 
@override
 
void onDragStart(DragStartEvent event) {
   
super.onDragStart(event);
   
if (body.bodyType == BodyType.static) {
     
_dragStart = event.localPosition;
   
}
 
}

 
@override
 
void onDragUpdate(DragUpdateEvent event) {
   
if (body.bodyType == BodyType.static) {
     
_dragDelta = event.localEndPosition - _dragStart;
   
}
 
}

 
@override
 
void onDragEnd(DragEndEvent event) {
   
super.onDragEnd(event);
   
if (body.bodyType == BodyType.static) {
     
children
         
.whereType<CustomPainterComponent>()
         
.firstOrNull
         
?.removeFromParent();
     
body.setType(BodyType.dynamic);
     
body.applyLinearImpulse(_dragDelta * -50);
     
add(RemoveEffect(delay: 5.0));
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
 
_DragPainter(this.player);

 
final Player player;

 
@override
 
void paint(Canvas canvas, Size size) {
   
if (player.dragDelta != Vector2.zero()) {
     
var center = size.center(Offset.zero);
     
canvas.drawLine(
       
center,
       
center + (player.dragDelta * -1).toOffset(),
       
Paint()
         
..color = Colors.orange.withAlpha(180)
         
..strokeWidth = 0.4
         
..strokeCap = StrokeCap.round,
     
);
   
}
 
}

 
@override
 
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

这比上一步中的 Brick 组件更高级。此 Player 组件有两个子组件,一个是您应该熟悉的 SpriteComponent,另一个是新的 CustomPainterComponentCustomPainter 概念来自 Flutter,可让您在画布上绘制。此处使用它是为了向玩家提供反馈,让玩家知道圆形外星人被抛出后会飞到哪里。

玩家如何发起外星人飞行?使用拖动手势,Player 组件会使用 DragCallbacks 回调检测此手势。目光敏锐的用户会发现这里还有一些其他变化。

Ground 组件是静态物体,砖块组件是动态物体。此处的 Player 是这两者的组合。玩家一开始是静态的,等待玩家拖动它,在松开拖动操作后,它会从静态转换为动态,按拖动力度添加线性冲量,并让外星人头像飞起来!

Player 组件中还有一些代码,用于在该组件超出边界、进入休眠状态或超时时将其从屏幕中移除。这里的意图是让玩家弹出外星人,看看会发生什么,然后再试一次。

通过修改 game.dartPlayer 组件集成到游戏中,如下所示:

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());
   
await addPlayer();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(             // Add from here...
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted && world.children.whereType<Player>().isEmpty) {
     
addPlayer();
   
}
 
}                                                        // To here.
}

将玩家添加到游戏中的过程与之前的组件类似,但有一个额外的注意事项。玩家的外星人会在特定条件下从游戏中移除自己,因此这里有一个更新处理脚本,用于检查游戏中是否没有 Player 组件,如果没有,则重新添加一个。运行游戏的界面如下所示。

应用窗口,背景是绿色山丘、地面层、地面上的方块和正在飞行的玩家头像。

7. 对影响做出回应

添加敌人

您已经看到静态对象和动态对象之间的相互作用。不过,要想真正有所成效,您需要在代码中在发生冲突时获取回调。您将为玩家引入一些敌人。这为胜利条件提供了一条路径:从游戏中移除所有敌人!

lib/components 目录中创建一个 enemy.dart 文件,并添加以下内容:

lib/components/enemy.dart

import 'dart:math';

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

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
 
pink(color: 'pink', boss: false),
 
blue(color: 'blue', boss: false),
 
green(color: 'green', boss: false),
 
yellow(color: 'yellow', boss: false),
 
pinkBoss(color: 'pink', boss: true),
 
blueBoss(color: 'blue', boss: true),
 
greenBoss(color: 'green', boss: true),
 
yellowBoss(color: 'yellow', boss: true);

 
final bool boss;
 
final String color;

 
const EnemyColor({required this.color, required this.boss});

 
static EnemyColor get randomColor =>
     
EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

 
String get fileName =>
     
'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
 
Enemy(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.dynamic,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(enemySize),
           
position: Vector2(0, 0),
         
),
       
],
     
);

 
@override
 
void beginContact(Object other, Contact contact) {
   
var interceptVelocity =
       
(contact.bodyA.linearVelocity - contact.bodyB.linearVelocity).length
           
.abs();
   
if (interceptVelocity > 35) {
     
removeFromParent();
   
}

   
super.beginContact(other, contact);
 
}

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

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

您之前与 Player 和 Brick 组件互动过,因此此文件的大部分内容应该都很熟悉。不过,由于有一个新的未知基类,编辑器中会出现几个红色下划线。现在,通过向 lib/components 添加名为 body_component_with_user_data.dart 的文件(其中包含以下内容)来添加此类:

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
 
BodyComponentWithUserData({
   
super.key,
   
super.bodyDef,
   
super.children,
   
super.fixtureDefs,
   
super.paint,
   
super.priority,
   
super.renderBody,
 
});

 
@override
 
Body createBody() {
   
final body = world.createBody(super.bodyDef!)..userData = this;
   
fixtureDefs?.forEach(body.createFixture);
   
return body;
 
}
}

此基类与 Enemy 组件中的新 beginContact 回调相结合,为程序化地接收有关物体之间碰撞的通知奠定了基础。事实上,您需要修改要接收影响通知的所有组件。因此,请继续修改 BrickGroundPlayer 组件,以使用此 BodyComponentWithUserData 替换这些组件使用的 BodyComponent 基类。例如,以下是修改 Ground 组件的方法:

lib/components/ground.dart

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

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

如需详细了解 Forge2d 如何处理接触,请参阅 Forge2D 文档中的接触回调

赢得比赛

现在,您已经有了敌人,以及从世界中移除敌人的方法,接下来可以通过一种简单的方法将此模拟转换为游戏。设定目标,消灭所有敌人!现在,请按如下方式修改 game.dart 文件:

lib/components/game.dart

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

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

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
   
await addPlayer();

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted &&                                       // Modify from here...
       
world.children.whereType<Player>().isEmpty &&
       
world.children.whereType<Enemy>().isNotEmpty) {
     
addPlayer();
   
}
   
if (isMounted &&
       
enemiesFullyAdded &&
       
world.children.whereType<Enemy>().isEmpty &&
       
world.children.whereType<TextComponent>().isEmpty) {
     
world.addAll(
       
[
         
(position: Vector2(0.5, 0.5), color: Colors.white),
         
(position: Vector2.zero(), color: Colors.orangeAccent),
       
].map(
         
(e) => TextComponent(
           
text: 'You win!',
           
anchor: Anchor.center,
           
position: e.position,
           
textRenderer: TextPaint(
             
style: TextStyle(color: e.color, fontSize: 16),
           
),
         
),
       
),
     
);
   
}
 
}

 
var enemiesFullyAdded = false;

 
Future<void> addEnemies() async {
   
await Future<void>.delayed(const Duration(seconds: 2));
   
for (var i = 0; i < 3; i++) {
     
await world.add(
       
Enemy(
         
Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 7 - 3.5),
           
(_random.nextDouble() * 3),
         
),
         
aliens.getSprite(EnemyColor.randomColor.fileName),
       
),
     
);
     
await Future<void>.delayed(const Duration(seconds: 1));
   
}
   
enemiesFullyAdded = true;                              // To here.
 
}
}

现在为你发布挑战,如果你想接受,请运行游戏并进入此界面。

应用窗口,背景是绿色山丘、地面层、地面上的积木块,以及“You win!”叠加文本

8. 恭喜

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

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

后续操作

查看下列 Codelab…

深入阅读