Saltar al contenido principal

Añadir soporte multijugador usando Firestore

Cómo utilizar Firebase Cloud Firestore para implementar el modo multijugador en tu juego.

Los juegos multijugador necesitan una forma de sincronizar los estados del juego entre los jugadores. A grandes rasgos, existen dos tipos de juegos multijugador:

  1. Alta tasa de actualización (High tick rate). Estos juegos necesitan sincronizar los estados del juego muchas veces por segundo con baja latencia. Esto incluiría juegos de acción, juegos deportivos, juegos de lucha.

  2. Baja tasa de actualización (Low tick rate). Estos juegos solo necesitan sincronizar los estados del juego ocasionalmente, teniendo la latencia un menor impacto. Esto incluiría juegos de cartas, juegos de estrategia, juegos de rompecabezas.

Esto se asemeja a la diferenciación entre juegos en tiempo real versus juegos por turnos, aunque la analogía se queda corta. Por ejemplo, los juegos de estrategia en tiempo real se ejecutan —como sugiere el nombre— en tiempo real, pero eso no se correlaciona con una alta tasa de actualización. Estos juegos pueden simular gran parte de lo que sucede entre las interacciones de los jugadores en las máquinas locales. Por lo tanto, no necesitan sincronizar los estados del juego tan a menudo.

Una ilustración de dos teléfonos móviles y una flecha bidireccional entre ellos

Como desarrollador, si puedes elegir tasas de actualización bajas, deberías hacerlo. Una tasa baja disminuye los requisitos de latencia y los costos del servidor. A veces, un juego requiere altas tasas de sincronización. Para esos casos, soluciones como Firestore no son una buena opción. Elige una solución de servidor multijugador dedicada como Nakama. Nakama tiene un paquete de Dart.

Si prevés que tu juego requiere una tasa de sincronización baja, continúa leyendo.

Esta receta demuestra cómo usar el paquete cloud_firestore para implementar capacidades multijugador en tu juego. Esta receta no requiere un servidor. Utiliza dos o más clientes que comparten el estado del juego usando Cloud Firestore.

1. Preparar tu juego para multijugador

#

Escribe el código de tu juego para permitir cambiar el estado del juego en respuesta tanto a eventos locales como a eventos remotos. Un evento local podría ser una acción del jugador o alguna lógica de juego. Un evento remoto podría ser una actualización del mundo proveniente del servidor.

Captura de pantalla del juego de cartas

Para simplificar esta receta del libro de recetas, comienza con la plantilla card que encontrarás en el repositorio flutter/games. Ejecuta el siguiente comando para clonar ese repositorio:

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

Abre el proyecto en templates/card.

2. Instalar Firestore

#

Cloud Firestore es una base de datos de documentos NoSQL en la nube que se escala horizontalmente. Incluye sincronización en vivo incorporada. Esto es perfecto para nuestras necesidades. Mantiene el estado del juego actualizado en la base de datos de la nube, de modo que cada jugador ve el mismo estado.

Si deseas una introducción rápida de 15 minutos a Cloud Firestore, echa un vistazo al siguiente video:

Ver en YouTube en una nueva pestaña: "What is a NoSQL Database? Learn about Cloud Firestore"

Para añadir Firestore a tu proyecto de Flutter, sigue los primeros dos pasos de la guía Get started with Cloud Firestore:

Los resultados esperados incluyen:

  • Una base de datos de Firestore lista en la nube, en Test mode
  • Un archivo firebase_options.dart generado
  • Los plugins correspondientes añadidos a tu pubspec.yaml

En este paso no necesitas escribir ningún código Dart. Tan pronto como comprendas el paso de escribir código Dart en esa guía, regresa a esta receta.

3. Inicializar Firestore

#
  1. Abre lib/main.dart e importa los plugins, así como el archivo firebase_options.dart que fue generado por flutterfire configure en el paso anterior.

    dart
    import 'package:cloud_firestore/cloud_firestore.dart';
    import 'package:firebase_core/firebase_core.dart';
    
    import 'firebase_options.dart';
    
  2. Añade el siguiente código justo antes de la llamada a runApp() en lib/main.dart:

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

    Esto asegura que Firebase se inicialice al iniciar el juego.

  3. Añade la instancia de Firestore a la aplicación. De esa manera, cualquier Widget puede acceder a esta instancia. Los Widgets también pueden reaccionar si falta la instancia, si es necesario.

    Para hacer esto con la plantilla card, puedes usar el paquete provider (que ya está instalado como dependencia).

    Reemplaza la plantilla estándar runApp(MyApp()) por lo siguiente:

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

    Coloca el provider por encima de MyApp, no dentro de él. Esto te permite probar la aplicación sin Firebase.

    :::note En caso de que no estés trabajando con la plantilla card, debes bien instalar el paquete provider o usar tu propio método para acceder a la instancia de FirebaseFirestore desde varias partes de tu base de código. :::

