多人游戏需要一种在玩家之间同步游戏状态的方式。广义上讲,存在两种类型的多人游戏:

  1. 高刷新率。这些游戏需要以低延迟每秒同步多次游戏状态。这包括动作游戏、体育游戏和格斗游戏。

  2. 低刷新率。这些游戏只需要偶尔同步游戏状态,延迟影响较小。这包括纸牌游戏、策略游戏和益智游戏。

这类似于实时游戏与回合制游戏之间的区别,尽管这个类比并不完全恰当。例如,实时策略游戏正如其名,实时运行,但这与高刷新率无关。这些游戏可以在本地机器上模拟玩家交互之间发生的大部分内容。因此,它们不需要经常同步游戏状态。

An illustration of two mobile phones and a two-way arrow between them

如果您作为开发者可以选择低刷新率,您应该这样做。低刷新率可以降低延迟要求和服务器成本。有时,游戏需要高刷新率同步。对于这些情况,Firestore 等解决方案并不适合。请选择专用的多人游戏服务器解决方案,例如 Nakama。Nakama 有一个 Dart 包

如果您预计您的游戏需要低刷新率同步,请继续阅读。

本指南演示了如何使用 cloud_firestore在您的游戏中实现多人游戏功能。本指南不需要服务器。它使用两个或更多客户端通过 Cloud Firestore 共享游戏状态。

1. 为多人游戏准备您的游戏

#

编写您的游戏代码,使其能够响应本地事件和远程事件来改变游戏状态。本地事件可以是玩家操作或某些游戏逻辑。远程事件可以是来自服务器的世界更新。

Screenshot of the card game

为简化本指南,请从您在 flutter/games 仓库中找到的 card 模板开始。运行以下命令克隆该仓库:

git clone https://github.com/flutter/games.git

打开 templates/card 中的项目。

2. 安装 Firestore

#

Cloud Firestore 是一个云端水平扩展的 NoSQL 文档数据库。它包含内置的实时同步功能。这非常适合我们的需求。它使游戏状态在云数据库中保持更新,因此每个玩家都能看到相同的状态。

如果您想快速了解 Cloud Firestore(15 分钟入门),请观看以下视频:

在新标签页中观看 YouTube 视频:“什么是 NoSQL 数据库?了解 Cloud Firestore”

要将 Firestore 添加到您的 Flutter 项目中,请按照 Cloud Firestore 入门指南的前两个步骤操作:

预期结果包括:

  • 一个在云端准备就绪的 Firestore 数据库,处于测试模式
  • 一个生成的 firebase_options.dart 文件
  • 已将适当的插件添加到您的 pubspec.yaml

在此步骤中,您无需编写任何 Dart 代码。一旦您理解了该指南中编写 Dart 代码的步骤,请返回本指南。

3. 初始化 Firestore

#
  1. 打开 lib/main.dart 并导入插件,以及上一步中由 flutterfire configure 生成的 firebase_options.dart 文件。

    Dart
    import 'package:cloud_firestore/cloud_firestore.dart';
    import 'package:firebase_core/firebase_core.dart';
    
    import 'firebase_options.dart';
  2. 将以下代码添加到 lib/main.dart 中调用 runApp() 的正上方:

    Dart
    WidgetsFlutterBinding.ensureInitialized();
    
    await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

    这确保了 Firebase 在游戏启动时被初始化。

  3. 将 Firestore 实例添加到应用中。这样,任何 widget 都可以访问此实例。如果需要,widget 也可以对实例缺失做出反应。

    要使用 card 模板执行此操作,您可以使用 provider 包(该包已作为依赖项安装)。

    将样板代码 runApp(MyApp()) 替换为以下内容:

    Dart
    runApp(Provider.value(value: FirebaseFirestore.instance, child: MyApp()));

    将 provider 放在 MyApp 的上方,而不是内部。这使您无需 Firebase 即可测试应用程序。

4. 创建 Firestore 控制器类

#

