⑯UNETでFPSを作る

UNETを使って、ネットワーク対応のFPSゲームを作ります。

UNETでFPSのゲームを作るのは、地味に面倒です。下図のように、全プレイヤーが同じカメラで撮影され、どの端末上においても同じように表示されるゲームのほうが、作ったり動作確認したりするの楽です。

f:id:motoyamablog:20180114215210p:plain

FPSの面倒なところは、カメラはそれぞれのプレイヤーによって違うとか、プレイヤーの見た目が自分には見えないけど相手には見えるとか、そういうとこです。

準備

Unityで新規3Dプロジェクトを作成します。

Planeで床を作りましょう。Positionは(0, 0, 0)にし、そのままでは狭いので、Scaleをとりあえず(3, 1, 3)にします。

f:id:motoyamablog:20180114221037p:plain

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

FPSController

ガチで最高のFPSを作り上げたいなら、プレイヤーの移動処理等も全て自作する必要があるかもしれませんが、今回はStandardAssetsのFPSControllerを使います。

メニュー>Assets>Import Package>Characters をインポートします。

Projectビュー>Assets>Standard Assets>Characters>FirstPersonCharacter>Prefabs>FPSController をシーンに設置します。

設置したオブジェクトの名前はPlayerに変更しましょう。

Positionは(0, 1, 0)にしましょう。

Main Cameraオブジェクトは削除してください。

あとPlayerタグを付けておきましょう。

f:id:motoyamablog:20180114232700p:plain

見た目の作成

プレイヤーオブジェクトの見た目(他人から見たときの自分の見た目)を作ります。まあ普通は格好良い3Dモデルを用意すると思いますが、今回はCapsuleで作ります。

Playerオブジェクトの中にCapsuleを新規追加します。

Capsuleオブジェクトの名前は「Avatar」としましょう。

 

AvatarオブジェクトからCapsule Colliderを除去します。

そのAvatarオブジェクトの中にさらにCubeを新規追加します。CubeのTransformを次のようにします。

f:id:motoyamablog:20180114220447p:plain

新規Materialを作って、黒い色を設定し、Cubeに適用します。するとあら不思議。

f:id:motoyamablog:20180114220536p:plain

サングラスをかけたキャラクターの出来上がりです。
サングラスのCubeからはBox Colliderは除去しておいてください。

調子に乗ってモヒカンにしてみました。

f:id:motoyamablog:20180114220734p:plain

Capsuleの高さが2なので、CharacterControllerの当たり判定の大きさを合わせておきましょう。

f:id:motoyamablog:20180114222828p:plain

ネットワーク対応

PlayerオブジェクトにNetworkIdentityをアタッチします。
Local Player Authorityをオンにします。

f:id:motoyamablog:20180114223000p:plain

NetworkTransformをアタッチします。
Transform Sync ModeをSync Character Controllerにします。

f:id:motoyamablog:20180114223137p:plain

↑Rotation AxisをXY (FPS)にしておくと、Z軸回転量を送信しなくなり、その分、通信量が減ります。

 

新規C#スクリプトをアタッチします。スクリプト名は「Player」とします。

using UnityEngine;
using UnityEngine.Networking;
using UnityStandardAssets.Characters.FirstPerson;

public class Player : NetworkBehaviour
{
    void Start()
    {
        // 自分のキャラクターの場合
        if (isLocalPlayer)
        {
            // 見た目を無効に
            transform.Find("Avatar").gameObject.SetActive(false);
        }
        // 他人のキャラクターの場合
        else
        {
            // カメラ等がついているオブジェクトを無効に
            transform.Find("FirstPersonCharacter").gameObject.SetActive(false);

            // 移動処理を無効に
            GetComponent<FirstPersonController>().enabled = false;
        }
    }
}

処理内容はコメントの通りです。FPSにおけるPlayerオブジェクトは、自分のプレイヤーと他人のプレイヤーで振る舞いが大きく異なるため、それぞれで異なる初期化処理を行います。

 

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

シーンに空のオブジェクトを新規作成し、名前を「NetworkManager」とします。

