Chapter 05

🔄 同期2 : RPC

o8que
o8que
2021.04.02に更新

ネットワークオブジェクトに追加されているスクリプトのメソッドは、RPC(Remote Procedure Call)という機能で、ルーム内の他プレイヤー側で実行して同期することができます。

RPCの基礎

RPCの実行

[PunRPC]属性をつけたメソッドをPhotonViewのRPC()から呼び出すことで、ルーム内の他プレイヤー側でもメソッドを実行することができます。RPC()の第一引数は実行するメソッド名、第二引数はRPCを実行する対象、そして第三引数以降が実行するメソッドの引数です。

 using Photon.Pun;
 using UnityEngine;

 public class RpcSample : MonoBehaviourPunCallbacks
 {
     private void Update() {
         // マウスクリック毎に、ルーム内のプレイヤー全員にメッセージを送信する
         if (Input.GetMouseButtonDown(0)) {
-            RpcSendMessage("こんにちは");
+            photonView.RPC(nameof(RpcSendMessage), RpcTarget.All, "こんにちは");
         }
     }

+    [PunRPC]
     private void RpcSendMessage(string message) {
         Debug.Log(message);
     }
 }

PUN2のRPCの仕様として、RPCで実行したいメソッドを持つスクリプトは、PhotonViewコンポーネントと同じゲームオブジェクトに追加されている必要があります。子要素のゲームオブジェクトに追加されているスクリプトのメソッドを、親のゲームオブジェクトのPhotonViewのRPC()から呼び出すようなことはできませんので注意してください。


スクリプトはPhotonViewコンポーネントと同じゲームオブジェクトに追加する

RPCを実行する対象の指定

(RPC()の第二引数で指定できる)RPCを実行する対象の一覧を以下の表に示します。

RPCを実行する対象 送信者自身 他プレイヤー 途中参加者
RpcTarget.All 即座に実行される 通信を介して実行される 実行されない
RpcTarget.Others 実行されない 通信を介して実行される 実行されない
RpcTarget.AllBuffered 即座に実行される 通信を介して実行される 実行される
RpcTarget.OthersBuffered 実行されない 通信を介して実行される 実行される
RpcTarget.AllViaServer 通信を介して実行される 通信を介して実行される 実行されない
RpcTarget.AllBufferedViaServer 通信を介して実行される 通信を介して実行される 実行される
RPCを実行する対象 マスタークライアント(送信者自身) マスタークライアント(他プレイヤー) それ以外のプレイヤー
RpcTarget.MasterClient 即座に実行される 通信を介して実行される 実行されない

通常はRpcTarge.Allを指定すればOKです。RPCを送信するプレイヤー自身は通信を介さずにメソッドが即座に実行されるため、メソッドが実行される順番はプレイヤーごとに変わることがあります。

RpcTarget.AllViaServerを指定することで、RPCを送信するプレイヤー自身も通信を介してメソッドを実行できます。ルーム内のプレイヤー全員のRPCが送信された(サーバーが受信した)順番で実行されることが保証されるようになるので、例えば、レースゲームの先着順位を決めたり、早押しクイズゲームの回答権を与えたりする処理に活用できます。

また、RpcTarget.AllBufferedを指定するとRPCがサーバーに保存されて、RPCが送信された後にルームへ途中参加したプレイヤーでもRPCが実行されるようになります。例えば、ゲームのイベントログや、チャットのログなどの履歴を同期したい場合に活用できます。ただし、保存されたRPCの数が多くなると、ルームへ途中参加するプレイヤー側で大量の通信と処理が発生する可能性があるので、使用には注意が必要です。

特別な引数

RPCで実行するメソッドの引数の最後にPhotonMessageInfoを追加すると、RPCの通信で内部的に使われる送信者のIDやプレイヤー名などを取得できます。

 using Photon.Pun;
 using UnityEngine;

 public class RpcSample : MonoBehaviourPunCallbacks
 {
     private void Update() {
         if (Input.GetMouseButtonDown(0)) {
             photonView.RPC(nameof(RpcSendMessage), RpcTarget.All, "こんにちは");
         }
     }

    [PunRPC]
+    private void RpcSendMessage(string message, PhotonMessageInfo info) {
+        // メッセージを送信したプレイヤー名も表示する
+        Debug.Log($"{info.Sender.NickName}: {message}");
     }
 }

🎮 弾の発射と当たり判定を同期しよう

RPCを使って、サンプルプロジェクトのアバターから弾を発射する処理、アバターと弾の当たり判定の処理を同期してみましょう。

そもそも発射した弾を同期するなら、弾をネットワークオブジェクトにした上で、オブジェクト同期を利用して弾の座標を同期させていく方法もあります。しかし、単純に直進するだけの弾なら、発射された座標・角度・速度さえわかっていれば、単純な計算だけでほぼ正しく座標を更新することができるので、オブジェクト同期(ネットワーク上で定期的に座標を通信して同期する方法)では無駄な通信負荷が発生してしまうでしょう。

