0
0

More than 1 year has passed since last update.

Game Player Matchmaking(GPM)でチャット作った!②

Last updated at Posted at 2021-11-02

前回のまとめ

「Game Player Matchmaking(GPM)でチャット作った!①」ではTencent Cloudが提供するGame Player Matchmaking(以下GPM)の主要な機能と、
GPMを使用して作る最小構成のチャットを紹介しました。
最小構成で作成したチャットには大きく2つの課題があります。

課題

・チャットルームがない
・途中参加させたい

本記事では「Game Server Elastic-scaling」(以下GSE)とGPMを連携したチャットの完成とゲームサービスへの適応について紹介します。

GPMとGSEの連携

GPMにはGSEと連携する機能があります。
GPMとGSEを連携させると、マッチングが成立したときにGSEがゲームサーバをスケールしてくれるようになります。
ゲームサーバをチャットサーバにすれば、チャットサーバ自体が1つのチャットルームと捉えられます。

またGPMの Matchmaking Process APIs には StartMatchingBackfill というAPIがあります。
StartMatchingBackfill は既にマッチングされたゲームサーバにプレイヤーが入れる空きがあればマッチングをしてくれるAPIです。

これらの機能を使ってチャットルームごとにサーバを分離し、途中からプレイヤーが参加出来るように改修していきます!

チャットサーバの分離

最小構成のチャットでマッチングとチャットの両方を担うサーバから、チャット部分を切り出します。
Tencent Cloud にあるGSEのドキュメントのチュートリアルを参考に切り出しを検討します。

GSEは主流なサーバ言語だけでなくUnityのようなゲームエンジンにも対応してくれています。
筆者はクライアントエンジニアなので親しみ深いUnityでゲームサーバを構築することにしました。

GSEはゲームサーバとgRPCで通信し、プレイヤーの接続やゲームサーバ自体の終了を管理します。
gRPCという仕組みの登場で難しく感じる方もいると思いますが、
Tencent Cloud のチュートリアルでは demoプロジェクトがあり、GSEと通信する部分がほとんど実装されています。

demoを参考にすればチャットサーバを開発するだけなので簡単に実装が出来ます。

チャットサーバ構築方法

チュートリアルに従い、gRPCのUnityライブラリを導入します。
またマッチングサーバで行っていたWebSocketによるチャット通信部分をUnityで実装するため WebSocketSharpも導入します。

導入が完了したら起動Sceneにスクリプトをアタッチします。
スクリーンショット 2021-10-15 173152.png

アタッチしたスクリプトの Start() 関数でチャットサーバを起動するメソッドを記述します。
引数で指定するポートは demo プロジェクトと同様に6001 ~ 10000 の中で使われていないポートを取得するようにします。

StartServers.cs
    private void Start()
    {
        // チャットサーバ開始
        ChatServer.StartChatServer(_chatPort);

#if !UNITY_EDITOR
        // Grpcサーバ開始
        MyGrpcServer.StartGrpcServer(_chatPort, _grpcPort, LOGPath);
#endif
    }



ChatServer.StartChatServer メソッドの中で WebSocket で待ち受ける処理を記述します。
プレイヤーがチャットサーバに接続/切断した際、GSEにプレイヤーセッションの開始/終了を通知するようにします。

GSEとの連携部分は demo プロジェクトで実装されている GSEManager が利用出来ます。
また接続プレイヤー数が0になったらチャットサーバを終了する処理も記述します。

チャットサーバのプログラムは以上で完了です。

using Chat;
using UnityEngine;
using WebSocketSharp;
using WebSocketSharp.Server;

/// <summary>
/// チャットサーバ
/// </summary>
public class ChatServer
{
    /// <summary>
    /// WebSocketサーバ
    /// </summary>
    private static WebSocketServer _webSocketServer;

#if !UNITY_EDITOR
    /// <summary>
    /// プレイヤーのセッション数
    /// </summary>
    private static int _plyaerSessionCount;
#endif

