共有ゲームロジックにおける役割の定義

﨑健悟氏(以下、﨑):続いて、協力型MOアクション実現のための設計や実装についてお話しします。まずは共有ゲームロジックについて、どういった役割かを最初に説明します。

共有ゲームロジックは、バトルに必要なすべての処理のうち、描画などを含まない純粋な論理部分を実装するかたちになります。例えばバトルの全体的な進行管理であったり、キャラクターや敵、遠距離攻撃の弾などのすべてのオブジェクトの管理であったり、ダメージの算出や死亡処理といったゲーム内で起こるイベントの処理の諸々であったりといった、論理部分のすべて(を担う)になります。

バトルの論理的な部分がゲームロジックで完結しているので、残る2つのコンポーネントであるクライアントとサーバーは、ゲームロジックを内包したうえで入出力を各々の環境に応じたものにつなげれば、MOバトルのゲームが実行できるかたちになります。

例えばクライアントであれば、タッチやスワイプといったユーザーの操作を、共有のロジックにユーザーの入力として渡します。そうするとゲーム内のキャラクターが動いて、その結果としてキャラクターの動きや、ダメージを受けたエフェクトの描画情報といった出力がUnityの描画につなぐことで画面に表示されて、アプリケーションとして実行できることになります。

一方サーバーでは、クライアントが行ったアクションを要求として受け取り、その要求をネットワーク越しにまずゲームロジックに入力します。そうすると、ゲームロジック側はそれをキャラクターごとの動作として処理するかたちになってゲームが進行して、その結果として起こった内容を出力として各クライアントに通知することで、マルチプレイが実現できるかたちになります。

この方式を取ることによるメリットについては、まずは前項でも説明したとおり、二重実装を避けられるというメリットがあります。それ以外にも大きなメリットが1つあって、この方式を取ると、ゲームロジックでゲームとしての論理的な部分が完結しているので、サーバー側でクライアントからの要求を受け取った時に、それがゲーム的に妥当であるか判断する部分についてあらためて実装する必要がないことになります。

例えば、倒れている時には突然攻撃したりはできませんが、その攻撃要求を受け取った時に、そもそもゲームロジックで(その行動が)できるかどうか処理されています。ですから、クライアントからの要求をネットワーク越しに受け取ったときにあらためてチェックをする必要がありません。

もちろん、サーバー特有のチェックは追加で必要になります。例えば、本来のクライアント、正しい相手から要求がきているかのような部分に関しては実装が当然追加で必要になりますが、それでもゲームルールとしてダメなことを一律で弾けるのは大きなメリットでした。

また副次的な効果として、バトルの論理的な実行がUnityを必要としないように実装されているので、共有(ゲーム)ロジックを利用することで、シミュレーションサーバーなどを用意することも可能になります。

共有ゲームロジックにおける設計と実装

(スライドを示して)ゲームロジックの役割を共有したところで、続けて実際の設計と実装についてお話しします。まず共有ゲームロジックのエンジンですが、エンジンについては非Unity環境のサーバーを利用するので、UnityのEntity Component Systemが利用できないかたちになります。これは問題ではありますが、今回に関しては社内で開発していたUnity非依存のゲームエンジンをカスタマイズして採用することで解決しました。

このエンジンはUnityに依存しないECSと、アクションの判定に必要な物理判定などが実装されています。共有ゲームロジックは、このエンジンの上に通常のUnity環境での開発と同様に、バトル部分の実装を行いました。

これでゲームの論理部分ができたということで、通信や描画などの環境固有の機能はどうしたかになりますが、それに関しては、エンジンがECSを採用しているので、Unityと.NETでそれぞれ必要とする側でコンポーネントとして実装して、個別に有効化するかたちで解決しました。

例えば通信の処理とか入力(の受け付け機能)などをそれぞれ別のコンポーネントとして実装して、必要な側でだけそのコンポーネントを登録することで実現しています。 基本的にはこのようにコンポーネントで分けていますが、コンポーネントの内部でその環境ごとに処理の内容を分けたいケースもあり、そちらはインターフェース経由で利用する形式にすることで、各環境で必要な拡張を行えるようにしました。

