跳至主要內容

使用 Firestore 新增多人支援

多人遊戲需要一種在玩家之間同步遊戲狀態的方法。廣義來說,存在兩種多人遊戲

  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 分鐘入門教學,請查看以下影片


什麼是 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.dartrunApp() 呼叫的正上方

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

    這確保在遊戲啟動時初始化 Firebase。

  3. 將 Firestore 執行個體新增至應用程式。如此一來,任何小工具都可以存取此執行個體。如果需要,小工具也可以對遺失的執行個體做出反應。

    若要使用 card 範本執行此操作,您可以使用 provider 套件 (已安裝為相依性)。

    將樣板程式碼 runApp(MyApp()) 替換為以下程式碼

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

    將提供者置於 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. 將可為 null 的欄位新增至 _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 個不同視窗中執行遊戲。

  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 程式碼實驗室,以熟悉 API。

首先,單一比賽應該足以讓您與同事和朋友測試您的多人遊戲。當您接近發佈日期時,請考慮驗證和配對。值得慶幸的是,Firebase 提供內建的驗證使用者方法,而且 Firestore 資料庫結構可以處理多場比賽。您可以使用任意多筆記錄來填入比賽集合,而不是單一的 match_1

Screenshot of the Firebase Firestore data view with additional matches

線上比賽可以在「等待」狀態下開始,只有第一位玩家在場。其他玩家可以在某種大廳中看到「等待」中的比賽。一旦足夠的玩家加入比賽,它就會變成「活動」。再次強調,確切的實作取決於您想要哪種類型的線上體驗。基本原理仍然相同:大量的文件集合,每個文件都代表一場活動或潛在的比賽。