Liberent-Dev’s blog

株式会社リベル・エンタテインメントのテックブログです。

AmazonGameLiftを試してみた

こんにちわ!
システム開発部のK.Mです。

今までの記事とは少し趣を変えて、ゲーム実装部分に関する内容を紹介していきます。

近年、様々な形でリアルタイム通信を使ったゲームが増えてきているように感じます。
モバイル向けのゲームに関しては今後5Gが普及していけば通信環境が今より良くなり、リアルタイム通信を使ったものが、ますます増えてくるのではと個人的に考えています。

しかし、リアルタイム通信を最初から実装するとなると非常に大変なものとなります。
その大変なリアルタイム通信の実装をサポートするサービスが色々と存在しています。
有名どころであればPhotonやモノビット、MagicOnion、最近だとdiarkis等があったりしますが、サービスによってどこまで担ってくれるかが微妙に異なっており、利用する場合はゲームの仕様の性質に応じて検討することをお勧めします。

今回は触る機会がありました、AWSが提供しているGameLiftというサービスを使い、 簡単な実装例を交えて紹介していきます。

GameLiftとは?

Amazonが提供しているAWS内の1サービスとなります。
GameLiftにはユーザー側で実装を全て行うカスタムサーバ機能と一部の実装だけを行うリアルタイムサーバ機能の2つが用意されています。
GameLift内に内包されている機能でFleetIQなどが独自して存在しておりますが、今回はGameLiftのリアルタイムサーバ機能の話にフォーカスしています。

また、今回後述するAmazonが用意している導入手順ではリアルタイムサーバ機能での手順のため、この記事内もリアルタイムサーバでの手順となります。

リアルタイムサーバ機能ですが、サーバ側の実装に必要な処理はJavaScriptで記載してサーバに配置する形となります。
Unity側の通信周りの実装はAWSにて用意されているSDKを使うことで実装出来ます。

今回対象外になるカスタムサーバ機能ですが、C#C++が使えるので複雑で高度なオンラインゲーム(MMOなど)であれば、
こちらを利用するのがベストです。

確認環境

  • MacOS 10.15.7
  • VisualStudio for Mac 8.10.7
  • Unity 2019.3.4f1

ローカルで動く対戦ゲームを作る

ここがメインの話では無いので詳しい説明は省きますが、まずはUnityで下記のようなゲームを作ります。
Unityだと当たり判定が簡単に出来るので楽ですね!

  • スペースキーを押してから離すと心ちゃんが前進
    • 必然的にボタンの連打が必要になり簡単なものだがゲームしてる感を出すようにしました
  • 同じようにエンターキーを押してから離すと桃助が前進
  • 上下移動しているカメロンパンに接触している間は前進不可
  • 画面左のクマ校長が居る場所まで移動したらゴール

f:id:Liberent-Dev:20220217162442g:plain 出来上がったものです。

GameLiftを導入する

UnityのプロジェクトにGameLiftを動かすためのSDK諸々を導入したり、AWS上でGameLiftやLambda、Cognitoの設定をしていきます。

基本的にはAmazonが用意している導入用の記事通りで問題ないのですが、記事の内容がWindows向けであったので、少し古いため最新の状況とあってない箇所やMacを使っているとエラーが出たり、分かりにくい箇所があったため、相違点があった部分を記載していきます。
(オリジナルのものを作る前に一度導入記事にあるカエルのゲームを手順通りに対応するのをお勧めします。)

LambdaにUUIDパッケージを導入する

セキュリティ観点でGameLiftに直接アクセスする前にLambdaとCognitoを使って認証を行う手順になっているのですが、導入手順ではLambdaでNode.js 8.10と書いていますが既に8.10は使えない状態になっているので、設定時の最新のバージョンで問題無いです。
自分が試した際は10.X以上を使用しました。

また、Lambda用に用意されているサンプルコード内の先頭にある

const uuid = require('uuid');

ですが、Node.jsのバージョン8.10では内包されていたuuidのモジュールが10.Xでは存在しないため、エラーになってしまいます。
エラーを回避するためにuuidのモジュールを別途追加する必要があります。
LambdaLayerという機能で追加する必要がありますが、詳細な手順はこちらを確認してください。

各種DLLの作り方

導入手順に記載がある、Demigiant DOTweenはカエルのゲーム内で使うものなので、全く別のものを作る際は不要ですが、カエルのゲームを動かす場合は必須です。

