使用 Firestore 新增多人遊戲支援
如何使用 Firebase Cloud Firestore 在遊戲中實現多人遊戲。
多人遊戲需要一種在玩家之間同步遊戲狀態的方式。廣義上講,存在兩種型別的多人遊戲
-
高滴答率。這些遊戲需要在每秒多次同步遊戲狀態,且延遲較低。這些包括動作遊戲、體育遊戲、格鬥遊戲。
-
低滴答率。這些遊戲只需要偶爾同步遊戲狀態,且延遲影響較小。這些包括紙牌遊戲、策略遊戲、益智遊戲。
這類似於即時遊戲與回合制遊戲之間的區別,但這種類比並不完全準確。例如,即時策略遊戲——顧名思義——是即時執行的,但這與高滴答率並不相關。這些遊戲可以在玩家互動之間在本地機器上模擬很多內容。因此,它們不需要那麼頻繁地同步遊戲狀態。
如果您可以作為開發者選擇低滴答率,您應該這樣做。低滴答率降低了延遲要求和伺服器成本。對於需要高滴答率同步的情況,Firestore 不適合。選擇專用的多人遊戲伺服器解決方案,例如 Nakama。Nakama 有一個 Dart 包。
如果您預計您的遊戲需要低滴答率的同步,請繼續閱讀。
本教程演示瞭如何使用 cloud_firestore 包 在遊戲中實現多人遊戲功能。本教程不需要伺服器。它使用兩個或多個客戶端使用 Cloud Firestore 共享遊戲狀態。
1. 準備遊戲以支援多人遊戲
#編寫遊戲程式碼,以響應本地事件和遠端事件來更改遊戲狀態。本地事件可以是玩家操作或某些遊戲邏輯。遠端事件可以是來自伺服器的世界更新。
為了簡化本教程,請從 card 模板開始,該模板位於 flutter/games 倉庫 中。執行以下命令來克隆該倉庫
git clone https://github.com/flutter/games.git
在 templates/card 中開啟專案。
2. 安裝 Firestore
#Cloud Firestore 是雲中的水平擴充套件的 NoSQL 文件資料庫。它包括內建的即時同步。這非常適合我們的需求。它使遊戲狀態在雲資料庫中保持更新,因此每個玩家都看到相同狀態。
如果您想快速瞭解 Cloud Firestore,請觀看以下影片
要將 Firestore 新增到您的 Flutter 專案,請遵循 開始使用 Cloud Firestore 指南的前兩個步驟
期望的結果包括
- 在雲中準備好的 Firestore 資料庫,處於 測試模式
- 生成的
firebase_options.dart檔案 - 將適當的外掛新增到您的
pubspec.yaml
您不需要在此步驟中編寫任何 Dart 程式碼。一旦您理解了該指南中編寫 Dart 程式碼的步驟,請返回本教程。
3. 初始化 Firestore
#-
開啟
lib/main.dart並匯入外掛以及在上一步中由flutterfire configure生成的firebase_options.dart檔案。dartimport 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'firebase_options.dart'; -
在
lib/main.dart中,在runApp()呼叫之上新增以下程式碼dartWidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);這確保了在遊戲啟動時初始化 Firebase。
-
將 Firestore 例項新增到應用程式。這樣,任何小部件都可以訪問此例項。小部件還可以對例項缺失做出反應(如果需要)。
要使用
card模板執行此操作,可以使用provider包(已安裝為依賴項)。將佔位符
runApp(MyApp())替換為以下內容dartrunApp(Provider.value(value: FirebaseFirestore.instance, child: MyApp()));將 provider 放在
MyApp之上,而不是在其內部。這使您可以在沒有 Firebase 的情況下測試應用程式。:::note 如果您不使用
card模板,則必須 安裝provider包 或使用您自己的方法從程式碼庫的不同部分訪問FirebaseFirestore例項。 ::
4. 建立 Firestore 控制器類
#雖然可以直接與 Firestore 通訊,但您應該編寫一個專門的控制器類,以使程式碼更具可讀性和可維護性。
如何實現控制器取決於您的遊戲以及多人遊戲體驗的確切設計。對於 card 模板,您可以同步兩個圓形遊戲區域的內容。這不足以提供完整的多人遊戲體驗,但這是一個好的開始。
要建立控制器,請複製,然後將以下程式碼貼上到名為 lib/multiplayer/firestore_controller.dart 的新檔案中。
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../game_internals/board_state.dart';
import '../game_internals/playing_area.dart';
import '../game_internals/playing_card.dart';
class FirestoreController {
static final _log = Logger('FirestoreController');
final FirebaseFirestore instance;
final BoardState boardState;
/// For now, there is only one match. But in order to be ready
/// for match-making, put it in a Firestore collection called matches.
late final _matchRef = instance.collection('matches').doc('match_1');
late final _areaOneRef = _matchRef
.collection('areas')
.doc('area_one')
.withConverter<List<PlayingCard>>(
fromFirestore: _cardsFromFirestore,
toFirestore: _cardsToFirestore,
);
late final _areaTwoRef = _matchRef
.collection('areas')
.doc('area_two')
.withConverter<List<PlayingCard>>(
fromFirestore: _cardsFromFirestore,
toFirestore: _cardsToFirestore,
);
late final StreamSubscription<void> _areaOneFirestoreSubscription;
late final StreamSubscription<void> _areaTwoFirestoreSubscription;
late final StreamSubscription<void> _areaOneLocalSubscription;
late final StreamSubscription<void> _areaTwoLocalSubscription;
FirestoreController({required this.instance, required this.boardState}) {
// Subscribe to the remote changes (from Firestore).
_areaOneFirestoreSubscription = _areaOneRef.snapshots().listen((snapshot) {
_updateLocalFromFirestore(boardState.areaOne, snapshot);
});
_areaTwoFirestoreSubscription = _areaTwoRef.snapshots().listen((snapshot) {
_updateLocalFromFirestore(boardState.areaTwo, snapshot);
});
// Subscribe to the local changes in game state.
_areaOneLocalSubscription = boardState.areaOne.playerChanges.listen((_) {
_updateFirestoreFromLocalAreaOne();
});
_areaTwoLocalSubscription = boardState.areaTwo.playerChanges.listen((_) {
_updateFirestoreFromLocalAreaTwo();
});
_log.fine('Initialized');
}
void dispose() {
_areaOneFirestoreSubscription.cancel();
_areaTwoFirestoreSubscription.cancel();
_areaOneLocalSubscription.cancel();
_areaTwoLocalSubscription.cancel();
_log.fine('Disposed');
}
/// Takes the raw JSON snapshot coming from Firestore and attempts to
/// convert it into a list of [PlayingCard]s.
List<PlayingCard> _cardsFromFirestore(
DocumentSnapshot<Map<String, Object?>> snapshot,
SnapshotOptions? options,
) {
final data = snapshot.data()?['cards'] as List<Object?>?;
if (data == null) {
_log.info('No data found on Firestore, returning empty list');
return [];
}
try {
return data
.cast<Map<String, Object?>>()
.map(PlayingCard.fromJson)
.toList();
} catch (e) {
throw FirebaseControllerException(
'Failed to parse data from Firestore: $e',
);
}
}
/// Takes a list of [PlayingCard]s and converts it into a JSON object
/// that can be saved into Firestore.
Map<String, Object?> _cardsToFirestore(
List<PlayingCard> cards,
SetOptions? options,
) {
return {'cards': cards.map((c) => c.toJson()).toList()};
}
/// Updates Firestore with the local state of [area].
Future<void> _updateFirestoreFromLocal(
PlayingArea area,
DocumentReference<List<PlayingCard>> ref,
) async {
try {
_log.fine('Updating Firestore with local data (${area.cards}) ...');
await ref.set(area.cards);
_log.fine('... done updating.');
} catch (e) {
throw FirebaseControllerException(
'Failed to update Firestore with local data (${area.cards}): $e',
);
}
}
/// Sends the local state of `boardState.areaOne` to Firestore.
void _updateFirestoreFromLocalAreaOne() {
_updateFirestoreFromLocal(boardState.areaOne, _areaOneRef);
}
/// Sends the local state of `boardState.areaTwo` to Firestore.
void _updateFirestoreFromLocalAreaTwo() {
_updateFirestoreFromLocal(boardState.areaTwo, _areaTwoRef);
}
/// Updates the local state of [area] with the data from Firestore.
void _updateLocalFromFirestore(
PlayingArea area,
DocumentSnapshot<List<PlayingCard>> snapshot,
) {
_log.fine('Received new data from Firestore (${snapshot.data()})');
final cards = snapshot.data() ?? [];
if (listEquals(cards, area.cards)) {
_log.fine('No change');
} else {
_log.fine('Updating local data with Firestore data ($cards)');
area.replaceWith(cards);
}
}
}
class FirebaseControllerException implements Exception {
final String message;
FirebaseControllerException(this.message);
@override
String toString() => 'FirebaseControllerException: $message';
}
請注意此程式碼的以下特性
-
控制器的建構函式接受一個
BoardState。這使控制器能夠操作遊戲本地狀態。 -
控制器訂閱本地更改以更新 Firestore,並訂閱遠端更改以更新本地狀態和 UI。
-
欄位
_areaOneRef和_areaTwoRef是 Firebase 文件引用。它們描述了每個區域的資料駐留位置,以及如何在本地 Dart 物件(List<PlayingCard>)和遠端 JSON 物件(Map<String, dynamic>)之間進行轉換。Firestore API 允許我們使用.snapshots()訂閱這些引用,並使用.set()寫入它們。
5. 使用 Firestore 控制器
#-
開啟負責啟動遊戲會話的檔案:對於
card模板,在lib/play_session/play_session_screen.dart中。您將從該檔案例項化 Firestore 控制器。 -
匯入 Firebase 和控制器
dartimport 'package:cloud_firestore/cloud_firestore.dart'; import '../multiplayer/firestore_controller.dart'; -
將一個可為空的欄位新增到
_PlaySessionScreenState類,以包含控制器例項dartFirestoreController? _firestoreController; -
在同一類的
initState()方法中,新增程式碼以嘗試讀取 FirebaseFirestore 例項,如果成功,則構造控制器。您已在 初始化 Firestore 步驟中將FirebaseFirestore例項新增到main.dart。dartfinal firestore = context.read<FirebaseFirestore?>(); if (firestore == null) { _log.warning( "Firestore instance wasn't provided. " 'Running without _firestoreController.', ); } else { _firestoreController = FirestoreController( instance: firestore, boardState: _boardState, ); } -
使用同一類的
dispose()方法銷燬控制器。dart_firestoreController?.dispose();
6. 測試遊戲
#-
在兩臺單獨的裝置上或在同一裝置上的 2 個不同的視窗中運行遊戲。
-
觀察在一個裝置上新增卡片到區域,它如何在另一個裝置上出現。
-
開啟 Firebase Web 控制檯 並導航到您的專案 Firestore 資料庫。
-
觀察它如何即時更新資料。您甚至可以在控制檯中編輯資料,並檢視所有正在執行的客戶端更新。