NetworkManagerコンポーネントとNetworkManagerHUDコンポーネントをアタッチします。

NetworkManagerのPlayer Prefab欄にPlayerプレハブを登録します。

f:id:motoyamablog:20180114224348p:plain
完成です。

実行ファイルを作成し、確認してみましょう。

f:id:motoyamablog:20180114225132g:plain

若干、視点(カメラ位置)が高すぎる気がします。Playerプレハブ内のFirstPersonCharacterオブジェクトのY座標を変えることで、視点の高さが変わります。今回はPosition Y = 0.6くらいが良いと思います。

f:id:motoyamablog:20180114225517p:plain

攻撃処理の作成

FPSFirst Person Shooterの略であり、Shootするゲームなので、発砲処理を作りたいと思います。

照準マークの作成

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

f:id:motoyamablog:20180114230358p:plain

Canvasを右クリックし、 UI>Image を追加します。オブジェクトの名前を「Reticle」にします。
WidthとHeightを4くらいにします。

f:id:motoyamablog:20180114230631p:plain

これで画面の中央に、照準の点が表示されるようになります。

自分の残りライフのUIの作成

自分の残りライフの表示機能を作成します。単純に画面の左下にテキストで数値を表示します。

Canvasを右クリックし、UI>Text を追加します。オブジェクト名は「MyLifeText」とします。

下記のように設定します。

f:id:motoyamablog:20180114231601p:plain

画面左下にテキストが表示されます。

他プレイヤーの残りライフのUIの作成

他プレイヤーのライフは、頭上にテキストで表示しようと思います。

一度Playerプレハブをシーンに設置し直します。

Playerオブジェクトを右クリックし、3D Object>3D Textを追加します。
オブジェクト名を「LifeText」にします。
下図のように設定します。

f:id:motoyamablog:20180114232010p:plain

Playerオブジェクトの頭上にTextが出ればOKです。

f:id:motoyamablog:20180114232439p:plain

プレハブに対して変更をApplyしてから、Playerオブジェクトはシーンから削除します。

f:id:motoyamablog:20180114232547p:plain

攻撃処理の作成

Player.csを改修し、マウスクリックで相手のライフを減らす処理を追加します。

using UnityEngine;
using UnityEngine.Networking;
using UnityStandardAssets.Characters.FirstPerson;
using UnityEngine.UI;

public class Player : NetworkBehaviour
{
    // 残りライフ。SyncVarによる同期対象。
    [SyncVar(hook = "OnLifeChanged")]
    int m_Life = 10;

    // ローカルプレイヤーの残りライフの表示
    Text m_MyLifeText;

    // 他人のプレイヤーキャラクターの頭上のライフ表示
    TextMesh m_LifeText;

    void Start()
    {
        // 頭上のライフ表示を取得
        m_LifeText = transform.Find("LifeText").GetComponent<TextMesh>();

        // 自分のキャラクターの場合
        if (isLocalPlayer)
        {
            // 見た目を無効に
            transform.Find("Avatar").gameObject.SetActive(false);

            // 頭上のライフ表示は無効に
            m_LifeText.gameObject.SetActive(false);

            // ローカルプレイヤーの残りライフ表示を取得
            m_MyLifeText = GameObject.Find("MyLifeText").GetComponent<Text>();
        }
        // 他人のキャラクターの場合
        else
        {
            // カメラ等がついているオブジェクトを無効に
            transform.Find("FirstPersonCharacter").gameObject.SetActive(false);

            // 移動処理を無効に
            GetComponent<FirstPersonController>().enabled = false;
        }

        // 途中参加した際に、表示と実態を合わせるために必要
        OnLifeChanged(m_Life);
    }

    void Update()
    {
        // 他人のプレイヤーに対しては何も行わない
        if (!isLocalPlayer)
            return;

        // マウス左ボタンが押されたら
        if (Input.GetMouseButtonDown(0))
        {
            // 画面の中央に対してRaycastを行う
            Ray ray = new Ray(Camera.main.transform.position, Camera.main.transform.forward);

            RaycastHit hitInfo;

            if (Physics.Raycast(ray, out hitInfo))
            {
                // Raycastが当たった相手がPlayerであれば、
                if (hitInfo.collider.CompareTag("Player"))
                {
                    // 撃ったことをCommandによってサーバーに通知する
                    CmdShotPlayer(hitInfo.collider.gameObject);
                }
            }
        }
    }

