122
97

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Flutter, Flame, Supabaseを使ってリアルタイム対戦シューティングゲームを作る

Last updated at Posted at 2023-03-01

どうも、Supabase DevRelのタイラーです!

Flutterはあらゆるプラットフォームで動作するアプリを作成するためのUIライブラリですが、Flutterの上で動くオープンソースのゲームエンジンFlameのおかげで、インタラクティブなゲーム作ることも可能です。Flameは、衝突検出や画像の読み込みなどの処理を行い、すべてのFlutter開発者にゲーム開発を提供します。今回はそこからさらに一歩進んで、プレイヤー同士がリアルタイムで対戦できるように、リアルタイム通信機能を導入したゲームを作ってみましょう!

この記事では、Flutter、Flame、Supabaseのリアルタイム機能を使って、リアルタイムの対戦型シューティングゲームの作り方をお教えします。このチュートリアルのコード一式は、こちらでご覧になれます。

今回作るゲームの概要

ゲーム自体はシンプルなシューティングゲームです。各プレイヤーは自分のUFOを画面をドラッグすることで操作できます。UFOは自動的に3方向に弾を放ち、自分のUFOが相手の弾で破壊される前に、相手に弾を当て、破壊することがゲームの目的です。プレーヤーの位置とHPは、Supabaseが提供するbroadcast機能(低遅延のWebソケット接続)を使用して対戦相手と同期されます。

ゲーム本編に入る前にロビーがあり、他のプレイヤーが現れるのを待つことができます。他のプレイヤーが現れたら、スタートを押して、両者でゲームを開始することができます。

まず、基本的なUIを構築するために使用するFlutterウィジェットを構築し、次にFlameゲームを構築し、最後に接続されたクライアント間でデータを共有するためのネットワーク接続を処理します。

アプリの作成

Step 1. Flutterアプリを作る

まずはFlutterアプリを作成します。ターミナルを開き、以下のコマンドでアプリ名を新規に作成します。

flutter create flame_realtime_shooting

作成したアプリをお好きなIDEで開いて、さっそくコーディングしてみましょう。

Step 2. Flutterウィジェットの部分を作っていく

今回作成するアプリはこんな感じのディレクトリー構造になります。ウィジェットは2、3個しかないので全部main.dartファイルの中に記述しちゃいます。

├── lib/
|   ├── game/
│   │   ├── game.dart
│   │   ├── player.dart
│   │   └── bullet.dart
│   └── main.dart

ゲームページの作成

ゲームロジックのほとんどは後でFlame Gameクラスで処理されるため、このアプリでは生のFlutterウィジェットはちょこっとだけしか作りません。構造はシンプルで、ゲームをプレイするページがあり、そこにゲーム開始前のダイアログや開始後のダイアログが表示されるだけです。ページにはGameWidgetを配置し、背景画像を形になります。後ほど、リアルタイムのデータを送受信するためのメソッドを追加するので、StatefulWidgetにして、initStateなどでネットワーク系の関数を呼べるようにしておきます。以下を main.dart ファイルに追加してください。

import 'package:flame/game.dart';
import 'package:flame_realtime_shooting/game/game.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'UFO Shooting Game',
      debugShowCheckedModeBanner: false,
      home: GamePage(),
    );
  }
}

class GamePage extends StatefulWidget {
  const GamePage({Key? key}) : super(key: key);

  @override
  State<GamePage> createState() => _GamePageState();
}

