1. 簡介
Flame 是以 Flutter 為基礎的 2D 遊戲引擎。在本程式碼研究室中,您將建構一個靈感來自 70 年代電玩遊戲 70 年代經典電玩遊戲的 Steve Wozniak 的 Breakout 影片。您將使用 Flame 的元件來繪製球棒、球和磚。您將使用 Flame 的 Effects 為蝙蝠的移動繪製動畫,並學習如何將 Flame 與 Flutter 的狀態管理系統整合。
完成後,遊戲看起來應該會像這張 GIF 動畫,但現在可以放慢速度。
課程內容
- Flame 的基本運作方式 (從
GameWidget
開始)。 - 如何使用遊戲迴圈。
- Flame 的
Component
運作方式。這些角色與 Flutter 的Widget
相似。 - 如何處理衝突。
- 如何使用
Effect
為Component
建立動畫。 - 如何在 Flame 遊戲上疊加 Flutter
Widget
。 - 如何整合 Flame 與 Flutter 的狀態管理。
建構項目
在本程式碼研究室中,您將使用 Flutter 和 Flame 建構 2D 遊戲。完成後,遊戲必須符合下列規定
- 在 Flutter 支援的六個平台上函式:Android、iOS、Linux、macOS、Windows 和網頁
- 使用 Flame 的遊戲迴圈,維持至少 60 fps。
- 使用
google_fonts
套件和flutter_animate
等 Flutter 功能,重現 80 年代街機遊戲的氛圍。
2. 設定 Flutter 環境
編輯者
為簡化本程式碼研究室,我們假設 Visual Studio Code (VS Code) 是您的開發環境。VS Code 完全免費,適用於所有主要平台。本程式碼研究室會使用 VS Code,因為操作說明預設為 VS Code 專屬快速鍵。「按一下此按鈕」會讓工作變得更簡單明瞭或「按下這個鍵即可執行 X」而不是「在編輯器中採取適當動作 X」。
您可以視需求使用任何編輯器:Android Studio、其他 IntelliJ IDE、Emacs、Vim 或 Notepad++。他們都會與 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 安裝、開發目標相關工具和編輯器外掛程式。在這個程式碼研究室中,請安裝下列軟體:
- Flutter SDK
- 含 Flutter 外掛程式的 Visual Studio Code
- 適用於所選開發目標的編譯器軟體。(您需要 Visual Studio 以指定 Windows,或用 Xcode 指定 macOS 或 iOS)
在下一節中,您將建立第一個 Flutter 專案。
如果您需要排解任何問題,不妨參考 StackOverflow 中的一些問題和答案,以便排解問題。
常見問題
- 如何找出 Flutter SDK 路徑?
- 找不到 Flutter 指令時該怎麼辦?
- 如何修正「Waiting for another flutter 指令以釋放啟動鎖定」問題該怎麼辦?
- 如何告知 Flutter 安裝的 Android SDK 位置?
- 執行
flutter doctor --android-licenses
時,如何處理 Java 錯誤? - 我找不到 Android
sdkmanager
工具,該如何處理? - 如何處理「
cmdline-tools
元件遺失」錯誤訊息? - 如何在 Apple Silicon (M1) 上執行 CocoaPods?
- 如何停用儲存 VS Code 時的自動格式設定功能?
3. 建立專案
建立您的第一個 Flutter 專案
包括開啟 VS Code,並在您選擇的目錄中建立 Flutter 應用程式範本。
- 啟動 Visual Studio Code。
- 開啟指令區塊面板 (
F1
、Ctrl+Shift+P
或Shift+Cmd+P
),然後輸入「flutter new」。出現時,請選取「Flutter: New Project」指令。
- 選取「Empty Application」(空白應用程式)。選擇要在哪個目錄建立專案。這應該是不需要進階權限,或在路徑中包含空格的目錄。例如主目錄或
C:\src\
。
- 為專案「
brick_breaker
」命名。本程式碼研究室的其餘部分會假設您將應用程式命名為brick_breaker
。
Flutter 現在會建立專案資料夾,VS Code 會隨即開啟。現在,您可以使用應用程式的基本 Scaffold 覆寫兩個檔案的內容。
複製和貼上初始應用程式
這樣做會將本程式碼研究室提供的範例程式碼新增至您的應用程式。
- 在 VS Code 的左側窗格中,按一下「Explorer」Explorer,然後開啟
pubspec.yaml
檔案。
- 將這個檔案的內容替換成以下內容:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flame: ^1.16.0
flutter:
sdk: flutter
flutter_animate: ^4.5.0
google_fonts: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter:
uses-material-design: true
pubspec.yaml
檔案會指定應用程式的基本資訊,例如目前版本、其依附元件,以及要用於運送的資產。
- 在
lib/
目錄中開啟main.dart
檔案。
- 將這個檔案的內容替換成以下內容:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- 執行這個程式碼,確認一切運作正常。應該會顯示只有空白背景的新視窗。世界上最糟糕的電玩遊戲將以 60 fps 轉譯!
4. 建立遊戲
測量遊戲體驗
如果遊戲是以二維 (2D) 尺寸玩法,就必須提供遊戲區。您將打造特定維度的區域,然後使用這些維度調整遊戲的其他部分。
您可以透過多種方式在播放區域中排列座標。你可以透過一種慣例,測量畫面中心與畫面中央的(0,0)
方向,正值會沿著 X 軸向右移動,並沿著 Y 軸向上移動。這項標準適用於目前大多數的遊戲,特別是涉及三大尺寸的遊戲。
原先發起分組討論的慣例是在左上角設定原點。正的 x 方向維持不變,但是 y 扭轉。x 的正向 x 方向是右,y 則朝下。為了貫徹這個年代,這款遊戲將畫面左上角設為起點。
在名為 lib/src
的新目錄中建立名為 config.dart
的檔案。這個檔案將在下列步驟中取得更多常數。
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
這個遊戲的寬度和高度為 820 像素,高度為 1600 像素。遊戲區域會縮放至符合顯示視窗的視窗,但加到畫面中的所有元件都符合這個高度和寬度。
建立 PlayArea
在 Breakout 遊戲中,球會在遊戲區域的牆上彈跳。為因應衝突,您需要先有 PlayArea
元件。
- 在名為
lib/src/components
的新目錄中建立名為play_area.dart
的檔案。 - 將以下字串加入這個檔案。
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 的元件已經過最佳化,適合呈現遊戲機制。本程式碼研究室會從下一個步驟介紹的遊戲迴圈。
- 如要控管檔案清爽,請新增包含這項專案中所有元件的檔案。在
lib/src/components
中建立components.dart
檔案,並新增下列內容。
lib/src/components/components.dart
export 'play_area.dart';
export
指令扮演 import
的反向角色。並在匯入其他檔案時宣告這個檔案公開的功能。隨著您按照下列步驟新增元件,這個檔案就會加入更多項目。
打造 Flame 遊戲
為了消滅上個步驟的紅色小松鼠,請為 Flame 的 FlameGame
產生新的子類別。
- 在
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
覆寫的方法中,程式碼會執行兩項動作。
- 將左上角設為觀景窗的錨點。根據預設,觀景窗會使用該區域的中間區域做為
(0,0)
的錨點。 - 將
PlayArea
新增至world
。世界代表遊戲世界,該函式會透過CameraComponent
的檢視畫面轉換,投影其所有子項。
在螢幕上取得遊戲
如要查看此步驟進行的所有變更,請依據下列變更更新 lib/main.dart
檔案。
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
完成這些變更後,請重新啟動遊戲。遊戲應會如下圖所示。
在下一個步驟中,您要將球加入世界,然後開始運動!
5. 顯示球
建立球元件
將球放在螢幕上時,需要建立另一個元件,並將球加入遊戲世界。
- 按照下列方式編輯
lib/src/config.dart
檔案的內容。
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
將已命名常數定義為衍生值的設計模式,會在本程式碼研究室中多次傳回。這可讓您修改頂層 gameWidth
和 gameHeight
,瞭解遊戲的外觀與風格如何隨之變化。
- 在
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
,因此這裡指的是有更多形狀存在。CircleComponent
(和 RectangleComponent
) 衍生自 PositionedComponent
,因此您可以將球放在螢幕上。更重要的是,可以更新其位置。
這個元件會導入 velocity
的概念,或位置隨時間的變化。速率是指 Vector2
物件,因為「速度同時是速度和方向」。如要更新位置,請覆寫 update
方法,遊戲引擎會針對每個影格呼叫這個方法。dt
是指前一個影格與此影格的時間長度。這樣一來,您就可以因應因運算量過多而造成的影格速率 (60 Hz 或 120 hz) 或長影格等因素。
請特別留意 position += velocity * dt
的更新內容。您可以透過這個方式,逐步更新獨立的動作模擬。
- 如要在元件清單中加入
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。
為了正確取得這些各種值,您需要進行反覆測試,也稱為業界遊戲測試。
最後一行會開啟偵錯顯示畫面,在畫面上加入額外資訊,方便偵錯。
現在當您執行遊戲時,應與下列畫面類似。
PlayArea
元件和 Ball
元件都含有偵錯資訊,但背景形狀會裁剪 PlayArea
的數字。這是因為您已開啟整個元件樹狀結構的 debugMode
,所以系統顯示了偵錯資訊。您也可以只為所選元件開啟偵錯功能 (如果這麼做有所幫助)。
如果您重新啟動遊戲數次,可能會發現球迷與牆壁互動的方式不如預期。如要達到此效果,您必須新增衝突偵測,這將在下一個步驟進行。
6. 來回跳動
新增衝突偵測功能
衝突偵測功能會新增行為,讓遊戲辨識兩個物件彼此接觸的情況。
如要在遊戲中加入衝突偵測,請在 BrickBreaker
遊戲中新增 HasCollisionDetection
混音,如以下程式碼所示。
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true;
}
}
這會追蹤元件命中方塊,並觸發每個遊戲滴答的衝突回呼。
如要開始為遊戲的點擊方塊填入資料,請修改 PlayArea
元件,如下所示。
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
將 RectangleHitbox
元件新增為 RectangleComponent
的子項,可建構出與父項元件大小相符的衝突偵測命中方塊。如果想讓命中方塊小於或大於父項元件,可以使用名為 relative
的 RectangleHitbox
工廠建構函式。
彈力球
到目前為止,加入碰撞偵測技術對遊戲體驗沒有任何影響。當您修改 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
相關聯。目前看似多方,因為遊戲世界中沒有其他要素。在下一個步驟中,當你在世界加入 bat 時,就會改變這個想法。然後,這也會新增 else
條件,處理球與球體以外的物體衝突時。在此提醒您實作其餘邏輯 (如有需要)。
當球與底部牆衝突時,球會從播放表面消失,並保持顯示在畫面上。您將利用 Flame's Effects 的強大功能,在日後處理這個構件。
把球打入賽牆後,肯定能為玩家賦予球員打球、打球...
7. 拿著球棒
創作球棒
如果想在遊戲中加入球棒,
- 在
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.
batHeight
和 batWidth
常數都很簡單明瞭。另一方面,batStep
常數需要顯示說明。在這款遊戲中,玩家可以使用滑鼠或手指拖曳球棒 (視平台而定),或使用鍵盤。batStep
常數可設定每次按下向左鍵或向右鍵時,系統棒子步驟的距離。
- 定義
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),
));
}
}
這個元件推出了幾項新功能。
首先,蝙蝠元件是 PositionComponent
,而不是 RectangleComponent
或 CircleComponent
。這表示此程式碼需要在螢幕上算繪 Bat
。為此,此動作會覆寫 render
回呼。
只要仔細查看 canvas.drawRRect
(繪製圓角矩形) 呼叫,您可能會想問:「矩形在哪裡?」Offset.zero & size.toSize()
會在建立 Rect
的 dart:ui
Offset
類別上使用 operator &
超載。這種簡寫可能會導致您一開始就不知所措,但在低階的 Flutter 和 Flame 程式碼中,經常會發現這種現象。
其次,這個 Bat
元件可以透過手指或滑鼠拖曳,視平台而定。如要實作這項功能,請新增 DragCallbacks
混合,並覆寫 onDragUpdate
事件。
最後,Bat
元件需要回應鍵盤控制項。moveBy
函式可讓其他程式碼告訴這個 bat 向左或向右移動特定數量的虛擬像素。這個函式推出了 Flame 遊戲引擎的新功能:Effect
s。將 MoveToEffect
物件新增為這個元件的子項後,玩家會看到以新位置呈現的球棒動畫。Flame 提供一系列可執行各種效果的 Effect
。
Effect 的建構函式引數包含 game
getter 的參照。因此,請將 HasGameReference
混合納入這個類別。此混合作業會將類型安全的 game
存取子新增至這個元件,以存取元件樹狀結構頂端的 BrickBreaker
執行個體。
- 如要讓
BrickBreaker
使用Bat
,請按照下列方式更新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(Bat( // Add from here...
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
方法會處理鍵盤輸入。請回想您稍早新增的程式碼,以適當的步數大小移動棒子。
其餘的程式碼部分則會以適當的比例,將 bat 加到遊戲世界。若能在此檔案中公開所有這些設定,可讓您更輕鬆地微調球棒和球子的相對大小,進而取得適當的遊戲風格。
如果您在這時開始玩遊戲,可以看到可以移動球棒攔截球,但沒有顯示的回應 (除了您留在 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( // Modify from here...
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 { // To here.
debugPrint('collision with $other');
}
}
}
這些程式碼變更修正兩個不同的問題。
第一,修正會在觸碰畫面底部時彈出的球。如要修正這個問題,請將 removeFromParent
呼叫替換為 RemoveEffect
。RemoveEffect
離開可觀看的遊戲區域後,球員就會從遊戲世界中移除。
第二,這些變更可以修正球棒與球之間的碰撞處理。這類處理程式碼非常有效,只要玩家搭配球棒輕觸球,球就會回到畫面頂端。如果這讓你感到厭倦,想獲得更有真實感的體驗,可以根據需要的遊戲感受改變這個處理方式。
建議您指出 velocity
更新的複雜度。而不是對速率的 y
元件進行反轉,與牆衝突相同。也會根據「接觸時」左右球棒和球棒的相對位置,更新 x
元件。如此一來,玩家就能進一步掌控球的表現,但是除了透過遊戲以外的方式,玩家無法以任何方式向玩家傳達資訊。
既然有一個球棒可以用來打球,那就快在球上打幾塊磚吧!
8. 打破牆壁
建立磚塊
想在遊戲中加入磚塊
- 在
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.
- 請按照以下方式插入
Brick
元件。
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
現在,您應該對於此程式碼大部分的熟悉了。此程式碼使用 RectangleComponent
,同時偵測衝突偵測,並在元件樹狀結構頂端對 BrickBreaker
遊戲進行類型安全參照。
此程式碼介紹的最重要新概念是玩家如何達成勝出條件。成品檢查會查詢世界各地是否有積木,確認只有其中一塊殘骸。由於前線會將這個磚從父項中移除,因此這可能會有些混淆。
重點是移除元件是一項排入佇列的指令。直到這個程式碼執行後,在遊戲世界的下一階段出現前,才會移除磚塊。
如要讓 BrickBreaker
存取 Brick
元件,請按照下列方式編輯 lib/src/components/components.dart
。
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
為世界加入積木
請按照下列步驟更新 Ball
元件。
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
這加入唯一的新面向,也就是難度修飾符,可在每次發生磚塊衝突後增加球速度。這個可微調的參數需要透過測試,才能找出適合您遊戲的難度曲線。
按照下列方式編輯 BrickBreaker
遊戲。
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
如果您是以目前的方式執行遊戲,其中會顯示所有主要的遊戲機制。您可以關閉偵錯功能,然後將其呼叫完成,但似乎缺少某些操作。
您覺得歡迎畫面、遊戲畫面或比數更是好嗎?Flutter 可以在遊戲中新增這些功能,你接下來要將注意力轉到這裡。
9. 獲勝
新增播放狀態
在這個步驟中,您將將 Flame 遊戲嵌入 Flutter 包裝函式,然後為歡迎、遊戲結束和獲勝畫面新增 Flutter 疊加層。
首先,您需要修改遊戲和元件檔案,實作播放狀態來反映是否顯示疊加層;如果是顯示,則會說明是否顯示。
- 按照下列方式修改
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,將其例項化為隱藏狀態。當遊戲觸發條件的 Play 狀態轉換時,這些 getter 和 setter 可以修改疊加層。
接著,您會將 onLoad
中的程式碼分割為 onLoad 和新的 startGame
方法。在此之前,您只能透過重新啟動遊戲開始新遊戲。新增這些新增項目後,玩家就能開始新遊戲,不必進行那麼激烈的手段。
為了讓玩家開始新遊戲,您為遊戲設定了兩個新的處理常式。您新增了輕觸處理常式並擴充鍵盤處理常式,讓使用者能以多種模式啟動新遊戲。根據模擬播放狀態建立模型時,建議您更新元件,在玩家獲勝或失敗時觸發播放狀態轉換。
- 請按照下列方式修改
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);
}
}
}
這項小幅變更會將 onComplete
回呼新增至 RemoveEffect
,以觸發 gameOver
的播放狀態。如果玩家允許球從畫面底部跳出,這應該感覺是恰到好處的。
- 請按照下列方式編輯
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.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
另一方面,如果玩家能擊破所有磚塊,就贏得「遊戲贏得」。做得好!玩家,做得好!
新增 Flutter 包裝函式
如要在某個位置嵌入遊戲並新增播放狀態重疊,請新增 Flutter 殼層。
- 在
lib/src
下建立widgets
目錄。 - 新增
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(
useMaterial3: true,
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
的鍵必須與 BrickBreaker
中 playState
setter 新增或移除的疊加層一致。如果嘗試設定此地圖未列出的疊加層,將導致周圍出現不愉快的臉孔。
- 如要在螢幕上顯示這項新功能,請將
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 中,有兩個檔案需要編輯。
- 根據下列程式碼編輯
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>
- 按照下列程式碼編輯
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 狀態管理。如此一來,遊戲程式碼就能在每次玩家破關時更新分數。
- 按照下列方式修改
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 狀態管理。
- 在玩家打破磚塊時修改
Brick
類別,在分數中加入分數。
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
製作外觀精美的遊戲
現在您已經能在 Flutter 中保留分數,接下來就是要整合小工具,讓分數看起來更美觀。
- 在
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!,
),
);
},
);
}
}
- 在
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
元件中發生了大量變更。首先,如要啟用 ScoreCard
存取 score
,請將其從 StatelessWidget
轉換為 StatefulWidget
。新增評量表時,必須新增 Column
,才能將分數堆疊在遊戲上方。
第二,為強化歡迎體驗、遊戲結束和贏得的體驗,您新增了 OverlayScreen
小工具。
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
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 目標平台上執行這款遊戲。遊戲應會如下所示。
11. 恭喜
恭喜,您成功運用 Flutter 和 Flame 打造遊戲!
您已使用 Flame 2D 遊戲引擎打造遊戲,並將該遊戲嵌入 Flutter 包裝函式。你使用了 Flame 的效果來為元件製作動畫和移除元件。你使用了 Google Fonts 和 Flutter Animate 套件,打造整體遊戲美觀體驗。
後續步驟
查看一些程式碼研究室…