UNET⑮ ネットワーク対戦のレースゲームを作る

今まで学んだことを組み合わせて、ネットワーク対戦のレースゲームを作ってみたいと思います!!

・・・といっても、本格的な車を走らせたりするのは大変なので、プレイヤーオブジェクトは玉です。玉転がしレースです。

なるべく簡単に作りたかったので、見た目はショボいですが・・・しかし、ちゃんと複数人で遊べるネットワーク対戦ゲームです!

 

ゲームの仕様

作り始める前に、ゲームの仕様を確認しておきたいと思います。

  • ゲーム開始前にプレイヤー名とプレイヤーオブジェクトの色を決定する。
  • ホストが開始すると即レースが始まる(一人で)。
  • プレイヤーがゲーム中に接続(途中参加)すると、開催中のレースは観戦するだけとなり、次のレースから参戦となる。
  • 金色の大きな玉がゴール。
  • コースの外側にはマグマが広がっているため、コースアウトすると死亡。
  • ゴールまたは死亡すると、プレイヤーオブジェクトは非表示・操作不能となる。
  • 全員がゴールまたは死亡すると、数秒後に次のレースが始まる。
  • コースは複数あり、ランダムで選ばれる。
  • 各プレイヤーがゴールまたは死亡すると、画面左にその情報が表示される

ゲーム画面の作成

まずは、ゲームのメインの画面を作りましょう。レースを行うコースの画面です。

新規シーンを作成します。

シーンを「Cource1」という名前で保存しておきましょう。

 

Terrain(コース)の作成

コースは、手軽に自由な形を作成するために、Terrainで作ります。
シーンにTerrainを追加してください。

初期設定だと広すぎるので、大きさを調整します。設定アイコンを押し、
f:id:motoyamablog:20171204102620p:plain

幅(Width)と奥行き(Length)を100にします。
f:id:motoyamablog:20171204102654p:plain

Positionを次のようにします。
f:id:motoyamablog:20171204102825p:plain

 

コースを作りましょう!Terrainペイントツールを次のように設定し、道となる場所を作っていきます。

f:id:motoyamablog:20171204103701p:plain

 

f:id:motoyamablog:20171204103716g:plain

あっという間にコースが完成しました。

※今回のゲームは、コースを何周も回るのではなく、ゴールに到達したら終わりなので、ドーナツ状にしないように注意。

プレイヤーオブジェクトの作成

シーンにSphereを追加して、名前を「Player」とします。

Positionのy座標は10.5とし、xとzはコース上の任意の場所にしてください。

Rigidbodyを追加します。

そのままだと、重力が弱くてふわふわするので、重力を強くします。メニュー>Edit>Project Settings>Physics>Gravity>Yを-20にします。

新規スクリプト「Player」を作成・アタッチします。

using UnityEngine;

public class Player : MonoBehaviour
{
    // 回転力
    [SerializeField]
    float m_Torque = 7f;

    Rigidbody m_Rigidbody;

    void Start()
    {
        m_Rigidbody = GetComponent<Rigidbody>();
        m_Rigidbody.maxAngularVelocity = 100f;
    }

    void FixedUpdate()
    {
        // プレイヤーの移動処理
        float x = Input.GetAxis("Vertical") * m_Torque;
        float z = -Input.GetAxis("Horizontal") * m_Torque;
        m_Rigidbody.AddTorque(x, 0, z);
    }
}

玉が操作できることを確認してください。

f:id:motoyamablog:20171204110040g:plain

 

スタート位置の作成

今回、スタート位置決定の仕組みは、UNET標準のNetworkStartPositionを使うのではなく、自作してみましょう。

空のオブジェクトを追加し、名前を「StartPosition」とします。

新規タグ「StartPosition」を追加し、StartPositionオブジェクトに設定してください。

StartPositionオブジェクトのPositionのy座標を10.5にします。

空のオブジェクトなので、アイコンを設定すると、Sceneビューで見やすくなります。