例えば、キャラクターを作成するみたいな用途であれば、まずICharacterFactoryというインターフェースを用意します。そして利用する側はこのインターフェースを実装したものを利用するかたちにします。

その上で、まずはインターフェースの実装としてBaseCharacterFactoryという、そのサーバーとクライアント共通で必要となる基本的なキャラクター情報の作成を行う部分を実装します。さらにそのBaseCharacterFactoryを内包するかたちでクライアントとサーバーそれぞれのファクトリーであるUnityCharacterFactory、ServerCharacterFactoryを別途作ります。Unity側であればBaseCharacterFactoryでできた情報に対して描画のための情報や入力の受付機能などを追加し、サーバー側であれば接続元クライアントの情報であったり同期のための通信機能を追加して返すかたちになります。

ゲームロジック側に関しては、あくまでICharacterFactoryのインターフェースを介してそれらを処理しているので、「ICharacterFactoryのインターフェースの実装に対してキャラクターを作成してください」という命令を出すことで、共通した処理としてゲームロジックを書けるかたちになっています。

通信フレームワークの検討

続いて通信方式について。まずは通信フレームワークの検討です。Unityクライアントと.NETサーバーの構成ということで、まず代表的なOSSであるMagicOnionを検討しました。このMagicOnionというのは、先ほどのC#大統一理論を提唱されたneueccさんが中心となって開発されたOSSになります。

(MagicOnionは)クライアントサーバー間の通信を、単純な非同期メソッドの呼び出しのように扱えるというすごく魅力的なOSSになっており、内部で行われている実装も、とても洗練されており採用実績も十分でした。(スライドを示して)しかし、当時は以下のように挙げるような理由があって、残念ながら採用を断念しました。

この理由ですが、まず1つ目がループベースモデル。フレーム更新の処理をループにしておいて、それをグルグル回してゲームを進めていくという形式ですが、当時はこちらのサポートがなかったというところがあります。“当時”と言っているのは、現在では同じくOSSとして公開されているLogicLooperがあり、こちらはループベースモデルをサポートしているので、現在であればこちらを利用するという手もあるかなと思います。

2つ目はゲーム側の設計としてECS上で各コンポーネントを適切な順序で処理していくことが作りの前提となっていたのですが、メッセージを受信した際に、受け取った全体の順序を維持したまま(コンポーネントにとって)適切なタイミングで処理させるのが難しいという問題がありました。

これはどういうことかというと、MagicOnionで処理した場合、(通信は)非同期メソッドになるので、返事があったタイミングでawaitからの継続処理として動くかたちになります。そうなると、awaitからの継続で勝手に動き出してしまうので、これを順序を変えずにゲーム側として望ましい適切なタイミングまで待たせる必要があり、これを実現するのは難しいということになりました。

最後に3つ目です。通信を非同期メソッドのかたちで行えるようにするために、MagicOnionでは動的コード生成を駆使したすごく高度な実装がされています。これはすごくおもしろい実装になっているので、みなさんもぜひ見ていただきたいです。

で、この高度な実装が、動的コード生成で実現しているために相応に複雑になっています。そのため、運営中に万が一問題が発生した場合に、運用タイトルとして迅速に問題を解決できる体制を継続して担保できるかというところで、確証が得られない問題がありました。

これはどういうことかというと、初期の担当者、今回のタイトルであれば自分に関しては、最初に採用するにあたってMagicOnionを読み込んでいるので、当然一定のレベルで対応できるという、ある程度の保証があります。しかし、長期でタイトルとして運用していくことになってくると、メンバーの入れ替わりが進んでいきます。

そうなってくると、いざ問題が起こった時に、その時点でMagicOnionをちゃんと読み込んで問題を解決できることを保証できるかが若干不安であるというところがありました。そのため、残念ながらMagicOnionに関しては採用を断念しました。その後、他にもいくつか検討を重ねて、今回に関してはgRPCをベースに独自で拡張を行うことを決断しました。

