Flame with Flutter 簡介

使用 Flutter 介紹 Flame

程式碼研究室簡介

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

1. 簡介

Flame 是基於 Flutter 的 2D 遊戲引擎。在本程式碼研究室中,您將以 70 年代經典電玩遊戲之一 Steve Wozniak 的 Breakout 為靈感,建構遊戲。您將使用 Flame 的元件繪製球棒、球和磚塊。您將使用 Flame 的效果為蝙蝠的動作製作動畫,並瞭解如何將 Flame 與 Flutter 的狀態管理系統整合。

完成後,您的遊戲應會看起來像這張 GIF 動畫,但速度會稍微慢一些。

正在進行遊戲的螢幕錄影畫面。遊戲速度大幅提升。

課程內容

  • Flame 的基本運作方式,從 GameWidget 開始。
  • 如何使用遊戲迴圈。
  • Flame 的 Component 運作方式。這類似於 Flutter 的 Widget
  • 如何處理碰撞。
  • 如何使用 EffectComponent 製作動畫。
  • 如何在 Flame 遊戲上重疊 Flutter Widget
  • 如何將 Flame 與 Flutter 的狀態管理整合。

建構項目

在本程式碼研究室中,您將使用 Flutter 和 Flame 建構 2D 遊戲。完成後,您的遊戲應符合下列規定:

  • 可在 Flutter 支援的所有六個平台上運作:Android、iOS、Linux、macOS、Windows 和網頁
  • 使用 Flame 的遊戲迴圈,維持至少 60 fps。
  • 使用 Flutter 功能 (例如 google_fonts 套件和 flutter_animate),重現 80 年代街機遊戲的感受。

2. 設定 Flutter 環境

編輯者

為了簡化這個程式碼研究室,我們假設您使用 Visual Studio Code (VS Code) 做為開發環境。VS Code 是免費軟體,可在所有主要平台上使用。我們會在本程式碼研究室中使用 VS Code,因為操作說明預設會使用 VS Code 專屬的快速鍵。任務變得更簡單明瞭:「按一下這個按鈕」或「按下這個鍵執行 X」,而不是「在編輯器中執行適當動作來執行 X」。

您可以使用任何喜歡的編輯器:Android Studio、其他 IntelliJ IDE、Emacs、Vim 或 Notepad++,這些工具都與 Flutter 相容。

VS Code 螢幕截圖,其中包含一些 Flutter 程式碼

選擇開發目標

Flutter 可為多個平台產生應用程式。您的應用程式可在下列任一作業系統上執行:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • 網路

一般來說,您可以選擇一個作業系統做為開發目標。這是應用程式在開發期間執行的作業系統。

插圖:筆記型電腦和手機透過線纜連接至筆記型電腦。筆記型電腦標示為

舉例來說,假設您使用 Windows 筆記型電腦開發 Flutter 應用程式,然後選擇 Android 做為開發目標。如要預覽應用程式,請使用 USB 傳輸線將 Android 裝置連接至 Windows 筆電,然後在連接的 Android 裝置或 Android 模擬器中執行開發中的應用程式。您可以選擇 Windows 做為開發目標,讓開發中的應用程式以 Windows 應用程式形式,與編輯器一同執行。

您可能會想選擇網頁做為開發目標。這在開發過程中會帶來缺點:您將無法使用 Flutter 的有狀態熱重載功能。Flutter 目前無法熱重新載入網頁應用程式。

請先選擇要採用的選項,再繼續操作。您之後隨時可以將應用程式在其他作業系統上執行。選擇開發目標可讓後續步驟更順利。

安裝 Flutter

如需最新的 Flutter SDK 安裝操作說明,請前往 docs.flutter.dev

Flutter 網站上的操作說明涵蓋 SDK 安裝作業,以及與開發目標相關的工具和編輯器外掛程式。針對本程式碼研究室,請安裝下列軟體:

  1. Flutter SDK
  2. 搭配 Flutter 外掛程式的 Visual Studio Code
  3. 您所選開發目標的編譯器軟體。(您需要使用 Visual Studio 指定 Windows 目標,或使用 Xcode 指定 macOS 或 iOS 目標)

在下一節中,您將建立第一個 Flutter 專案。