f:id:motoyamablog:20171204104310p:plain
(※見えない場合はGizmos>3D Iconsを調整してください)

xとzはコースに合わせて、見ながら設定してください。

f:id:motoyamablog:20171204104430g:plain

 

ゴールの作成

今回は、大きめの金ピカの玉を用意し、それに触れたらゴールということにしたいと思います。

シーンにSphereを追加し、名前を「Goal」とします。

新規タグ「Goal」を追加し、Goalオブジェクトに設定します。

Scaleを10倍くらいにします。

f:id:motoyamablog:20171204104805p:plain

適当に金ピカのMaterialを作成し、Goalオブジェクトに適用します。

GoalオブジェクトのPositionのy座標を10にし、xとzは見ながら調整してください。

f:id:motoyamablog:20171204105018g:plain

 

マグマの作成

コースの外にマグマを作ります。

シーンにCubeを追加し、名前を「Magma」とします。

新規タグ「Magma」を追加し、Magmaオブジェクトに設定します。

PositionとScaleを次のようにします。

f:id:motoyamablog:20171204110455p:plain

新規Materialを作成し、 Magmaオブジェクトに色をつけてください。

 

カメラの設定

MainCameraのTransformを次のようにします。

f:id:motoyamablog:20171204103021p:plain

 

UIの作成

Canvasを追加して、Canvas ScalerのUI Scale ModeをScale With Screen Sizeにします。

Canvas配下にTextを追加して、名前を「CenterText」とします。「3・2・1・GO」と表示するためのTextです。次のような感じで設定します。

f:id:motoyamablog:20171204174230p:plain

 

さらに、Canvas配下にTextを追加して、名前を「InformationText」とします。「○○がゴールしました!!××が落下しました!!」などと表示するためのTextです。次のような感じで設定します。

f:id:motoyamablog:20171204174310p:plain

 

こんな感じになります。

f:id:motoyamablog:20171204112759p:plain

確認して問題なければ、CenterTextとInformationTextのText欄は空にしておいてください。

 

以上で、ネットワーク以外の要素は完成です。ここから、ネットワークゲームとして作り込んでいきます。

 

Playerをネットワーク対応させる

Playerスクリプトを改修します。

using UnityEngine;
using UnityEngine.Networking;

public class Player : NetworkBehaviour
{
    // 状態種別
    public enum State
    {
        // Readyになるのを待ってる状態
        WaitReady,
        // Start直前の状態
        Ready,
        // プレイ中の操作可能な状態
        Play,
        // ゴール後の状態
        Finished,
        // 死亡後の状態
        Dead,
    }

    // 状態
    [SyncVar]
    public State m_State = State.WaitReady;
    // プレイヤー名
    [SyncVar]
    public string m_PlayerName;
    // プレイヤーオブジェクトの色
    [SyncVar]
    public Color m_PlayerColor;

    // 回転力
    [SerializeField]
    float m_Torque = 7f;

    Rigidbody m_Rigidbody;

    // クライアント上で生成済みかどうかをサーバーが判断するためのフラグ
    public bool m_SpawnOnClient;
    
    void Start()
    {
        m_Rigidbody = GetComponent<Rigidbody>();
        m_Rigidbody.maxAngularVelocity = 100f;

        // 色を適用する
        GetComponent<Renderer>().material.color = m_PlayerColor;

        // ReadyまたはPlay状態の場合のみ有効化する。
        SetEnable(m_State == State.Ready || m_State == State.Play);

        if (isLocalPlayer)
        {
            CmdInitializeComplete();
        }
    }

    // クライアント上で生成済みであることをサーバーへ通知する
    [Command]
    void CmdInitializeComplete()
    {
        m_SpawnOnClient = true;
    }

    void FixedUpdate()
    {
        if (!isLocalPlayer)
            return;

        if (m_State != State.Play)
            return;

        // プレイヤーの移動処理
        float x = Input.GetAxis("Vertical") * m_Torque;
        float z = -Input.GetAxis("Horizontal") * m_Torque;
        m_Rigidbody.AddTorque(x, 0, z);
    }