gRPCと独自拡張

というわけで、gRPCと独自拡張についてお話しします。まずgRPCですが、こちらはGoogleが公開している通信用のフレームワークで、名前のとおりRPCを実現するフレームワークになります。クライアント・サーバー間の通信の基本的なパターンを実装してくれています。

ただ、個々の通信をすべてgRPC自身のRPC定義を用いて行おうとすると、ゲーム側としては若干望ましくない仕様がありました。そのため、本タイトルではgRPCでは双方向ストリーム、クライアント・サーバー間それぞれで必要な情報をやりとりするためのストリームを1つのみ定義して、そこで送受信したアプリケーション定義のメッセージを扱う、上位レイヤーを別途構築するかたちになりました。こちらに関しては、MagicOnionも同様なアプローチになっています。

どういった拡張を上位レイヤーとして行ったかですが、まずは受信時の呼び出し順序とタイミングの制御を行いました。gRPCの要求を受信した場合、それぞれの要求がgRPC側の実装から非同期メソッドとして呼び出されるので、そのままだと呼び出されるタイミングや順序がアプリケーションから制御できないかたちになってしまいます。

なので、受信メッセージをそのまま即座に処理するのではなく、メッセージキューにいったん投入する形式とすることで、ゲームロジックにとって適切なタイミングで順序どおり処理できる構造としました。

もう1つの拡張としては、送信内容のバッファリング。こちらは特にサーバーからクライアントへの通信についてで、ゲーム内で行われた各メッセージの通知を都度gRPCにそのままメッセージとして渡していると、アクションゲームでは極短期間の間にユーザーの攻撃・移動であったり、ダメージであったりといった、たくさんの状態変化のイベントが発生してしまい、通信頻度がとてつもないことになってしまう問題があります。

そのため、今回は受信メッセージと同様に送信メッセージも送信用のキューにいったん投入して、任意のタイミングでまとめて相手に送れるようにしました。

こういった細かい特性をタイトルに合わせて作り込めるのは、自前実装のメリットと言えると思います。ただし、一方で実装のコストも相応に発生するので、バランスとして考えた場合、gRPC+独自実装が現状では最適解の1つではないかと思っています。

同期単位の設計

というわけで、続けてユーザーの体感を損ねない同期の実現についてお話しします。まずは同期単位の設計です。最初のほうで触れたとおり、今回は非同期型を採用しています。この非同期型も時間軸のズレはあるにせよ、自分以外の情報を共有してゲームの状態を同期していく必要はあります。

ではその同期は具体的に何をどうやってしていくかですが、本タイトルでは大きく2つの内容を同期の対象としました。

まず1つ目はアクション、「誰が何をしたか」です。これはゲーム内のキャラクターの行動です。前進・通常攻撃・スキルなどを、一定の粒度でそれぞれをアクションとして定義しているので、これをクライアント・サーバー間でやりとりすれば、まず「誰が何をしたか」という部分が同期できます。

2つ目にレポート。「何が起こったか」というものがあります。こちらでは、キャラクターのアクションの結果として発生したダメージや状態変化をレポートとして送ることで、前項に続いてアクションの結果、「何が起こったか」を同期できるかたちになります。

クライアントとサーバーは共通のゲームロジックを持っているので、誰がどのアクションをして、結果何が起こったかを把握できれば、その間の動き、アニメーションや遷移は各環境で自力で再現できます。

クライアントは自キャラの能動的なアクションをサーバーに通知して、それ以外のキャラのアクションやレポートをサーバーから受け取って採用するかたちで同期が行われていきます。これはどういうことかというと、攻撃をする場合であれば、例えば攻撃をするというアクションはクライアントが起点になって送りますが、それによって誰にどれだけダメージを与えたかのような情報は他者が絡んでくるので、サーバーが一律で処理をする形式を取りました。