class _GamePageState extends State<GamePage> {
  late final MyGame _game;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          Image.asset('assets/images/background.jpg', fit: BoxFit.cover),
          GameWidget(game: _game),
        ],
      ),
    );
  }

  @override
  void initState() {
    super.initState();
    _initialize();
  }

  Future<void> _initialize() async {
    _game = MyGame(
      onGameStateUpdate: (position, health) async {
        // TODO: ゲームの状態が更新された際のコールバックの実装
      },
      onGameOver: (playerWon) async {
        // TODO: ゲームが終了した際のコールバックの実装
      },
    );

    // Widgetがマウントするのを待つために1フレームawaitする
    await Future.delayed(Duration.zero);

    if (mounted) {
      _openLobbyDialog();
    }
  }

  void _openLobbyDialog() {
    showDialog(
        context: context,
        barrierDismissible: false,
        builder: (context) {
          return _LobbyDialog(
            onGameStarted: (gameId) async {
              // TODO: ゲーム開始処理の実装
            },
          );
        });
  }
}

まだ作成していないファイルをインポートしているため、いくつかエラーが表示されますが、この後順次それらのファイルを作っていくので一旦放置して先に進みましょう!

ロビーダイアログの作成

Lobby dialog

LobbyDialogクラスは、表面上は単純なアラートダイアログですが、ロビーで待機しているプレイヤーのリストなど、ちょっとした状態を保持するようになっています。後ほど「ロビーで待機中」のプレーヤー一覧を取得したり、待機中であることを他のロビーのプレーヤーに知らせる機能を実装しますが、とりあえず今は単純なAlertDialogを用意して終わりです。main.dartファイルの末尾に以下のコードを追加してください。

class _LobbyDialog extends StatefulWidget {
  const _LobbyDialog({
    required this.onGameStarted,
  });

  final void Function(String gameId) onGameStarted;

  @override
  State<_LobbyDialog> createState() => _LobbyDialogState();
}

class _LobbyDialogState extends State<_LobbyDialog> {
  final List<String> _userids = [];
  bool _loading = false;

  /// TODO: ユニークなIDを生成してアサインする
  final myUserId = '';

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('Lobby'),
      content: _loading
          ? const SizedBox(
              height: 100,
              child: Center(child: CircularProgressIndicator()),
            )
          : Text('${_userids.length} users waiting'),
      actions: [
        TextButton(
          onPressed: _userids.length < 2
              ? null
              : () async {
                  setState(() {
                    _loading = true;
                  });

                  // TODO: 他のプレーヤーにロビーに入ったことを知らせる
                },
          child: const Text('start'),
        ),
      ],
    );
  }
}

Step 3. Flame関係のクラスを作っていく

FlameGameの作成

