🎮

FlameとDart Frogでオンラインゲームを開発している話

2023/12/19に公開1

この記事は、Flutter 大学アドベントカレンダー 2023 19 日目の記事です。
また、2023/12/08に行われたYOUTRUST x ゆめみ Flutter LT会@渋谷 #4でLTをした内容です。発表資料もアップロードしているので、気になる方はそちらも併せてご覧ください。

はじめに

こんにちは、みやジックです。

今回は最近自分が趣味で開発しているオンライン対戦可能なエアホッケーゲームについて紹介します。
まだまだ、プロトタイプですが結構気に入っている技術構成だったり、まだまだ悩み途中の遅延対策の話だったりを満遍なく書こうかなと思います。

https://github.com/miyaji555/flutter_air_hokey

Flameって何?

Flutterのサードパーティのゲームエンジンライブラリの1つです。
ゲームエンジンってなんなんだろうという話ですが、GPTさんによるとビデオゲームの開発に使用されるソフトフェア開発環境とのことです。
どこまで出来ればゲームエンジンと呼んでいいのかよくわかりませんが、とりあえずゲーム作る用のフレームワークのことかなと思います。多くの場合は物理演算ができるクラスなどが用意されていて位置から物理演算を組まなくて良かったり、衝突判定の実装などが用意されていたりします。

Flameの公式ページに行くといくつかのTutorialsが用意されていて、簡単にソリティアのようなカードゲームや横スクロールのアクションゲームなどが入門できます。

FlameGameクラスを継承したGameのクラスを作成し、Componentと呼ばれるゲームの部品を作成して、Game内に追加したり、Componentの動作を定義することでゲームを開発していきます。
GameクラスはGameWidgetと呼ばれるWidgetに渡して使うので、Game自体の実装はFlutterのWidgetを並べてUIを組み立てる世界観とはかなり切り離されていると感じました。
https://docs.flame-engine.org/latest/index.html

Dart Frogって何?

Dartのサーバーサイドフレームワークの1つです。
Dartという言語はFlutterが有名なので、サーバーサイドのイメージがありませんが、サーバーサイドフレームワークも複数あり、そのうちの1つがDart Frogです。

Dart FrogもFlameと同様に公式ページにTutorialsが用意されており、簡単なAPIやWebSocketサーバーの入門ができます。
またDeployについても公式ページにTutrialsがありました。

Dartでサーバーサイドを書ける大きな利点はAPIのインターフェースにおいて、サーバーサイド側とクライアント側で同じクラスを使いまわせることにあると思います。(今時OpenAPIなどで自動生成すると思うので)それがどれくらい開発の効率化につながるかは定かではないですが、やってみて「気持ちぇぇ」となりました。

https://dartfrog.vgv.dev/

今回の構成

今回の構成に移る前に、そもそもなんの開発してんねんですが、
よくゲーセンにあるようなエアホッケーを開発しています。エアホッケー作ろうと思ったのは僕がゲームセンターで一番好きなゲームだからです。


自分で開発中のゲームに翻弄される様子

上で触れたFlameとDart FrogのチュートリアルをやっているうちにWebSocket通信を使ってリアルタイムに通信できるゲームを作れたら面白そうということで今回のプロジェクトを始めました。

今回はDart Frogを使ってWebSocket通信をすることで、クライアントとサーバー間のIFを同じクラスで書きたいというのが実現したいことの一つだったので、マルチパッケージ構成にしました。
Gitのsubmoduleを使うかも迷いましたが、マルチパッケージ構成で事足りたので現時点ではこのまま行こうと思っています。今回のパターンにおいてsubmoduleを使う利点がありそうでしたら、教えていただけると喜びます。

/packages
    ├ client/
    ├ server/
    └ model/

パッケージはシンプルにclient、server、modelの3つにしました。clientとserverがそれぞれmodelに依存している感じです。

WebSocket通信では一つのチャンネルの中でいくつかのモデルを送信したり、受信したりするのでClientRequestクラスとServerResponseクラスを用意しています。それぞれ、リクエストとレスポンスのクラスをジェネリクスで持つことで1つのインターフェースに対して複数のモデルを型安全にやり取りできるようになっています。

(genericArgumentFactories: true)
class ServerResponse<T> with _$ServerResponse<T> {
  const factory ServerResponse({
    required ServerResponseType type,
    required T responseDetail,
  }) = _ServerResponse<T>;

