文/ Very Good Ventures 團隊,5 月 11 日發表於 Flutter 官方部落格
為了今年的 Google I/O 大會,Flutter 團隊使用 Flutter 以及 Firebase 構建了一款經典的彈球遊戲。下面將會介紹我們是如何通過 Flame 遊戲引擎將 I/O 彈球遊戲 帶到 Web 端的。
遊戲開發要點
使用 Flutter 打造使用者互動型別的遊戲是一個很棒的選擇,例如拼圖或者文字遊戲這樣的遊戲。Flame 是一個在 Flutter 上構建的 2D 遊戲引擎,當涉及到使用遊戲迴圈的遊戲時它會非常有用。I/O 彈球遊戲使用了 Flame 提供的一系列特性,例如動畫、物理引擎、碰撞檢測等等,同時還藉助了 Flutter 框架的基礎架構。如果你能用 Flutter 構建應用,你就獲得 Flame 構建遊戲所需的基礎。
遊戲迴圈
通常來說應用螢幕在沒有使用者互動事件的時候都會保持視覺靜止狀態。遊戲中則是相反的—— UI 會持續的渲染,而且遊戲狀態會不斷變化。Flame 提供了一個 game widget,它內部管理了一個遊戲迴圈,所以能恆定且高效地進行渲染。Game
類包含了遊戲元件以及其邏輯的實現,然後被交給 widget 樹中的 GameWidget
。在 I/O 彈球遊戲中,遊戲迴圈反映了彈球在遊戲場的位置以及狀態,然後如果球與物體碰撞或跌出比賽則需要給出必要的反饋。
@override
void update(double dt) {
super.update(dt);
final direction = -parent.body.linearVelocity.normalized();
angle = math.atan2(direction.x, -direction.y);
size = (_textureSize / 45) *
parent.body.fixtures.first.shape.radius;
}
使用 2D 元件渲染 3D 空間
在做 I/O 彈球遊戲的時候,其中遇到的一個挑戰即是如何使用 2D 元素渲染一個 3D 的互動體驗。元件需要知道在螢幕上渲染的前後順序。例如,當小球發射到斜坡上時,它的順序會向前,這樣就會讓它看起來出現在斜坡的頂部。
彈球、彈射活塞、擋板以及 Chrome 小恐龍等等這些元素都是可活動的,這意味著它應該遵循真實世界的物理規則。而且彈球也需要根據它在板子上的位置改變其大小。當彈球滾到頂部時,它應該越來越小,以讓它看著離使用者更遠。此外,重力還會讓彈球調整角度,讓它能在斜坡上更快地落下。
/// Scales the ball's body and sprite according to its position on the board.
class BallScalingBehavior extends Component with ParentIsA<Ball> {
@override
void update(double dt) {
super.update(dt);
final boardHeight = BoardDimensions.bounds.height;
const maxShrinkValue = BoardDimensions.perspectiveShrinkFactor;
final standardizedYPosition = parent.body.position.y + (boardHeight / 2);
final scaleFactor = maxShrinkValue +
((standardizedYPosition / boardHeight) * (1 - maxShrinkValue));
parent.body.fixtures.first.shape.radius = (Ball.size.x / 2) * scaleFactor;
final ballSprite = parent.descendants().whereType<SpriteComponent>();
if (ballSprite.isNotEmpty) {
ballSprite.single.scale.setValues(
scaleFactor,
scaleFactor,
);
}
}
}
Forge 2D 的物理引擎
I/O 彈球遊戲很大程度依賴了 Flame 團隊維護的 forge2d package。這個 package 將開源的 Box2D 物理引擎 移植到 Dart 中,以便可以輕鬆整合到 Flutter。我們使用 forge2d
增強遊戲中的物理特性,例如物體(夾板)在遊戲場上的之間的碰撞檢測。使用 forge2D
能夠我們監聽夾板發生碰撞的時機。我們就可以在這裡向夾板新增互動的回撥,當兩個物體發生碰撞的時候我們就能收到通知。例如,彈球(它是圓形的)與彈簧(它是橢圓形的)接觸時,我們就會增加它的得分。在這些回撥中,我們可以清楚地設定接觸開始和結束的位置,以便當兩個物體相互接觸時,會發生碰撞。
@override
Body createBody() {
final shape = CircleShape()..radius = size.x / 2;
final bodyDef = BodyDef(
position: initialPosition,
type: BodyType.dynamic,
userData: this,
);
return world.createBody(bodyDef)
..createFixtureFromShape(shape, 1);
}
Sprite sheet 動畫
在彈球遊戲場中有一些小東西,例如 Android、Dash(Dart 吉祥物)、Sparky(Firebase 吉祥物)以及 Chrome 小恐龍,這些都是動畫。對於這些東西,我們使用了 sprite sheets,它已經包含在 Flame 引擎中了,叫做 SpriteAnimationComponent
。對於每個元素,我們都有一個檔案,其中包含不同方向的影像、檔案中的幀數以及幀之間的時間。使用這些資料,Flame 中的 SpriteAnimationComponent
能夠在一個迴圈中將所有影像編在一起,使元素看起來在運動。
△ Sprite sheet 示例
final spriteSheet = gameRef.images.fromCache(
Assets.images.android.spaceship.animatronic.keyName,
);
const amountPerRow = 18;
const amountPerColumn = 4;
final textureSize = Vector2(
spriteSheet.width / amountPerRow,
spriteSheet.height / amountPerColumn,
);
size = textureSize / 10;
animation = SpriteAnimation.fromFrameData(
spriteSheet,
SpriteAnimationData.sequenced(
amount: amountPerRow * amountPerColumn,
amountPerRow: amountPerRow,
stepTime: 1 / 24,
textureSize: textureSize,
),
);
接下來詳細解析 I/O 彈球遊戲程式碼。
來自 Firebase 的實時積分排行榜
I/O 彈球排行榜實時地顯示世界各地玩家的最高分數,玩家還可以在 Twitter 和 Facebook 上分享他們的分數。我們使用 Firebase Cloud Firestore 記錄排名前十的分數,將其顯示在排行榜上。當一個新的分數計入排行榜時,一個 Cloud Function 會將分數按降序排列並刪除目前不在前十的分數。
/// Acquires top 10 [LeaderboardEntryData]s.
Future<List<LeaderboardEntryData>> fetchTop10Leaderboard() async {
try {
final querySnapshot = await _firebaseFirestore
.collection(_leaderboardCollectionName)
.orderBy(_scoreFieldName, descending: true)
.limit(_leaderboardLimit)
.get();
final documents = querySnapshot.docs;
return documents.toLeaderboard();
} on LeaderboardDeserializationException {
rethrow;
} on Exception catch (error, stackTrace) {
throw FetchTop10LeaderboardException(error, stackTrace);
}
}
構建 Web 應用
與傳統應用相比,製作響應式的遊戲更容易。彈球遊戲只需要根據裝置的大小進行縮放即可。對於 I/O 彈球遊戲,我們基於固定比例的裝置大小進行縮放。確保了無論在何種顯示大小,座標系統總是相同的。保證元件在不同裝置之間的一致顯示和互動非常重要。
I/O 彈球遊戲也適配了移動和桌面瀏覽器。在移動瀏覽器上,使用者可以點選啟動按鈕開始播放,也可以點選螢幕左右兩側來控制相應的擋板。在桌面瀏覽器上,使用者可以使用鍵盤來發射彈球和控制擋板。
程式碼架構
彈球程式碼遵循分層架構,每個功能都在自己的資料夾中。在這個專案中,遊戲邏輯也與視覺元件分離。讓我們能獨立於遊戲邏輯輕鬆地更新視覺元素。彈球遊戲的主題取決於玩家在遊戲開始前選擇的角色。主題是由 CharacterThemeCubit
類控制的。根據角色的選擇,球的顏色、背景和其他元素都會更新。
/// {@template character_theme}
/// Base class for creating character themes.
///
/// Character specific game components should have a getter specified here to
/// load their corresponding assets for the game.
/// {@endtemplate}
abstract class CharacterTheme extends Equatable {
/// {@macro character_theme}
const CharacterTheme();
/// Name of character.
String get name;
/// Asset for the ball.
AssetGenImage get ball;
/// Asset for the background.
AssetGenImage get background;
/// Icon asset.
AssetGenImage get icon;
/// Icon asset for the leaderboard.
AssetGenImage get leaderboardIcon;
/// Asset for the the idle character animation.
AssetGenImage get animation;
@override
List<Object> get props => [
name,
ball,
background,
icon,
leaderboardIcon,
animation,
];
}
I/O 彈球的遊戲狀態是用 flam_bloc 這個 package 處理的,這是一個組合了 bloc 和 Flame 元件的 package。例如,我們使用 flame_bloc
來記錄剩餘的遊戲回合數、遊戲中獲得的獎勵以及當前的遊戲分數。另外,在 wdget 樹頂層有一個 widget,它包含載入頁面的邏輯以及玩遊戲的說明。我們還遵循 行為型模式 來封裝和隔離基於元件的遊戲功能元素。例如,保險槓在被球擊中時會發出聲音,所以我們實現了 BumperNoiseBehavior
類來處理這個問題。
class BumperNoiseBehavior extends ContactBehavior {
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
readProvider<PinballPlayer>().play(PinballAudio.bumper);
}
}
程式碼庫還包含全面的單元測試、元件測試和黃金測試。測試遊戲會帶來一些挑戰,因為一個元件可能具有多個職責,使得它們很難單獨地進行測試。最終我們定義了更好的隔離和測試元件的模式,並將其改進整合到 flame_test 這個 package 中。
元件沙盒
這個專案高度依賴於 Flame 元件帶來的模擬彈球體驗。程式碼附帶了一個元件沙盒,它類似於 Flutter Gallery 中展示的 UI 元件庫。在開發遊戲時,這是一個很有用的工具,因為它提供了獨立的每個遊戲元件以確保它們的外觀和行為符合預期,然後再將它們整合到遊戲中。
接下來
邀請你來 I/O Pinball 試玩並獲取高分!關注積分排行榜並且在社交媒體上分享你的分數,也可以在 GitHub repo 訪問並學習專案的開原始碼。
原文連結:
https://medium.com/flutter/i-o-pinball-powered-by-flutter-and-firebase-d22423f3f5d
本地化: CFUG 團隊