👌

Flutterでマッチング対戦ゲームを作った話

2021/05/27に公開
3

はじめに

個人開発でborberというアプリをリリースしました。内容については、ストアを閲覧頂ければなと思いますので割愛させて頂きます。ぜひお友達と遊んでみてください。(有難いことになかなか好評です)
iOS
https://apps.apple.com/jp/app/borber/id1559548637

Android
https://play.google.com/store/apps/details?id=app.borber

また、Flutter × WebSocketの構成なので、挙動はどんなものなのかといった調査目的でも全然構いませんのでぜひ遊んでみてください。
Flutter製のマッチング対戦ゲームの事例は伺ったことがないため、記事のタイトルに「Flutter初」と謳ってしまいました。(もし前例があれば教えてください🙇‍♂️)
前例を教えて頂いたため、タイトルを訂正致します🙇‍♂️

執筆する目的

Flutterを用いた、マッチング対戦ゲームを作りたいと考えている方へ少しでも参考になれれば幸いです。
また、個人開発をする上でのtipsなどを共有出来ればなと思います。

※当アプリは、激しいUIの描画はないです。

使用技術

  • Flutter(riverpod, state_notifier, freezed, flutter_hooks)
  • Flutter for Web(利用規約とプライバシーポリシーのために使用した)
  • Node(typescript, socket.io)
  • Redis
  • Firebase(firestore, storage, functions, remoteConfig, hosting)
  • Github Actions

マッチング対戦の実現方法

今回採用した技術は、この類では有名なWebSocketです。
簡単に言うと、サーバを介してクライアント間で双方向通信を実現出来る技術です。

サーバ

Nodeにsocket.ioを組み込んでWebSocketサーバを作っています。
フレンド対戦のように、特定ユーザのみが入室出来る部屋もシンプルに実装出来ます。
https://socket.io/

データベース

使用する目的は、マッチング待機中のユーザの保持です。今回はredisを採用しました。
理由は、シンプルなデータ構造を実現したく、待機中のユーザを先入先出で動かすキュー(下図の通り)を作りたいからです。

redisには、lpushとrpopというメソッドがあり、これらを駆使することでお手軽にキューを作れます。
http://redis.shibu.jp/commandreference/lists.html

クライアント

socket.ioクライアントをFlutter向けに作られているライブラリがあるのでそれを使います。
https://pub.dev/packages/socket_io_client

インストール手順はドキュメントの通りなので、割愛させて頂きます。

注意点としまして、ライブラリのバグ?とissue上で言われているものですが、socketインスタンスを作成する際に、自動でサーバへ接続する機能(デフォルトでON)があります。ただ、この機能は発動が不安定であり、自分で明示的に接続を行った方が安全です。以下のように、自動接続機能(autoConnectパラメータ)をオフにし、connectを実行するだけです。

const url = 'socketサーバのURL';
final socket = IO.io(url, <String, dynamic>{
    'transports': ['websocket'],
    'autoConnect': false,
});

socket.connect();

サンプル

onメソッドでサーバからのコールバック、emitメソッドでサーバへリクエストを実装します。ここでは、その一例を載せます。実際のコードではないですが実装イメージが湧きやすいかなと思います。
ちなみに、僕はflutter_hooksのuseEffect(初期化処理や一度のみ実行したい時に活躍)を利用して実装しています。シンプルに初期化処理が実装出来るのでオススメです。

// socket変数は、socket.ioクライアントのインスタンス

useEffect(() {

    // サーバ接続時に呼ばれる
    socket.onConnect((dynamic data) {
        // ランダム対戦の場合
        if (isRandomMatching) {
	    // サーバ側でマッチング成功したらここが呼ばれる。第二引数に、レスポンスが入る。
	    socket.on('successful_matching', (dynamic data)) {
	        // TODO
	    }
	    
	    // ランダム対戦のリクエストをサーバへ送信。第二引数に、必要なパラメータを渡す。
	    socket.emit('random/match', randomMatchingInfo);
	    return;
	}
	
	// フレンド対戦の場合
	// サーバ側でマッチング成功したらここが呼ばれる。第二引数に、レスポンスが入る。
	socket.on('successful_matching', (dynamic data)) {
	    // TODO
	}
	
	// 入室しようとした部屋が満員であればここが呼ばれる。第二引数に、レスポンスが入る。
	socket.on('error_room_is_full', (dynamic data)) {
	    // TODO
	}
	
	// フレンド対戦のリクエストをサーバへ送信。第二引数に、必要なパラメータを渡す。
        socket.emit('friend/match', friendMatchingInfo);
    });
    
    return null;
}, const []);

※本来は、可読性向上のためコードを分裂させますが、サンプルのため1つのブロックで表現しています。'successful_matching'や'error_room_is_full'と書いておりますが、最適な命名規則は把握しておりません。

Firestoreのrule

ユーザ情報を保持するDBとして、Firestoreを使用していますが、これには安全のためにセキュリティルールというものを実装する必要があります。しかし、今回は認証機能を用いていないため上手に制限をかけることが出来ません。
そういう場合は、CloudFunctionsを利用してDB操作をAdmin限定にしましょう。そして、そのメソッド達をアプリ内から呼び出すように実装しましょう。
https://firebase.google.com/docs/functions/callable?hl=ja

こうして、admin以外からの操作を一切受け付けない最強のセキュリティルールが完成します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

CI/CD

個人開発であれば、自分以外の人に開発途中のアプリを展開することはないので、リリースするタイミングで導入するのも一石二鳥で効率が良いと思います(パッケージをストアへ自動アップロードするため、組めたらそのままリリース作業が出来る)。実際に僕はそのスケジュールで行いました。

基本的に、Flutter × GithubActionsのCI/CDは以下の記事だけで完成します(めちゃくちゃ参考になりました)。

https://zenn.dev/pressedkonbu/articles/254ca2fc3cd1ab
https://zenn.dev/pressedkonbu/articles/github-actions-for-android

mainブランチにコミットが入ると、iOS/Androidのパッケージがそれぞれストアにアップロードされるので、アップデート作業が楽勝になります。

最後に

Flutterでのアプリ開発は今回で3回目となりましたが、改めて開発体験がとても良いですね。

Discussion

tomokitomoki

突然ですみません。Node.jsのデプロイ環境をお教えいただけませんか?