運用 Flutter 和 Flame 建構 2D 物理遊戲

使用 Flutter 和 Flame 建構 2D 物理遊戲

程式碼研究室簡介

subject上次更新時間:6月 23, 2025
account_circle作者:Brett Morgan

1. 事前準備

Flame 是基於 Flutter 的 2D 遊戲引擎。在本程式碼研究室中,您將建構遊戲,使用 Box2D 的 2D 物理模擬功能,稱為 Forge2D。您可以使用 Flame 的元件,在螢幕上繪製模擬的物理現實,供使用者體驗。完成後,您的遊戲應會如下 GIF 動畫所示:

這款 2D 物理遊戲的遊戲動畫

必要條件

課程內容

  • 瞭解 Forge2D 的基本運作方式,從不同類型的物理體開始。
  • 如何在 2D 中設定物理模擬。

需求條件

您所選開發目標的編譯器軟體。本程式碼研究室適用於 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));
}

這會使用 GameWidget 啟動應用程式,該 GameWidget 會將 FlameGame 例項化。本程式碼研究室中沒有任何 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. 新增圖片素材資源

新增圖片

任何遊戲都需要藝術素材資源,才能以有趣的方式繪製畫面。本程式碼研究室會使用 Kenney.nl 提供的 Physics 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.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 專案目錄的檔案清單,其中以醒目顯示素材資源目錄

繪製背景

專案已新增圖片素材資源,現在是時候將圖片放到畫面上。畫面上有一個圖片。後續步驟會提供更多資訊。

在名為 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 類別開始。與先前的程式碼研究室不同,這個研究室會擴充 Forge2DGame,而非 FlameGameForge2DGame 本身會擴充 FlameGame,並進行一些有趣的調整。首先,根據預設,zoom 會設為 10。這個 zoom 設定與 Box2D 樣式物理模擬引擎可順利運作的實用值範圍有關。引擎是使用 MKS 系統編寫,其中的單位假設為公尺、公斤和秒。物體的範圍如果在 0.1 公尺到數十公尺之間,就不會出現明顯的數學錯誤。如果直接輸入像素尺寸,而沒有進行某種程度的縮放,Forge2D 就會超出其可用範圍。簡單來說,您可以模擬從汽水罐到公車的物件。

CameraComponent 的解析度設為 800 x 600 虛擬像素,即可滿足 Background 元件中的假設。也就是說,遊戲區域的寬度為 80 個單位,高度為 60 個單位,以 (0,0) 為中心。這不會影響顯示解析度,但會影響遊戲場景中物件的放置位置。

除了 camera 建構函式引數外,還有另一個名為 gravity 的引數,可提供更準確的物理效果。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 中,物體有三種不同類型。靜態物體不會移動。它們實際上具有零質量 (不會對重力做出反應) 和無限質量 (無論其他物體有多重,都不會移動)。因此,靜態物體非常適合用於地面,因為它不會移動。

其他兩種物體類型是運動學和動態。動態物體是完全模擬的物體,會對重力和碰撞的物體做出反應。您將在本程式碼研究室的其餘部分中看到許多動態物件。運動體介於靜態和動態之間。雖然這些物件會移動,但不會受到重力或其他物體撞擊的影響。這項功能很實用,但不在本程式碼研究室的範圍內。

主體本身並沒有做太多事情。身體需要相關聯的形狀才能有實體。在本例中,這個主體有一個關聯形狀,即設為 BoxXYPolygonShape。這類方塊的軸會與世界對齊,不像設為 BoxXYPolygonShape,可在旋轉點周圍旋轉。這也是實用的做法,但不在本程式碼研究室的範圍內。形狀和主體會透過固定物連結在一起,這對於將 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 中的動態元件是玩家體驗的基礎,這些元件會移動並與周遭環境互動。在這個步驟中,您將引入積木,系統會隨機選擇積木,並以一組積木的形式顯示在畫面上。你會看到它們掉落並互相碰撞。

系統會使用元素的 Sprite 工作表建立積木。如果您查看 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 會隨時間加入隨機叢集。這項操作分為兩個部分,第一個部分是新增 Brickawait 方法,這是 sleep() 呼叫的非同步等價項目。Future.delayed不過,還有第二個部分需要處理,就是 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.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 元件中也有程式碼,可在元件超出邊界、進入休眠狀態或逾時時,將其從畫面中移除。這裡的意圖是讓玩家擲出外星人,看看會發生什麼事,然後再試一次。

如要將 Player 元件整合至遊戲,請按照下列方式編輯 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 '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.
 
}
}

你的任務是執行遊戲並前往這個畫面 (如果選擇接受挑戰)。

應用程式視窗,背景為綠色山丘、地面層、地面上的方塊,以及「You win!」的文字疊加層。

8. 恭喜

恭喜!您已成功使用 Flutter 和 Flame 建構遊戲!

您使用 Flame 2D 遊戲引擎建構遊戲,並將遊戲嵌入 Flutter 包裝函式中。您使用 Flame 的效果來製作動畫並移除元件。您使用 Google 字型和 Flutter Animate 套件,讓整個遊戲看起來設計精美。

後續步驟

查看一些程式碼研究室…

延伸閱讀