    void OnCollisionEnter(Collision collision)
    {
        if (!isLocalPlayer)
            return;

        GameObject obj = collision.gameObject;

        // Goalに当たったらゴール
        if (obj.CompareTag("Goal"))
        {
            Goal();
            CmdGoal();
        }
        // Magmaに当たったら死亡
        else if (obj.CompareTag("Magma"))
        {
            Die();
            CmdDie();
        }
    }

    // Ready状態にする
    public void Ready()
    {
        m_State = State.Ready;
        SetEnable(true);
    }

    // Play状態(操作可能)にする
    public void StartPlay()
    {
        m_State = State.Play;
    }

    // ゴール時の処理
    void Goal()
    {
        if (m_State == State.Play)
        {
            m_State = State.Finished;
            SetEnable(false);
        }
    }

    // ゴールしたことをサーバーに通知する
    [Command]
    void CmdGoal()
    {
        RpcGoal();
    }

    // クライアント上でゴール処理を行う
    [ClientRpc]
    void RpcGoal()
    {
        Goal();
    }
    
    // 死亡時の処理
    void Die()
    {
        if (m_State == State.Play)
        {
            m_State = State.Dead;
            SetEnable(false);
        }
    }

    // 死んだことをサーバーへ伝える
    [Command]
    void CmdDie()
    {
        RpcDie();
    }

    // クライアント上で死亡処理を行う
    [ClientRpc]
    void RpcDie()
    {
        Die();
    }

    // 見た目、当たり判定等の有効/無効を設定する
    void SetEnable(bool enable)
    {
        GetComponent<Collider>().enabled = enable;
        GetComponent<Renderer>().enabled = enable;
        GetComponent<Rigidbody>().isKinematic = !enable;
    }
}

PlayerオブジェクトにNetworkIdentityコンポーネントをアタッチし、LocalPlayerAuthorityをオンにします(操作感向上のために、リモートクライアントの位置情報を主とし、サーバーを従とするため)。

PlayerオブジェクトにNetworkTransformコンポーネントをアタッチします。

このゲームにおいて、玉の向きはほとんど重要ではないため、CompressRotationをLowまたはHighにすると、少しですが帯域を節約できます。

f:id:motoyamablog:20171204111529p:plain

プレイヤーオブジェクトをプレハブ化し、シーンからは削除します。

 

RaceManagerの作成

レースを管理するRaceManagerを作成しましょう。

空のオブジェクトを作成し、名前を「RaceManager」とします。

新規タグ「RaceManager」を作成し、RaceManagerオブジェクトに設定します。

新規スクリプト「RaceManager」を作成・アタッチしてください。

using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;

public class RaceManager : NetworkBehaviour
{
    // 状態種別
    public enum State
    {
        // 全てのプレイヤーが生成されるのを待っている状態
        WaitingSpawnPlayer,
        // カウントダウン中の状態
        Ready,
        // レース中
        Play,
        // 全員がゴールまたは死亡し、次のレースへ移る直前
        GoToNextRace,
    }

    // 現在のゲームの状態
    State m_State = State.WaitingSpawnPlayer;

    // 画面中央のカウントダウン表示等に使うテキストの参照
    Text m_CenterText;
    // 誰がゴールしたとかの情報を表示するテキストの参照
    Text m_InformationText;

    void Start()
    {
        m_CenterText = GameObject.Find("CenterText").GetComponent<Text>();
        m_InformationText = GameObject.Find("InformationText").GetComponent<Text>();
    }

    // 全てのプレイヤーが準備完了しているか?
    bool AreAllPlayersReady()
    {
        foreach (NetworkConnection conn in NetworkServer.connections)
        {
            // 切断されると、nullのConnectionが混入するので、無視する
            if (conn == null)
                continue;

            if (!conn.isReady)
                return false;

            if (conn.playerControllers.Count == 0)
                return false;

            if (conn.playerControllers[0].gameObject == null)
                return false;

            if (!conn.playerControllers[0].gameObject.GetComponent<Player>().m_SpawnOnClient)
                return false;
        }

        // ここまで来たということは、全ての接続のプレイヤーが準備完了である
        return true;
    }