  factory ServerResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,
  ) =>
      _$ServerResponseFromJson(json, fromJsonT);
}
(genericArgumentFactories: true)
class ClientRequest<T> with _$ClientRequest<T> {
  /// コンストラクタ
  const factory ClientRequest({
    required ClientRequestType type,
    required T requestDetail,
  }) = _ClientRequest<T>;

  const ClientRequest._();

  /// JSONからインスタンスを生成する
  factory ClientRequest.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,
  ) =>
      _$ClientRequestFromJson(json, fromJsonT);
}

また、マルチパッケージの管理にはMelosを使っています。
マルチパッケージ構成において、Flutterでの開発でよく使うpub getコマンドやbuild runnerなどのコマンドをすべてのパッケージで行ってくれるCLIツールです。Melosについてはあまり深掘りできていないので、これくらいの紹介にしておきます。

https://melos.invertase.dev/

ゲーム開発と普段のアプリ開発の大きな違い

今回題材にしたエアホッケーゲームですが、自分で選んでおきながら初めてやるにはかなりハイレベルなテーマだったと感じています。(途中で若干後悔しました。。)

オンラインゲームで考えてみると

① 将棋やポケモンのようなターン制のゲーム
② ぷよぷよのオンラインのような、相手への干渉のタイミングがあまりシビアではないゲーム
③ エアホッケーゲームや格闘ゲームのように相手への干渉のタイミングがシビアなゲーム

などが思い付きます。
①のようなターン制のゲームはアクションゲームとは違い、データの送信タイミングやラグの影響をほとんど考える必要がありません。
②のようなアクションゲームにおいても、自分から相手にする干渉が、相手の次の行動へ大きく影響しないため、多少のタイムラグを許容できます。
③のようなアクションゲームにおいては、これらのタイムラグを許容することが難しいです。例えば格闘ゲームではお互いがほぼ同時に攻撃を出した時、理想的には1msでも早く攻撃をした方の攻撃が通り、遅く攻撃入力をした方は吹っ飛ばされるため、攻撃できません。
しかし、オンライン通信をしている以上タイムラグは発生するため、本当にどちらが先に攻撃したのかをサーバーが判断することができなくなります。

60FPS(1秒間に60回描画される → 1回の描画に16ms)で動いているゲームにおいて、高いレベルのプレイヤーは1~3フレーム程度で描画された情報から次の入力まで行うといわれています。

これがゲーム開発と普段のアプリの開発の大きな違いで、1フレーム単位で正しく、もしくは適切に描画していく必要があるということです。

何で遅延対策が必要なの?

今回Flameをクライアントサイドで動かしています。今回相手と自分の2クライアントのみを考えていますが、自分のクライアントで計算した値と相手のクライアントで計算した値が常に一緒である必要があります。
Flameでの計算にランダムな要素入れていないですし、初期の状態(ボールの位置や、ボールの初速度等)はすべて同じにし、ゲームの開始タイミングもサーバから開始の合図を受け取ったタイミングで同時に行います。
しかし、計算に使う情報が少しでもずれてしまうと、各クライアントの情報がズレていきます。例えば、パドルの位置は自分のパドルが移動したという情報をサーバーに伝えて、サーバーが相手のクライアントに自分のパドルの位置を伝えます。するとそこに通信の遅延が発生します。
当然相手から見えている自分のパドルの位置は本当の(自分から見えている)パドルの位置とずれることになります。このタイミングでボールとパドルが接触した時に接触判定の計算がズレることが考えられます。
ほんの少しのズレが最終的には大きな結果のずれを生むことをカオス理論と言いますが、ゲームではまさにそれが起きてしまい、一つ計算がズレるとその後どんどんズレが大きくなってしまいます。

そのためズレた状態で計算をしないや定期的にズレを修正するなどなんらかの遅延によるずれの対策が必要になります。

今回採用した遅延対策

上述した通り、通信による遅延で発生するずれを解消する必要がありました。
今回は最も簡単な遅延対策とも言えるであろう同期方式を取りました。

同期方式はクライアントが計算した値をそれぞれサーバで検証してから再度クライアントに情報を戻す方式です。手順は下記です。

  1. クライアントで計算した値をサーバーに送信
  2. サーバーは全ての情報が集まったら状態を更新、ブロードキャスト
  3. クライアントはブロードキャストされた状態をViewに反映し、1に戻る。