故障排除
#測試 Firebase 整合時,您可能會遇到以下常見問題
-
遊戲在嘗試訪問 Firebase 時崩潰。
- Firebase 整合未正確設定。重新訪問 步驟 2 並確保將
flutterfire configure作為該步驟的一部分執行。
- Firebase 整合未正確設定。重新訪問 步驟 2 並確保將
-
遊戲在 macOS 上無法與 Firebase 通訊。
- 預設情況下,macOS 應用程式沒有網際網路訪問許可權。首先啟用 網際網路授權。
7. 後續步驟
#此時,遊戲具有近乎即時且可靠的跨客戶端狀態同步。它缺少實際的遊戲規則:何時可以玩哪些卡牌以及結果如何。這取決於遊戲本身,由您來嘗試。
此時,比賽的共享狀態僅包括兩個遊戲區域和其中的卡牌。您還可以將其他資料儲存到 _matchRef 中,例如玩家是誰以及輪到誰了。如果您不確定從哪裡開始,請遵循 Firestore 程式碼實驗室 以熟悉 API。
起初,與同事和朋友測試您的多人遊戲時,單個比賽就足夠了。在接近釋出日期時,請考慮身份驗證和匹配。幸運的是,Firebase 提供了 內建方式來驗證使用者,Firestore 資料庫結構可以處理多個比賽。您可以將比賽集合填充為需要的記錄數,而不是單個 match_1。
線上比賽可以從“等待”狀態開始,只有第一名玩家存在。其他玩家可以在某種大廳中看到“等待”比賽。一旦有足夠多的玩家加入比賽,它將變為“活動”。再次,確切的實現取決於您想要什麼樣的線上體驗。基本原理仍然相同:一個大型文件集合,每個文件代表一個活動或潛在的比賽。