如果您需要排解任何問題,這些問題和解答 (來自 StackOverflow) 或許能幫助您排除問題。

常見問題

3. 建立專案

建立第一個 Flutter 專案

這包括開啟 VS Code,並在您選擇的目錄中建立 Flutter 應用程式範本。

  1. 啟動 Visual Studio Code。
  2. 開啟指令區塊面板 (F1Ctrl+Shift+PShift+Cmd+P),然後輸入「flutter new」。畫面顯示後,請選取「Flutter:新專案」指令。

VS Code 螢幕截圖,其中

  1. 選取「Empty Application」。選擇要建立專案的目錄。這個目錄不應需要提升權限,也不應在路徑中含有空格。例如主目錄或 C:\src\

VS Code 螢幕截圖,其中顯示空白應用程式,並標示為新應用程式流程中的選項

  1. 為專案命名為 brick_breaker。本程式碼研究室的其餘部分假設您將應用程式命名為 brick_breaker

VS Code 的螢幕截圖,其中包含

Flutter 會建立專案資料夾,並由 VS Code 開啟。您現在將使用應用程式的基本架構覆寫兩個檔案的內容。

複製並貼上初始應用程式

這會將本程式碼研究室提供的範例程式碼新增至應用程式。

  1. 在 VS Code 的左側窗格中,按一下「Explorer」,然後開啟 pubspec.yaml 檔案。

VS Code 的部分螢幕截圖,箭頭標示 pubspec.yaml 檔案的位置

  1. 將這個檔案的內容替換成以下內容:

pubspec.yaml

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

environment:
  sdk: ^3.8.0

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

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

pubspec.yaml 檔案會指定應用程式的基本資訊,例如目前版本、依附元件,以及要一併發布的資產。

  1. lib/ 目錄中開啟 main.dart 檔案。

VS Code 的部分螢幕截圖,箭頭指向 main.dart 檔案的位置

  1. 將這個檔案的內容替換成以下內容:

lib/main.dart

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

void main() {
 
final game = FlameGame();
 
runApp(GameWidget(game: game));
}
  1. 執行這段程式碼,確認一切正常運作。畫面上應該會顯示新視窗,其中只有空白的黑色背景。全球最糟糕的電玩遊戲現在以 60fps 的速度運算!

螢幕截圖:顯示 brick_breaker 應用程式視窗全黑的畫面。

4. 建立遊戲

評估遊戲

以二維 (2D) 方式進行的遊戲需要遊戲區。您將建構特定維度的區域,然後使用這些維度來調整遊戲的其他部分大小。

在遊戲區域中排列座標的方式有很多種。根據一項慣例,您可以從螢幕中心測量方向,其中原點 (0,0) 位於螢幕中心,正值會沿著 x 軸將項目移至右側,沿著 y 軸向上移動。這個標準適用於目前大多數的遊戲,尤其是涉及三維度的遊戲。

原始的 Breakout 遊戲建立時,慣例是在左上角設定起點。正 x 方向保持不變,但 y 方向已翻轉。x 正向是右,y 是下。為了忠於時代背景,這個遊戲將原點設在左上角。

在名為 lib/src 的新目錄中建立名為 config.dart 的檔案。這個檔案會在後續步驟中獲得更多常數。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

這個遊戲的寬度為 820 像素,高度為 1600 像素。遊戲區域會縮放至符合顯示視窗的大小,但新增至畫面的所有元件都會符合這個高度和寬度。

建立 PlayArea

在 Breakout 遊戲中,球會彈回遊戲區的牆壁。如要處理碰撞問題,您必須先建立 PlayArea 元件。

  1. 在名為 lib/src/components 的新目錄中建立名為 play_area.dart 的檔案。
  2. 在這個檔案中加入下列內容。

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

在 Flutter 中,Widget 對應到 Flame 中的 Component。在 Flutter 應用程式中,您需要建立小工具樹狀結構,而 Flame 遊戲則需要維護元件樹狀結構。

這正是 Flutter 和 Flame 之間有趣的差異所在。Flutter 的小工具樹狀結構是暫時性的說明,用於更新持續性和可變動的 RenderObject 層。Flame 的元件是持久且可變動的,開發人員應將這些元件用於模擬系統。

