MagicOnionを使ってリアルタイム通信ゲームを作る -2. アーキテクチャ-

システムアーキテクチャとネットワークアーキテクチャの解説

目次

概要


作ったゲームの技術解説記事2回目です。本記事ではシステム構成について軽く解説していきます。

他の記事へのリンクやソースコードはこちらから。

  1. MagicOnionを使ってリアルタイム通信ゲームを作る -1. 概要-
  2. MagicOnionを使ってリアルタイム通信ゲームを作る -2. アーキテクチャ-
  3. MagicOnionを使ってリアルタイム通信ゲームを作る -3. クライアント処理-
  4. MagicOnionを使ってリアルタイム通信ゲームを作る -4. サーバー処理-
  5. Githubソースコード / LineDeleteGame

システムアーキテクチャ


今回作ったゲームのシステム回りは下図に要約されます。前記事のライブラリ紹介と重複する点もありますが、一つずつ見ていきましょう。

クライアント

クライアントはあくまでサーバーから受け取った情報を描画するだけの「ブラウザ」として振舞わせています。 ユーザー入力の受付とサーバーへの送信、タイトル遷移やブロックが消えるときのエフェクト再生など、いわゆるゲーム的演出を実現するためUnityを使用します。

サーバーとの通信を実現するのには MagicOnion を採用しました。 MagicOnionは.Net用に提供されたRPC(Remote Procedure Call)フレームワークです。 .Net向けに用意されているため、API的な単方向通信から双方向ストリーミング通信までクライアント・サーバーで一貫してC#で記述することが可能です。 一貫してC#で記述できると、サーバーロジックをクライアントへ持っていく、またはその逆も容易になるのがうれしいポイントです。

今回はもう一つ、非同期処理も触ってみたかったので UniTask を採用しました。 UniTaskはUnity用にチューニングされた非同期機構を提供してくれるライブラリです。

Unity、というよりガベージコレクションが働く環境でゲームを作る場合、GC Allocを警戒する必要があります。 GCとは「Garbage Collection / ゴミ拾い」の略です。 C#はメモリ割り当て時に空きが足りない場合、使用していない不要なメモリ、つまりゴミ拾いで空きスペースを作りメモリを確保しようとします。 このGCは重い処理なので、画面が一瞬止まる、いわゆる「カクツキ」が発生することがあります。 たとえば格闘ゲームのコンボ中にカクツキが発生してコンボが途切れてしまったらすごく残念ですね。 そこでゲームプログラムでは、インゲーム開始前の暗転中に予めオブジェクトを作成しておき、実行中は極力newによるメモリ割り当てをしないように処理を組んだりします。

しかし厄介なことに、頑張ってインゲーム内でnewを明示的に呼ばないようにしたとしても、システムライブラリの内部処理が暗黙的にnewが呼ぶことがあります。 C#では「Task」という機構を用いて非同期処理を記述することが可能ですが、この「Task」も内部でガンガンGC Allocを呼びます。 そこでUnity用に最適化された非同期処理を提供してくれるのがUniTaskです。 今回は勉強用と開き直って凍ったバナナをトンカチにするが如く各所で使ってみました。

サーバー

サーバーでもC#処理を記述するため、ホスティングには .Net Core を採用しています。 .Net Core はMicrosoftが提供するオープンソースのクロスプラットフォームなC#フレームワークです。 コンソールアプリはもちろん、ウェブサーバーとしても動作し、OSを問わず動かすことが可能です。 今回のサーバー処理も最初はWindows上で動作検証をしていましたが、最終的にはMacやLinuxに持って行っても動作しました。 MagicOnionは.Net Coreでの使用も想定されており容易に導入可能です。

もう一つサーバーで特筆すべきは、ライブラリに LogicLooper を採用した点です。 「サーバーでゲームロジックを記述する」ということはつまり、「サーバーでゲームループを実行」できる必要があります。 しかし自力でサーバー処理を書くのが初めてだったので、「どこにゲームループを書くべきか?そもそもどうやって書くのか?」が全くわからず最初は途方に暮れていました。 Googleの力を借りて調べた結果、本ライブラリにたどり着きました。 内部のサンプルを見てみたところ、各プレイヤーのゲームループ処理を記述できそうだと判断し導入を決意しました。 スレッド処理にあまり馴染みがなかったので大分無理やりな実装にはなってしまいましたが、当初の目標は達することができました。

共有ロジック / Shared

前記事で述べた通り、オフラインプレイもサポートする都合上ゲームロジックはクライアント、サーバーで共有できるSharedな場所に配置、及び参照できる必要がありました。 しかし、ロジックが共有できるということは、逆に言うとクライアント、サーバー、各ドメインに依存する処理は記述できないということです。 例えばUnityのuGUIなどUnity上でしか使用できない処理をSharedな場所に配置できません。 そもそも純粋なゲームロジックにクライアント固有のUI処理が混入している時点で、ロジックと描画処理であるビューがうまく分離できていない状態と言えます。

そのためゲームロジックはUnityに依存しない純粋なC#のみで記述、またはインターフェース定義しつつ両ドメインに実装を書いて対処します。 同じくMagicOnionのインターフェースもSharedな場所に配置します。

ネットワークアーキテクチャ


簡単にですがネットワーク構成は下図です。 主にマッチングサーバーとメインゲーム用サーバーの2つに大別され、全てMagicOnionによるgRPCを用いて通信します。

今回はどちらも同一サーバーで、しかもダイレクトにリクエストを受けて動作させていますが、 実運用する場合はもっとスケールアウトまで考慮した構成にすべきだと思います。 例えばサーバークライントの両者間にロードバランサーやプロキシサーバーを立てて、彼らが各々独立したサーバーにリクエストを振り分ける、等が考えられます。

2つのサーバーの更に後ろにRedisやDBなどのデータサーバーを配置しユーザーデータを保存、スコアランキング更新などもやろうかと思いましたが、 今回はゲームループサーバーを動かすのに力を使い果たしたのでやめました。

マッチングサーバー

クライアントはまずマッチングサーバーにマッチング要求を出し、対戦相手を探します。 今回は簡便のためサーバーにアクセスした順でマッチングするシンプルな方法を採用しました。 ユーザーのスコア等を用いた高度なマッチング処理をする場合はデータサーバーからユーザーデータを引っ張ってくるなど判断材料を増やす必要がありますが今回はそこまでは対応しませんでした。

また、現状の処理は排他制御をあまり真面目に書いていないので3人以上が一気にアクセスするとおかしくなるかもしれません。

メインゲームサーバー

マッチングができたら、マッチング情報を元にメインゲームサーバーへゲーム開始要求を出します。 互いがゲームを開始できる状態まで準備が整ったらサーバーから各端末へゲーム開始レスポンスが返され、ゲーム開始です。

基本はサーバーのゲームループで毎フレーム盤面計算が実行され、 セッションが維持される限りは常にサーバーからクライアントへ、盤面情報やスコアなどの計算結果をリアルタイム送信します。 クライアントはサーバーから受け取った盤面配列を描画しつつ、ひたすらユーザーからのキー入力をサーバーへ送り続けるだけです。

次の記事


第2回目の記事ではシステム、ネットワークのアーキテクチャを簡単に解説しました。 次回の記事ではクライアントプログラムを組むうえで使用したテクニック、苦労した点などを解説していこうと思います。

y-tomita
y-tomita
ゲームプログラマ
ビルドエンジニア
次へ
前へ

関連項目