さあ、いよいよメインディッシュです!まず、ゲームクラスを作成します。FlameGameクラスを継承した MyGameクラスを作成します。FlameGameはコリジョン検出とドラッグ(pan)の検出を行い、これからゲームに追加するすべてのコンポーネントの親になります。このゲームには2つの子コンポーネント、PlayerBullet があり、MyGame `はゲームのすべてのコンポーネントの親に当たるクラスで、子コンポーネントを制御することができます。

Game structure

アプリにflameを追加するために、pubspec.yaml ファイルに以下を追加してflameをインストールしましょう。

flame: ^1.6.0

次に、MyGameクラスを作成します。以下のコードを lib/game.dart ファイルに追加してください。

import 'dart:async';

import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/image_composition.dart' as flame_image;
import 'package:flame_realtime_shooting/game/bullet.dart';
import 'package:flame_realtime_shooting/game/player.dart';
import 'package:flutter/material.dart';

class MyGame extends FlameGame with PanDetector, HasCollisionDetection {
  MyGame({
    required this.onGameOver,
    required this.onGameStateUpdate,
  });

  static const _initialHealthPoints = 100;

  /// ゲームが終了した際のコールバック
  final void Function(bool didWin) onGameOver;

  /// ゲームの状態がアップデートされた際のコールバック
  final void Function(
    Vector2 position,
    int health,
  ) onGameStateUpdate;

  /// 自分自身の`Player`インスタンス
  late Player _player;

  /// 相手プレーヤーの`Player`インスタンス
  late Player _opponent;

  bool isGameOver = true;

  int _playerHealthPoint = _initialHealthPoints;

  late final flame_image.Image _playerBulletImage;
  late final flame_image.Image _opponentBulletImage;

  @override
  Color backgroundColor() {
    return Colors.transparent;
  }

  @override
  Future<void>? onLoad() async {
    final playerImage = await images.load('player.png');
    _player = Player(isMe: true);
    final spriteSize = Vector2.all(Player.radius * 2);
    _player.add(SpriteComponent(sprite: Sprite(playerImage), size: spriteSize));
    add(_player);

    final opponentImage = await images.load('opponent.png');
    _opponent = Player(isMe: false);
    _opponent.add(SpriteComponent.fromImage(opponentImage, size: spriteSize));
    add(_opponent);

    _playerBulletImage = await images.load('player-bullet.png');
    _opponentBulletImage = await images.load('opponent-bullet.png');

    await super.onLoad();
  }

  @override
  void onPanUpdate(DragUpdateInfo info) {
    _player.move(info.delta.global);
    final mirroredPosition = _player.getMirroredPercentPosition();
    onGameStateUpdate(mirroredPosition, _playerHealthPoint);
    super.onPanUpdate(info);
  }

  @override
  void update(double dt) {
    super.update(dt);
    if (isGameOver) {
      return;
    }
    for (final child in children) {
      if (child is Bullet && child.hasBeenHit && !child.isMine) {
        _playerHealthPoint = _playerHealthPoint - child.damage;
        final mirroredPosition = _player.getMirroredPercentPosition();
        onGameStateUpdate(mirroredPosition, _playerHealthPoint);
        _player.updateHealth(_playerHealthPoint / _initialHealthPoints);
      }
    }
    if (_playerHealthPoint <= 0) {
      endGame(false);
    }
  }

  void startNewGame() {
    isGameOver = false;
    _playerHealthPoint = _initialHealthPoints;

    for (final child in children) {
      if (child is Player) {
        child.position = child.initialPosition;
      } else if (child is Bullet) {
        child.removeFromParent();
      }
    }

    _shootBullets();
  }

  /// 双方のプレーヤーから弾を発射するための関数
  ///
  /// 500ミリ秒ごとに呼ばれる
  Future<void> _shootBullets() async {
    await Future.delayed(const Duration(milliseconds: 500));

    /// Player's bullet
    final playerBulletInitialPosition = Vector2.copy(_player.position)
      ..y -= Player.radius;
    final playerBulletVelocities = [
      Vector2(0, -100),
      Vector2(60, -80),
      Vector2(-60, -80),
    ];
    for (final bulletVelocity in playerBulletVelocities) {
      add((Bullet(
        isMine: true,
        velocity: bulletVelocity,
        image: _playerBulletImage,
        initialPosition: playerBulletInitialPosition,
      )));
    }

    /// 相手プレーヤーの弾
    final opponentBulletInitialPosition = Vector2.copy(_opponent.position)
      ..y += Player.radius;
    final opponentBulletVelocities = [
      Vector2(0, 100),
      Vector2(60, 80),
      Vector2(-60, 80),
    ];
    for (final bulletVelocity in opponentBulletVelocities) {
      add((Bullet(
        isMine: false,
        velocity: bulletVelocity,
        image: _opponentBulletImage,
        initialPosition: opponentBulletInitialPosition,
      )));
    }

    _shootBullets();
  }

  void updateOpponent({required Vector2 position, required int health}) {
    _opponent.position = Vector2(size.x * position.x, size.y * position.y);
    _opponent.updateHealth(health / _initialHealthPoints);
  }

  /// いずれかのプレーヤーのHPが0になったら呼ばれる関数
  void endGame(bool playerWon) {
    isGameOver = true;
    onGameOver(playerWon);
  }
}

結構ボリューミーになってしまったので、細かく説明していきます。まず、onLoadメソッドの中で、ゲーム中に使われるすべての画像データをロードし、自分と対戦相手のPlayerコンポーネントゲームに追加しています。

onPanUpdateの中では、ユーザーが画面をドラッグしたときの処理を行います。onGameStateUpdateコールバックを呼び出してプレイヤーの位置を渡し、親のウィジェットからネットワークを介して相手プレーヤーの端末に新しい位置情報とHPを伝えるような形になっています。一方、updateOpponentメソッドは、ネットワークから入ってくる情報を使って対戦相手の状態を更新するために使用します。こちらも後ほどSupabase周りの実装をする際に相手プレーヤーの情報を受け取ってこちらの関数を呼ぶ件を実装します。

ゲームを開始すると、_shootBullets()が呼び出され、プレイヤーと対戦相手の両方の弾丸を撃ち出します。_shootBullets() はRecursionで、500ミリ秒ごとに再び呼び出されています。弾がプレイヤーに当たると、毎フレーム呼び出される udpate()それを検知し、HPを減らすなどの処理をしています。

Playerコンポーネントの作成

PlayerコンポーネントはUFOの画像のついたプレーヤー自身と相手プレーヤーが操作するクラスです。このクラスはFlameの PositionComponent を継承しています。lib/player.dart` に以下を追加してください。

