跳转至正文

使用 Firestore 添加多人游戏支持

如何使用 Firebase Cloud Firestore 在游戏中实现多人游戏。

多人游戏需要在玩家之间同步游戏状态。概括而言,存在两类多人游戏:

  1. 高 tick rate。这类游戏需要每秒多次以低延迟同步游戏状态。包括动作游戏、体育游戏、格斗游戏等。

  2. 低 tick rate。这类游戏只需偶尔同步游戏状态,延迟影响较小。包括纸牌游戏、策略游戏、益智游戏等。

这与即时制与回合制游戏的区分类似,但类比并不完全准确。例如,即时战略游戏顾名思义是实时运行的,但这并不等同于高 tick rate。这类游戏可以在本地机器上模拟玩家交互之间发生的许多内容,因此不需要频繁同步游戏状态。

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

如果你能作为开发者选择低 tick rate,就应该这样做。低 tick 可降低延迟要求和服务器成本。有时游戏需要高 tick rate 的同步,此时 Firestore 等方案 并不合适。应选择专用多人服务器方案,例如 Nakama。Nakama 提供 Dart package

如果你预计游戏需要低 tick rate 的同步,请继续阅读。

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

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

#

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

Screenshot of the card game

为简化本实用教程,请从 flutter/games repository 中的 card 模板开始。运行以下命令克隆该仓库:

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

templates/card 中打开项目。

2. 安装 Firestore

#

Cloud Firestore 是云端可水平扩展的 NoSQL 文档数据库,内置实时同步,非常适合我们的需求。它会在云数据库中保持游戏状态更新,使每位玩家看到相同的状态。

若想快速了解 Cloud Firestore(约 15 分钟),可观看以下视频:

Watch on YouTube in a new tab: "What is a NoSQL Database? Learn about Cloud Firestore"

要将 Firestore 添加到你的 Flutter 项目,请遵循 Cloud Firestore 入门指南 的前两步:

预期结果包括:

  • 云端已就绪的 Firestore 数据库,处于 Test mode

  • 已生成的 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.dartrunApp() 调用正上方添加以下代码:

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

    这可确保游戏启动时初始化 Firebase。

  3. 将 Firestore 实例添加到应用中,这样任何 widget 都可以访问该实例,必要时 widget 也可对实例缺失做出响应。

    对于 card 模板,你可以使用 provider package(已作为依赖安装)。

    将样板 runApp(MyApp()) 替换为:

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

    Put the provider above MyApp, not inside it. This enables you to test the app without Firebase. 将 provider 放在 MyApp 之上,而非其内部,这样你可以在不使用 Firebase 的情况下测试应用。

    如果你 使用 card 模板,则必须 安装 provider package,或使用你自己的方式从代码库各处访问 FirebaseFirestore 实例。 :::

4. 创建 Firestore controller 类

#

虽然可以直接与 Firestore 通信,但你应编写专用的 controller 类,使代码更易读、更易维护。

如何实现 controller 取决于你的游戏以及多人体验的具体设计。对于 card 模板,你可以同步两个圆形游戏区域的内容。这不足以构成完整的多人体验,但是个良好的起点。

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

要创建 controller,请将以下代码复制并粘贴到新文件 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 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';
}

请注意此代码的以下特点:

  • controller 的构造函数接受 BoardState,使 controller 能够操作游戏的本地状态。

  • controller 既订阅本地变更以更新 Firestore,也订阅远程变更以更新本地状态和 UI。

  • 字段 _areaOneRef_areaTwoRef 是 Firebase 文档引用。它们描述每个区域数据的存放位置,以及如何在本地 Dart 对象(List<PlayingCard>)与远程 JSON 对象(Map<String, dynamic>)之间转换。 Firestore API 允许我们通过 .snapshots() 订阅这些引用,并通过 .set() 写入。

5. 使用 Firestore controller

#
  1. 打开负责开始对局会话的文件:对于 card 模板,即 lib/play_session/play_session_screen.dart。你将在此文件中实例化 Firestore controller。

  2. 导入 Firebase 和 controller:

    dart
    import 'package:cloud_firestore/cloud_firestore.dart';
    import '../multiplayer/firestore_controller.dart';
    
  3. _PlaySessionScreenState 类中添加可空字段以保存 controller 实例:

    dart
    FirestoreController? _firestoreController;
    
  4. 在同一类的 initState() 方法中,添加尝试读取 FirebaseFirestore 实例并在成功时构建 controller 的代码。你在 初始化 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() 方法中释放 controller。

    dart
    _firestoreController?.dispose();
    

6. 测试游戏

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

  2. 观察在一台设备的某个区域添加卡牌后,它如何出现在另一台设备上。

  3. 打开 Firebase 网页控制台,导航到你项目的 Firestore Database。

  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,你可以在 matches 集合中填充所需数量的记录。

Screenshot of the Firebase Firestore data view with additional matches

在线对局可以以「等待」状态开始,仅首位玩家在场。其他玩家可在某种大厅中看到「等待」中的对局。足够玩家加入后,对局变为「活跃」。具体实现再次取决于你想要的在线体验类型。基本原理不变:一个大型文档集合,每个文档代表一个活跃或潜在的对局。