    /// <summary>
    /// チャットサーバを起動する
    /// </summary>
    /// <param name="clientPort">サーバプロセスを実行するポート</param>
    public static void StartChatServer(int clientPort)
    {
        _webSocketServer = new WebSocketServer(clientPort);

        // ルーティング
        _webSocketServer.AddWebSocketService<ChatBehavior>("/");

        // 起動
        _webSocketServer.Start();
    }

    /// <summary>
    /// チャットサーバを停止する
    /// </summary>
    public static void StopChatServer()
    {
        _webSocketServer.Stop();
        _webSocketServer = null;
    }

    /// <summary>
    /// チャットメッセージ受信時の振る舞い
    /// </summary>
    private class ChatBehavior : WebSocketBehavior
    {
        /// <summary>
        /// メッセージ受信処理
        /// </summary>
        /// <param name="e">メッセージイベント</param>
        protected override void OnMessage(MessageEventArgs e)
        {
            var chatRequest = JsonUtility.FromJson<ChatRequest>(e.Data);

            switch (chatRequest.action)
            {
                case "ENTER":
#if !UNITY_EDITOR
                    GseManager.GseManager.AcceptPlayerSession(chatRequest.playerSessionId);
                    _plyaerSessionCount++;
#endif
                    break;

                case "EXIT":
#if !UNITY_EDITOR
                    GseManager.GseManager.RemovePlayerSession(chatRequest.playerSessionId);
                    _plyaerSessionCount--;
                    if (_plyaerSessionCount == 0)
                    {
                        GseManager.GseManager.TerminateGameServerSession();
                        GseManager.GseManager.ProcessEnding();
                    }
#endif
                    break;
            }

            // クライアントから来たメッセージをそのままブロードキャストするだけ
            Sessions.Broadcast(chatRequest.message);
        }
    }
}

Assetの作成

Asset はGSEのゲームサーバ上で展開するプログラムです。
Unityでビルドを行い、GSEがスケールした時にチャットサーバが起動出来るように登録を進めます。

今回はLinux(CentOS)でチャットサーバを動かすこととして、Linux向けにビルドしました。
ビルドする際は「Server Build」にチェックを入れます。
Server Build をチェックすると、Unity本来の描画は行わずサーバ用のプログラムとして動作させることが可能です。
スクリーンショット 2021-10-15 174442.png

ビルドが完了すると実行ファイルが指定フォルダに出力されるので、フォルダごと zip に圧縮します。

次に Tencent Cloud のGSEコンソールから Asset を選択し Create ボタンを押します。
「OS」の項目でチャットサーバを稼働させるOSを設定します。
「Upload Codes」の項目にある「Select file」ボタンで圧縮したzipをアップロードします。

Assetの登録は以上で完了です!
スクリーンショット 2021-10-15 174757.png

Fleetの作成

Fleet ではAssetの関連付けや起動するインスタンスの設定を行います。
GSEのコンソールから Fleet を選択し Create ボタンを押して以下の項目を入力します。

設定項目 入力値
AssetPackageName アップロードしたAssetを選択
LaunchPath 展開先の実行ファイルまでのパス
※アップロードしたzipは/local/game/以下に展開されます
Concurrent processes allowed 1台のインスタンスで起動できるゲームサーバの最大数
Max Concurrent Game ゲームサーバの同時起動数の最大値
Sever Instance Type ゲームサーバを動かすインスタンスのスペック
Internet ゲームサーバへアクセス出来るポートとIPアドレスの制限 (複数可)

スクリーンショット 2021-10-15 175902.png
スクリーンショット 2021-10-15 184117.png

添付したスクリーンショットでは1つのインスタンスで最大20のチャットサーバを起動するように設定しました。
ゲームの要件や負荷に応じて自由に設定が可能です。

Queueの作成

Queue はGPMからゲームサーバ作成のリクエストを受け取ります。
Queue がリクエストを受け取ると Fleet に設定した情報を元にゲームサーバを構築します。

GSEのコンソールから Queue を選択して Create ボタンを押し、赤枠の部分に作成した Fleet を設定すれば完了です。
スクリーンショット 2021-10-15 184540.png

GPMとGSEの連携設定