尽管您可以直接与 Firestore 通信,但您应该编写一个专用的控制器类,以使代码更具可读性和可维护性。

您如何实现控制器取决于您的游戏以及多人游戏体验的具体设计。对于 card 模板的情况,您可以同步两个圆形游戏区域的内容。这不足以提供完整的多人游戏体验,但这是一个很好的开始。

Screenshot of the card game, with arrows pointing to playing areas

要创建控制器,请将以下代码复制并粘贴到名为 lib/multiplayer/firestore_controller.dart 的新文件中。

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 控制器

#
  1. 打开负责启动游戏会话的文件:对于 card 模板,该文件是 lib/play_session/play_session_screen.dart。您将在此文件中实例化 Firestore 控制器。

  2. 导入 Firebase 和控制器

    Dart
    import 'package:cloud_firestore/cloud_firestore.dart';
    import '../multiplayer/firestore_controller.dart';
  3. _PlaySessionScreenState 类中添加一个可为空的字段以包含控制器实例

    Dart
    FirestoreController? _firestoreController;
  4. 在该类的 initState() 方法中,添加尝试读取 FirebaseFirestore 实例的代码,如果成功,则构造控制器。您在初始化 Firestore 步骤中将 FirebaseFirestore 实例添加到了 main.dart

    Dart
    final firestore = context.read<FirebaseFirestore?>();
    if (firestore == null) {
      _log.warning(
        "Firestore instance wasn't provided. "
        'Running without _firestoreController.',
      );
    } else {
      _firestoreController = FirestoreController(
        instance: firestore,
        boardState: _boardState,
      );
    }
  5. 使用该类的 dispose() 方法处理控制器。

    Dart
    _firestoreController?.dispose();

6. 测试游戏

#
  1. 在两台单独的设备上或在同一设备的两个不同窗口中运行游戏。

  2. 观察在一台设备上向某个区域添加卡牌如何使其在另一台设备上出现。

  3. 打开 Firebase Web 控制台并导航到您项目的 Firestore 数据库。

  4. 观察它如何实时更新数据。您甚至可以在控制台中编辑数据,并看到所有正在运行的客户端都随之更新。

    Screenshot of the Firebase Firestore data view

故障排除

#

测试 Firebase 集成时可能遇到的最常见问题包括:

  • 尝试连接 Firebase 时游戏崩溃。

    • Firebase 集成尚未正确设置。请重新查看步骤 2 并确保在该步骤中运行了 flutterfire configure
  • 游戏无法在 macOS 上与 Firebase 通信。

    • 默认情况下,macOS 应用没有互联网访问权限。首先启用互联网授权

7. 后续步骤

#

至此,游戏已实现客户端之间近乎即时和可靠的状态同步。它缺少实际的游戏规则:何时可以打出哪些牌,以及结果如何。这取决于游戏本身,留给您自行尝试。

An illustration of two mobile phones and a two-way arrow between them

此时,对局的共享状态仅包括两个游戏区域和其中的卡牌。您也可以将其他数据保存到 _matchRef 中,例如玩家是谁以及轮到谁的回合。如果您不确定从何开始,请参阅一两个 Firestore Codelab,以熟悉该 API。

首先,一场比赛足以与同事和朋友测试您的多人游戏。随着发布日期的临近,请考虑身份验证和匹配功能。幸运的是,Firebase 提供了一种内置的用户身份验证方式,并且 Firestore 数据库结构可以处理多场比赛。您可以向比赛集合填充所需数量的记录,而不是仅有一个 match_1

Screenshot of the Firebase Firestore data view with additional matches

在线比赛可以从“等待”状态开始,只有第一位玩家在场。其他玩家可以在某种大厅中看到处于“等待”状态的比赛。一旦有足够多的玩家加入比赛,比赛就会变为“活跃”状态。同样,具体的实现取决于您想要的在线体验类型。基本原理保持不变:一个包含大量文档的集合,每个文档代表一个活跃或潜在的比赛。