お問い合わせ

ブログ

これまでに経験してきたプロジェクトで気になる技術の情報を紹介していきます。

Unity初心者がawsサーバーとWebSocketを使ってのリアルタイム同期通信について学ぶ③

T, M T, M 3 years
Unity初心者がawsサーバーとWebSocketを使ってのリアルタイム同期通信について学ぶ③

=============================================================================== ◆Unity初心者がawsサーバーとWebSocketを使ってのリアルタイム同期通信について学ぶ③

◆まえがき

このシリーズもやっと最後です。
ここでは同期処理について記述をしていきます。

なお、本ブログは「Unity初心者がawsサーバーとWebSocketを使ってのリアルタイム同期通信について学ぶ②」の続きとなります。
もし前を見ていない方は下記より、見て頂ければと思います。

Unity初心者がawsサーバーとWebSocketを使ってのリアルタイム同期通信について学ぶ②:
https://www.aska-ltd.jp/jp/blog/106

サーバー部については前回で概ね終了しているので、残るはクライアント(Unity)側の修正となります。
主にクライアントの処理をWebSocketを使っての同期通信に対応するように修正となります。
基本的には、本ブログの①で作成したUnityのプログラムを元に追加修正していく形となります。

◆クライアント部の作成(同期あり)
ここからはサーバーとクライアントの連携の準備に入ります。
通信データについては、極力簡単なJSONにしたく、下図の通りとします。
見た通りクライアントから送信するJSONと、サーバーから返信してくるJSONの2種類だけです。



①通信データの扱うクラスの作成