    void Update()
    {
        // サーバーでのみ処理を行う
        if (!isServer)
            return;

        // プレイヤーの生成待ちの場合
        if (m_State == State.WaitingSpawnPlayer)
        {
            // 全プレイヤーが準備完了であれば
            if (AreAllPlayersReady())
            {
                // Readyの処理を開始する
                RpcReady();
            }
        }
        else if (m_State == State.Ready)
        {
            // Ready中は何もしない
        }
        else if (m_State == State.Play)
        {
            // プレイ中のプレイヤーがいるか調べる
            bool playing = false;

            foreach (var player in FindObjectsOfType<Player>())
            {
                if (player.m_State == Player.State.Play)
                {
                    playing = true;
                    break;
                }
            }

            // プレイ中のプレイヤーがいなければ、次のレースに移る
            if (!playing)
            {
                StartCoroutine(GoToNextRace());
            }
        }
        else if (m_State == State.GoToNextRace)
        {
            // GoToNextRaceは何もしない
        }
        else
        {
            Debug.LogError("Unknown State : " + m_State);
        }
    }

    [ClientRpc]
    void RpcReady()
    {
        StartCoroutine(Ready());
    }

    // 準備中の処理。
    IEnumerator Ready()
    {
        m_State = State.Ready;

        foreach (Player player in FindObjectsOfType<Player>())
        {
            player.Ready();
        }

        m_CenterText.text = "3";
        yield return new WaitForSeconds(1);
        m_CenterText.text = "2";
        yield return new WaitForSeconds(1);
        m_CenterText.text = "1";
        yield return new WaitForSeconds(1);
        m_CenterText.text = "GO";

        m_State = State.Play;

        foreach (Player player in FindObjectsOfType<Player>())
        {
            // 状態がReadyのプレイヤーのみが、Startの対象である。
            // カウントダウン中に新規接続してきたプレイヤーは無視する。
            if (player.m_State == Player.State.Ready)
                player.StartPlay();
        }

        yield return new WaitForSeconds(1);
        m_CenterText.text = "";
    }

    IEnumerator GoToNextRace()
    {
        m_State = State.GoToNextRace;
        yield return new WaitForSeconds(0.5f);
        RpcAddInformation("次のレースへ移動します...");
        yield return new WaitForSeconds(2f);
        // TODO 後でコメントを外す
        // FindObjectOfType<MyNetworkManager>().ServerChangeSceneToNextCource();
    }

    [ClientRpc]
    public void RpcAddInformation(string text)
    {
        m_InformationText.text += text + System.Environment.NewLine;
    }
}

 

RaceManagerオブジェクトに、NetworkIdentityコンポーネントをアタッチしてください。

以上でゲーム画面の完成です。

保存しておいてください。

 

タイトル画面の作成

タイトル画面(接続画面)を作りましょう。

新規Sceneを作成します。「Title」という名前で保存しておきましょう。

UIの作成

Canvasを追加して、UI Scale ModeをScale With Screen Sizeにします。

f:id:motoyamablog:20171204094934p:plain

Canvas配下に、Text、InputField、Image、Buttonを追加し、それぞれのオブジェクト名を次のようにします(後でスクリプトから検索するので、名前は正しく付けてください)。

f:id:motoyamablog:20171204095058p:plain

 

TitleLogoのText欄はゲーム名「BALL RACING」として、Font Sizeはデカくしましょう。

PlayerNameInputFieldは子のPlaceholderのTextを「Input your name...」とします。

ChangePlayerColorButtonは子のTextのTextを「Change Color」とします。

レイアウトは任意ですが、私は次のようにしました。

f:id:motoyamablog:20171204095439p:plain

 