いかに通信して同期するかを考えることと同じくらい、いかに通信せずに処理を済ませるかを考えることも、オンラインゲーム開発では重要になるということを覚えておきましょう。

弾のオブジェクトを作成しよう

まず、弾のゲームオブジェクトのプレハブを作成します。ネットワークオブジェクトにはしないので、PhotonViewコンポーネントを追加する必要はありません。

Bullet.cs
using UnityEngine;

public class Bullet : MonoBehaviour
{
    private Vector3 velocity;

    public void Init(Vector3 origin, float angle) {
        transform.position = origin;
        velocity = 9f * new Vector3(Mathf.Cos(angle), Mathf.Sin(angle));
    }

    private void Update() {
        transform.Translate(velocity * Time.deltaTime);
    }

    // 画面外に移動したら削除する
    // (Unityのエディター上ではシーンビューの画面も影響するので注意)
    private void OnBecameInvisible() {
        Destroy(gameObject);
    }
}

アバターのゲームオブジェクトに弾を発射するスクリプト(AvatarFireBullet)を追加して、インスペクターから作成した弾のプレハブをアタッチしましょう。

AvatarFireBullet.cs
using Photon.Pun;
using UnityEngine;

public class AvatarFireBullet : MonoBehaviourPunCallbacks
{
    [SerializeField]
    private Bullet bulletPrefab = default;

    private void Update() {
        if (photonView.IsMine) {
            // 左クリックでカーソルの方向に弾を発射する
            if (Input.GetMouseButtonDown(0)) {
                var mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
                var direction = mousePosition - transform.position;
                float angle = Mathf.Atan2(direction.y, direction.x);
                
                FireBullet(angle);
            }
        }
    }

    // 弾を発射するメソッド
    private void FireBullet(float angle) {
        var bullet = Instantiate(bulletPrefab);
        bullet.Init(transform.position, angle);
    }
}

Unityのエディター上で実行してみて、弾がマウスカーソルの方向に発射されたら成功です。

弾を発射する処理を同期しよう

ここから弾を発射するメソッドに[PunRPC]属性をつけて、メソッドをphotonView.RPC()から呼び出すだけで、弾を発射する処理を同期することができます。

AvatarFireBullet.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarFireBullet : MonoBehaviourPunCallbacks
 {
     [SerializeField]
     private Bullet bulletPrefab = default;

     private void Update() {
         if (photonView.IsMine) {
             if (Input.GetMouseButtonDown(0)) {
                 var mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
                 var direction = mousePosition - transform.position;
                 float angle = Mathf.Atan2(direction.y, direction.x);

+                photonView.RPC(nameof(FireBullet), RpcTarget.All, angle);
             }
         }
     }

+    [PunRPC]
     private void FireBullet(float angle) {
         var bullet = Instantiate(bulletPrefab);
         bullet.Init(transform.position, angle);
     }
 }

アバターと弾に当たり判定を追加しよう

アバターと弾との当たり判定を実装するために、それぞれのゲームオブジェクトにコライダーを追加してください。アバター側はRigidbody2Dコンポーネントを追加して、弾側はコライダーのisTriggerにチェックを入れます。


弾のスクリプトには、弾のIDと弾を発射したプレイヤーのIDを持たせるようにします。これらのIDを通信することで、どの弾がアバターに当たったのか?その弾を発射したプレイヤーは誰か?などを判別できるようになります。

Bullet.cs
 using UnityEngine;

 public class Bullet : MonoBehaviour
 {
     private Vector3 velocity;

+    // 弾のIDを返すプロパティ
+    public int Id { get; private set; }
+    // 弾を発射したプレイヤーのIDを返すプロパティ
+    public int OwnerId { get; private set; }
+    // 同じ弾かどうかをIDで判定するメソッド
+    public bool Equals(int id, int ownerId) => id == Id && ownerId == OwnerId;

+    public void Init(int id, int ownerId, Vector3 origin, float angle) {
+        Id = id;
+        OwnerId = ownerId;
         transform.position = origin;
         velocity = 9f * new Vector3(Mathf.Cos(angle), Mathf.Sin(angle));
     }

     private void Update() {
         transform.Translate(velocity * Time.deltaTime);
     }

     private void OnBecameInvisible() {
         Destroy(gameObject);
     }
 }

弾を発射するスクリプトを修正して、弾のIDと弾を発射したプレイヤーのIDを渡しましょう。