Flame 的元件經過最佳化,可用於呈現遊戲機制。本程式碼研究室將從遊戲迴圈開始,並在下一個步驟中介紹。

  1. 為避免雜亂,請新增一個檔案,其中包含此專案中的所有元件。在 lib/src/components 中建立 components.dart 檔案,並新增下列內容。

lib/src/components/components.dart

export 'play_area.dart';

export 指令扮演 import 的反向角色。這項資訊會宣告匯入其他檔案時,這個檔案會公開哪些功能。在接下來的步驟中新增新元件後,這個檔案的項目數量就會增加。

建立 Flame 遊戲

如要消除上一個步驟產生的紅色波紋,請為 Flame 的 FlameGame 衍生新的子類別。

  1. lib/src 中建立名為 brick_breaker.dart 的檔案,並加入下列程式碼。

lib/src/brick_breaker.dart

import 'dart:async';

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

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

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

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

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

   
camera.viewfinder.anchor = Anchor.topLeft;

   
world.add(PlayArea());
 
}
}

這個檔案會協調遊戲的動作。在建構遊戲例項期間,這段程式碼會將遊戲設定為使用固定解析度的轉譯功能。遊戲會調整大小,以填滿包含遊戲的畫面,並視需要加入黑邊

您會公開遊戲的寬度和高度,讓 PlayArea 等子項元件可以將自身設為適當大小。

在覆寫的 onLoad 方法中,程式碼會執行兩項動作。

  1. 將左上方設為觀景窗的錨點。根據預設,viewfinder 會使用區域中間做為 (0,0) 的錨點。
  2. PlayArea 新增至 world。world 代表遊戲世界。它會透過 CameraComponent 檢視轉換作業投射所有子項。

將遊戲顯示在螢幕上

如要查看您在此步驟中所做的所有變更,請更新 lib/main.dart 檔案,並加入下列變更。

lib/main.dart

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

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

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

完成這些變更後,請重新啟動遊戲。遊戲應如下圖所示。

螢幕截圖:顯示 brick_breaker 應用程式視窗,應用程式視窗中間有沙色矩形

在下一個步驟中,您將在世界中加入球,並讓它移動!

5. 顯示球

建立球元件

將移動中的球放在畫面上,需要建立另一個元件並將其新增至遊戲世界。

  1. 按照下列方式編輯 lib/src/config.dart 檔案的內容。

lib/src/config.dart

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

在本程式碼研究室中,會多次使用將命名常數定義為衍生值的設計模式。這樣一來,您就能修改頂層 gameWidthgameHeight,探索遊戲外觀和風格變化。

  1. lib/src/components 中,於名為 ball.dart 的檔案中建立 Ball 元件。

lib/src/components/ball.dart

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

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

 
final Vector2 velocity;

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

您先前使用 RectangleComponent 定義 PlayArea,因此理應存在更多形狀。CircleComponentRectangleComponent 一樣,都是從 PositionedComponent 衍生而來,因此您可以將球放在畫面上。更重要的是,可以更新位置。

這個元件會介紹 velocity 的概念,也就是位置隨時間變化的情形。速度是 Vector2 物件,因為速度是速度和方向。如要更新位置,請覆寫 update 方法,這是遊戲引擎在每個影格中呼叫的方法。dt 是指前一個影格和這個影格之間的時間間隔。這可讓您因應各種因素,例如不同的影格速率 (60hz 或 120hz),或是因過度運算而產生的長影格。

請密切留意 position += velocity * dt 更新。這是實作更新時間內動作離散模擬值的方式。

  1. 如要在元件清單中加入 Ball 元件,請按照下列方式編輯 lib/src/components/components.dart 檔案。

lib/src/components/components.dart

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

將球加入場景

你有一個球。將其放入世界中,並設定其在遊戲區域中移動。

按照下列方式編輯 lib/src/brick_breaker.dart 檔案。

lib/src/brick_breaker.dart

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

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

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

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

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

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

   
camera.viewfinder.anchor = Anchor.topLeft;

   
world.add(PlayArea());

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

   
debugMode = true;                                           // To here.
 
}
}

這項變更會將 Ball 元件新增至 world。為了將球的 position 設為顯示區域的中心,程式碼會先將遊戲大小減半,因為 Vector2 有運算子超載 (*/),可根據標量值縮放 Vector2

