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

1. 准备工作

Flame 是一个基于 Flutter 的 2D 游戏引擎。在此 Codelab 中,您将构建一个游戏,它将按照 Box2D 的风格使用 2D 物理模拟,名为 Forge2D。您将使用 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.
  1. 修改项目的依赖项以添加 Flame 和 Forge2D:
$ cd forge2d_game
$ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
Resolving dependencies... 
Downloading packages... 
  characters 1.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 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.nlPhysics Assets 包。这些资源已获得 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.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

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 文件的组合,后者用于说明小图片在雪碧图中的显示位置。Sprite 工作表是一种通过仅加载一个文件(而不是加载数十个乃至数百个图片文件)来缩短加载时间的技术。

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

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

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

绘制背景

现在您的项目已添加图片素材资源,是时候将它们放到屏幕上了。嗯,屏幕上有一张图片。后续步骤将会涉及更多内容。

在名为 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 米到 10 米之间,您不会看到明显的数学误差。直接输入像素尺寸而不进行一定程度的缩小会让 Forge2D 超出其实用范围。下面的实用摘要可考虑模拟苏打水、汽水和公交车等范围内的物体。

现在,通过将 CameraComponent 的分辨率修正为 800x600 虚拟像素,可满足背景组件中的假设。这意味着,游戏区域的宽度为 80 个单位,高度为 60 个单位,以 (0,0) 为中心。这对显示的分辨率没有影响,但会影响对象在游戏场景中的放置位置。

除了 camera 构造函数实参之外,还有另一个更符合物理特性的参数,名为 gravity。Gravity 设置为 Vector2x 为 0,y 为 10。10 与公认的每秒 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() 相机实例会根据需要添加信箱模式,以使游戏的 800x600 宽高比正常运行。

一个应用窗口,其背景图片显示连绵起伏的绿色山丘和奇特的抽象树木。

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 的其余部分,您会看到许多动态正文。运动体是介于静态和动态之间的中间站。它们会移动,但不会对重力或其他击中物体做出反应。有用,但超出了此 Codelab 的讨论范围。

身体本身并无太大作用。身体需要关联的形状才有物质。在本例中,此正文有一个关联的形状,即设置为 BoxXYPolygonShape。这种类型的框与世界的轴对齐,这与设置为可围绕某个旋转点旋转的 BoxXYPolygonShape 不同。同样有用,但也超出了此 Codelab 的讨论范围。形状和正文通过夹具连接在一起,这对于向系统添加 friction 等元素非常有用。

默认情况下,body 会以一种有助于调试的方式渲染其相连的形状,但对游戏体验没有太大影响。将 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 代码是如何集成到此代码库中,以便根据材质、尺寸和使用情况快速轻松地选择砖块图片。回顾 enumBrick 组件本身,您会发现此代码的大部分内容与上一步中的 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 会逐渐添加到随机集群中。此操作包含两个部分,第一部分是将 Brick await 添加到 Future.delayed 的方法,这是 sleep() 调用的异步等效项。不过,若要使此生效,还需要完成另外一个步骤,即 onLoad 方法中对 addBricks 的调用不会执行 await 操作。否则,只有当所有积木都显示在屏幕上时,onLoad 方法才能完成。将对 addBricks 的调用封装在 unawaited 调用中可让 linter 满意,并让未来的程序员清楚地知道我们的意图。不等待此方法返回是有意为之。

运行游戏,您会看到积木出现,它们相互碰撞,并泼溅到地面上。

一个应用窗口,背景是绿色的山丘,地面层和着陆块。

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.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

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

这比上一步中的 Brick 组件更进了一步。此 Player 组件有两个子组件:一个应该识别的 SpriteComponent,以及一个新的 CustomPainterComponentCustomPainter 概念来自 Flutter,可让您在画布上绘图。在这里,玩家可以使用它向玩家反馈外星人被抛下后要去哪里。

玩家如何发起抛掷外星人的动作?使用拖动手势,播放器组件会通过 DragCallbacks 回调检测到该手势。你们眼前的鹰会发现这里出现了其他情况。

其中 Ground 组件是静态正文,Brick 组件是动态正文。这里的播放器是两者的组合。玩家一开始是静止的,等待玩家拖动,在拖动释放时,它会将自身从静态转换为动态,添加与拖动成比例的线性冲动,然后让外星人虚拟角色飞起来!

Player 组件中还有相关代码,用于在超出边界、进入休眠或超时后将其从屏幕中移除。这样做的目的是让玩家抛掷外星人,看看会发生什么,然后再试一次。

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

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 组件的互动中,您应该会熟悉此文件的大部分内容。不过,由于存在新的未知基类,编辑器中会显示几条红色下划线。现在,通过将名为 body_component_with_user_data.dart 的文件添加到 lib/components 并在其中包含以下内容来添加此类:

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

您的挑战(如果您选择接受的话)就是运行游戏并进入此屏幕。

一个应用窗口,背景是绿色的山丘,地面层,地面上的积木,以及“您赢了!”的文字叠加层

8. 恭喜

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

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

后续操作

查看下列 Codelab…

深入阅读