また、この形式を取った場合の設計の基本的なルールとして、最終的な真実の歴史はサーバーが決定するようにしました。これはどういうことかというと、(各クライアントは)非同期でそれぞれの時間軸を進行していくため、攻撃が入れ違ったり、サーバー側からの最終的な通知とクライアントの状態がどうしても一致しないケースが出てきます。こちらについて常にサーバーを正とすることで、サーバーの状態に合わせて、(その情報を)受け取ったクライアント側が修正する設計を行っています。

どうやって実際に同期を行うか

基本的な同期単位が決まったので、ではどうやって同期していくかというところになります。(スライドを示して)まず基本的な同期の流れについてですが、図にも書いてあるとおり、各クライアントは自身の操作キャラのアクションを変えるタイミングで、サーバーにアクション変更の要求を送信します。

受け取ったサーバーは、その要求を対象キャラクターへのアクション要求としてゲームロジックに投入してます。ゲームロジックはその要求がゲームルール上妥当なものであるかどうかを判断して、実行もしくは拒否をします。受け入れられた場合、サーバーは最終的に発生したキャラクターのアクションと、場合によってはそれによって引き起こされた変化のレポートを、各クライアントに通知します。

クライアント側は、サーバーから通知されたアクションとレポートを自環境のキャラクターに反映していくことで、ゲームが進行していく流れになります。

ネットワーク往復のための遅延問題

これで基本的な同期の流れとしては原理上動きますが、問題があります。クライアントが操作キャラのアクション変更を要求してから実際にアクション変更が全体に周知されるまでに、ネットワークを往復する時間がかかり、さらに内部の処理時間でも+αの時間がかかってしまいます。この往復遅延の時間はユーザーの環境によって大きく変わってきますが、例えば国内の携帯回線であっても、100ミリ秒前後になることも珍しくはありません。

そのため、素直に結果が出るまで待っているとすると、何かしようと操作するたび、例えば攻撃しようと思ってタップするたびに、100ミリ秒も反応が遅れるようなことになってきます。当然ですが、これではアクション性の高いゲームをやろうとした場合、体感が著しく損なわれるかたちになってしまいます。

このような問題をどうしたかなんですが、先ほど言ったようにサーバーの判定結果を待っていたらゲームにならないので、本タイトルではクライアントは結果を待たずに先行して処理を開始するという、先行処理の概念を取り入れました。

この先行処理の流れは図にも書いてあるとおりです。クライアントはまずアクション変更の要求を送ると同時に、サーバーの判定結果を待たず、アクションをすぐに開始させてしまいます。送られた情報を受け取ったサーバーは基本案と同様に処理をして、やはり同様にクライアントに通知をします。

クライアントは通知を受け取った際に、すでに自身が実行を始めているアクションについての通知を受け取ることになりますが、この実行済みのアクションについてスルーすることで、問題なくゲームが実行できるかたちになります。

こうしておくと、サーバーでの実際の実行はネットワーク通信遅延分遅れてしまっていますが、クライアントの環境で見た場合、自キャラの動きはユーザー操作のタイミングと一致することになるので、ユーザーから見た場合に、普通に端末上でプレイしているのと同様の状態に感じられることになります。

割り込み問題

というわけで「これでめでたし」といきたいんですが、やはりこちらにも問題があります。具体的には、割り込みという問題が発生してしまいます。この先行処理の場合ですが、クライアントはアクションが実際にできたか確証のないまま後続の処理を行うことになるので、状況によっては最終的な結果との間に矛盾が生じる場合が出てきます。

例えば、敵から攻撃を受けてダメージリアクションを取らされてしまった場合、サーバー起点のアクションが、クライアントで要求したものよりも先に実行される可能性が出てきます。(スライドを示して)図で言うと、例えばクライアントは攻撃してその後すぐに前進しようと思っていて、実際に前進を実行しているのですが、サーバーのほうに(要求が)送られた時点では、先に敵からの攻撃を受けてしまって、よろけてしまっています。

そのため、要求を受け取った時には前進ができないような問題が発生してしまいます。こういった場合は基本的には順序の入れ替えは起こりますが、割り込まれたアクションはその後実行すれば、基本的にはそのまま続けられるかたちになります。

