お問い合わせ

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

  • T, M T, M
  • — 4 months ago
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を使ってのリアルタイム同期通信について学ぶ」は終了です。
ありがとうございました。

T, M

System engineer

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

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

お問い合わせ