gracetory’s blog

東池袋にある合同会社グレストリのエンジニアブログです

初めてのPhoton(Unity2018.1.0) Part 2

f:id:grshizawa:20180511015717p:plain

まえがき

こんにちは、プログラマのshizawaです。

最近VTuberの波がすごくて、専門的な知識がなくても簡単に参入できる
ツールが増えてきましたね。
www.moguravr.com

VTuberになる気はないですが、それを再現するのは楽しそうですね。
配信できるところまで、用意してみようかな、なんて思っていたり・・・
(昨日、フェイストラッキングの為にWEBカメラをポチりました)

さて、今回は前回記事の続きからです。
techblog.gracetory.co.jp

実行環境

・macOS Sierra
・Unity 2018.1.0
・Photon Unity Networking Free (ver 1.90)

※前回はUnity2017.4.2を使ってましたが、2018.1.0でも動くことを確認したので
こちらの表記にしています。

やりたいこと

チャットでやりとりするメッセージを
ニコニコ動画のコメントのように右から左に流れるようにすること。

前知識

今回は同期について説明していきたいと思います。

同期概要

オンラインマルチプレイを実現するにあたり、必要なのがオブジェクト情報の同期です。
例えばモンスターハンターをオンラインマルチプレイするとして、必要な情報は
各プレイヤーの位置座標や体力、スタミナ、状態異常。
あとモンスターの位置座標、体力、クエスト残り時間...etc
挙げればキリがありませんが、これらの情報を各プレイヤー間でやりとりして実現させている訳です。

ではどうやって同期をしているのでしょうか。

例で同期するものを挙げましたが、これらを同期オブジェクトとして考えてみると
下記のようにオブジェクトによって同期方法が変わります。

そのオブジェクトの内訳は下記です。

オブジェクト 同期方法
①全プレイヤーが共通で扱うオブジェクト オーナーのプレイヤーが情報を管理し、他プレイヤーに送信する 残り時間の表示、モンスター位置座標など
②自プレイヤーのオブジェクト 他プレイヤーに情報を送信する 操作プレイヤー(ローカルプレイヤー)の位置座標や体力
③上記以外のオブジェクト 他プレイヤーから情報を受け取る 他プレイヤーの位置座標や体力

ここでは詳細な説明を省きますが、もう少し詳細に知りたい方は下記ご参考ください。

同期の図などがわかりやすく載っています。
マルチプレイヤーネットワーキングのプレイヤー位置同期について Part1~Server Authoritative Movement

「LocalPlayer」、「Ownershipをリクエストする」の項目がわかりやすいです。
Photonを使ってネットワーク同期させる - e.blog

Photon Viewコンポーネント

ネットワーク上で同期したいオブジェクトに必要なコンポーネント。
Photon Viewに同期したいコンポーネント(例えばTransfromなど)を設定することで同期してくれます。
今回はTransfromではなく、便利なPhoton Transform Viewコンポーネントを設定しています
(理由は後述)。

Photon Viewをつけたオブジェクトをインスタンス生成するときは専用の関数を使用することで、
他プレイヤーに生成したことを送信(同期)してくれます。
→ PhotonNetwork.Instantiate()

Photon Transform Viewコンポーネント

Transformの同期をサポートしてくれるコンポーネント。
PositionやRotate、Scaleなどを同期する。
例えば、単純に座標位置を同期するとして、1秒間に30回同期を行うように設定すれば動きは滑らかに動きそうです。
しかしそこまで同期の頻度を多くしてしまうと、ユーザが増えればその分サーバに負荷がかかり、ネットワークが重くなってしまいます。
だからと言って同期の頻度を減らしてしまえば、動きがガクガクしてしまう。。。
そこで、同期と同期の間のPositionやRotate、Scaleを補正してくれるコンポーネントが
Photon Transform Viewです。

同期するもの/しないもの

今回のプロジェクトで使用するコンポーネントは以上ですが、これだけだと
同期する内容が不足しています。

前述の通り、Photon ViewPhoton Transform Viewコンポーネントが同期してくれるのは
 ・インスタンス生成
 ・Position
 ・Rotate
 ・Scale
のみです。

今回の目的である「入力したメッセージを右から左に動かす」ために必要なのは、大まかに以下の処理。
 1. メッセージを入力(同期の必要なし)
 2. Messageオブジェクトを生成(Photon Viewコンポーネントが同期)
 3. Messageオブジェクトの移動(Photon Transform Viewコンポーネントが同期)
 4. Messageオブジェクトの破棄 → ???