これも図に書いてあるとおりですが、クライアントが攻撃の直後によろけが発生した場合では、クライアント側では前進を始めています。その後、攻撃のあとでよろけが発生したというサーバーからの通知を受け取ったら、その時点で(前進を止めて)すぐによろけを開始します。

一方のサーバー側は、よろけを処理している間に前進が届くので、「よろけ中なのでできない」ということで待たせておいて、よろけが終わったあとに、あらためてユーザーから要求されていた前進の処理を実行していきます。こうしておけば、クライアント側はサーバー側からの通知で(アクションの)上書きを実施しますが、よろけが終わったタイミングで今度は前進の通知が来ているので、自身が当初予定していた前進という処理が行われることになります。こうして順序の入れ替えは発生しますが、全体としては問題なくゲームが進行できるかたちになります。

割り込みによって実行不能になる問題

ということで割り込みについても対応ができたので、「めでたし」と言いたかったのですが、これにもちょっと問題がありました。このやり方でいった場合、発生した割り込みと後続のアクションの種類によっては、割り込みによって実行不能になるケースが出てきてしまうという問題がありました。

例えば(攻撃の)コンボを考えると、コンボになっている攻撃の1段目と2段目の間にダメージリアクションが挟まってきた場合、遅れて届く2段目のアクションは、そもそも1段目がダメージリアクションで中断してしまっているのでコンボにならなくて、よろけのあとではもう実行できないということが発生してしまうわけです。

そのため、本タイトルの場合では、前後の状況を判断して後続の要求を取り消せる仕組みを用意しました。コンボの話でいくと、コンボ1、2とクライアントが始めた直後によろけが発生したという通知を受け取った場合、クライアントのほうで「自分の2段目はもうよろけが発生してしまっているので、このあとはもう実行できない」ということを判断して取り消しを行い、よろけが終わったところであらためて1段目から実行する対応を行いました。

こういった取り消しが必要な状況は他にもいくつかのパターンがありましたが、それぞれに対して文脈を意識して必要な取り消しが行える仕組みを統一して用意しています。

先行処理導入で得た効果

こういった先行処理をいろいろと行ってどうなったかですが、例外ケースをうまくフォローした上で先行処理を導入することで、概ね70〜80ミリ秒程度まではシングルプレイと同程度の操作感を実現できました。

さすがに100ミリ秒を超えてくると割り込みによる巻き戻りの頻度が上がってきて徐々に体感が悪化しますが、本タイトルでは極力これを避けるために、マッチングの段階でできるだけ近いサーバーでプレイできるようにアサインを行っています。

これは弊社としてもとてもいい方式だと考えていますが、ただ1つ注意点があります。この方式が成立した大きな要因として、協力型MOだったという点があるのは注意が必要だと思っています。

これはどういうことかというと、協力型MOアクションであれば、アクション(ゲーム)として過剰なストレスを与えないように、そもそもユーザーからの操作が中断されるような敵の動作、要は自分が殴ろうとしたところでふっとばされてみたいな話が、そもそもあまり起こらないようにゲームデザイン側で考慮されているという部分があります。

逆に対戦格闘ゲームのように、自分の行動がイコールで相手の妨害となる、自分がパンチを繰り出して相手に当たったら相手側の行動がその時点で中断されるようなタイプのゲームだと、割り込みが多発することになるので、この方式はうまく機能しないと考えられます。

ゲームの特性に合わせた実装が重要

というわけでまとめになります。まずMOを実現するにあたって、システム構成から具体的な実装まで、それぞれのレイヤーでさまざまな選択肢があります。そのため、ゲームの特性に合わせて適切なものを選ぶことが何より重要になります。また、アクションゲームのような複雑なルールのMOでも、C#による統一実装を採用することで、効率的に実現が可能です。

そして、モバイルゲームにおいてはネットワークの遅延が大きな問題となりますが、こちらは協力型MOであればという限定はつきますが、先行処理を導入することで、ユーザーにストレスを感じさせないように改善することも可能です。

以上で発表終了となります。ご清聴ありがとうございました。