跳到主內容

使用 Firestore 新增多人遊戲支援

如何使用 Firebase Cloud Firestore 在遊戲中實現多人模式。

多人遊戲需要一種在玩家之間同步遊戲狀態的方法。廣義上講,多人遊戲有兩種型別:

  1. 高頻率(High tick rate)。這些遊戲需要以低延遲每秒多次同步遊戲狀態。例如動作遊戲、體育遊戲、格鬥遊戲。

  2. 低頻率(Low tick rate)。這些遊戲只需要偶爾同步遊戲狀態,延遲的影響較小。例如卡牌遊戲、策略遊戲、益智遊戲。

這類似於即時遊戲與回合制遊戲之間的區別,但該類比並不完全準確。例如,即時戰略遊戲正如其名,是在即時執行的,但這並不代表它需要高頻率同步。這些遊戲可以在本地機器上模擬玩家互動之間發生的大部分邏輯,因此它們不需要那麼頻繁地同步遊戲狀態。

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 文件資料庫。它包含內建的即時同步功能,非常適合我們的需求。它能保持雲端資料庫中的遊戲狀態更新,從而確保每個玩家看到的都是相同的狀態。

如果您想進行 15 分鐘的 Cloud Firestore 快速入門,請觀看以下影片:

在 YouTube 新標籤頁中觀看:“什麼是 NoSQL 資料庫?瞭解 Cloud Firestore”

要將 Firestore 新增到您的 Flutter 專案中,請按照 Cloud Firestore 入門指南的前兩個步驟操作:

期望的結果包括:

  • 在雲端準備好一個處於測試模式 (Test mode) 的 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 例項新增到應用程式中,以便任何 Widget 都能訪問此例項。如果需要,Widget 也可以對例項的缺失做出反應。

    使用 card 模板時,可以使用 provider 包(該包已作為依賴項安裝)。

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

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

    將 provider 放在 MyApp 之上,而不是內部。這樣您就可以在沒有 Firebase 的情況下測試應用。

    :::note 如果您沒有使用 card 模板,則必須 安裝 provider,或者使用您自己的方法從程式碼庫的各個部分訪問 FirebaseFirestore 例項。 ::

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

#
  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 個不同視窗中運行遊戲。

  2. 觀察在一個裝置上向區域新增卡牌如何使它出現在另一個裝置上。

  3. 開啟 Firebase Web 控制檯 並導航到您專案的 Firestore 資料庫。

  4. 觀察它是如何即時更新資料的。您甚至可以在控制檯中編輯資料,並看到所有正在執行的客戶端都進行了更新。

    Screenshot of the Firebase Firestore data view

故障排除

#

測試 Firebase 整合時最常見的問題包括:

  • 嘗試連線 Firebase 時遊戲崩潰。

    • Firebase 整合未正確設定。請重新訪問第 2 步,並確保執行 flutterfire configure 作為該步驟的一部分。
  • 遊戲無法在 macOS 上與 Firebase 通訊。

7. 後續步驟

#

至此,遊戲已具備跨客戶端即時且可靠的狀態同步功能。它目前還缺少實際的遊戲規則:哪些卡牌可以在何時出,以及產生什麼結果。這取決於具體遊戲,需要您自行實現。

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

此時,比賽的共享狀態僅包含兩個遊戲區域及其內部的卡牌。您也可以將其他資料儲存到 _matchRef 中,例如誰是玩家以及輪到誰出牌。如果您不知道從哪裡開始,請參考一兩個 Firestore Codelab 來熟悉該 API。

起初,單場比賽足以與同事和朋友測試您的多人遊戲。隨著釋出日期的臨近,請考慮身份驗證和匹配系統。值得慶幸的是,Firebase 提供了 內建的使用者身份驗證方式,並且 Firestore 資料庫結構可以處理多場比賽。您可以根據需要向 matches 集合中填充任意數量的記錄,而不是僅有一個 match_1

Screenshot of the Firebase Firestore data view with additional matches

線上比賽可以以“等待中”狀態開始,此時只有第一位玩家在場。其他玩家可以在某種大廳中看到“等待中”的比賽。一旦足夠多的玩家加入比賽,它就會變為“活躍”狀態。具體的實現取決於您想要哪種線上體驗。基本原理保持不變:一個包含大量文件的集合,每個文件代表一場活躍或潛在的比賽。