また、導入手順の中で各種DLLをビルドして作成する必要があるのですが、導入手順では作成方法が少し分かりにくいので自分の方で実施した手順を記載しておきます。

  1. GameLift Realtime Client SDKをDLしてきて解凍
  2. その中にある、GameLiftRealtimeClientSdkNet45.slnを開く(要VisualStudio)
  3. 「プロジェクト」 -> 「NuGetパッケージの更新」を実施
  4. 「ビルド」 -> 「すべてビルド」を実施
  5. GameLiftRealtimeClientSdkNet45/bin/Debug配下にDLLが出来ているので、そちらを利用する

自分が作成した時はNuGetの更新で色々と追加されたのか、導入手順の記載より多いDLLが出来ていたので全てコピーしています。

  • GameLiftRealtimeClientSdkNet45.dll
  • System.Runtime.CompilerServices.Unsafe.dll
  • System.Memory.dll
  • WebSocket4Net.dll
  • System.Buffers.dll
  • SuperSocket.ClientEngine.dll
  • log4net.dll
  • Google.Protobuf.dll

Mac特有の修正

導入手順のカエルのゲームですが、MacのUnityで動かそうとするGetActiveUdpListenersが存在しないというエラーが出ます。

System.Net.NetworkInformation.UnixIPGlobalProperties.GetActiveUdpListeners()をコールするのですが、MonoのNetにはGetActiveUdpListeners()が実装されていないのです・・・

回避方法として、RTSClient.csのFindAvailableUDPPort()の処理を書き換える先人の知恵がありますのでこちらを参考にしてください。

ローカルで動いている部分をリアルタイム通信化する

さてここからはローカルで動いていたゲームをリアルタイム通信していきます。

簡単にチートされないように、キャラの進み具合(位置情報)はサーバーで計算することにしたいので下記のような修正を行います。

  • 1vs1なので、対戦相手が揃うまで待機する処理と同じタイミングで動き出せるようにスタートの同期を取る(カエルのゲームにも同じような処理が入っています。)

  • 規定回数サーバへ移動ボタンが先に押した(通知した)側の勝ちという形にする(カエルのゲームと同じような形)

    • 規定回数はキャラの位置とゴール位置、移動距離で計算する
      • 今回は58回押したらゴールという判定にしています
    • カメロンパンに接触して移動出来ない間は移動ボタンを押してもサーバへは通知をしない
    • 可能であれば人間の連打速度を超えた場合はサーバーで破棄するような処理を入れるのが良いが、今回は入れない
      • 1秒間に16回より多いボタン離した通知がサーバーに来たら破棄するとかの処理を入れるとチート対策になる
    • 相手側でボタンが押された情報は、同じルーム内に居るプレイヤーにブロードキャストで通知を送るようにして通知を受けたら画面のキャラ位置を更新する
      • 押された総回数を送ることで、通信が上手く出来なかった際の位置ズレを無くす
  • カメロンパンの位置を同期したいが、今回は考慮しない

    • カメロンパンの位置情報も一定間隔やボタン離した際の通信時に同期した方がチートに強くなるが、今回は気にしないことにします
    • そのため、カメロンパンの移動距離は一定としてfpsも固定化する。
      • 固定化しないと、起動しているPCによってゲームループの実施回数が違ってきて、かメロンパンの位置がプレイヤー間で変わってしまうため

具体的な実装例を見ていきましょう。
実装のベースに関しては導入手順のサンプルになっているカエルのゲームとなります。
注意点:通信が切断した際の後処理とかは考慮していないですが、カエルのゲームには実装されているので参考にしてください。