UIのスクリプトの作成

UIのためのスクリプトを書きます。

スクリプトをアタッチする場所はどこでも良いので、Canvasにでもくっつけましょうか。Canvasに新規スクリプト「PlayerInfoManager」を作成・アタッチします

using UnityEngine;
using UnityEngine.UI;

// プレイヤー名と色の情報を保持する。
// また、それらを編集するUIの管理をする。
public class PlayerInfoManager : MonoBehaviour
{
    // プレイヤー名
    public static string s_PlayerName = "Anonymous";
    // プレイヤーカラー
    public static Color s_PlayerColor = Color.white;

    void Start()
    {
        // プレイヤー名入力欄の参照を取得
        InputField playerNameInputField = GameObject.Find("PlayerNameInputField").GetComponent<InputField>();

        // プレイヤー名入力欄の編集完了時の処理を登録
        playerNameInputField.onEndEdit.AddListener((string newValue)=> {
            s_PlayerName = newValue;
        });

        // プレイヤーカラー変更ボタンが押された時の処理を登録
        GameObject.Find("ChangePlayerColorButton").GetComponent<Button>().onClick.AddListener(()=>{
            // ランダムに色を決定
            s_PlayerColor = Random.ColorHSV(0f, 1f, 1f, 1f, 1f, 1f, 1f, 1f);

            // プレビューUIに反映させる
            GameObject.Find("PlayerColorPreview").GetComponent<Image>().color = s_PlayerColor;
        });
    }
}

今回は各UIのイベントの登録を全部プログラムで行ってみました。

プレイヤー名の入力が終わると、入力された文字列をs_PlayerNameという変数に格納します。

色変更ボタンを押すと、色をランダムで決定し、s_PlayerColorという変数に格納し、プレビュー用Imageに反映させています。

 

NetworkManagerのカスタマイズ

今回は、各プレイヤーがそれぞれ名前と色を持つので、それをプレイヤーオブジェクトに持たせてあげる必要があります。また、レースにおける各プレイヤーのスタート位置の決定方法を独自のものにしたいです。それらのために、NetworkManagerをカスタマイズします。

空のオブジェクトを追加し、NetworkManagerという名前にしましょう。

そのオブジェクトに新規スクリプト「MyNetworkManager」を作成・アタッチします。

using UnityEngine;
using UnityEngine.Networking;

public class MyNetworkManager : NetworkManager
{
    // コースのScene名をエディタ上で登録する
    [SerializeField] string[] m_CourceScenes;
   
    // AddPlayer実施時に一緒に送る追加情報用クラス
    class PlayerInfoMessage : MessageBase
    {
        public string playerName;
        public Color playerColor;
    }
    
    // プレイヤー生成依頼メッセージをサーバーへ送信する
    void AddPlayer(NetworkConnection conn)
    {
        // プレイヤー生成命令にカスタムの情報を追加
        PlayerInfoMessage msg = new PlayerInfoMessage();
        msg.playerName = PlayerInfoManager.s_PlayerName;
        msg.playerColor = PlayerInfoManager.s_PlayerColor;

        ClientScene.AddPlayer(conn, 0, msg);
    }

    public override void OnClientSceneChanged(NetworkConnection conn)
    {
        // クライアントにてシーンの準備が完了したことをサーバーへ通知する
        ClientScene.Ready(conn);

        // プレイヤーオブジェクト生成依頼
        AddPlayer(conn);
    }
        