この1~3を1ループと考えます。
各クライアントはサーバーから情報が返ってくるまでは次の計算処理を行わずに待機します。そうすることで、どちらかだけ先にゲームが進んで行ったりしてしまうことはなくなります。
また、上述の通り各クライアントで計算した値がズレることがあります。その場合は2において、適切な値を決定します。ここはゲームの内容に応じてどのような値を採用するかを決めることができると思います。ここでは自分が送信した値と採用されてブロードキャストした値がズレているとプレイヤーがそれを知覚できる可能性があります。例えば自分の手元では間に合っていたはずなのに、採用された値は間に合っていない判定でボールがすり抜けてしまうなどです。
そのように知覚した場合にそれが自分にとって損だと感じるとゲームをする気も失せてしまうかもしれません。

そのため、今回は各クライアントがズレた値を送ってきた場合にボールがゴールに直接入らない方(ゴールから遠い位置にある方)を採用することにしました。

これも1つ普段のアプリ開発と違うところで、ゲームの場合何か他の実益のためではなくゲームそれ自体を娯楽としてプレイすることがほとんどなので、どっちの方が面白いか、つまらないと感じないかが仕様の判断要素に入ってきます。

マッチングについて

今回のエアホッケー開発ではオンライン対戦なので、誰と対戦するかを決める必要があります。カジュアルに遊べた方がいいと思うのでランダムマッチングできるようにしたいと考えました。
しかし、任意の誰かと遊びたいという需要も考えたのでどちらも叶えられる仕様にすることにしました。

まず、マッチングを考えるにあたり、Room(=部屋)という概念を取り入れました。
プレイヤーはゲーム開始時にあるRoomに入り、その部屋に居合わせた他のプレイヤーとゲームをすることができます。また、基本的に2人で行うため部屋に1人だけユーザがいる状態を「空き」がある。と表現し、部屋に2人以上いる状態を「空き」がないとします。(ここで2人以上としているのは3人目以降は観戦者としてゲームを観戦できる機能を想定しているためです。)
この部屋をランダムに割り当てることでランダムマッチングを実現します。

今回はランダムな部屋に入室する人にidを割り当てるmatch APIを実装しました。
match APIではランダムに空きのある部屋のidを返却するか、新規の部屋を作成しそのidを返却することにしました。
また、空きのある部屋のどれか1つが選ばれる確率と新規の部屋を作成する確率を同じにしました。
こうすることで空きが少ないほど新規の部屋は作成されやすく、空きが多いほど新規の部屋ではなく既存の部屋が選ばれるようにしました。

例えば、空きのある部屋が1つの場合は1/2の確率で既存の部屋が選択され、1/2の確率で新規の部屋が作成されます。空きのある部屋が5つの場合は4/5の確率で既存の部屋が選択され、1/5の確率で新規の部屋が作成されます。
これによって、新規の部屋が無駄に作られすぎてしまうことを避けています。

また、上述した通り任意の誰かと遊びたい人のためにRoomIdを入力してその部屋に入ることも可能になっています

トップページ
トップページ

また、WebではパスパラメータにroomIdを用いているため、URLを共有するだけでも同じ部屋に入室できます。

おわりに

今回はFlameとDart Frogを使って開発中のエアホッケーゲームについて概要とゲームならではの悩みどころを書いてみました。

まだまだ改良の余地として

  • 遅延対策の向上
  • モバイルアプリ対応
  • 観戦機能の実装

など盛り沢山ですが、地道に改良していこうかなと思っています。

実際にどういうコードになっているかなどはGitHubを覗いてもらえればと思いますが、もう少し色々整えれたら、チュートリアル的にZennの本とかにまとめられたらなあと思ったり思わなかったりしてます。

個人的にはFlutterでのゲーム開発が少しずつ盛り上がってきているのを肌で感じています。今後MetaQuestやVisionProを皮切りにXRゴーグル全盛期が来るとすれば、"Build for any screen" を掲げているFlutterが対応しないわけがないと思っています。それを見据えてゲームに力を入れ始めているのではと思ったりもしてます。UnityやUnreal Engineなどの名だたるゲームエンジンと肩を並べるほどまで成長するかはわかりませんが、行く末を応援したいと思ってます。

この記事を読んでFlutterでゲーム開発してみようという方が1人でも増えたら幸いです。

Discussion