import 'dart:async';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame_realtime_shooting/game/bullet.dart';
import 'package:flutter/material.dart';

class Player extends PositionComponent with HasGameRef, CollisionCallbacks {
  Vector2 velocity = Vector2.zero();

  late final Vector2 initialPosition;

  Player({required bool isMe}) : _isMyPlayer = isMe;

  /// このインスタンスが自分なのか相手なのか
  final bool _isMyPlayer;

  static const radius = 30.0;

  @override
  Future<void>? onLoad() async {
    anchor = Anchor.center;
    width = radius * 2;
    height = radius * 2;

    final initialX = gameRef.size.x / 2;
    initialPosition = _isMyPlayer
        ? Vector2(initialX, gameRef.size.y * 0.8)
        : Vector2(initialX, gameRef.size.y * 0.2);
    position = initialPosition;

    add(CircleHitbox());
    add(_Gauge());
    await super.onLoad();
  }

  void move(Vector2 delta) {
    position += delta;
  }

  void updateHealth(double healthLeft) {
    for (final child in children) {
      if (child is _Gauge) {
        child._healthLeft = healthLeft;
      }
    }
  }

  @override
  void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is Bullet && _isMyPlayer != other.isMine) {
      other.hasBeenHit = true;
      other.removeFromParent();
    }
  }

  /// 相手目線での自分の位置を返してくれる関数
  /// 相手にbroadcastで位置情報を送る際にこの値を渡す
  Vector2 getMirroredPercentPosition() {
    final mirroredPosition = gameRef.size - position;
    return Vector2(mirroredPosition.x / gameRef.size.x,
        mirroredPosition.y / gameRef.size.y);
  }
}

class _Gauge extends PositionComponent {
  double _healthLeft = 1.0;

  @override
  FutureOr<void> onLoad() {
    final playerParent = parent;
    if (playerParent is Player) {
      width = playerParent.width;
      height = 10;
      anchor = Anchor.centerLeft;
      position = Vector2(0, 0);
    }
    return super.onLoad();
  }

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRect(
        Rect.fromPoints(
          const Offset(0, 0),
          Offset(width, height),
        ),
        Paint()..color = Colors.white);
    canvas.drawRect(
        Rect.fromPoints(
          const Offset(0, 0),
          Offset(width * _healthLeft, height),
        ),
        Paint()
          ..color = _healthLeft > 0.5
              ? Colors.green
              : _healthLeft > 0.25
                  ? Colors.orange
                  : Colors.red);
  }
}