マッチングが成立した時に、チャットルームをGSEに要求する設定を行います。
GPMのコンソールで作成した Match を開き、赤枠の部分にGSEのキューを設定します。
スクリーンショット 2021-10-15 185433.png

これでマッチングが成立したらチャットルーム(=GSEに登録したチャットサーバのプロセス)が自動で立ち上がるようになります!

GSEとの連携設定が完了すると、マッチング成立時のイベント通知にチャットサーバへの接続情報が含まれるようになります。
その情報をマッチングサーバからクライアントへレスポンスし、クライアントからチャットサーバへ接続するようにします。

event_notification_MatchResult
{
    "DnsName": "",
    "GameServerSessionId": "チャットサーバのセッションID",
    "IpAddress": "チャットサーバのIPv4アドレス",
    "Port": "チャットサーバのポート番号",
    "MatchedPlayers": [
        {
            "PlayerId": "マッチングしたプレイヤーのID1",
            "PlayerSessionId": "プレイヤーのセッションID",
            "MatchTicketId": "マッチングリクエストのID"
        },
        {
            "PlayerId": "マッチングしたプレイヤーのID1",
            "PlayerSessionId": "プレイヤーのセッションID",
            "MatchTicketId": "マッチングリクエストのID"
        }
    ]
}

クライアントから送信した「PlayerSessionId」を、チャットサーバを経由してGSE渡すことでコンソールから接続しているプレイヤーのセッション数が見えるようになります。

GPMとGSEの連携設定は以上で完了です!

途中参加の実装

作成したチャットルームに後からプレイヤーが参加出来るようにします。
今回は1プレイヤー同士のチャットから始めて、最大10人までチャットに参加出来るようにします。

ルールの変更

1名のプレイヤー同士のマッチングから、最大10名のプレイヤーまで同じチャットサーバに接続出来るようにGPMでルールを作成します。
変更する箇所は「maxPlayer」の値だけです!

以下の設定で1プレイヤー同士からマッチングが可能になります。
1プレイヤー同士のマッチングでは「maxPlayer」の設定により4×2=8プレイヤー分の空席が生まれます。
そこで StartMatchingBackfill APIを使用して空席を埋めることを考えます。

        {
            "name": "red",
            "minPlayers": 1,
            "maxPlayers": 5
        },
        {
            "name": "team2",
            "minPlayers": 1,
            "maxPlayers": 5
        }

StartMatchingBackfillについて

StartMatchingBackfill は既に成立したマッチングに空席があれば、後から参加するプレイヤーともマッチングする というAPIになります。
ただし使い方が少々複雑です。

例えばプレイヤーAとプレイヤーBのマッチングが成立している状況を考えます。

この状況でプレイヤーCが StartMatching をリクエストし、プレイヤーA・Bとマッチング出来る状態だとしてもマッチングは成立しません。
StartMatching はゲームサーバの情報を持っていない(ゲームサーバの情報は成立して初めて通知される)ので空きがあるかを判断出来ないからです。

そこで StartMatchingBackfill を使います。

StartMatchingBackfill はチャットサーバの情報をリクエストに使用します。つまりチャットサーバの情報を持っているわけです。
この状態でプレイヤーCが StartMatching をリクエストし、プレイヤーA・Bとマッチング条件が合えばマッチングは成立します。
そしてGPMのイベント通知に、StartMatchingBackfill が使用したチャットサーバの情報が通知されるため、プレイヤーCは成立したチャットサーバへ接続出来るようになります。

言い換えれば、マッチングが成立してもその情報は破棄せず、空きがある限りプレイヤーが参加する可能性も考慮して実装する必要があります。
今回作成したチャットサーバでは node-cron を使用して、マッチングサーバから定期的に StartMatchingBackfill をリクエストするようにしました。
バックフィル.jpg

StartMatchingBackfillの実装

StartMatchingBackfill APIに利用するマッチ情報をリスト管理します。
マッチングが成立した際、参加するプレイヤーやチャットサーバのセッション情報をクラスに格納して保持します。