通信データを扱うクラスを記述するスクリプトを作成します。
(Project」タブのAssets/Scriptを選択、右クリック > create > C# script を実行 名前を「PlayerActionData」で作成)

作成したスクリプトに下記のコードを記述します。
(Projectタブより、作成した「PlayerActionData」をエディターで開いて編集)

using Newtonsoft.Json;
using System.Collections.Generic;

public class PlayerActionData
{
    [JsonProperty("action")]
    public string action;

    [JsonProperty("room_no")]
    public int? room_no;

    [JsonProperty("user")]
    public string user;

    [JsonProperty("pos_x")]
    public float pos_x;

    [JsonProperty("pos_y")]
    public float pos_y;

    [JsonProperty("pos_z")]
    public float pos_z;

    [JsonProperty("way")]
    public string way;

    [JsonProperty("range")]
    public float range;

    /// <summary>
    /// クライアントからサーバへ送信するデータをJSON形式に変換
    /// </summary>
    /// <returns></returns>
    public string ToJson()
    {
        // オブジェクトをjsonに変換
        return JsonConvert.SerializeObject(this, Formatting.None);
    }

    /// <summary>
    /// サーバーから送信してきたJSONデータを配列データに変換
    /// </summary>
    /// <param name="json"></param>
    /// <param name="roomNo"></param>
    /// <returns></returns>
    public static Dictionary<string, PlayerActionData> FromJson(string json, int roomNo)
    {
        // json文字列を多階層のDictionaryに変換
        var jsonHash = JsonConvert.DeserializeObject<Dictionary<string, Dictionary<string, Dictionary<string, object>>>>(json);

        // 戻り値のDictionaryの初期化
        var playerActionHash = new Dictionary<string, PlayerActionData>();

        // jsonの中に該当のルーム番号の情報がなければ空のDictionaryを返却
        if (!jsonHash.ContainsKey("room" + roomNo))
        {
            return playerActionHash;
        }

        // ルームの中にユーザ情報が含まれているのでPlayerActionData型に変換
        var roomPlayerHash = jsonHash["room" + roomNo];
        foreach (var playerHash in roomPlayerHash)
        {
            var PlayerActionData = new PlayerActionData
            {
                user  = (string)playerHash.Value["user"],
                pos_x = float.Parse(playerHash.Value["pos_x"].ToString()),
                pos_y = float.Parse(playerHash.Value["pos_y"].ToString()),
                pos_z = float.Parse(playerHash.Value["pos_z"].ToString()),
                way   = (string)playerHash.Value["way"],
                range = float.Parse(playerHash.Value["range"].ToString()),
            };
            playerActionHash.Add(PlayerActionData.user, PlayerActionData);
        }

        return playerActionHash;
    }
}

処理の説明をすると、下記のようになります。

・「ToJson」
PlayerActionDataのオブジェクトをJSONデータに変換するメソッドです。
クライアントから送信するJSONデータを作成する時に使用します。

・「FromJson」
サーバーから送信してきたJSONデータを読み込むメソッドです。
送信してきたJSONデータをクライアントで読めるように配列型に変換します。

②WebScoketを扱うクラスの作成

WebSocketの接続、送信、受信、切断回りを扱うクラスを記述するスクリプトを作成します。
(ProjectタブのAssets/Scriptを選択、右クリック > create > C# script を実行 名前を「WebSocketClientManager」で作成)

作成したスクリプトに下記のコードを記述します。
(Projectタブより、作成した「WebSocketClientManager」をエディターで開いて編集)

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using WebSocketSharp;

public class WebSocketClientManager
{
    public static WebSocket webSocket;
    public static UnityAction<Dictionary<string, PlayerActionData>> recieveCompletedHandler;

    /// <summary>
    /// WebSocket接続
    /// </summary>
    public static void Connect()
    {
        if (webSocket == null)
        {
            webSocket = new WebSocket("ws://xx.xx.xx.xx:3000");
            webSocket.OnMessage += (sender, e) => RecieveAllUserAction(e.Data);
            webSocket.Connect();
        }
    }

    /// <summary>
    /// WebSocket切断
    /// </summary>
    public static void DisConnect()
    {
        webSocket.Close();
        webSocket = null;
    }

    /// <summary>
    /// WebSocket送信
    /// </summary>
    /// <param name="action"></param>
    /// <param name="pos"></param>
    /// <param name="way"></param>
    /// <param name="range"></param>
    public static void SendPlayerAction(string action, Vector3 pos, string way, float range)
    {
        var userActionData = new PlayerActionData
        {
            action  = action,
            way     = way,
            room_no = 1,
            user    = UserLoginData.userName,
            pos_x   = pos.x,
            pos_y   = pos.y,
            pos_z   = pos.z,
            range   = range
        };

        webSocket.Send(userActionData.ToJson());
    }

    /// <summary>
    /// WebSocket受信
    /// </summary>
    /// <param name="json"></param>
    public static void RecieveAllPlayerAction(string json)
    {
        var allUserActionHash = PlayerActionData.FromJson(json, 1);
        recieveCompletedHandler?.Invoke(allUserActionHash);
    }
}

処理の説明をすると、下記のようになります。

・「Connect」
WebSocketサーバーへ接続するメソッドです。
サーバーへの接続と、接続サーバーからメッセージを受けた時に実行するメソッド「RecieveAllUserAction」の設定を行っています。
なおソース内の、「"ws://xx.xx.xx.xx:3000"」は、接続するサーバー(AWS)のIPアドレスを設定して下さい。

・「DisConnect」
WebSocketサーバーから切断するメソッドです。
切断処理は、お決まりの書き方と思ってくれていいです。

・「SendPlayerAction」
接続中のWebSocketサーバーへクライアントの情報を送信するメソッドです。
送信データのレイアウトは①の、クライアントからサーバーへの送信情報に従う形にします。

送信情報の部屋番号(room_no)ですが、今回は1固定でやっています。
本格的にする場合、複数ルームを想定して組んでみるといいと思います。

・「RecieveAllPlayerAction」
接続中のWebSocketサーバーからクライアントへ情報を送信した時に実行されるメソッドです。
サーバーからクライアントに送信してくる全プレイヤーの情報を、クライアントに取り込んでいます。
受信データのレイアウトは①の、サーバーからクライアントへの送信情報に従う形にします。
※受信JSONの分解は①で作成したのFromJsonメソッドを使用しています。

取り込み後は「recieveCompletedHandler」に登録されたメソッドにパラメータを渡して実行します。
※recieveCompletedHandlerに登録されたメソッドについては後で記載します。

③プレイ画面の処理の修正

自プレイヤー単品で動いていたプレイ画面に、他プレイヤーを同期させます。

以前作成したスクリプト「PlayerManager」をエディターで下記のようにコードを追記します。
(ProjectタブのAssets/Scriptsを選択し、「PlayerManager」を編集)

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class PlayManager : MonoBehaviour
{
    private GameObject playerPrefab = null;     // プレイヤーのリソース(プレハブ)
    private GameObject player;                  // 自プレイヤー情報
    private const float KEY_MOVEMENT = 0.5f;    // 移動ボタン1回クリックでの移動量

    // 全プレイヤーの行動情報
    private Dictionary<string, PlayerActionData> PlayerActionMap;      

    // 全プレイヤーのオブジェクト情報
    private readonly Dictionary<string, GameObject> playerObjectMap = new Dictionary<string, GameObject>();

    // Start is called before the first frame update
    void Start()
    {
        // 自プレイヤーの作成
        player = MakePlayer(Vector3.zero, UserLoginData.userName);

        // WebSocket開始
        StartWebSocket();
    }

    /// <summary>
    /// 定期更新
    /// </summary>
    void Update()
    {
        // ユーザーの行動情報があったら同期処理を行い、ユーザーの行動情報を初期化
        if (PlayerActionMap != null)
        {
            Synchronaize();
            PlayerActionMap = null;
        }
    }

    /// <summary>
    /// 上ボタン押下時の処理
    /// </summary>
    public void OnClickUpButton()
    {
        // 行動情報を送信&移動
        WebSocketClientManager.SendPlayerAction("move", player.transform.position, "up", KEY_MOVEMENT);
        player.transform.Translate(0, 0, KEY_MOVEMENT);
    }

    /// <summary>
    /// 下ボタン押下時の処理
    /// </summary>
    public void OnClickDownButton()
    {
        // 行動情報を送信&移動
        WebSocketClientManager.SendPlayerAction("move", player.transform.position, "down", KEY_MOVEMENT);
        player.transform.Translate(0, 0, -KEY_MOVEMENT);
    }

    /// <summary>
    /// 左ボタン押下時の処理
    /// </summary>
    public void OnClickLeftButton()
    {
        // 行動情報を送信&移動
        WebSocketClientManager.SendPlayerAction("move", player.transform.position, "left", KEY_MOVEMENT);
        player.transform.Translate(-KEY_MOVEMENT, 0, 0);
    }

    /// <summary>
    /// 右ボタン押下時の処理
    /// </summary>
    public void OnClickRightButton()
    {
        // 行動情報を送信&移動
        WebSocketClientManager.SendPlayerAction("move", player.transform.position, "right", KEY_MOVEMENT);
        player.transform.Translate(KEY_MOVEMENT, 0, 0);
    }

    /// <summary>
    /// 退室ボタン押下時の処理
    /// </summary>
    public void OnClickExitButton()
    {
        // WebSocket通信終了
        EndWebsocket();

        // タイトルシーンに戻る
        SceneManager.LoadScene("TitleScene");
    }

    /// <summary>
    /// WebSocketの開始
    /// </summary>
    private void StartWebSocket()
    {
        // WebSocket通信開始
        WebSocketClientManager.Connect();

        // WebSocketのメッセージ受信メソッドの設定
        WebSocketClientManager.recieveCompletedHandler += OnReciveMessage;

        // 自プレイヤーの初期情報をWebSocketに送信
        WebSocketClientManager.SendPlayerAction("connect", Vector3.zero, "neutral", 0.0f);
    }

    /// <summary>
    /// WebSocketの終了
    /// </summary>
    private void EndWebsocket()
    {
        WebSocketClientManager.SendPlayerAction("disconnect", Vector3.zero, "neutral", 0.0f);
        WebSocketClientManager.DisConnect();
    }

    /// <summary>
    ///  WebSocketのメッセージ(ユーザーの行動情報)受信メソッド
    /// </summary>
    /// <param name="synchronizeData"></param>
    private void OnReciveMessage(Dictionary<string, PlayerActionData> PlayerActionMap)
    {
        // 同期情報を取得
        this.PlayerActionMap = PlayerActionMap;
    }

    /// <summary>
    /// 同期処理
    /// </summary>
    private void Synchronaize()
    {

        // 退出した他プレイヤーの検索
        List<string> otherPlayerNameList = new List<string>(playerObjectMap.Keys);
        foreach (var otherPlayerName in otherPlayerNameList)
        {
            // 退出したプレイヤーの削除
            if (!PlayerActionMap.ContainsKey(otherPlayerName))
            {
                Destroy(playerObjectMap[otherPlayerName]);
                playerObjectMap.Remove(otherPlayerName);
            }
        }

        // プレイヤーの位置を更新
        foreach (var playerAction in PlayerActionMap.Values)
        {
            // 自分は移動済みなのでスルー
            if (UserLoginData.userName == playerAction.user)
            {
                continue;
            }

            // 入室中の他プレイヤーの移動
            if (playerObjectMap.ContainsKey(playerAction.user))
            {
                playerObjectMap[playerAction.user].transform.position = GetMovePos(playerAction);

            // 入室中した他プレイヤーの生成
            } 
            else
            {
                // 他プレイヤーの作成
                var player = MakePlayer(GetMovePos(playerAction), playerAction.user);

                // 他プレイヤーリストへの追加
                playerObjectMap.Add(playerAction.user, player);
            }
        }
    }

    /// <summary>
    /// プレイヤーを作成
    /// </summary>
    /// <param name="pos"></param>
    /// <param name="name"></param>
    private GameObject MakePlayer(Vector3 pos, string name)
    {
        // プレイヤーのリソース(プレハブ)を取得 ※初回のみ
        playerPrefab = playerPrefab ?? (GameObject)Resources.Load("SphPlayer");

        // プレイヤーを生成
        var player = (GameObject)Instantiate(playerPrefab, pos, Quaternion.identity);

        // プレイヤーのネームプレートの設定
        var otherNameText = player.transform.Find("TxtUserName").gameObject;
        otherNameText.GetComponent<TextMesh>().text = name;

        return player;
    }

    /// <summary>
    /// 各プレイヤーの移動後の座標を取得
    /// </summary>
    /// <param name="playerAction"></param>
    /// <returns></returns>
    private Vector3 GetMovePos(PlayerActionData playerAction)
    {
        var pos = new Vector3(playerAction.pos_x, playerAction.pos_y, playerAction.pos_z);
        pos.z += (playerAction.way == "up") ? playerAction.range : 0;
        pos.z -= (playerAction.way == "down") ? playerAction.range : 0;
        pos.x -= (playerAction.way == "left") ? playerAction.range : 0;
        pos.x += (playerAction.way == "right") ? playerAction.range : 0;

        return pos;
    }
}

前に作成した時から変更した部分について説明します。

・共通部分
共通部分には下記を追加しています。

> // 全プレイヤーの行動情報
> private Dictionary<string, PlayerActionData> PlayerActionMap;      
>
> // 全プレイヤーのオブジェクト情報
> private readonly Dictionary<string, GameObject> playerObjectMap = new Dictionary<string, GameObject>();

自分を除く他プレイヤーの、行動情報と表示用オブジェクトを管理するための配列です。
Dictionary形式のデータで管理します。
ちなみに、Dictionary形式の変数名の後ろにMapとつけるのはお作法?らしいです。
なんでそうなってるのかは筆者にもよくわかりません(汗)

・「Start」
Startメソッドには下記を追加しています。

> // WebSocket開始
> StartWebSocket();

同期処理の基本であるソケット通信の開始処理を追加してます。
メソッドの中身については後ほど説明します。

・「Update」
追加メソッドで、プレイ画面でフレーム毎に実行されるメソッドです。
サーバーから送信された全プレイヤーの行動情報(PlayerActionMap)があれば、「Synchronaize」メソッドにてプレイ画面に反映させて同期を取ります。
同期が終了した後の全プレイヤーの行動情報は不要なので初期化します。

ちなみに、全プレイヤーの行動情報は、他プレイヤーが行動する度に、サーバーがクライアントの「OnReciveMessage」メソッドを呼び出して設定します。

・上下左右ボタンの「OnClick(Up、Down、Right、Left)Button」
OnClick(Up、Down、Right、Left)Buttonメソッドには下記を追加しています。
※ボタンによって引数のパラメータが微妙に違うので注意してください。

> WebSocketClientManager.SendPlayerAction("move", player.transform.position, "up", KEY_MOVEMENT);

自プレイヤーの行動情報をサーバーに送信しています。
実装としては②で記述したスクリプト「WebSocketClientManager」の「SendPlayerAction」メソッドを使用して送信しています。

・「OnClickExitButton」
OnClickExitButtonメソッドには下記を追加しています。

> EndWebsocket();

接続しているWebSocketを切断しています。
実装として、同クラスにあるWebSocketを切断する「EndWebsocket」メソッドで実行しています。

・「StartWebSocket」
追加メソッドで、WebSocketサーバーへ接続するメソッドです。
Startメソッド内で呼ばれる形で、WebSocketサーバーへの接続と併せて、接続後にサーバーからのメッセージ受信した時に実行するメソッド「OnReciveMessage」の設定と、自プレイヤーの初期位置(画面中央)の情報の送信も行っています。

・「EndWebsocket」
追加メソッドで、WebSocketサーバーから切断するメソッドです。
OnClickExitButtonメソッド内で呼ばれる形で、接続中のWebSocket通信を切断してます。

・「OnReciveMessage」
追加メソッドで、全プレイヤーの行動情報を保存するメソッドです。
サーバーからプレイヤーの行動情報が送信された際に呼ばれるメソッドで、全プレイヤーの移動情報をクライアント側で保持しています。
保持された全プレイヤーの行動情報は、「Update」メソッドにて同期され、プレイ画面に反映されます。

・「Synchronaize」
追加メソッドで、プレイ画面にて全プレイヤーの状態の同期をとるメソッドです。
Updateメソッドにて実行され、サーバーより送信された全プレイやーの行動情報を元に、プレイ画面内の全プレイヤーの同期を取ります。
また、プレイ画面内に入退室した他プレイヤーの作成、削除も行っています。

・「GetMovePos」
追加メソッドで、各プレイヤーの移動後の座標を取得するメソッドです。
Synchronaizeメソッド内で呼ばれ、各プレイヤーの移動先の座標を計算します。

クライアントのソースは以上となります。

◆サーバー連携

ここまで出来れば後はサーバーとの連携を行うのみです。
動作確認を行う前に、前回のブログの最後に記載した方法で、WebSocketサーバーを起動しておきます。

修正したクライアントのソースをビルドしてEXEを作成し、複数のPCで起動するとログインしたプレイヤー同士がわちゃわちゃ同期して動きます。
静止画となりますが、完成すると下図のような感じになります。
・・・ホントは動画で上げたかったのですが、ここのブログだと動画はダメなようです(涙)



以上で「Unity初心者がawsサーバーとWebSocketを使ってのリアルタイム同期通信について学ぶ」は終了です。
ありがとうございました。

Unity初心者がawsサーバーとWebSocketを使ってのリアルタイム同期通信について学ぶ③ 2022-11-25 15:08:33

コメントはありません。

4502

お気軽に
お問い合わせください。

お問い合わせ
gomibako@aska-ltd.jp