使用 Firestore 添加多人游戏支持
多人游戏需要一种方法来同步玩家之间的游戏状态。从广义上讲,存在两种类型的多人游戏
**高 Tick 率**。这些游戏需要每秒多次同步游戏状态,并且延迟很低。这包括动作游戏、体育游戏、格斗游戏。
**低 Tick 率**。这些游戏只需要偶尔同步游戏状态,延迟的影响较小。这包括纸牌游戏、策略游戏、益智游戏。
这类似于实时游戏与回合制游戏的区别,尽管这种类比并不完全准确。例如,实时策略游戏(顾名思义)以实时运行,但这并不意味着其 Tick 率很高。这些游戏可以在本地机器上模拟玩家交互之间发生的大部分事件。因此,它们不需要经常同步游戏状态。
如果作为开发者,你可以选择低 Tick 率,那么你应该这样做。低 Tick 率可以降低延迟要求和服务器成本。有时,游戏需要高 Tick 率的同步。对于这些情况,Firestore 等解决方案**并不适用**。选择专用的多人游戏服务器解决方案,例如 Nakama。Nakama 有一个 Dart 包。
如果你预计你的游戏需要低 Tick 率的同步,请继续阅读。
此食谱演示了如何使用 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(大约 15 分钟),请查看以下视频
什么是 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 的情况下测试应用程序。
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);
StreamSubscription? _areaOneFirestoreSubscription;
StreamSubscription? _areaTwoFirestoreSubscription;
StreamSubscription? _areaOneLocalSubscription;
StreamSubscription? _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, dynamic>> snapshot,
SnapshotOptions? options,
) {
final data = snapshot.data()?['cards'] as List?;
if (data == null) {
_log.info('No data found on Firestore, returning empty list');
return [];
}
final list = List.castFrom<Object?, Map<String, Object?>>(data);
try {
return list.map((raw) => PlayingCard.fromJson(raw)).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. 测试游戏
#在两台单独的设备上或同一设备上的两个不同窗口中运行游戏。
观察如何在其中一台设备上向一个区域添加卡片,使其出现在另一台设备上。
打开 Firebase Web 控制台 并导航到项目的 Firestore 数据库。
观察它如何实时更新数据。你甚至可以在控制台中编辑数据,并看到所有正在运行的客户端更新。
故障排除
#测试 Firebase 集成时可能遇到的最常见问题包括以下内容
尝试访问 Firebase 时游戏崩溃。
- Firebase 集成未正确设置。重新访问**步骤 2** 并确保在该步骤中运行
flutterfire configure
。
- Firebase 集成未正确设置。重新访问**步骤 2** 并确保在该步骤中运行
游戏在 macOS 上不与 Firebase 通信。
- 默认情况下,macOS 应用程序没有 Internet 访问权限。首先启用 Internet 授权。
7. 下一步
#此时,游戏在客户端之间具有近乎即时且可靠的状态同步。它缺少实际的游戏规则:何时可以玩哪些牌,以及结果如何。这取决于游戏本身,留给读者自行尝试。
此时,比赛的共享状态仅包括两个游戏区域及其中的卡片。你也可以将其他数据保存到 _matchRef
中,例如玩家是谁以及轮到谁。如果你不确定从哪里开始,请按照 一两个 Firestore 代码实验室 来熟悉 API。
首先,一个比赛就足以与同事和朋友一起测试你的多人游戏。随着发布日期的临近,请考虑身份验证和匹配机制。值得庆幸的是,Firebase 提供了一种 内置方法来验证用户,并且 Firestore 数据库结构可以处理多个比赛。你可以使用尽可能多的记录填充比赛集合,而不是单个 match_1
。
在线比赛可以从“等待”状态开始,只有第一个玩家在场。其他玩家可以在某种大厅中看到“等待”中的比赛。一旦有足够多的玩家加入比赛,它就会变为“活动”状态。再次强调,确切的实现取决于你想要创建的在线体验类型。基本原理保持不变:一个包含大量文档的集合,每个文档代表一场正在进行或潜在的比赛。
除非另有说明,否则本网站上的文档反映了 Flutter 的最新稳定版本。页面上次更新于 2024-07-06。 查看源代码 或 报告问题。