    // ライフを減らす処理。サーバーで実施
    [Command]
    void CmdShotPlayer(GameObject target)
    {
        target.GetComponent<Player>().m_Life -= 1;
    }

    // ライフが変化した際のフック処理。
    // 最新のライフをUIに反映させる。
    void OnLifeChanged(int newValue)
    {
        m_Life = newValue;

        if (isLocalPlayer)
        {
            m_MyLifeText.text = m_Life.ToString();
        }
        else
        {
            m_LifeText.text = m_Life.ToString();
        }
    }
}

 

f:id:motoyamablog:20180114235515g:plain

 

という感じで、FPSに最低限必要な処理は実装できてきました。

しかし、本格的なFPSを作るためには、実は、問題が山積みです・・・

当たり判定をクライアントでやるのはどうなの!?

今回は、当たり判定(射撃が当たったかどうかの判定)をローカルクライアントで行っていますが、これはちょっと物議を醸します。

ローカルで判定するほうが実装は楽だし、操作感も良いのですが、チートされやすくなります。そのため、商用のFPSでは、判定をサーバーで行うことが多いようです。

判定をサーバーで行う場合、工夫しないとうまくいきません。なんの工夫も無しに、ローカルクライアントで撃った場所と方角だけをサーバーに送信し、それをもとに普通に判定を行ったのでは、弾が当たらなくなってしまいます。サーバー内では、撃たれる側のキャラクターは既に他の場所に移動しているからです。そのため、サーバーでは、各キャラクターの位置情報を過去の分まで保存しておき、過去にさかのぼって判定を行うような実装をしたりするらしいです。

このあたりの話は次の記事が詳しいです。

[CEDEC 2010]ネットゲームの裏で何が起こっているのか。ネットワークエンジニアから見た,ゲームデザインの大原則

 

ライフの減少をSyncVarでやるのはどうなの!?

今回、ライフの同期をSyncVarに任せて実装していますが、もっと本格的なゲームとして仕上げる場合は、これじゃあマズいです。多くのFPSゲームでは、撃たれた時、加害者の方角が表示されたり、「誰々にキルされた!」とか表示されたりするじゃないですか。SyncVarでただライフを同期しただけでは、そういうのが実装できません。ClientRPCまたはTargetRPCまたはMessageなどを使って、ライフだけではなく加害者の情報などを送る必要があります。

リスポーンさせるのが結構難しい

大抵のFPSでは、死んだら別の場所からリスポーンすると思いますが、NetworkTransformを使っていると、うまくリスポーン処理が作れません😭

NetworkTransform の Transform Sync Mode を Sync CharacterController にして使うと、バグにより、位置を瞬間移動させた際に、異常な挙動をしてしまいます。

Transform Sync Mode を Sync Transform にすれば、その問題は回避できますが、今度は補間無しでの移動となるため、カクカクとなってしまい、現実的ではありません。

なので、位置同期の仕組みを自作する必要が出てきます。こちらに詳しく書きました。

もしくは、死んだら一度プレイヤーオブジェクトをDestroyし、新たに生成するという富豪的プログラミングで乗り切れますが・・・NetworkServer.ReplacePlayerForConnection()を使ってプレイヤー権限を付け替えたりしなくてならず、結構面倒です・・・。

using UnityEngine;
using UnityEngine.Networking;
using UnityStandardAssets.Characters.FirstPerson;
using UnityEngine.UI;

public class Player : NetworkBehaviour
{
    // 残りライフ。SyncVarによる同期対象。
    [SyncVar(hook = "OnLifeChanged")]
    int m_Life = 10;

    // ローカルプレイヤーの残りライフの表示
    Text m_MyLifeText;

    // 他人のプレイヤーキャラクターの頭上のライフ表示
    TextMesh m_LifeText;
    