リスト管理内にチャットサーバのセッション情報がある場合は、プレイヤーが新しく参加したことになるのでプレイヤー情報のみを更新します。

マッチングの管理
case "COMPLETED":

    // マッチングの制御
    const json = JSON.parse(message);
    let match = this._matches.find(e => e.getGameServerSessionId() == json.GameServerSessionId);

    // 既存のマッチングを更新する
    if (match) {
        match.setPlayers(matchTicket.Players);
    } else {
        match = new Match();
        match.setPlayers(matchTicket.Players);
        match.setGameServerSessionId(json.GameServerSessionId);
        this._matches.push(match);
    }

管理したマッチの情報を使って node-cron から定期的に StartMatchingBackfill をリクエストします。
これで後から参加するプレイヤーが StartMatching をリクエストすると、
StartMatchingBackfill のリクエストとマッチングが成立し、同じチャットサーバに接続出来るようになります。

StartMatchingBackfillの定期実行
// 成立したマッチングと新規接続ユーザーのマッチングを行うため、成立したマッチングでBackfill
cron.schedule("*/30 * * * * *", ()=>{

    MatchManager.getMatches().forEach(match => {
        const params = {
            "MatchCode": GpmSetting.getMatchCode(),
            "Players": match.getPlayers(),
            "GameServerSessionId": match.getGameServerSessionId()
        };

        GpmRepository.startMatchingBackfill(params).then(
            (data) => {
                console.log(data);
            },
            (err) => {
                console.log(err);
            }
        );
    });
});

記事で紹介するチャットは以上で完了です!!

GPMのゲーム適応を考える

今回作成したチャットは簡単なルールを用いてマッチングを行いました。
実際、ゲームではもっと複雑なルールを考える必要があります。

そこでゲームを想定したルールを考えてみます。

ゲームマッチングの仕様

スマートフォンアプリによくあるカードバトルGvGを想定します。
プレイヤーはギルドに所属し、ギルドは最大10名のプレイヤーで構成されていると仮定します。
GvGではプレイヤーが選んだ10枚のカードでバトルを行い、バトルで相手カードに与えたダメージの総量をギルド毎にランキングで競うものとします。

GvG.jpg

ルールの作成

GvGの構成を表すルール

まずは1~10名のプレイヤーで構成されるギルドのマッチングから考えます。
ここまで紹介してきたチャットと同様です。

gvg
{
    "version": "v1.0",
    "teams": [
        {
            "name": "guild1",
            "minPlayers": 1,
            "maxPlayers": 10
        },
        {
            "name": "guild2",
            "minPlayers": 1,
            "maxPlayers": 10
        }
    ]
}

これだけではランダムマッチングになります。
ゲームではギルドに所属するプレイヤーの強弱を考慮する必要があります。
そこでプレイヤー属性に「level」「damage」を追加し、なるべく同じ強さのギルドがマッチングされるように調整します。

「level」はギルドに所属する各プレイヤーのレベル、
「damage」はこれまでの試合でプレイヤーが与えたダメージの総量を表します。(1週間でリセット)

Distance Rule で以下のように設定することで、
「プレイヤーの平均レベル差が5以下、これまでの試合で与えたダメージ総量の平均差が10,000以下」のギルド同士のマッチングが実現出来ます。

gvg
{
    "version": "v1.0",
    "teams": [
        {
            "name": "guild1",
            "minPlayers": 1,
            "maxPlayers": 10
        },
        {
            "name": "guild2",
            "minPlayers": 1,
            "maxPlayers": 10
        }
    ],
    "playerAttributes": [
        {
            "name": "damage",
            "type": "number"
        },
        {
            "name": "level",
            "type": "number"
        }
    ],
    "rules": [
        {
            "name": "damageRule",
            "type": "distanceRule",
            "description": "各チームのプレイヤーが与えた平均ダメージの差が10,000以下",
            "measurements": [
                "avg(teams[*].players.playerAttributes[damage])"
            ],
            "referenceValue": "avg(flatten(teams[*].players.playerAttributes[damage]))",
            "maxDistance": 10000
        },
        {
            "name": "levelRule",
            "type": "distanceRule",
            "description": "各チームのプレイヤーの平均レベルの差が5以下",
            "measurements": [
                "avg(teams[*].players.playerAttributes[level])"
            ],
            "referenceValue": "avg(flatten(teams[*].players.playerAttributes[level]))",
            "maxDistance": 5
        }
    ]
}

