使用 Firestore 添加多人游戏支持
如何使用 Firebase Cloud Firestore 在游戏中实现多人模式。
多人游戏需要一种在玩家之间同步游戏状态的方法。广义上讲,多人游戏有两种类型:
-
高频率(High tick rate)。这些游戏需要以低延迟每秒多次同步游戏状态。例如动作游戏、体育游戏、格斗游戏。
-
低频率(Low tick rate)。这些游戏只需要偶尔同步游戏状态,延迟的影响较小。例如卡牌游戏、策略游戏、益智游戏。
这类似于实时游戏与回合制游戏之间的区别,但该类比并不完全准确。例如,即时战略游戏正如其名,是在实时运行的,但这并不代表它需要高频率同步。这些游戏可以在本地机器上模拟玩家交互之间发生的大部分逻辑,因此它们不需要那么频繁地同步游戏状态。
如果您作为开发者可以选择低频率同步,请务必选择。低频率降低了延迟要求和服务器成本。有时,游戏确实需要高频率同步,对于这些情况,Firestore 并不适用。请选择专用的多人游戏服务器解决方案,例如 Nakama。Nakama 提供了一个 Dart 包。
如果您预计您的游戏需要低频率同步,请继续阅读。
本教程演示如何使用 cloud_firestore 包 在您的游戏中实现多人游戏功能。本教程不需要服务器,它通过 Cloud Firestore 让两个或多个客户端共享游戏状态。
1. 为多人模式准备游戏
#编写游戏代码,使其能够响应本地事件和远程事件以更改游戏状态。本地事件可以是玩家操作或某些游戏逻辑。远程事件可以是来自服务器的世界更新。
为了简化本教程,请从 flutter/games 仓库 中的 card 模板开始。运行以下命令克隆该仓库:
git clone https://github.com/flutter/games.git
打开 templates/card 中的项目。
2. 安装 Firestore
#Cloud Firestore 是一款云端水平扩展的 NoSQL 文档数据库。它包含内置的实时同步功能,非常适合我们的需求。它能保持云端数据库中的游戏状态更新,从而确保每个玩家看到的都是相同的状态。
如果您想进行 15 分钟的 Cloud Firestore 快速入门,请观看以下视频:
要将 Firestore 添加到您的 Flutter 项目中,请按照 Cloud Firestore 入门指南的前两个步骤操作:
期望的结果包括:
- 在云端准备好一个处于测试模式 (Test mode) 的 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 实例添加到应用程序中,以便任何 Widget 都能访问此实例。如果需要,Widget 也可以对实例的缺失做出反应。
使用
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 DocumentReference<Map<String, Object?>> _matchRef = instance
.collection('matches')
.doc('match_1');
late final DocumentReference<List<PlayingCard>> _areaOneRef = _matchRef
.collection('areas')
.doc('area_one')
.withConverter<List<PlayingCard>>(
fromFirestore: _cardsFromFirestore,
toFirestore: _cardsToFirestore,
);
late final DocumentReference<List<PlayingCard>> _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 应用没有互联网访问权限。请先启用 互联网访问权限 (internet entitlement)。
7. 后续步骤
#至此,游戏已具备跨客户端即时且可靠的状态同步功能。它目前还缺少实际的游戏规则:哪些卡牌可以在何时出,以及产生什么结果。这取决于具体游戏,需要您自行实现。
此时,比赛的共享状态仅包含两个游戏区域及其内部的卡牌。您也可以将其他数据保存到 _matchRef 中,例如谁是玩家以及轮到谁出牌。如果您不知道从哪里开始,请参考一两个 Firestore Codelab 来熟悉该 API。
起初,单场比赛足以与同事和朋友测试您的多人游戏。随着发布日期的临近,请考虑身份验证和匹配系统。值得庆幸的是,Firebase 提供了 内置的用户身份验证方式,并且 Firestore 数据库结构可以处理多场比赛。您可以根据需要向 matches 集合中填充任意数量的记录,而不是仅有一个 match_1。
在线比赛可以以“等待中”状态开始,此时只有第一位玩家在场。其他玩家可以在某种大厅中看到“等待中”的比赛。一旦足够多的玩家加入比赛,它就会变为“活跃”状态。具体的实现取决于您想要哪种在线体验。基本原理保持不变:一个包含大量文档的集合,每个文档代表一场活跃或潜在的比赛。