    void Start()
    {
        // 頭上のライフ表示を取得
        m_LifeText = transform.Find("LifeText").GetComponent<TextMesh>();

        // 自分のキャラクターの場合
        if (isLocalPlayer)
        {
            // 見た目を無効に
            transform.Find("Avatar").gameObject.SetActive(false);

            // 頭上のライフ表示は無効に
            m_LifeText.gameObject.SetActive(false);

            // ローカルプレイヤーの残りライフ表示を取得
            m_MyLifeText = GameObject.Find("MyLifeText").GetComponent<Text>();
        }
        // 他人のキャラクターの場合
        else
        {
            // カメラ等がついているオブジェクトを無効に
            transform.Find("FirstPersonCharacter").gameObject.SetActive(false);

            // 移動処理を無効に
            GetComponent<FirstPersonController>().enabled = false;
        }

        // 途中参加した際に、表示と実態を合わせるために必要
        OnLifeChanged(m_Life);
    }

    void Update()
    {
        // 他人のプレイヤーに対しては何も行わない
        if (!isLocalPlayer)
            return;

        // マウス左ボタンが押されたら
        if (Input.GetMouseButtonDown(0))
        {
            // 画面の中央に対してRaycastを行う
            Ray ray = new Ray(Camera.main.transform.position, Camera.main.transform.forward);

            RaycastHit hitInfo;

            if (Physics.Raycast(ray, out hitInfo))
            {
                // Raycastが当たった相手がPlayerであれば、
                if (hitInfo.collider.CompareTag("Player"))
                {
                    // 撃ったことをCommandによってサーバーに通知する
                    CmdShotPlayer(hitInfo.collider.gameObject);
                }
            }
        }
    }

    // ライフを減らす処理。サーバーで実施
    [Command]
    void CmdShotPlayer(GameObject target)
    {
        Player targetPlayer = target.GetComponent<Player>();
        targetPlayer.m_Life -= 1;

        if (targetPlayer.m_Life <= 0)
        {
            targetPlayer.Respawn();
        }
    }

    // ライフが変化した際のフック処理。
    // 最新のライフをUIに反映させる。
    void OnLifeChanged(int newValue)
    {
        m_Life = newValue;

        if (isLocalPlayer)
        {
            m_MyLifeText.text = m_Life.ToString();
        }
        else
        {
            m_LifeText.text = m_Life.ToString();
        }
    }
    
    [Server]
    void Respawn()
    {
        // ControllerIdを取得
        short controllerId = GetComponent<NetworkIdentity>().playerControllerId;

        // プレハブから新しいプレイヤーオブジェクトを生成
        GameObject newPlayerObject = Instantiate(NetworkManager.singleton.playerPrefab);

        // 好きな位置・向きを設定する
        newPlayerObject.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);

        // プレイヤー権限を新しいプレイヤーオブジェクトに付け替える
        NetworkServer.ReplacePlayerForConnection(connectionToClient, newPlayerObject, controllerId);

        // 古いプレイヤーオブジェクトはDestroy
        NetworkServer.Destroy(gameObject);
    }
}

んー、、でも、やってみたら、それほど面倒でもないかもですね。同じインスタンスを使いまわす場合は、初期化漏れしないように気をつけて初期化する必要がありますが、それを考えなくて良くなるので、簡単かもしれません。

今回はこのプログラムで進めます。

チーム対抗のFPSにする

「自分以外は全て敵」というバトルロワイヤルやゲームもありますが、FPSといったら、2つのチームに分かれて戦うものが多いですよね。

今回は赤チームと青チームに分かれて戦うFPSにしていきたいと思います。

あと、プレイヤー名も必須だと思うので、実装します。

Team列挙体

Teamの種別を表すenumを作ります。新規C#スクリプトを作成します。名前は「Team」とします。

public enum Team
{
    Red,
    Blue,
}
Playerの改修

プレイヤーに2つの機能を追加します。

  • プレイヤー名を頭上に表示する
  • 体の色をチームカラーにする

f:id:motoyamablog:20180115113114p:plain

Playerプレハブを一旦シーンに配置します。

LifeTextを複製して、次のように設定します。

f:id:motoyamablog:20180115113314p:plain