    // サーバーがAddPlayerメッセージを受信したときの処理
    public override void OnServerAddPlayer(NetworkConnection conn, short playerControllerId, NetworkReader extraMessageReader)
    {
        // 追加情報のプレイヤー名と色を取り出す
        PlayerInfoMessage playerInfoMessage = extraMessageReader.ReadMessage<PlayerInfoMessage>();
        string playerName = playerInfoMessage.playerName;
        Color playerColor = playerInfoMessage.playerColor;
        
        // プレイヤーの位置をStartPositionタグのオブジェクトを中心に、8メートル四方の中で適当に決める
        Vector3 startPosition = GameObject.FindGameObjectWithTag("StartPosition").transform.position;
        startPosition += new Vector3(Random.Range(-4f, 4f), 0, Random.Range(-4f, 4f));

        GameObject playerObject = Instantiate(playerPrefab, startPosition, Quaternion.identity);

        Player player = playerObject.GetComponent<Player>();
        player.m_PlayerName = playerName;
        player.m_PlayerColor = playerColor;

        NetworkServer.AddPlayerForConnection(conn, playerObject, playerControllerId);
    }

    // ランダムにコース(Scene)を決定して切り替える
    public void ServerChangeSceneToNextCource()
    {
        string newSceneName = m_CourceScenes[Random.Range(0, m_CourceScenes.Length)];

        ServerChangeScene(newSceneName);
    }
}

 

接続画面のUIは、本当は自作しないといけませんが、面倒なので今回は標準のNetworkManagerHUDを使います(ただし、標準のHUDを使う場合、キーボードのH, C, Sなどに反応してそれぞれホスト、クライアント、ServerOnlyとして起動したりしてしまいます。プレイヤー名を入力する際は気をつけましょう)。

NetworkManagerオブジェクトにNetworkManagerHUDコンポーネントをアタッチします。こだわる人は以前の記事を参考に、UIを自作してください。

 

メニューのFile>BuildSettingsを開き、TitleシーンとCource1シーンを登録してください。

f:id:motoyamablog:20171204114629p:plain

 

NetworkManagerの次の欄を設定します。

f:id:motoyamablog:20171204114817p:plain

※下部のCourceScenesにScene名を登録するのは、文字列で入力してください。

 

以上でタイトル画面(接続画面)の完成です。

 

RaceManagerスクリプトの下記の場所のコメントアウトを外します。

    IEnumerator GoToNextRace()
    {
        m_State = State.GoToNextRace;
        yield return new WaitForSeconds(0.5f);
        RpcAddInformation("次のレースへ移動します...");
        yield return new WaitForSeconds(2f);
        // TODO 後でコメントを外す
        FindObjectOfType<MyNetworkManager>().ServerChangeSceneToNextCource();
    }

 

動作確認

ゲームを複数起動して、動作確認してみましょう。

f:id:motoyamablog:20171204115527g:plain

 

情報表示機能の追加

プレイヤーがゴールしたり、落下したりしたことをInformationTextに表示する機能がまだ無いので、追加します。Playerスクリプトを改修します。

public class Player : NetworkBehaviour
{

    (略)

    RaceManager m_RaceManager;

    void Start()
    {
        m_Rigidbody = GetComponent<Rigidbody>();
        m_Rigidbody.maxAngularVelocity = 100f;

        // 色を適用する
        GetComponent<Renderer>().material.color = m_PlayerColor;

        // ReadyまたはPlay状態の場合のみ有効化する。
        SetEnable(m_State == State.Ready || m_State == State.Play);

        if (isLocalPlayer)
        {
            CmdInitializeComplete();
        }

        m_RaceManager = FindObjectOfType<RaceManager>();
    }

    (略)

    // ゴールしたことをサーバーに通知する
    [Command]
    void CmdGoal()
    {
        m_RaceManager.RpcAddInformation(m_PlayerName + "がゴール!!");
        RpcGoal();
    }

    (略)

    // 死んだことをサーバーへ伝える
    [Command]
    void CmdDie()
    {
        m_RaceManager.RpcAddInformation(m_PlayerName + "が落下!!");
        RpcDie();
    }

    (略)

}

 

これで、誰がゴールしたとか落下したとかが、画面に表示されるようになります。動作確認してみましょう。

f:id:motoyamablog:20171204122823g:plain

 

 

コースを増やそう!

コースが1種類じゃつまらないので、色んなコースを追加しましょう。

コースを追加するには、Cource1シーンを複製すると楽です。ProjectビューでCource1を選択してCtrl+Dで複製できます。自動的にCource2という名前になるかと思います。