AvatarFireBullet.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarFireBullet : MonoBehaviourPunCallbacks
 {
     [SerializeField]
     private Bullet bulletPrefab = default;

+    private int nextBulletId = 0;

     private void Update() {
         if (photonView.IsMine) {
             if (Input.GetMouseButtonDown(0)) {
                 var mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
                 var direction = mousePosition - transform.position;
                 float angle = Mathf.Atan2(direction.y, direction.x);

+                // 弾を発射するたびに弾のIDを1ずつ増やしていく
+                photonView.RPC(nameof(FireBullet), RpcTarget.All, nextBulletId++, angle);
             }
         }
     }

     [PunRPC]
+    private void FireBullet(int id, float angle) {
         var bullet = Instantiate(bulletPrefab);
+        bullet.Init(id, photonView.OwnerActorNr, transform.position, angle);
     }
 }

弾の当たり判定の処理を同期しよう

ここまで準備ができた所で、誰が当たり判定の処理を行うかを考える必要があります。Photon Cloudではサーバー側で処理を行うことはできないので除外すると、弾を受けるプレイヤー側か、弾を当てるプレイヤー側のどちらかが処理することになります。それぞれにメリットとデメリットがあり、どちらが適切かはゲームによっても変わってくるため、考えて選びましょう。

判定 メリット デメリット
弾を受ける側 相手の弾を見た目通りに避けられる 相手に当てたはずの弾が当たらないことがある
弾を当てる側 自身の弾が見た目通りに相手に当たる 避けたはずの相手の弾に当たることがある

弾を受ける側が当たり判定を行う場合

この場合は、自身のアバターが他プレイヤーが発射した弾に当たったかを判定しましょう。

AvatarHitBullet.cs
using Photon.Pun;
using UnityEngine;

public class AvatarHitBullet : MonoBehaviourPunCallbacks
{
    private void OnTriggerEnter2D(Collider2D other) {
        if (photonView.IsMine) {
            if (other.TryGetComponent<Bullet>(out var bullet)) {
                if (bullet.OwnerId != PhotonNetwork.LocalPlayer.ActorNumber) {
                    photonView.RPC(nameof(HitBullet), RpcTarget.All, bullet.Id, bullet.OwnerId);
                }
            }
        }
    }

    [PunRPC]
    private void HitBullet(int id, int ownerId) {
        var bullets = FindObjectsOfType<Bullet>();
        foreach (var bullet in bullets) {
            if (bullet.Equals(id, ownerId)) {
                Destroy(bullet.gameObject);
                break;
            }
        }
    }
}

弾を当てる側が当たり判定を行う場合

この場合は、他プレイヤーのアバターが自身が発射した弾に当たったかを判定しましょう。当たり判定が行われる弾を発射したプレイヤーのIDはかならず自分自身になるので、PhotonMessageInfoの送信者のIDを使うことで、わずかに通信するデータを削減できます。

AvatarHitBullet.cs
using Photon.Pun;
using UnityEngine;

public class AvatarHitBullet : MonoBehaviourPunCallbacks
{
    private void OnTriggerEnter2D(Collider2D other) {
        if (!photonView.IsMine) {
            if (other.TryGetComponent<Bullet>(out var bullet)) {
                if (bullet.OwnerId == PhotonNetwork.LocalPlayer.ActorNumber) {
                    photonView.RPC(nameof(HitBullet), RpcTarget.All, bullet.Id);
                }
            }
        }
    }

    [PunRPC]
    private void HitBullet(int id, PhotonMessageInfo info) {
        var bullets = FindObjectsOfType<Bullet>();
        foreach (var bullet in bullets) {
            if (bullet.Equals(id, info.Sender.ActorNumber)) {
                Destroy(bullet.gameObject);
                break;
            }
        }
    }
}

FindObjectsOfType()はかなり処理が重い関数です。ここではサンプルコードを簡潔にするために使用していますが、実践では弾をリストで管理するクラスなどを作成して、処理を高速化する方が良いでしょう。

🌶 RPCを実行するスクリプトのキャッシュ

PUN2の実装内部では、RPCが実行されるたびにGetComponents<MonoBehaviour>()でスクリプトを取得し、リフレクションで該当メソッドを探す処理が行われます。これは、ネットワークオブジェクトのスクリプトが動的に変更される場合などには有益ですが、そうでない場合には無駄なオーバーヘッドになってしまうため、スクリプトのキャッシュを有効にすることで実行時のパフォーマンスを改善できます。

PhotonNetwork.UseRpcMonoBehaviourCache = true;

🌶 RPCのメソッド名について

ネットワーク上の通信では、文字列は文字数に比例してデータのサイズが増えるという問題があります。例えば、プレイヤー名やチャットの自由入力文など、文字列の通信が必要なデータ以外では、文字列の使用自体を避ける、または可能な限り短い文字列で通信するのが望ましいです。

PUN2は、[PunRPC]属性がついたメソッドの名前をbyte型の値に変換するリストが自動的に作成されます。この変換リストを内部的に使用することで、文字列(メソッド名)の通信を避けているので、RPCで実行するメソッド名の長さは通信データサイズに影響しません。変換リストは、PhotonServerSettingsから更新したりハッシュコードを確認したりできます。