Player.csを改修します。

using UnityEngine;
using UnityEngine.Networking;
using UnityStandardAssets.Characters.FirstPerson;
using UnityEngine.UI;

public class Player : NetworkBehaviour
{
    // 残りライフ。SyncVarによる同期対象。
    [SyncVar(hook = "OnLifeChanged")]
    int m_Life = 10;

    // ローカルプレイヤーの残りライフの表示
    Text m_MyLifeText;

    // 他人のプレイヤーキャラクターの頭上のライフ表示
    TextMesh m_LifeText;

    // 他人のプレイヤーキャラクターの頭上の名前表示
    TextMesh m_PlayerNameText;

    // 所属チーム
    [SyncVar]
    public Team m_Team;

    // プレイヤー名
    [SyncVar]
    public string m_PlayerName;

    void Start()
    {
        // 頭上のライフ表示を取得
        m_LifeText = transform.Find("LifeText").GetComponent<TextMesh>();

        // 頭上のプレイヤー名表示を取得
        m_PlayerNameText = transform.Find("PlayerNameText").GetComponent<TextMesh>();

        // 自分のキャラクターの場合
        if (isLocalPlayer)
        {
            // 見た目を無効に
            transform.Find("Avatar").gameObject.SetActive(false);

            // 頭上のライフ表示は無効に
            m_LifeText.gameObject.SetActive(false);

            // 頭上のプレイヤー名表示は無効に
            m_PlayerNameText.gameObject.SetActive(false);

            // ローカルプレイヤーの残りライフ表示を取得
            m_MyLifeText = GameObject.Find("MyLifeText").GetComponent<Text>();
        }
        // 他人のキャラクターの場合
        else
        {
            // カメラ等がついているオブジェクトを無効に
            transform.Find("FirstPersonCharacter").gameObject.SetActive(false);

            // 移動処理を無効に
            GetComponent<FirstPersonController>().enabled = false;

            // 頭上のプレイヤー名表示に文字列を設定
            m_PlayerNameText.text = m_PlayerName;

            // 見た目の色を変える
            transform.Find("Avatar").GetComponent<Renderer>().material.color = m_Team == Team.Red ? Color.red : Color.blue;
        }

        // 途中参加した際に、表示と実態を合わせるために必要
        OnLifeChanged(m_Life);

        // デバッグ用にオブジェクト名にPlayerNameを入れとく(エディタでわかりやすくするため)
        name = "Player_" + m_PlayerName;
    }

    void Update()
    {
        // 他人のプレイヤーに対しては何も行わない
        if (!isLocalPlayer)
            return;

        // マウス左ボタンが押されたら
        if (Input.GetMouseButtonDown(0))
        {
            // 画面の中央に対してRaycastを行う
            Ray ray = new Ray(Camera.main.transform.position, Camera.main.transform.forward);

            RaycastHit hitInfo;

            if (Physics.Raycast(ray, out hitInfo))
            {
                // Raycastが当たった相手がPlayerであれば、
                if (hitInfo.collider.CompareTag("Player"))
                {
                    // 撃ったことをCommandによってサーバーに通知する
                    CmdShotPlayer(hitInfo.collider.gameObject);
                }
            }
        }
    }

    // ライフを減らす処理。サーバーで実施
    [Command]
    void CmdShotPlayer(GameObject target)
    {
        Player targetPlayer = target.GetComponent<Player>();
        targetPlayer.m_Life -= 1;

        if (targetPlayer.m_Life <= 0)
        {
            targetPlayer.Respawn();
        }
    }

    // ライフが変化した際のフック処理。
    // 最新のライフをUIに反映させる。
    void OnLifeChanged(int newValue)
    {
        m_Life = newValue;

        if (isLocalPlayer)
        {
            m_MyLifeText.text = m_Life.ToString();
        }
        else
        {
            m_LifeText.text = m_Life.ToString();
        }
    }

    [Server]
    void Respawn()
    {
        // ControllerIdを取得
        short controllerId = GetComponent<NetworkIdentity>().playerControllerId;

        // プレハブから新しいプレイヤーオブジェクトを生成
        GameObject newPlayerObject = Instantiate(NetworkManager.singleton.playerPrefab);

        // 好きな位置・向きを設定する
        newPlayerObject.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);