そうしたら、Cource2を開きます。

ここで注意ですが、このままTerrainを編集してはいけません!

現状、Cource1とCource2のTerrainは同じデータを参照しており、Cource2でTerrainを変形させると、Cource1のTerrainも一緒に変わってしまいます。Terrainの地形データを入れ替えることも可能ですが、やや面倒なので、Terrainオブジェクト自体を削除し、新たに作成するほうが速いです。

その際は、Terrain WidthとHeightを100にするのを忘れずに。

そんな感じでCource2、Cource3と好きなだけ作成してください。

気が済むまで作成したら、BuildSettingsのScenesInBuild に追加します。

f:id:motoyamablog:20171204124408p:plain

そしてTitleシーンのNetworkManagerのCourceScenes欄にシーン名を登録してください。

f:id:motoyamablog:20171204124242p:plain

これで、色んなコースを次々と遊べるようになります。

f:id:motoyamablog:20171204124655g:plain

※ランダムなので、連続して同じコースが出る場合もあります。

 

改良しよう!

いろいろと改良して、もっと面白くしてみてください!
例えば・・・

  • 自分がゴールした時に順位を表示する
  • 誰が何回勝ったかとかの情報を表示する
  • カメラを俯瞰じゃなくてTPS視点にする
  • 玉の近くにプレイヤー名を表示する
  • マリカーのアイテムみたいなのを追加する
  • 同じコースが連続で出ないようにする
  • 他のプレイヤーと重ならないようにスタート位置を決める

 

UNETの不具合について

以上で、ゲームとしては完成なのですが、UNET由来の不具合があることに気付いてしまいました・・・

その不具合はめったに発生しないのですが、サーバーの負荷が高まると、発生率が上がります。私の環境では、同一マシン内でゲームを10個とか起動・接続して動作確認すると、たまに発生します。発生すると、ゲームが続行不能になる致命的なものです。 

3日くらいかかりましたが、不具合の原因を突き止められました😭
ServerChangeSceneを呼ぶと、サーバーと各リモートクライアントでシーン読み込みが実施されますが、現状のUNETのソースコードでは、サーバーのほうが先に読み込み完了しているだろうという前提に基いた処理の流れになっています。だろう運転です。

ServerChangeSceneはサーバー上で発火されるし、ネットワークのレイテンシーもあるので、ほとんどの場合はサーバーのほうが先にシーン読み込みが完了するのですが、サーバーの負荷が高まると、リモートクライアントのほうが先にシーン読み込み完了してしまうことがあり、その場合に、誤動作が起きます

結果だけいうと、先に読み込み完了してしまったリモートクライアントのプレイヤーオブジェクトが生成されません!!

今回作ったゲームでは、全てのプレイヤーオブジェクトが準備完了したらカウントダウンが始まるようになっているので、いつまで経ってもカウントダウンが始まらず、ゲームが続行不能となってしまいます。

ほんと勘弁してほしい・・・UNET作った人、本気でUNET使ってないでしょ絶対。ドッグフーディングしてください。

今回は下記の対症療法コードをMyNetworkManagerに追加することで、回避できます。

using UnityEngine;
using UnityEngine.Networking;

public class MyNetworkManager : NetworkManager
{
    // 最後にAddPlayerを実行した時刻
    float m_AddPlayerLastSendTime = 0f;

    // コースのScene名をエディタ上で登録する
    [SerializeField] string[] m_CourceScenes;

    // AddPlayer実施時に一緒に送る追加情報用クラス
    class PlayerInfoMessage : MessageBase
    {
        public string playerName;
        public Color playerColor;
    }