まだ問題がります。
それは毎日同じギルドとマッチングする可能性です。
ゲームとしてそれでは面白くはありません。
そこで1週間のうち、1度でも対戦したことがあるギルドはマッチング対象から除外することを考えます。

ゲーム側でギルドにIDを設定し、そのIDを Comparison Rule で比較します。
「guildId」という属性をプレイヤー情報に追加し、プレイヤーが所属するギルドのIDを渡して referenceValue とします。
設定値は「guild1に所属するプレイヤーの「guildId」をリスト化しその平均を取る」という内容ですが、
所属プレイヤーの guildId は全員共通なので平均を取得しても値は変わりません。

「opponentGuildId1~6」もプレイヤー属性に追加します。
opponentGuildId1~6 には過去1週間にマッチングした対戦相手のギルドIDを設定し measurements にその値を渡します。

以上でComparision Rule で referenceValue と measurements が比較され、
「guild1のIDとguild2が過去1週間にマッチングしたIDが不一致の時だけマッチングする」ことが出来ます。

gvg
{
    "version": "v1.0",
    "teams": [
        {
            "name": "guild1",
            "minPlayers": 1,
            "maxPlayers": 10
        },
        {
            "name": "guild2",
            "minPlayers": 1,
            "maxPlayers": 10
        }
    ],
    "playerAttributes": [
        {
            "name": "damage",
            "type": "number"
        },
        {
            "name": "level",
            "type": "number"
        },
        {
            "name": "guildId",
            "type": "number"
        },
        {
            "name": "opponentGuildId1",
            "type": "number"
        },
        {
            "name": "opponentGuildId2",
            "type": "number"
        },
        {
            "name": "opponentGuildId3",
            "type": "number"
        },
        {
            "name": "opponentGuildId4",
            "type": "number"
        },
        {
            "name": "opponentGuildId5",
            "type": "number"
        },
        {
            "name": "opponentGuildId6",
            "type": "number"
        }
    ],
    "rules": [
        {
            "name": "damageRule",
            "type": "distanceRule",
            "description": "各チームのプレイヤーが与えた平均ダメージの差が10,000以下",
            "measurements": [
                "avg(teams[*].players.playerAttributes[damage])"
            ],
            "referenceValue": "avg(flatten(teams[*].players.playerAttributes[damage]))",
            "maxDistance": 10000
        },
        {
            "name": "levelRule",
            "type": "distanceRule",
            "description": "各チームのプレイヤーの平均レベルの差が5以下",
            "measurements": [
                "avg(teams[*].players.playerAttributes[level])"
            ],
            "referenceValue": "avg(flatten(teams[*].players.playerAttributes[level]))",
            "maxDistance": 5
        },
        {
            "name": "opponetRule",
            "type": "comparisonRule",
            "description": "1週間のうち一度でも対戦したことがあるギルドとはマッチングさせない",
            "measurements": [
                "teams[guild2].players.playerAttributes[opponentGuildId1]",
                "teams[guild2].players.playerAttributes[opponentGuildId2]",
                "teams[guild2].players.playerAttributes[opponentGuildId3]",
                "teams[guild2].players.playerAttributes[opponentGuildId4]",
                "teams[guild2].players.playerAttributes[opponentGuildId5]",
                "teams[guild2].players.playerAttributes[opponentGuildId6]"
            ],
            "referenceValue": "avg(flatten(teams[guild1].players.playerAttributes[guildId]))",
            "operation": "!="
        }
    ]
}

その他の問題として「圧倒的な強さを誇るギルド」や「始めて間もないギルド」はマッチングしない可能性が考えられます。
緩和した別のルールを用意して、マッチングしなかったギルド同士で再リクエストするなどのやり方でカバーすると良いでしょう。