        // プレイヤー権限を新しいプレイヤーオブジェクトに付け替える
        NetworkServer.ReplacePlayerForConnection(connectionToClient, newPlayerObject, controllerId);

        // 古いプレイヤーオブジェクトはDestroy
        NetworkServer.Destroy(gameObject);
    }
}

PlayerオブジェクトのApplyボタンを押して変更をプレハブに適用したら、Playerオブジェクトはシーンから削除します。

タイトルメニュー画面の作成

今まで作ってきたシーンは「Game」という名前で保存しておいてください。

新規シーンを作成し、「Menu」という名前で保存しておいてください。

UI>Canvasを新規作成し、次のように設定します。

f:id:motoyamablog:20180115111225p:plain

Canvas配下にText、InputField、Buttonを追加し、それぞれ次のように命名します。

f:id:motoyamablog:20180115111405p:plain

Titleはただの表示なので、適当に次のようにします。

f:id:motoyamablog:20180115111915p:plain

PlayerNameInputFieldとChangeTeamButtonは次のように適当に並べます。

f:id:motoyamablog:20180115111941p:plain

ChangeTeamButtonの表示Textは「Click to Change Team」としておいてください。

 

次は、UIを制御するスクリプトを作ります。

空のオブジェクトを新規作成します。名前は「TitleMenuManager」とでもします。

TitleMenuManagerオブジェクトに新規C#スクリプトを作成&アタッチします。スクリプト名は「TitleMenuManager」とします。

using UnityEngine;
using UnityEngine.UI;

public class TitleMenuManager : MonoBehaviour
{
    // 入力されたプレイヤー名を保持しておくためのstatic変数
    public static string s_PlayerName = "名無し";
    // 選択されたチームカラーを保持しておくためのstatic変数
    public static Team s_Team = Team.Red;

    // プレイヤー名入力欄の参照
    InputField m_PlayerNameInputField;
    // チームカラー表示・変更ボタンの参照
    Button m_ChangeTeamButton;

    void Start()
    {
        // UIの参照を取得
        m_PlayerNameInputField = GameObject.Find("PlayerNameInputField").GetComponent<InputField>();
        m_ChangeTeamButton = GameObject.Find("ChangeTeamButton").GetComponent<Button>();

        // 初期化
        m_PlayerNameInputField.text = s_PlayerName;
        SetButtonColor(s_Team);

        // プレイヤー名変更時のイベント登録
        m_PlayerNameInputField.onEndEdit.AddListener(val => s_PlayerName = val);

        // チーム変更ボタン押下時のイベント登録
        m_ChangeTeamButton.onClick.AddListener(() =>
        {
            s_Team = s_Team == Team.Red ? Team.Blue : Team.Red;
            SetButtonColor(s_Team);
        });
    }

    // ボタンの色をチームの色に変える
    void SetButtonColor(Team newTeam)
    {
        m_ChangeTeamButton.GetComponent<Image>().color = newTeam == Team.Red ? Color.red : Color.blue;
    }
}

一応起動して、ボタンをクリックしたら赤と青が切り替わることを確認しておいてください。

カスタムメッセージの器を作成

タイトルメニュー画面で入力した値をサーバーに送信するための器のクラスを作ります。

新規C#スクリプトを作成します。名前は「PlayerInfoMessage」とします。

using UnityEngine.Networking;

public class PlayerInfoMessage : MessageBase
{
    public string playerName;
    public Team team;
}
カスタムNetworkManagerの作成

プレイヤーオブジェクト生成時に名前とチームカラーを設定したいので、NetworkManagerを改造して、独自のプレイヤーオブジェクト生成処理を実装します。

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

NetworkManagerオブジェクトに、新規C#スクリプトを作成・アタッチします。名前を「MyNetworkManager」とします。

using UnityEngine;
using UnityEngine.Networking;