設定球的 velocity 會涉及更複雜的程序。意圖是讓球以合理速度以隨機方向向下移動。呼叫 normalized 方法會建立 Vector2 物件,並將其方向設為與原始 Vector2 相同,但縮減至 1 的距離。這樣一來,無論球往哪個方向移動,球的速度都會保持一致。接著,球的速度會向上調整,成為遊戲高度的 1/4。

要取得這些不同的值,就必須進行一些重複作業,也就是業界所稱的「遊戲測試」。

最後一行會開啟偵錯畫面,在畫面上加入額外資訊,以利偵錯。

執行遊戲時,畫面應會顯示類似下圖的畫面。

螢幕截圖:顯示 brick_breaker 應用程式視窗,沙色矩形上方有藍色圓圈。藍色圓圈上標示的數字代表其大小和畫面上的顯示位置

PlayArea 元件和 Ball 元件都含有偵錯資訊,但背景遮罩會裁剪 PlayArea 的數字。之所以會顯示所有偵錯資訊,是因為您為整個元件樹狀結構啟用了 debugMode。如果您認為只針對所選元件啟用偵錯功能更實用,也可以這麼做。

如果您重新啟動遊戲幾次,可能會發現球與牆壁的互動效果不如預期。如要實現這項效果,您必須新增碰撞偵測功能,這會在下一個步驟中進行。

6. 彈跳

新增碰撞偵測

碰撞偵測功能會新增行為,讓遊戲能夠偵測兩個物件相撞的情況。

如要為遊戲新增碰撞偵測功能,請將 HasCollisionDetection 混合物加入 BrickBreaker 遊戲,如以下程式碼所示。

lib/src/brick_breaker.dart

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

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

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

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

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

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

   
camera.viewfinder.anchor = Anchor.topLeft;

   
world.add(PlayArea());

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

   
debugMode = true;
 
}
}

這會追蹤元件的 hitbox,並在每次遊戲計時器時觸發碰撞回呼。

如要開始填入遊戲的 hitbox,請修改 PlayArea 元件,如下所示。

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

RectangleHitbox 元件新增為 RectangleComponent 的子項,即可建構碰撞偵測的撞擊方塊,與父項元件的大小相符。當您想要 hitbox 小於或大於父項元件時,RectangleHitbox 會使用名為 relative 的工廠建構函式。

彈球

到目前為止,加入碰撞偵測功能並未對遊戲體驗造成任何影響。修改 Ball 元件後,它就會變更。球與 PlayArea 碰撞時,球的行為必須變更。

請按照下列步驟修改 Ball 元件。

lib/src/components/ball.dart

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

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

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

 
final Vector2 velocity;

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

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

這個範例會新增 onCollisionStart 回呼,因此會進行重大變更。在先前範例中,新增至 BrickBreaker 的碰撞偵測系統會呼叫這個回呼。

首先,程式碼會測試 Ball 是否與 PlayArea 發生碰撞。由於遊戲世界中沒有其他元件,因此這項操作目前似乎多餘。在下一個步驟中,當您在世界中加入蝙蝠時,這會有所改變。接著,它也會新增 else 條件,用於處理球與非球棒碰撞的情況。溫馨提醒您,如果願意,請實作剩餘的邏輯。

當球與底部牆壁碰撞時,球會從遊戲表面消失,但仍會顯示在畫面上。您將在後續步驟中使用 Flame 的效果功能處理這個構件。

既然球會與遊戲牆壁碰撞,您可以為玩家提供球棒,讓他們擊球...

7. 將球棒揮向球

建立 bat

如要新增球棒,讓球在遊戲中持續移動,

  1. lib/src/config.dart 檔案中插入一些常數,如下所示。

lib/src/config.dart

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

batHeightbatWidth 常數都很容易理解。另一方面,batStep 常數需要一點說明。為了與遊戲中的球互動,玩家可以使用滑鼠或手指拖曳球棒 (視平台而定),或是使用鍵盤。batStep 常數會設定每次按下向左或向右箭頭按鍵時,蝙蝠的步幅。

  1. 定義 Bat 元件類別,如下所示。

lib/src/components/bat.dart

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

import '../brick_breaker.dart';

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

 
final Radius cornerRadius;

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

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

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

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

此元件提供幾項新功能。

