どうも、Supabase DevRelのタイラーです!
Flutterはあらゆるプラットフォームで動作するアプリを作成するためのUIライブラリですが、Flutterの上で動くオープンソースのゲームエンジンFlameのおかげで、インタラクティブなゲーム作ることも可能です。Flameは、衝突検出や画像の読み込みなどの処理を行い、すべてのFlutter開発者にゲーム開発を提供します。今回はそこからさらに一歩進んで、プレイヤー同士がリアルタイムで対戦できるように、リアルタイム通信機能を導入したゲームを作ってみましょう!
この記事では、Flutter、Flame、Supabaseのリアルタイム機能を使って、リアルタイムの対戦型シューティングゲームの作り方をお教えします。このチュートリアルのコード一式は、こちらでご覧になれます。
今回作るゲームの概要
FlutterってFlameっていうゲームエンジンを使ってゲームも作れちゃうの知ってました?
— タイラー (@dshukertjrjp) March 1, 2023
Flutter、FlameそしてSupabaseを使って二人で対戦できるシューティングゲームの作り方を解説してみました 🛸https://t.co/2dtSmztYyY pic.twitter.com/t0lZz3tg1t
ゲーム自体はシンプルなシューティングゲームです。各プレイヤーは自分の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: ゲーム開始処理の実装
},
);
});
}
}
まだ作成していないファイルをインポートしているため、いくつかエラーが表示されますが、この後順次それらのファイルを作っていくので一旦放置して先に進みましょう!
ロビーダイアログの作成
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つの子コンポーネント、
Playerと
Bullet があり、
MyGame `はゲームのすべてのコンポーネントの親に当たるクラスで、子コンポーネントを制御することができます。
アプリに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から別のゲームを開始することができます。
これで今回のシューティングゲームは完成です!友達に連絡してぜひ一緒にプレーしてみてください!