public class MyNetworkManager : NetworkManager
{
    // クライアント上でシーン読み込みが完了した際に呼ばれる。
    // サーバーにシーン読み込みしたことを通知する。
    // その際にカスタムメッセージによって、プレイヤー名とチームカラーを送信する。
    public override void OnClientSceneChanged(NetworkConnection conn)
    {
        // クライアント上でシーンの準備が完了したらこれを呼ぶ必要がある
        ClientScene.Ready(conn);

        // PlayerInfoMessageオブジェクトを作成し、プレイヤーの情報を格納する
        PlayerInfoMessage msg = new PlayerInfoMessage()
        {
            playerName = TitleMenuManager.s_PlayerName,
            team = TitleMenuManager.s_Team
        };

        // サーバーにAddPlayerメッセージを送信する。
        // その際、第3引数に追加情報(PlayerInfoMessage)を付与する。
        ClientScene.AddPlayer(conn, 0, msg);
    }

    // サーバーがAddPlayerメッセージを受信した際に呼ばれる。
    // プレイヤー生成処理を実施する。
    // その際、クライアントから送信されたカスタムメッセージを読み取ることにより、
    // プレイヤー毎に異なる生成処理を行うことが可能。
    public override void OnServerAddPlayer(NetworkConnection conn, short playerControllerId, NetworkReader extraMessageReader)
    {
        // クライアントから送られたカスタムメッセージを読み込む
        PlayerInfoMessage msg = extraMessageReader.ReadMessage<PlayerInfoMessage>();

        // プレイヤーオブジェクトをプレハブから生成する
        GameObject playerObject = Instantiate(playerPrefab, Vector3.zero, Quaternion.identity);

        // Playerコンポーネントに情報をセットする
        Player player = playerObject.GetComponent<Player>();
        player.m_PlayerName = msg.playerName;
        player.m_Team = msg.team;

        // これを行うことにより、全クライアント上でプレイヤーオブジェクトが生成され、
        // プレイヤー権限が設定される。
        NetworkServer.AddPlayerForConnection(conn, playerObject, playerControllerId);
    }
}

スクリプトが完成したら、BuildSettings画面にてシーンを登録します。
f:id:motoyamablog:20180115114448p:plain

MyNetworkManagerにシーンとPlayerプレハブを登録します。

f:id:motoyamablog:20180115114601p:plain

NetworkManagerHUDもアタッチしておいてください。

 
実行ファイルを作成し、複数ゲームを起動して確認してみてください。
プレイヤー名とチームカラーの機能が働いているはずです。

スポーン地点が全員一緒なのが気に入らないなら、MyNetworkManagerのプレイヤーをInstantiateしているところを改造すればOKです。

リスポーン処理を修正

実は現状、致命的な問題があります。

ライフが0になってリスポーンすると、プレイヤー名とチームカラーの情報が失われてしまうのです。

f:id:motoyamablog:20180115123656g:plain

これは直さねばなりません。

修正は簡単です。リスポーン時に、新しいプレイヤーオブジェクトに情報を引き渡せばいいだけです。

Player.csを改修します。

using UnityEngine;
using UnityEngine.Networking;
using UnityStandardAssets.Characters.FirstPerson;
using UnityEngine.UI;

public class Player : NetworkBehaviour
{
    // 残りライフ。SyncVarによる同期対象。
    [SyncVar(hook = "OnLifeChanged")]
    int m_Life = 10;

    // ローカルプレイヤーの残りライフの表示
    Text m_MyLifeText;

    // 他人のプレイヤーキャラクターの頭上のライフ表示
    TextMesh m_LifeText;

    // 他人のプレイヤーキャラクターの頭上の名前表示
    TextMesh m_PlayerNameText;

    // 所属チーム
    [SyncVar]
    public Team m_Team;

    // プレイヤー名
    [SyncVar]
    public string m_PlayerName;