首先,Bat 元件是 PositionComponent,而非 RectangleComponentCircleComponent。這表示這段程式碼需要在螢幕上算繪 Bat。為此,它會覆寫 render 回呼。

仔細查看 canvas.drawRRect (繪製圓角矩形) 呼叫時,您可能會問自己:「矩形在哪裡?」Offset.zero & size.toSize() 會在建立 Rectdart:ui Offset 類別上利用 operator & 超載。這個簡寫字元一開始可能會讓您感到困惑,但您會在較低層級的 Flutter 和 Flame 程式碼中經常看到它。

其次,這個 Bat 元件可使用手指或滑鼠拖曳,具體取決於平台。如要實作這項功能,請新增 DragCallbacks 混合器並覆寫 onDragUpdate 事件。

最後,Bat 元件需要回應鍵盤控制項。moveBy 函式可讓其他程式碼告知這個蝙蝠向左或向右移動特定數量的虛擬像素。這個函式會介紹 Flame 遊戲引擎的新功能:Effect。只要將 MoveToEffect 物件新增為這個元件的子項,玩家就會看到蝙蝠動畫轉移到新位置。Flame 提供一系列 Effect,可執行各種效果。

Effect 的建構函式引數包含對 game getter 的參照。因此,您需要在這個類別中加入 HasGameReference 混合器。這個混合函式會為此元件新增類型安全的 game 存取子,以便存取元件樹狀結構頂端的 BrickBreaker 例項。

  1. 如要讓 Bat 可供 BrickBreaker 使用,請按照下列方式更新 lib/src/components/components.dart 檔案。

lib/src/components/components.dart

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

將蝙蝠新增到世界

如要在遊戲世界中新增 Bat 元件,請按照下列方式更新 BrickBreaker

lib/src/brick_breaker.dart

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

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

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

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

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

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

   
camera.viewfinder.anchor = Anchor.topLeft;

   
world.add(PlayArea());

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

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

   
debugMode = true;
 
}

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

新增 KeyboardEvents 混合器和覆寫的 onKeyEvent 方法,即可處理鍵盤輸入。回想先前新增的程式碼,以適當的步進量移動蝙蝠。

新增的程式碼其餘部分會在適當位置和正確比例下,將蝙蝠加入遊戲世界。將所有這些設定公開至此檔案,即可簡化調整球棒和球相對大小的操作,以便獲得正確的遊戲體驗。

此時如果您玩遊戲,會發現可以移動球拍來攔截球,但除了您在 Ball 的碰撞偵測程式碼中留下的偵錯記錄外,不會有任何可見的回應。

是時候修正這個問題了。請按照下列步驟編輯 Ball 元件。

lib/src/components/ball.dart

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

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

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

 
final Vector2 velocity;

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

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

這些程式碼變更可解決兩個不同的問題。

首先,修正球碰到螢幕底部時會彈出的情況。如要修正這個問題,請將 removeFromParent 呼叫替換為 RemoveEffectRemoveEffect 會在球離開可視範圍後,將球從遊戲世界中移除。

第二,這些變更修正了球棒與球之間的碰撞處理方式。這段處理程式碼非常有利於玩家。只要玩家用球棒碰到球,球就會回到畫面頂端。如果您覺得這個處理方式太寬鬆,且希望更貼近真實情況,請變更這個處理方式,以便更符合您想要的遊戲體驗。

值得一提的是 velocity 更新的複雜性。這不只是反轉速度的 y 元件,就像牆壁碰撞時會發生的情況。它也會依據接觸時球棒和球的相對位置,以某種方式更新 x 元件。這樣一來,玩家就能進一步掌控球的動作,但除透過遊戲進行外,玩家無法以任何方式得知球的確切動作。

您現在有球棒可以打球,如果有磚塊可以用球擊破,那就更棒了!

8. 打破隔閡

建立積木

如要在遊戲中加入積木,請按照下列步驟操作:

  1. lib/src/config.dart 檔案中插入一些常數,如下所示。

lib/src/config.dart

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

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

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

lib/src/components/brick.dart

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

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

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

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

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

到目前為止,您應該已經熟悉大部分的程式碼。這段程式碼使用 RectangleComponent,同時在元件樹狀圖頂端提供碰撞偵測和類型安全的 BrickBreaker 遊戲參照。