※厳密には、ほかにも同期しなければならない処理は存在します。

このままだと「オブジェクトの破棄」が同期されません。
これは勝手には行ってくれるものではないので、自分で実装する必要があります。

そこで自分で用意したメソッドを他プレイヤーの環境で呼び出してくれる PunRPCという機能を使います。

PunRPC(RPC : リモートプロシージャコール)

RPCとは自分で指定した関数を他プレイヤーの環境で呼び出すことです。
つまり、オブジェクトの破棄を行う関数を用意しておき、下記のように呼び出すことで、
他プレイヤーの環境でも、オブジェクトの破棄を同期することができます。

   void Update() {
        // ある地点まで到達したら
        if (・・・) {
            // 他プレイヤーにもオブジェクトを破棄したことを同期
            this.pView.RPC("Destroy", PhotonTargets.All);
        }
    }

    // 破棄を同期
    [PunRPC]
    void Destroy() {
        Destroy(this.gameObject);
    }

this.pView.RPC("Destroy", PhotonTargets.All);
第1引数にメソッド名を、第2引数にターゲットを指定します。
ターゲットに指定できるのは下記。

  • PhotonTargets.All → 自分を含めた全プレイヤー
  • PhotonTargets.Others → 自分以外
  • PhotonTargets.MasterClient → ルームのマスタークライアント(オーナー)のみ
    など

今回の場合は自分の環境でもオブジェクトを破棄する必要があるので、
PhotonTargets.Allを指定しています。

詳細は下記を参照ください。
RPCとRaiseEvent | Photon Engine
【Unity】僕もPhotonを使いたい #08 RPC() PhotonTargets編 - うら干物書き

スクリプトの実行はどうなる?

他プレイヤーの所有するオブジェクトはPhotonViewによって
生成タイミングや、位置座標を同期しています。 しかし、そのオブジェクトに移動スクリプトがアタッチされているとしたら
同期を行なっているのに移動処理をすることになってしまいますよね。

その場合は下記のように、オブジェクトを所有しているプレイヤーが自分の場合のみ
移動処理を行うようにします。

   PhotonView pView;

    void Start() {
        this.pView = PhotonView.Get(this);  // 自身のPhotonViewコンポーネントを取得
    }

    void Update() {
        if (photonView.isMine) { 
            // 移動処理
        }
    }

実践

前回は二つのアプリでマッチングをするところまで終わりましたね。

入力フィールドの実装

入力フィールドのオブジェクトを制御するスクリプトを作成します。


InputController.cs

using UnityEngine;
using UnityEngine.UI;

public class InputController : MonoBehaviour {

    public InputField inputField;

    // 入力が完了したら呼ばれる(Enterや入力領域以外をクリック)
    public void OnEndEdit () {
        Debug.Log("入力完了");
    }
}
  1. InputController.csスクリプトをCanvasにアタッチ。
  2. メンバー変数の「inputField」にヒエラルキー上の「InputField」をアタッチ。
  3. 入力が完了した時に「OnEndEdit()」が呼ばれるように、インスペクタから設定します。 f:id:grshizawa:20180516142048p:plain

これで入力した時に、特定の処理を行うことができるようになりました。
実際に入力して、Enterを押して見ると
f:id:grshizawa:20180516142314p:plain
ログに「入力完了」表示されればOKです。

次にこの入力したテキストからオブジェクトを生成できるようにします。

Messageオブジェクトの設定

Messageをネットワークで同期するオブジェクトとして設定しましょう。

  1. MessageオブジェクトにPhoton ViewとPhoton Transform Viewを追加。
  2. Photon ViewのObserved ComponentsにPhoton Transform Viewをドラックアンドドロップ。
  3. Photon Transform Viewの設定(画像参照)
    f:id:grshizawa:20180517173816p:plain

Photon Transform Viewコンポーネントのパラメータについて、 Synchronize Positionのみ説明します。

  • Synchronize Position
    位置を同期したい場合はチェックを入れます。

  • Enable teleport for greater distances
    オブジェクト位置の同期が一定以上ズレた時に、オブジェクトの位置をワープして補正するかをON/OFF。 座標ズレの域値を「Teleport if distance greater than」に設定します。
    今回は座標位置はさほど重要ではないのでチェックはしていません。

  • Interpolate Option
    座標の補間処理を行います。下記設定の種類です。
    Disable : 補完しない。
    Fixed Speed : 指定した速度で補完する。
    Estimated Speed : 直前の座標と現在の座標の差から、速度を割り出して補間する。
    Synchronize Values : スクリプトで現在の速度を送信、他プレイヤーはその値で同期。
    Lerp : 線形補完。
    今回、Estimated Speedを選択しているのは、Messageは等速でしか動かないし、値の設定も必要ないからです。

  • Extrapolate Option
    オブジェクト補外のオプション。
    過去のデータから現在どこにいるのか推測する。