4. Crear una clase controladora de Firestore

#

Aunque puedes comunicarte con Firestore directamente, deberías escribir una clase controladora dedicada para hacer que el código sea más legible y mantenible.

La forma en que implementes el controlador depende de tu juego y del diseño exacto de tu experiencia multijugador. Para el caso de la plantilla card, podrías sincronizar el contenido de las dos áreas de juego circulares. No es suficiente para una experiencia multijugador completa, pero es un buen comienzo.

Captura de pantalla del juego de cartas, con flechas apuntando a las áreas de juego

Para crear un controlador, copia y pega el siguiente código en un nuevo archivo llamado 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';
}

Observa las siguientes características de este código:

  • El constructor del controlador recibe un BoardState. Esto permite al controlador manipular el estado local del juego.

  • El controlador se suscribe tanto a los cambios locales para actualizar Firestore como a los cambios remotos para actualizar el estado local y la UI.

  • Los campos _areaOneRef y _areaTwoRef son referencias a documentos de Firebase. Describen dónde residen los datos de cada área y cómo realizar la conversión entre los objetos locales de Dart (List<PlayingCard>) y los objetos JSON remotos (Map<String, dynamic>). La API de Firestore nos permite suscribirnos a estas referencias con .snapshots() y escribir en ellas con .set().

5. Usar el controlador de Firestore

#
  1. Abre el archivo responsable de iniciar la sesión de juego: lib/play_session/play_session_screen.dart en el caso de la plantilla card. Instanciarás el controlador de Firestore desde este archivo.

  2. Importar Firebase y el controlador:

    dart
    import 'package:cloud_firestore/cloud_firestore.dart';
    import '../multiplayer/firestore_controller.dart';
    
  3. Añade un campo que admita valores nulos (nullable) a la clase _PlaySessionScreenState para contener una instancia del controlador:

    dart
    FirestoreController? _firestoreController;
    
  4. En el método initState() de la misma clase, añade código que intente leer la instancia de FirebaseFirestore y, si tiene éxito, construya el controlador. Añadiste la instancia de FirebaseFirestore a main.dart en el paso de Inicializar Firestore.

    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. Libera el controlador usando el método dispose() de la misma clase.

    dart
    _firestoreController?.dispose();
    

6. Probar el juego

#
  1. Ejecuta el juego en dos dispositivos diferentes o en 2 ventanas distintas en el mismo dispositivo.

  2. Observa cómo añadir una carta a un área en un dispositivo hace que aparezca en el otro.

  3. Abre la consola web de Firebase y navega a la base de datos Firestore de tu proyecto.

  4. Observa cómo se actualizan los datos en tiempo real. Incluso puedes editar los datos en la consola y ver cómo se actualizan todos los clientes en ejecución.

    Captura de pantalla de la vista de datos de Firebase Firestore con vista de datos

Resolución de problemas

#

Los problemas más comunes que podrías encontrar al probar la integración de Firebase incluyen los siguientes:

  • El juego falla al intentar comunicarse con Firebase.

    • La integración de Firebase no se ha configurado correctamente. Vuelve a revisar el Paso 2 y asegúrate de ejecutar flutterfire configure como parte de ese paso.
  • El juego no se comunica con Firebase en macOS.

7. Siguientes pasos

#

En este punto, el juego tiene una sincronización de estado casi instantánea y confiable entre los clientes. Carece de reglas reales del juego: qué cartas se pueden jugar, cuándo y con qué resultados. Esto depende del juego en sí y te corresponde a ti probarlo.

Una ilustración de dos teléfonos móviles y una flecha bidireccional entre ellos

En este punto, el estado compartido de la partida solo incluye las dos áreas de juego y las cartas dentro de ellas. También puedes guardar otros datos en _matchRef, como quiénes son los jugadores y de quién es el turno. Si no estás seguro de por dónde empezar, sigue uno o dos codelabs de Firestore para familiarizarte con la API.

Al principio, una sola partida debería ser suficiente para probar tu juego multijugador con colegas y amigos. A medida que te acerques a la fecha de lanzamiento, piensa en la autenticación y el emparejamiento. Afortunadamente, Firebase proporciona una forma integrada de autenticar usuarios y la estructura de la base de datos de Firestore puede manejar múltiples partidas. En lugar de una única partida match_1, puedes poblar la colección de partidas con tantos registros como sea necesario.

Captura de pantalla de la vista de datos de Firebase Firestore con partidas adicionales

Una partida en línea puede comenzar en un estado de "espera", con solo el primer jugador presente. Otros jugadores pueden ver las partidas en "espera" en algún tipo de lobby. Una vez que suficientes jugadores se unen a una partida, esta se vuelve "activa". Una vez más, la implementación exacta depende del tipo de experiencia en línea que desees. Los conceptos básicos siguen siendo los mismos: una gran colección de documentos, cada uno representando una partida activa o potencial.