這段程式碼引入最重要的新概念,就是玩家如何達成勝利條件。勝利條件檢查會查詢世界中的積木,並確認只剩下一個。這可能會造成一點混淆,因為前一個行會將這個積木從其父項中移除。

您必須瞭解的關鍵重點是,移除元件是排入佇列的指令。這個函式會在程式碼執行後,但在遊戲世界下一個時脈之前移除積木。

如要讓 Brick 元件可供 BrickBreaker 存取,請按照下列方式編輯 lib/src/components/components.dart

lib/src/components/components.dart

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

在世界中加入積木

請更新 Ball 元件,如下所示。

lib/src/components/ball.dart

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

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

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

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

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

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

這會引入唯一的新方面,也就是難度修飾符,可在每次與積木碰撞後增加球的速度。您需要進行遊戲測試,找出適合遊戲的難度曲線。

請按照下列步驟編輯 BrickBreaker 遊戲。

lib/src/brick_breaker.dart

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

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

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

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

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

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

   
camera.viewfinder.anchor = Anchor.topLeft;

   
world.add(PlayArea());

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

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

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

   
debugMode = true;
 
}

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

如果您以目前的狀態執行遊戲,遊戲會顯示所有主要遊戲機制。您可以關閉偵錯功能並宣告完成,但總覺得缺少了什麼。

螢幕截圖:顯示 brick_breaker 遊戲畫面,其中有球、球棒和大部分的磚塊。每個元件都有偵錯標籤

歡迎畫面、遊戲結束畫面,以及分數如何?Flutter 可為遊戲新增這些功能,您接下來將會著手處理這部分。

9. 贏得遊戲

新增播放狀態

在這個步驟中,您會將 Flame 遊戲嵌入 Flutter 包裝函式中,然後為歡迎畫面、遊戲結束畫面和勝利畫面新增 Flutter 疊加層。

首先,您必須修改遊戲和元件檔案,實作播放狀態,以反映是否要顯示疊加層,以及顯示哪一個疊加層。

  1. 請按照下列步驟修改 BrickBreaker 遊戲。

lib/src/brick_breaker.dart

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

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

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

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

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

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

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

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

   
camera.viewfinder.anchor = Anchor.topLeft;

   
world.add(PlayArea());

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

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

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

   
playState = PlayState.playing;                              // To here.

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

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

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

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

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

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

這個程式碼會變更大部分的 BrickBreaker 遊戲。新增 playState 列舉需要花費大量心力。這可記錄玩家在進入、進行、輸掉或贏得遊戲時的位置。在檔案頂端定義列舉,然後將其例項化為隱藏狀態,並搭配相符的 getter 和 setter。當遊戲的各個部分觸發遊戲狀態轉換時,這些 getter 和 setter 可用於修改疊加層。

接下來,您將 onLoad 中的程式碼分割成 onLoad 和新的 startGame 方法。在這個變更生效前,您只能透過重新啟動遊戲來開始新的遊戲。有了這些新功能,玩家現在可以不必採取如此激烈的做法,就能開始新的遊戲。

為了讓玩家能夠開始新的遊戲,您為遊戲設定了兩個新的處理常式。您新增了輕觸處理常式並擴充鍵盤處理常式,讓使用者能夠透過多種模式啟動新遊戲。模擬遊戲狀態後,您可以更新元件,在玩家獲勝或失敗時觸發遊戲狀態轉換。

  1. 請按照下列步驟修改 Ball 元件。

lib/src/components/ball.dart

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

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

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

 
final Vector2 velocity;
 
final double difficultyModifier;

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

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

這項小變更會在 RemoveEffect 中新增 onComplete 回呼,觸發 gameOver 播放狀態。如果玩家讓球從畫面底部逃出,這應該是正確的操作方式。

  1. 請按照下列步驟編輯 Brick 元件。

lib/src/components/brick.dart

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

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

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

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

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

另一方面,如果玩家可以破壞所有磚塊,就會獲得「遊戲結束」畫面。做得好!

新增 Flutter 包裝函式

如要提供用於嵌入遊戲和新增遊戲狀態疊加層的空間,請新增 Flutter 殼層。

  1. lib/src 下建立 widgets 目錄。
  2. 新增 game_app.dart 檔案,並在該檔案中插入下列內容。

