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

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

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

この記事では、Flutter、Flame、Supabaseのリアルタイム機能を使って、リアルタイムの対戦型シューティングゲームの作り方をお教えします。このチュートリアルのコード一式は、[こちら](https://github.com/supabase/supabase/tree/master/examples/realtime/flutter-multiplayer-shooting-game)でご覧になれます。

## 今回作るゲームの概要

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">FlutterってFlameっていうゲームエンジンを使ってゲームも作れちゃうの知ってました？<br><br>Flutter、FlameそしてSupabaseを使って二人で対戦できるシューティングゲームの作り方を解説してみました 🛸<a href="https://t.co/2dtSmztYyY">https://t.co/2dtSmztYyY</a> <a href="https://t.co/t0lZz3tg1t">pic.twitter.com/t0lZz3tg1t</a></p>&mdash; タイラー (@dshukertjrjp) <a href="https://twitter.com/dshukertjrjp/status/1630795272493338624?ref_src=twsrc%5Etfw">March 1, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

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

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

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

## アプリの作成

### Step 1. Flutterアプリを作る

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

```bash
flutter create flame_realtime_shooting
```

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

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

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

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

### ゲームページの作成

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

```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がマウントするのを待つために１フレーム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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ce6iuw3pu8v6bpw536mr.png)

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

```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](https://docs.flame-engine.org/1.6.0/flame/game.html)クラスを継承した `MyGame`クラスを作成します。FlameGame` はコリジョン検出とドラッグ（pan）の検出を行い、これからゲームに追加するすべてのコンポーネントの親になります。このゲームには2つの子コンポーネント、 `Player` と `Bullet` があり、`MyGame `はゲームのすべてのコンポーネントの親に当たるクラスで、子コンポーネントを制御することができます。

![Game structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y4oylo7y4cqlesntswf1.png)

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

```yaml
flame: ^1.6.0
```

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

```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が０になったら呼ばれる関数
  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` に以下を追加してください。

```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`と経過時間を使って位置を更新しているのがわかると思います。このようにして、一定速度で一方向に移動するような形になっています。

```dart
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](https://supabase.com/docs/guides/realtime)を使用します。Supabaseプロジェクトをまだ作成していない場合は、[database.new](https://database.new/)にアクセスして作成してください！

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

```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` の呼び出しに貼り付けます。

```dart
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](https://supabase.com/docs/guides/realtime/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` コールバックを呼び出し、ダイアログを削除するナビゲーターをポップすることが確認できます。

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

```dart
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 [ブロードキャスト機能](https://supabase.com/docs/guides/realtime/broadcast)を介して、対戦相手のデバイスに同期します。

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

```dart
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`をタップすると、ユーザーはロビーダイアログに戻り、そこでまた１から別のゲームを開始することができます。

![Post-game dialog](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n90wsn4rgjo8lt1ikj86.png)

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

## 各種リンク

- [今回作ったゲームのコード](https://github.com/supabase/supabase/tree/master/examples/realtime/flutter-multiplayer-shooting-game)
- [FlutterとSupabaseでチャットアプリを作る](https://qiita.com/dshukertjr/items/46411d02deb5e9fed230)
- [FlutterとSupabaseで作ったチャットアプリをセキュアにする](https://qiita.com/dshukertjr/items/ff27741ce3d98924c10a)
- [Very Good CLIを使ってFlameのゲームを作る](https://verygood.ventures/blog/generate-a-game-with-our-new-template)
- [Supabase Flutter SDKの公式ドキュメンテーション](https://supabase.com/docs/reference/dart/start)