※ざっくりと概要を書きましたが、下記参考にさせて頂きました
Photon Unity Networking: Class List
【PhotonCloud】 PhotonTransformViewでTransformの同期を行う - Qiita

Messageオブジェクトの生成

InputController.csに追加。

InputController.cs

using UnityEngine;
using UnityEngine.UI;

public class InputController : MonoBehaviour {
    
    public InputField inputField;  // インスペクタからアタッチ

    // 入力が完了したら呼ばれる(Enterや入力領域以外をクリック)
    public void OnEndEdit () {
        // 他プレイヤーのクライアントと同期するオブジェクトはこの関数を使って生成する
        GameObject obj = PhotonNetwork.Instantiate("Message", Vector3.zero, Quaternion.identity, 0);

        // 初期位置を適当に設定
        float x = 380f;
        float y = Random.Range(-150f, 150f);
        Vector3 pos = new Vector3(x, y, 0);
        obj.GetComponent<RectTransform>().localPosition = pos;

        // 入力されているテキストをセット(Messageスクリプトについては後述)
        obj.GetComponent<Message>().SetText(this.inputField.textComponent.text);
        // 入力内容を空に
        this.inputField.text = ""; 

        Debug.Log("入力完了");
    }
}

※PhotonNetwork.Instantiateの引数は下記のようになってます。
PhotonNetwork.Instantiate("プレハブ名", "Position", "Rotate", "Group");
探し方が悪いのか公式リファレンスには、最後の引数についての詳細な説明が載っていなかったので、0としています。 PhotonViewのグループ番号らしいです。

Messageスクリプトを作成

Message.cs

using UnityEngine.UI;
using UnityEngine;

public class Message : Photon.MonoBehaviour {

    PhotonView pView;
    float moveSpeed = -60;    // 1秒間に動く距離

    void Update() {
        // 他プレイヤーのクライアントが生成したオブジェクトの場合は実行しない
        if (this.pView.isMine == false) {
            return;
        }

        // 指定した座標地点まで移動
        if (this.gameObject.transform.localPosition.x >= -500) {
            this.transform.Translate(this.moveSpeed * Time.deltaTime, 0, 0);
        } else {
            // 他プレイヤーにオブジェクトを破棄したことを同期
            this.pView.RPC("RpcDestroy", PhotonTargets.All);
        }
    }

    // PhotonNetwork.Instantiateでインスタンスを生成した時に呼ばれる
    void OnPhotonInstantiate(PhotonMessageInfo info) {
        this.pView = PhotonView.Get(this);
        // Messageオブジェクトの親(Canvas)設定を同期
        this.pView.RPC("RpcSetParentCanvas", PhotonTargets.All);
    }

    // テキストをセット(InputController.csから呼び出される)
    public void SetText(string setText) {
        this.pView.RPC("RpcSetText", PhotonTargets.All, setText);
    }

    // 破棄を同期
    [PunRPC]
    void RpcDestroy() {
        Destroy(this.gameObject);
    }

    // 親設定を同期
    [PunRPC]
    void RpcSetParentCanvas() {
        // タグからオブジェクトを取得
        Transform t = GameObject.FindWithTag("Canvas").transform;
        this.transform.SetParent(t);
    }

    // メッセージ内容を同期
    [PunRPC]
    void RpcSetText(string setText) {
        this.GetComponent<Text>().text = setText;
    }
}

スクリプトを作成したら、
Message.csをMessageオブジェクトにアタッチします。

プレハブ化

ヒエラルキー上のMessageオブジェクトをプレハブ化するため、Resources/~にドラッグ&ドロップします。
ヒエラルキー上のMessageオブジェクトは削除しておきます。
f:id:grshizawa:20180517184331p:plain

いざ実行

f:id:grshizawa:20180522184029g:plain

あとがき

実装しておいてアレですが、こんなことしなくてもメッセージだけ送信、受信を行い、
あとはそれぞれのクライアントで処理してしまえば、
わざわざ位置の同期を行わずとも実装はできそうですね(そもそも位置同期の必要ない)。
チャット専用のライブラリも別にあるみたいですし。

今回は入門ということで、こんなやり方があるんだというのが学べてよかったと思うことにします。