Unity側

  • 設定周り

    • fps固定化するために、File->Build Settings...を開いて左下にあるPlayer Settings...を押してPlayer Settingsの画面を表示する f:id:Liberent-Dev:20220217160331p:plain

    • 左側にあるメニューのQualityを選択してVSync CountDon'tSyncにしておく f:id:Liberent-Dev:20220217160534p:plain

      • 注意点でQualityを選択して一番上にあるLevelsという項目があるが、Very LowとかLowとかのレベル毎に設定するものになるので全てのレベルで設定しておく必要あります f:id:Liberent-Dev:20220217160548p:plain
  • 実装部分(ソースは一例です)

    • キャラクターの移動処理にサーバへ移動を通知する処理を追加します
      • カエルゲームを元にするのであれば、RTSClientクラスにあるHopButtonPressed()がボタンを押した処理をサーバへ通知している処理になるので、HopButtonPressed()をボタンを押した時の処理の後に呼ぶようにします
      • 移動ボタンはスペースキー
    if (Input.GetKeyUp(KeyCode.Space) && moveFlag) {
        // キャラ移動
        pos.x -= speed;
        myTransform.position = pos;
        // サーバへ通知
        Client.HopButtonPressed();
    }
  • RTSClientクラスのNotifyMovePlayer()がサーバから相手プレイヤーの位置情報(押した回数)を渡す処理となるので、floatになっているnewPositionをintに変更しておきます

    • 変更したnewPositionは相手プレイヤーの移動ボタン押した回数なので、この回数分*1回あたりの移動距離を相手プレイヤーキャラに反映する処理を追加しておきます
  • RTSClientクラスのWinCelebration()がサーバから勝敗判定を返してくるので、そこで呼ばれているWinCelebration()内の処理を勝利判定用の処理に変更しておきます

    • カエルのゲームだとプレイヤーIDが0と1しかなく、今回は心を使う方がID:0、桃介を使う方がID:1となるので、下記のような感じで処理を追加します(ResultはTextです)
        if(win == 0) {
            // KOKORO WIN!
            Result.text = "KOKORO WIN!";
        } else {
            // MOMOSUKE WIN!
            Result.text = "MOMOSUKE WIN!";
        }
        Result.gameObject.SetActive(true);
  • RTSClientクラスのOnDataReceived()がサーバから通信が来たら呼ばれる箇所になっていて、OpCodeという値で何の処理をするかの分岐判断しています
    • START_COUNTDOWN_OP_CODEがプレイヤーが揃ってゲーム開始までのカウントダウンを実施してくれという通知なので、ゲームをスタートする処理を行います
      • カメロンパンはSTART_COUNTDOWN_OP_CODEが通知されてから動かすようにします

サーバ側

  • MegaFrogRaceServer.jsをベースとして下記のような変更をかけます
    • const finishPosition = 1.0;をゴール判定の移動ボタンを押した回数とするので、58に変更する
    • let playerPosition = [0.0, 0.0];を少数不要なので、[0, 0]に変更する
    • クライアントからサーバへ移動通知が来たらonMessage()に来て、opCodeにはHOP_OP_CODEが設定されているので、ProcessHop()が呼ばれるため、この内部処理を下記のように全取っ替えします
    let notifyPlayers = [];

    // 通知きたプレイヤーの内部保持しているカウントをアップする
    playerPosition[logicalPlayer] += 1;
    // ブロードキャスト的になっているので、対戦相手だけへ通知するようにする
    for (let index = 0; index < players.length; ++index) {
        if (players[index] != logicalPlayer+1) {
            notifyPlayers.push(players[index]);
        }
    }
    SendStringToClient(notifyPlayers, MOVE_PLAYER_OP_CODE, logicalPlayer.toString() + ":" + playerPosition[logicalPlayer].toString());
    
    // 規定回数に到達したら、勝敗通知を送るようにする(これは全員)
    if(playerPosition[logicalPlayer] >= finishPosition) {
        let loser = logicalPlayer == 0 ? 1 : 0; //TODO this is hard coded to two players, should make more flexible
        SendStringToClient(players, WINNER_DETERMINED_OP_CODE, logicalPlayer.toString() + ":" + loser.toString());
    }

出来上がり

f:id:Liberent-Dev:20220217162557g:plain ビルドしたアプリ(手前)とUnity上で動かしたもの(奥)で通信している画面が↑となります。

実装部分の解説

カエルのゲームをベースに少し弄れば簡単にオリジナルのリアルタイム通信のゲームが作れますが、各種仕組みについて触れていきたいと思います。

GameLiftにアクセスするまでの流れ

導入記事の「ステップ2: ゲームとGameLift間のglue(つなぎ)」にも流れは書いてあるのですが、直接アプリからGameLiftへアクセスするのはセキュリティやメンテナンス面(フリートIDが変わるような変更があった場合にアプリ更新が必要になってくるなど)で良くないので、CognitoとLambdaを経由するのがベストな方法となっています。

RTSClient.csのConnectToGameLiftServer()でCognitoへアクセスして認証情報を取得して、取得した認証情報でLambdaへ接続します。 f:id:Liberent-Dev:20220217160658p:plain