    void Start()
    {
        // 頭上のライフ表示を取得
        m_LifeText = transform.Find("LifeText").GetComponent<TextMesh>();

        // 頭上のプレイヤー名表示を取得
        m_PlayerNameText = transform.Find("PlayerNameText").GetComponent<TextMesh>();

        // 自分のキャラクターの場合
        if (isLocalPlayer)
        {
            // 見た目を無効に
            transform.Find("Avatar").gameObject.SetActive(false);

            // 頭上のライフ表示は無効に
            m_LifeText.gameObject.SetActive(false);

            // 頭上のプレイヤー名表示は無効に
            m_PlayerNameText.gameObject.SetActive(false);

            // ローカルプレイヤーの残りライフ表示を取得
            m_MyLifeText = GameObject.Find("MyLifeText").GetComponent<Text>();
        }
        // 他人のキャラクターの場合
        else
        {
            // カメラ等がついているオブジェクトを無効に
            transform.Find("FirstPersonCharacter").gameObject.SetActive(false);

            // 移動処理を無効に
            GetComponent<FirstPersonController>().enabled = false;

            // 頭上のプレイヤー名表示に文字列を設定
            m_PlayerNameText.text = m_PlayerName;

            // 見た目の色を変える
            transform.Find("Avatar").GetComponent<Renderer>().material.color = m_Team == Team.Red ? Color.red : Color.blue;
        }

        // 途中参加した際に、表示と実態を合わせるために必要
        OnLifeChanged(m_Life);

        // デバッグ用にオブジェクト名にPlayerNameを入れとく(エディタでわかりやすくするため)
        name = "Player_" + m_PlayerName;
    }

    void Update()
    {
        // 他人のプレイヤーに対しては何も行わない
        if (!isLocalPlayer)
            return;

        // マウス左ボタンが押されたら
        if (Input.GetMouseButtonDown(0))
        {
            // 画面の中央に対してRaycastを行う
            Ray ray = new Ray(Camera.main.transform.position, Camera.main.transform.forward);

            RaycastHit hitInfo;

            if (Physics.Raycast(ray, out hitInfo))
            {
                // Raycastが当たった相手がPlayerであれば、
                if (hitInfo.collider.CompareTag("Player"))
                {
                    // 撃ったことをCommandによってサーバーに通知する
                    CmdShotPlayer(hitInfo.collider.gameObject);
                }
            }
        }
    }

    // ライフを減らす処理。サーバーで実施
    [Command]
    void CmdShotPlayer(GameObject target)
    {
        Player targetPlayer = target.GetComponent<Player>();
        targetPlayer.m_Life -= 1;

        if (targetPlayer.m_Life <= 0)
        {
            targetPlayer.Respawn();
        }
    }

    // ライフが変化した際のフック処理。
    // 最新のライフをUIに反映させる。
    void OnLifeChanged(int newValue)
    {
        m_Life = newValue;

        if (isLocalPlayer)
        {
            m_MyLifeText.text = m_Life.ToString();
        }
        else
        {
            m_LifeText.text = m_Life.ToString();
        }
    }

    [Server]
    void Respawn()
    {
        // ControllerIdを取得
        short controllerId = GetComponent<NetworkIdentity>().playerControllerId;

        // プレハブから新しいプレイヤーオブジェクトを生成
        GameObject newPlayerObject = Instantiate(NetworkManager.singleton.playerPrefab);

        // 好きな位置・向きを設定する
        newPlayerObject.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
        
        // 古いプレイヤーから新しいプレイヤーに、情報を移植する
        Player oldPlayer = gameObject.GetComponent<Player>();
        Player newPlayer = newPlayerObject.GetComponent<Player>();
        newPlayer.m_PlayerName = oldPlayer.m_PlayerName;
        newPlayer.m_Team = oldPlayer.m_Team;

        // プレイヤー権限を新しいプレイヤーオブジェクトに付け替える
        NetworkServer.ReplacePlayerForConnection(connectionToClient, newPlayerObject, controllerId);

        // 古いプレイヤーオブジェクトはDestroy
        NetworkServer.Destroy(gameObject);
    }
}

 

f:id:motoyamablog:20180115124117g:plain

 

以上でこのチュートリアルは終了です。
マップを作り込むのは当然必要ですが、それに加えて、チーム毎のキル数を表示したり、3分間でキル数の多いチームの勝利とするなどとすれば、ちゃんと遊べるゲームになるでしょう。

 

目次へ