lib/src/widgets/game_app.dart

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

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

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

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

此檔案中的大部分內容都遵循標準的 Flutter 小工具樹狀結構建構方式。Flame 專屬的部分包括使用 GameWidget.controlled 建構及管理 BrickBreaker 遊戲例項,以及 GameWidget 的新 overlayBuilderMap 引數。

這個 overlayBuilderMap 的鍵必須與 BrickBreakerplayState setter 新增或移除的疊加層保持一致。如果嘗試設定不在這個對應圖中顯示的疊加層,會導致所有人都不開心。

  1. 如要在畫面上使用這項新功能,請將 lib/main.dart 檔案替換為下列內容。

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

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

如果您在 iOS、Linux、Windows 或網頁上執行這段程式碼,遊戲中就會顯示預期的輸出內容。如果您指定 macOS 或 Android,則需要進行最後調整,才能顯示 google_fonts

啟用字型存取權

為 Android 新增網際網路權限

如果是 Android,您必須新增網際網路權限。請按照下列步驟編輯 AndroidManifest.xml

android/app/src/main/AndroidManifest.xml

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

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

編輯 macOS 的授權檔案

在 macOS 中,您需要編輯兩個檔案。

  1. 編輯 DebugProfile.entitlements 檔案,使其符合以下程式碼。

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. 編輯 Release.entitlements 檔案,使其符合以下程式碼

macos/Runner/Release.entitlements

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

執行這個程式時,應該會在所有平台上顯示歡迎畫面和遊戲結束或獲勝畫面。這些畫面可能過於簡單,如果能提供分數就好了。接下來,您將會進行下一個步驟!

10. 繼續評分

在遊戲中加入分數

在這個步驟中,您會將遊戲分數公開給周圍的 Flutter 情境。在這個步驟中,您會將 Flame 遊戲的狀態公開至周圍的 Flutter 狀態管理。這樣一來,遊戲程式碼就能在玩家每次打破磚塊時更新分數。

  1. 請按照下列步驟修改 BrickBreaker 遊戲。

lib/src/brick_breaker.dart

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

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

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

enum PlayState { welcome, playing, gameOver, won }

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

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

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

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

   
camera.viewfinder.anchor = Anchor.topLeft;

   
world.add(PlayArea());

   
playState = PlayState.welcome;
 
}

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

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

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

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

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

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

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

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

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

score 新增至遊戲,即可將遊戲狀態與 Flutter 狀態管理機制連結。

  1. 修改 Brick 類別,讓玩家破壞磚塊時分數增加 1 分。

lib/src/components/brick.dart

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

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

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

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

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

製作精美的遊戲

您現在已可在 Flutter 中記分,接下來就來組合小工具,讓畫面看起來更美觀。

  1. lib/src/widgets 中建立 score_card.dart,並新增下列內容。

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

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

 
final ValueNotifier<int> score;

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

這項功能可利用 flutter_animate 套件的強大功能,為疊加層加入動畫和樣式,讓疊加層更精緻。

lib/src/widgets/overlay_screen.dart

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

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

 
final String title;
 
final String subtitle;

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

如要進一步瞭解 flutter_animate 的強大功能,請查看「在 Flutter 中建構新一代 UI」程式碼研究室。

這段程式碼會大幅變更 GameApp 元件。首先,您必須將 scoreStatelessWidget 轉換為 StatefulWidget,才能讓 ScoreCard 存取 score。如要新增評分表,您必須新增 Column,以便在遊戲上方堆疊分數。

第二,為了改善歡迎、遊戲結束和獲勝的體驗,您新增了 OverlayScreen 小工具。

lib/src/widgets/game_app.dart

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

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

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

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

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

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

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

完成所有設定後,您現在應該可以在任何六個 Flutter 目標平台上執行這個遊戲。遊戲應類似於以下內容。

這是 brick_breaker 的螢幕截圖,顯示遊戲前畫面,邀請使用者輕觸畫面來玩遊戲

這張《brick_breaker》的螢幕截圖顯示遊戲結束畫面,疊加在蝙蝠和部分積木上

11. 恭喜

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

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

後續步驟

查看一些程式碼研究室…

延伸閱讀