    // プレイヤー生成依頼メッセージをサーバーへ送信する
    void AddPlayer(NetworkConnection conn)
    {
        // プレイヤー生成命令にカスタムの情報を追加
        PlayerInfoMessage msg = new PlayerInfoMessage();
        msg.playerName = PlayerInfoManager.s_PlayerName;
        msg.playerColor = PlayerInfoManager.s_PlayerColor;

        ClientScene.AddPlayer(conn, 0, msg);

        // 最後にAddPlayerを実行した時刻を覚えておく
        m_AddPlayerLastSendTime = Time.time;
    }

    public override void OnClientSceneChanged(NetworkConnection conn)
    {
        // クライアントにてシーンの準備が完了したことをサーバーへ通知する
        ClientScene.Ready(conn);

        // プレイヤーオブジェクト生成依頼
        AddPlayer(conn);
    }

    // サーバーがAddPlayerメッセージを受信したときの処理
    public override void OnServerAddPlayer(NetworkConnection conn, short playerControllerId, NetworkReader extraMessageReader)
    {
        // 追加情報のプレイヤー名と色を取り出す
        PlayerInfoMessage playerInfoMessage = extraMessageReader.ReadMessage<PlayerInfoMessage>();
        string playerName = playerInfoMessage.playerName;
        Color playerColor = playerInfoMessage.playerColor;

        // プレイヤーの位置をStartPositionタグのオブジェクトを中心に、8メートル四方の中で適当に決める
        Vector3 startPosition = GameObject.FindGameObjectWithTag("StartPosition").transform.position;
        startPosition += new Vector3(Random.Range(-4f, 4f), 0, Random.Range(-4f, 4f));

        GameObject playerObject = Instantiate(playerPrefab, startPosition, Quaternion.identity);

        Player player = playerObject.GetComponent<Player>();
        player.m_PlayerName = playerName;
        player.m_PlayerColor = playerColor;

        NetworkServer.AddPlayerForConnection(conn, playerObject, playerControllerId);
    }

    // ランダムにコース(Scene)を決定して切り替える
    public void ServerChangeSceneToNextCource()
    {
        string newSceneName = m_CourceScenes[Random.Range(0, m_CourceScenes.Length)];

        ServerChangeScene(newSceneName);
    }

    private void Update()
    {
        AddPlayerIfNeeded();
    }

    // プレイヤー生成処理が必要か調べ、必要であればAddPlayerを行う。
    void AddPlayerIfNeeded()
    {
        if (client == null)
            return;

        if (!client.isConnected)
            return;

        if (!client.connection.isReady)
            return;

        if (!ClientScene.ready)
            return;

        // あまり頻繁に送信してもしょうがないので、
        // 前回やってから2秒以上経過してないとやらない
        if (Time.time < m_AddPlayerLastSendTime + 2f)
            return;

        // プレイヤーオブジェクトが存在するか?
        if (ClientScene.localPlayers.Count > 0 && ClientScene.localPlayers[0].gameObject != null)
            return;

        // ここまで来たということは、接続が完了しているのにローカルプレイヤーが存在しないということ。
        // プレイヤー追加依頼をサーバーへ送信する。
        AddPlayer(client.connection);
    }
}

 

ServerOnlyでは動かない

今回のゲームは、ホスト/クライアント方式を前提としており、サーバーをServerOnlyで起動すると、正常に動作しません。理由は、ServerOnlyのときとホストのときで、ClientRPCの動作が違うからです。ClientRPCの関数を呼び出したとき、ホストの場合は、ホスト内でも実行されますが、ServerOnlyの場合はサーバー上でその関数は実行されません

ClientRPCという名前の通り、クライアント上でしか実行されないのです(※ホストはクライアントでもあるので実行される)。

今回は、サーバー上でClientRPCの関数が実行されることを期待した実装を行っているため、ServerOnlyでは動きません。もしServerOnlyにも対応したい場合は、ClientRPC呼び出し箇所で、ServerOnlyの場合は追加でサーバー用の処理を行うようにする必要があります。めんどくさいです。ServerOnlyなサーバーでもホストでもリモートクライアントでも、全箇所で一斉に同じ関数が呼び出される機能があれば良いのですが・・・。

 

 

 

次へ進む

目次へ