導入記事で用意したLambda側の処理でゲームセッション(ルーム)を検索して、空きのゲームセッションがあれば、プレイヤーをセッションに参加させて、空きのゲームセッションが無ければ新規にゲームセッションを作るという形になります。 f:id:Liberent-Dev:20220217160750p:plain f:id:Liberent-Dev:20220217160840p:plain f:id:Liberent-Dev:20220217160855p:plain

もし、ユーザー側でゲームセッションを自由に選べるような実装をするとなると、Lambdaで検索したゲームセッション一覧を返却するように修正をかけるか、Lambdaで行っているゲームセッション検索をUnity側で実装するという形になります。
(フリートID変わることでのアプリ更新を避けるのであれば前者のLambdaでの実装を選択することになります。)

GameLiftでのマッチング

ゲームセッションへのマッチングですが、今回のカエルのゲームであれば上記で記載した通りに単純に空きのゲームセッションを検索して一番目のセッションに入るような形になっています。
空きのゲームセッションがなければ新規でゲームセッションを作成してそこに参加する形となります。

ゲームセッション周りなどの操作は各種APIが用意されているので、詳しく知りたい場合はAPIリファランスを読むことをお勧めします。

クライアントとサーバのやりとり部分

クライアント部分

サーバからデータを受信したらGameLiftのClientクラスを生成した後のコールバック定義であるDataReceivedにて定義されたメソッドが呼ばれるようになっています。
今回のカエルのゲームではRTSClient.csのOnDataReceived()が定義されていますので、そちらがコールされるようになっています。

サーバへデータを送信するには、GameLiftのClientに存在するSendEvent()でイベント種別と情報(byte[]型)を送るようになっています。
他にもSendMessage()とかもあったりするので、GameLiftSDKのAPI情報を見るのをお勧めします。
(ただし、ここの説明だとSendEvent()が存在しないのが謎です。)

サーバ部分

クライアントからデータを受信したらonMessageにコールバック定義されているメソッドが呼ばれるようになっています。
今回のカエルのゲームでは、サーバへ設置するJavaScriptファイルのonMessage()が定義されていますので、そちらがコールされるようになっています。

クライアントへデータを送信するには、sendMessage()sendReliableMessage()を使うことになりますが、前者はUDP、後者はTCPになります。

3人以上のゲームセッション(ルーム)を作る場合はどうするのか?

今回のカエルのゲームであれば、Lambda側の処理でゲームセッションを作成しているのですが、ここでゲームセッションの最大人数を指定することが可能です。
1つのゲームセッションに3人以上を参加させる場合はゲームセッションの最大人数の変更と各種通信周りの実装を3人以上を想定した実装に変更しておく必要があります。
f:id:Liberent-Dev:20220217160917p:plain

ちなみにカエルのゲームは2人専用で実装されているので、部屋の人数だけを変えると最初にゲームセッションに参加した2人以外は何も操作出来ない状態になるハードコーディングしている箇所がありますので要注意です。

また、GameLiftのフリート設定にある「プロセス管理」->「サーバープロセスの割り当て」で設定出来る、起動させるJavaScriptファイルを選択する項目に同時プロセスという項目がありますが、1になっているとゲームセッションが1しか作れずに2つ目を作ろうとすると、
エラーが返ってくるのでテストや本番環境で複数ゲームセッションが存在する場合は注意してください。
f:id:Liberent-Dev:20220217160943p:plain

まとめ

このように、GameLiftのリアルタイムサーバ機能を使えばJavaScriptで簡単なリアルタイム通信を使ったゲームは簡単に作ることが可能なので、ゲームシステムによっては検討に値するのかなと感じています。

今回作ったもの以外でテトリスも作ってみましたが、クライアントで全て計算して盤面情報をサーバへ通知する形であれば容易に実装可能でした。
(チートを考えると、サーバ側で盤面情報を持つ形にしてキー入力をサーバに送って、サーバで計算して盤面情報などを返すのが最適解にはなりますが、GameLift自体の動作確認がしたかったので時間のかからないクライアント実装で試してみました。)

この記事を作成時の調査でカエルのゲーム以外にもAmazon側でワークショップ用の情報が出てきたので、
こちらを参考にして動かしてもらうのも有効な手立てかと思われます。

最後に

リベル・エンタテインメントでは、このような最新技術などの取り組みに興味のある方を募集しています。
もしご興味を持たれましたら下記サイトにアクセスしてみてください。
https://liberent.co.jp/recruit/