最終的にルールなしのマッチングにフォールバックさせれば、マッチング自体は成立します。
ゲーム仕様と折り合いをつけて、上手くルールを決定するのが良いと思います。

GPM・GSEの使用感

今回使った Tencent Cloud のGPM・GSEについてそれぞれ良かった点をまとめてみます。

GPMの良い点

・マッチング実装の工数削減

マッチング処理の複雑な条件判定を考慮すると、実装するコストも大きそうです。
反面、GPMではマッチングに必要な情報を渡してリクエストするだけなのでコストを大幅にカットしてくれます。
執筆時点でGPMサービス自体はbeta版で費用は発生しないので試すなら今がチャンスです!

・ルール変更の容易さ

GPMではマッチに紐づけたルールをコンソールから変更するだけで、マッチング処理を変えられます。
今回記事では取り扱いませんでしたが、GPMにはコンソールAPIもあり
APIから別のルールに差し替えたりすることも出来ます。
例えば朝から強敵とは戦いたくないので少し弱い相手とマッチング、夜はガッツリ戦える強敵とマッチングというように簡単に切り替えることが出来ます。
プログラムでも可能だと思いますが、ルールを実装するくらいならGPMを利用した方が断然楽だと思いました。

・GSEとの連携

GSEとの連携を設定するだけで、「マッチングが成立後にゲームサーバが起動する」というのはとても画期的だと感じました。
サービス間で簡単に連携出来るようになれば、プロダクトの開発や運営に注力が出来るのでとてもありがたいです。

GPMの改善点

・一部APIやパラメータの分かりにくさ

プロダクトが普及すればいずれ改善していくと思いますが、Tencent Cloud が提供しているドキュメントだけでGPMの機能を把握するのは困難だと感じました。
実装当時 StartMatchingBackfill API はリクエストするだけで既にあるチャットルームに参加できるようなAPIだと想像してしまいました。
ドキュメントの参照だけでは、実際の動作と異なる部分が少なからずあります。(Tencent Cloudに限った話ではありません)

ただし Tencent Cloud はリクエストを検証出来る API Explorer を提供しています。
困った時に、実装せずとも実際にリクエスト出来る環境がある点はとても良いと感じました。

・ルールに使える関数が少ない

Distance Rule や Comparision Rule でflatten関数を紹介しました。
ドキュメントを参照した限り flatten に加え avg(平均値)しか使える関数がありません。
ルールが複雑化すればするほどルールスクリプトの難読化が進みそうです。
まだbeta版ということもあり、今後の展開に期待しています!


GSEの良い点

チュートリアルの充実

今回はGPMと連携させてチャットルームを作るという目的で利用したので使い込めてはいませんが、
チュートリアルや demo プロジェクトなどサンプルが充実していたので無事構築することが出来ました。
これから始めるエンジニアにとっては嬉しい限りです。

サポート対象言語の広さ

GSEはサーバサイドの言語に限らず、Unityのようなゲームエンジンもサポートしています。
クライアントエンジニアの筆者でも取り組みやく、とても好印象でした。
クライアントエンジニアでもサーバ・インフラなどに技術領域を広げられることは素晴らしいと思います。

GSEの改善点

gRPCというハードル

いざ使おうというタイミングでgRPC概念が出てきて面喰いました。
紹介した通りそこまで難しくありませんが、初めて概念に触れる方は理解に時間がかかると思います。
是非 demo プロジェクトやチュートリアルを参考にしてみてください。

終わりに

約2週間という短い期間で使ったことのない Tencent Cloud のサービスや Websocket を使用した双方向通信の実装など
色々なことを経験させていただきました!ありがとうございます!

GPMやGSEのような新しいサービスを使用してモノづくりをするというのはエンジニアとしてアドレナリンが出ますね!
また機会があれば是非挑戦してみたいと思います!!

最後となりますが、株式会社マイネットでは一緒に働く仲間を募集しています!
弊社では様々なゲームタイトルをより長く、安定して運営していくために、インフラ最適化にも積極的に取り組んでいます。興味のある方、ご応募お待ちしております!
https://mynet.co.jp/recruitment/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0