Playerクラスは_isMyPlayer プロパティを持っていて、インスタンスが自分自身のの場合は true、対戦相手の場合は false になっているのがわかると思います。onLoadメソッドを見てみると、初期位置が対戦相手であれば上部に、自分であれば下部に配置する形になっています。また、CircleHitboxを追加しているのですが、こちらはFlame内で衝突検知をしたい際に必要なコンポーネントとなります。最後に、_Gauge を子として追加しています。これは各プレイヤーの上に表示されるHPゲージです。onCollisionコールバック内で、衝突したオブジェクトが相手の弾かどうかをチェックし、もしそうならその弾をhasBeenHit`とフラグしてその弾をゲームから削除しています。

getMirroredPercentPosition メソッドは相手のクライアントとポジションを共有するときに使用されます。これはプレイヤーのミラーリングされた位置を計算してくれます。updateHealth はHPが減ったときに呼び出され、 _Gauge クラスのバーの長さをアップデートしてくれます。

弾の作成

最後に Bullet クラスを追加します。これはPlayerから放たれる一発の弾を表しています。onLoadの中でCircleHitboxを追加して他のオブジェクトとの衝突判定が効くようにしています。また、コンストラクタでvelocityを受け取り、updateメソッドでvelocityと経過時間を使って位置を更新しているのがわかると思います。このようにして、一定速度で一方向に移動するような形になっています。

import 'dart:async';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/image_composition.dart' as flame_image;

class Bullet extends PositionComponent with CollisionCallbacks, HasGameRef {
  final Vector2 velocity;

  final flame_image.Image image;

  static const radius = 5.0;

  bool hasBeenHit = false;

  final bool isMine;

  /// Damage that it deals when it hits the player
  final int damage = 5;

  Bullet({
    required this.isMine,
    required this.velocity,
    required this.image,
    required Vector2 initialPosition,
  }) : super(position: initialPosition);

  @override
  Future<void>? onLoad() async {
    anchor = Anchor.center;

    width = radius * 2;
    height = radius * 2;

    add(CircleHitbox()
      ..collisionType = CollisionType.passive
      ..anchor = Anchor.center);

    final sprite =
        SpriteComponent.fromImage(image, size: Vector2.all(radius * 2));

    add(sprite);
    await super.onLoad();
  }

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;

    if (position.y < 0 || position.y > gameRef.size.y) {
      removeFromParent();
    }
  }
}

Step 4. プレーヤー間のリアルタイム通信の実装

この時点では、相手が動かないことを除けばシューティングゲームは動作しています。あとはクライアント間で通信できるようにしてあげればオッケー。クライアント間の通信には低遅延通信やプレゼンス機能を備えたSupabaseのrealtime featuresを使用します。Supabaseプロジェクトをまだ作成していない場合は、database.newにアクセスして作成してください!

通信周りのコーディングに入る前に、Supabase SDKをアプリにインストールしましょう。またユーザー固有のランダムなIDを生成するためにuuid パッケージもインストールします。pubspec.yamlに以下を追加してください。

supabase_flutter: ^1.4.0
uuid: ^3.0.7

pub get が完了したら、Supabaseを初期化しましょう。main関数を内でSupabaseを初期化します。SupabaseのURLとAnon Keyは、SupabaseダッシュボードのProject Setting > API で取得できます。それらをコピーして Supabase.initialize の呼び出しに貼り付けます。

void main() async {
  await Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_ANON_KEY',
    realtimeClientOptions: const RealtimeClientOptions(eventsPerSecond: 40),
  );
  runApp(const MyApp());
}

// Supabaseのクライアントを変数に抜き出しておくと便利。
final supabase = Supabase.instance.client;

RealtimeClientOptions は、各クライアントがSupabaseに送信する1秒あたりのイベント数を指定するためのパラメーターです。デフォルトは10イベント/秒ですが、より同期された体験を提供するために40イベント/秒に設定します。

これでリアルタイム機能の追加に取りかかる準備が整いました。

ロビーで他のプレーヤーが集まるのを待つ機能の実装

まず、_Lobby クラスを書き換えることから始めます。ロビーで最初にしなければならないことは、ロビーで他のオンラインユーザーを待つ、それとオンラインユーザーの人数を表示することです。これはSupabaseのpresenceという機能を使って実装することができます。

initState を追加し、その中で _lobbyChannelという変数にRealtimeChannelを初期化して入れておきます。リアルタイムデータを受け取るためのsubscribe() メソッドを見ると、ロビーチャンネルへのサブスクリプションに成功後に、_Lobbyの初期化時に作成したユニークなユーザ IDを追跡していることが確認できます。こうして他のプレーヤーに自分がロビーに来たことを伝える形です。

誰か他のユーザーがpresent状態になったときに通知を受けるために sync イベントをリスンしています。コールバック内では、ロビーにいるすべてのユーザーのuserIdsを抽出し、クラスのstateとして設定しています。こうしてロビーで待機中のユーザーの数を表示している形です。

誰かがStartボタンをタップすると、ゲームが始まります。onPressedコールバックを見てみると、2つの参加者 ID とランダムに生成されたゲーム IDを含む[broadcast](https://supabase.com/docs/guides/realtime/broadcast) イベントをロビーチャンネルに送信していることがわかります。[broadcast](https://supabase.com/docs/guides/realtime/broadcast)は、クライアント間で低遅延データを送受信するためのSupabaseの機能で、2人の参加者(そのうちの1人はstartボタンをタップした人)を両端で受信すると、ゲームが開始されます。initState 内の game_start イベントのコールバックで、ブロードキャストイベントを受信すると、プレイヤーが参加者の一人であるかどうかをチェックし、もしそうなら onGameStarted コールバックを呼び出し、ダイアログを削除するナビゲーターをポップすることが確認できます。

さあ、いよいよゲームが始まりました!

class _LobbyDialogState extends State<_LobbyDialog> {
  List<String> _userids = [];
  bool _loading = false;

  /// Unique identifier for each players to identify eachother in lobby
  final myUserId = const Uuid().v4();

  late final RealtimeChannel _lobbyChannel;

  @override
  void initState() {
    super.initState();

    _lobbyChannel = supabase.channel(
      'lobby',
      opts: const RealtimeChannelConfig(self: true),
    );
    _lobbyChannel.on(RealtimeListenTypes.presence, ChannelFilter(event: 'sync'),
        (payload, [ref]) {
      // Update the lobby count
      final presenceState = _lobbyChannel.presenceState();

      setState(() {
        _userids = presenceState.values
            .map((presences) =>
                (presences.first as Presence).payload['user_id'] as String)
            .toList();
      });
    }).on(RealtimeListenTypes.broadcast, ChannelFilter(event: 'game_start'),
        (payload, [_]) {
      // Start the game if someone has started a game with you
      final participantIds = List<String>.from(payload['participants']);
      if (participantIds.contains(myUserId)) {
        final gameId = payload['game_id'] as String;
        widget.onGameStarted(gameId);
        Navigator.of(context).pop();
      }
    }).subscribe(
      (status, [ref]) async {
        if (status == 'SUBSCRIBED') {
          await _lobbyChannel.track({'user_id': myUserId});
        }
      },
    );
  }

  @override
  void dispose() {
    supabase.removeChannel(_lobbyChannel);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('Lobby'),
      content: _loading
          ? const SizedBox(
              height: 100,
              child: Center(child: CircularProgressIndicator()),
            )
          : Text('${_userids.length} users waiting'),
      actions: [
        TextButton(
          onPressed: _userids.length < 2
              ? null
              : () async {
                  setState(() {
                    _loading = true;
                  });

                  final opponentId =
                      _userids.firstWhere((userId) => userId != myUserId);
                  final gameId = const Uuid().v4();
                  await _lobbyChannel.send(
                    type: RealtimeListenTypes.broadcast,
                    event: 'game_start',
                    payload: {
                      'participants': [
                        opponentId,
                        myUserId,
                      ],
                      'game_id': gameId,
                    },
                  );
                },
          child: const Text('start'),
        ),
      ],
    );
  }
}

ゲームの状態をプレーヤー間で共有する

ゲームが始まったら、2つのクライアント間でゲーム状態を同期させる必要があります。今回のアプいでは、プレイヤーの位置とHPだけを同期させることにします。プレイヤーが移動したり、HPが変化したりすると、MyGameインスタンスの onGameStateUpdate コールバックが起動し、位置とHPを、Supabase ブロードキャスト機能を介して、対戦相手のデバイスに同期します。

GamePageを以下のように書き換えましょう。

class GamePage extends StatefulWidget {
  const GamePage({Key? key}) : super(key: key);

  @override
  State<GamePage> createState() => _GamePageState();
}

class _GamePageState extends State<GamePage> {
  late final MyGame _game;

  /// Holds the RealtimeChannel to sync game states
  RealtimeChannel? _gameChannel;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          Image.asset('assets/images/background.jpg', fit: BoxFit.cover),
          GameWidget(game: _game),
        ],
      ),
    );
  }

  @override
  void initState() {
    super.initState();
    _initialize();
  }

  Future<void> _initialize() async {
    _game = MyGame(
      onGameStateUpdate: (position, health) async {
        ChannelResponse response;
        // Loop until the send succeeds if the payload is to notify defeat.
        do {
          response = await _gameChannel!.send(
            type: RealtimeListenTypes.broadcast,
            event: 'game_state',
            payload: {'x': position.x, 'y': position.y, 'health': health},
          );

          // wait for a frame to avoid infinite rate limiting loops
          await Future.delayed(Duration.zero);
          setState(() {});
        } while (response == ChannelResponse.rateLimited && health <= 0);
      },
      onGameOver: (playerWon) async {
        await showDialog(
          barrierDismissible: false,
          context: context,
          builder: ((context) {
            return AlertDialog(
              title: Text(playerWon ? 'You Won!' : 'You lost...'),
              actions: [
                TextButton(
                  onPressed: () async {
                    Navigator.of(context).pop();
                    await supabase.removeChannel(_gameChannel!);
                    _openLobbyDialog();
                  },
                  child: const Text('Back to Lobby'),
                ),
              ],
            );
          }),
        );
      },
    );

    // await for a frame so that the widget mounts
    await Future.delayed(Duration.zero);

    if (mounted) {
      _openLobbyDialog();
    }
  }

  void _openLobbyDialog() {
    showDialog(
        context: context,
        barrierDismissible: false,
        builder: (context) {
          return _LobbyDialog(
            onGameStarted: (gameId) async {
              // await a frame to allow subscribing to a new channel in a realtime callback
              await Future.delayed(Duration.zero);

              setState(() {});

              _game.startNewGame();

              _gameChannel = supabase.channel(gameId,
                  opts: const RealtimeChannelConfig(ack: true));

              _gameChannel!.on(RealtimeListenTypes.broadcast,
                  ChannelFilter(event: 'game_state'), (payload, [_]) {
                final position =
                    Vector2(payload['x'] as double, payload['y'] as double);
                final opponentHealth = payload['health'] as int;
                _game.updateOpponent(
                  position: position,
                  health: opponentHealth,
                );

                if (opponentHealth <= 0) {
                  if (!_game.isGameOver) {
                    _game.isGameOver = true;
                    _game.onGameOver(true);
                  }
                }
              }).subscribe();
            },
          );
        });
  }
}

openLobbyDialog 内に、ゲーム開始を知らせるonGameStarted コールバックがあります。ゲームが開始されると、ゲームIDをSupabase Realtimeのchannel IDとして新しいRealtimeチャンネルを作成し、対戦相手からのゲーム状態の更新を受け取り始めます。Back to Lobbyをタップすると、ユーザーはロビーダイアログに戻り、そこでまた1から別のゲームを開始することができます。

Post-game dialog

これで今回のシューティングゲームは完成です!友達に連絡してぜひ一緒にプレーしてみてください!

各種リンク

122
97
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
122
97

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?