Notifications
Article
PUN2で始めるオンラインゲーム開発入門【その3】
Updated 4 days ago
158
1
RPCで他のプレイヤーと弾を撃ち合えるようにしよう
【その1】|【その2】|【その3】|【その4】|【その5】

とりあえず弾を発射できるようにしよう

弾を発射する処理を同期する前に、まずオフライン環境で弾を発射できるようにしましょう。以下の画像とコードを参考に弾のプレハブ(Projectile)を作成してください。SpriteRendererで表示するスプライトは好きな画像でかまいません。
using UnityEngine; public class Projectile : 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() { var dv = velocity * Time.deltaTime; transform.Translate(dv.x, dv.y, 0f); } // 画面外になった時に削除する(エディターのSceneビューの画面も影響するので注意) private void OnBecameInvisible() { Destroy(gameObject); } }
プレイヤーのネットワークオブジェクト(GamePlayer)は、その2で座標の同期までが済んでいるものとします。左クリックでカーソルの方向に弾を発射する処理を追加し、インスペクターから弾のプレハブをアタッチしてください。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks { [SerializeField] private Projectile projectilePrefab; // Projectileプレハブの参照 private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); // 左クリックでカーソルの方向に弾を発射する処理を行う if (Input.GetMouseButtonDown(0)) { var playerWorldPosition = transform.position; var mouseWorldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition); var dp = mouseWorldPosition - playerWorldPosition; float angle = Mathf.Atan2(dp.y, dp.x); FireProjectile(angle); } } } // 弾を発射するメソッド private void FireProjectile(float angle) { var projectile = Instantiate(projectilePrefab); projectile.Init(transform.position, angle); } }
Unityのエディター上で実行してみて、弾がカーソルの方向に発射されたら成功です。

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

プレイヤーの環境によって、それほど速くない回線の人もいれば、たまに通信が不安定になってしまう人もいます。オンラインゲームの通信量の多さは、通信環境が原因でそのゲームを遊びたくても遊べないプレイヤーの多さに繋がります。 いかにして通信を行い同期させるかを考えることと同じくらい、いかにして通信せずに処理を済ませるかを考えることも、オンラインゲーム開発では重要になってきます。
自身側で発射した弾を他プレイヤー側で同期する方法の一つは、弾をネットワークオブジェクトにすることです。 しかし今回実装している弾の挙動は、決まった速度で進み、画面外になったら削除されるもので、ネットワーク上で定期的に同期を行わなくても処理することができます。この弾をネットワークオブジェクトにしてしまうと、無駄な通信が発生してしまう可能性があります。弾を通常のオブジェクトとして、他プレイヤー側で生成させる方法が必要になります。

RPCを使おう

他プレイヤー側でメソッドを呼び出して実行する仕組みはRPC(リモートプロシージャコール)と呼ばれます。PhotonではRPCで実行したいメソッドに[PunRPC]属性をつけることで、PhotonView.RPC()から呼び出せるようになります。 すでに定義済みのGamePlayer.FireProjectile()をRPCで実行するだけで、弾を発射する処理を同期させることができます。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks { [SerializeField] private Projectile projectilePrefab; private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); if (Input.GetMouseButtonDown(0)) { var playerWorldPosition = transform.position; var mouseWorldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition); var dp = mouseWorldPosition - playerWorldPosition; float angle = Mathf.Atan2(dp.y, dp.x); // FireProjectile(angle)をRPCで実行する photonView.RPC(nameof(FireProjectile), RpcTarget.All, angle); } } } // [PunRPC]属性をつけると、RPCでの実行が有効になる [PunRPC] private void FireProjectile(float angle) { var projectile = Instantiate(projectilePrefab); projectile.Init(transform.position, angle); } }
photonView.RPC()の第二引数では、RPCを送信する相手を指定することができます。主に使うのは以下の3つです。
  • RpcTarget.All - 自身を含むプレイヤー全員(自身は通信を介さず即座に実行される)
  • RpcTarget.Others - 自身を除くプレイヤー全員
  • RpcTarget.AllViaServer - 自身を含むプレイヤー全員(自身も通信を介して実行される)
RpcTarget.AllViaServerでは、プレイヤー全員のRPCが先に送信した(サーバーが受信した)順番で実行されることが保証されるようになるので、例えばRPCが実行された順番によって先着順位を決めたりすることができます。
この他にRpcTarget.AllBufferedのようにBufferedが付くものがありますが、これはRPCがサーバーに保存され、RPCが送信された後にルームに途中参加したプレイヤーでもRPCが実行されるようになります。ルームで発生した処理の履歴を取得させる場合には便利ですが、保存したRPCの数によって大量の通信と処理が発生してしまうため、使用には注意が必要です。

オブジェクトプールで弾を管理しよう

弾を発射する処理が同期できたところで、弾の処理を少し改善してみましょう。ご存知の人も多いかもしれませんが、Object.Instantiate()Object.Destroy()は重い処理なので、一度生成された弾のインスタンスはGameObject.SetActive()の切り替えで使い回せるようにします。まず弾のスクリプトからObject.Destroy()を取り除きましょう。
using UnityEngine; public class Projectile : MonoBehaviour { private Vector3 velocity; public bool IsActive => gameObject.activeSelf; public void Activate(Vector3 origin, float angle) { // メソッド名変更 transform.position = origin; velocity = 9f * new Vector3(Mathf.Cos(angle), Mathf.Sin(angle)); gameObject.SetActive(true); } public void OnUpdate() { // publicにしてメソッド名変更 var dv = velocity * Time.deltaTime; transform.Translate(dv.x, dv.y, 0f); } public void Deactivate() { gameObject.SetActive(false); } private void OnBecameInvisible() { Deactivate(); } }
次に弾のインスタンスを管理するオブジェクト(ProjectileManager)を作成し、ヒエラルキーに配置してください。タグを付けることで、GameObject.Find()より高速なGameObject.FindWithTag()から参照を取れるようにしておきます。
using System.Collections.Generic; using UnityEngine; public class ProjectileManager : MonoBehaviour { [SerializeField] private Projectile projectilePrefab; // Projectileプレハブの参照 // アクティブな弾のリスト private List<Projectile> activeList = new List<Projectile>(); // 非アクティブな弾のオブジェクトプール private Stack<Projectile> inactivePool = new Stack<Projectile>(); private void Update() { // 逆順にループを回して、activeListの要素が途中で削除されても正しくループが回るようにする for (int i = activeList.Count - 1; i >= 0; i--) { var projectile = activeList[i]; if (projectile.IsActive) { projectile.OnUpdate(); } else { Remove(projectile); } } } // 弾を発射(アクティブ化)するメソッド public void Fire(Vector3 origin, float angle) { // 非アクティブの弾があれば使い回す、なければ生成する var projectile = (inactivePool.Count > 0) ? inactivePool.Pop() : Instantiate(projectilePrefab, transform); projectile.Activate(origin, angle); activeList.Add(projectile); } // 弾を消去(非アクティブ化)するメソッド public void Remove(Projectile projectile) { activeList.Remove(projectile); projectile.Deactivate(); inactivePool.Push(projectile); } }
最後にプレイヤーのスクリプトを修正します。弾のインスタンスを生成する処理はProjectileManagerに移したので、弾のプレハブの参照は削除して、かわりにProjectileManagerの参照をタグから取得するようにします。弾を発射する処理は、ProjectileManager.Fire()にそのまま委譲させましょう。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks { private ProjectileManager projectileManager; private void Awake() { projectileManager = GameObject.FindWithTag("ProjectileManager").GetComponent<ProjectileManager>(); } private void Update() { // 省略 } [PunRPC] private void FireProjectile(float angle) { projectileManager.Fire(transform.position, angle); } }
Unityのエディター上で実行してみて、弾のインスタンスが使い回されているか確認してみましょう。

被弾処理を同期させよう

プレイヤーと弾の当たり判定を実装するため、まずそれぞれにコライダーを追加しましょう。
弾のスクリプトには、弾自体のIDと弾を発射したプレイヤーのIDを持たせるようにします。すると、誰が発射したどの弾に当たったのかを、IDで判別したりネットワーク上で通信したりできるようになります。
using UnityEngine; public class Projectile : MonoBehaviour { private Vector3 velocity; 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 bool IsActive => gameObject.activeSelf; public void Activate(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)); gameObject.SetActive(true); } // 以下省略 }
using System.Collections.Generic; using UnityEngine; public class ProjectileManager : MonoBehaviour { [SerializeField] private Projectile projectilePrefab; private List<Projectile> activeList = new List<Projectile>(); private Stack<Projectile> inactivePool = new Stack<Projectile>(); private void Update() { for (int i = activeList.Count - 1; i >= 0; i--) { var projectile = activeList[i]; if (projectile.IsActive) { projectile.OnUpdate(); } else { Remove(projectile); } } } public void Fire(int id, int ownerId, Vector3 origin, float angle) { var projectile = (inactivePool.Count > 0) ? inactivePool.Pop() : Instantiate(projectilePrefab, transform); projectile.Activate(id, ownerId, origin, angle); activeList.Add(projectile); } public void Remove(Projectile projectile) { activeList.Remove(projectile); projectile.Deactivate(); inactivePool.Push(projectile); } // IDから弾を消去するメソッド public void Remove(int id, int ownerId) { foreach (var projectile in activeList) { if (projectile.Equals(id, ownerId)) { Remove(projectile); break; } } } }
プレイヤーのスクリプトで実際のIDを渡します。PhotonView.Owner.ActorNumberから、ネットワークオブジェクトを生成したプレイヤーのIDを取得することができるので、それを使います。コードではPhotonView.OwnerActorNrという少しアクセスが速いプロパティの方を使っています。これで当たり判定の処理を実装する準備ができました。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks { private ProjectileManager projectileManager; // 弾を発射する時に使う弾のID private int projectileId = 0; private void Awake() { projectileManager = GameObject.FindWithTag("ProjectileManager").GetComponent<ProjectileManager>(); } private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); if (Input.GetMouseButtonDown(0)) { var playerWorldPosition = transform.position; var mouseWorldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition); var dp = mouseWorldPosition - playerWorldPosition; float angle = Mathf.Atan2(dp.y, dp.x); // 弾を発射するたびに弾のIDを1ずつ増やしていく photonView.RPC(nameof(FireProjectile), RpcTarget.All, ++projectileId, angle); } } } [PunRPC] private void FireProjectile(int id, float angle) { projectileManager.Fire(id, photonView.OwnerActorNr, transform.position, angle); } }
当たり判定の処理を実装する準備ができた所で、当たり判定の処理は誰が行うかを考える必要があります。 Photon Cloudではサーバー側の処理を入れることはできないので、サーバー側を除外すると、弾を受ける側か弾を当てる側のどちらかになります。それぞれにメリット・デメリットがあるため、適切だと思う方を選びましょう。
  1. 弾を受ける側 - 相手の弾を見た目通りに避けられるが、相手に当てたはずの弾が当たらないことがある
  2. 弾を当てる側 - 自身の弾が見た目通りに相手に当たるが、避けたはずの相手の弾に当たることがある
この記事では両方の実装を紹介します。それぞれのコードは、GamePlayerに追加することで動作確認することができます。

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

この場合は、自身のオブジェクトが他プレイヤーの発射した弾に当たったかを判定することになります。 自身のプレイヤーIDはPhotonNetwork.LocalPlayer.ActorNumberで取得できるので、それと弾を発射したプレイヤーのIDとを比較しましょう。
private void OnTriggerEnter2D(Collider2D collision) { if (photonView.IsMine) { var projectile = collision.GetComponent<Projectile>(); if (projectile != null && projectile.OwnerId != PhotonNetwork.LocalPlayer.ActorNumber) { photonView.RPC(nameof(HitByProjectile), RpcTarget.All, projectile.Id, projectile.OwnerId); } } } [PunRPC] private void HitByProjectile(int projectileId, int ownerId) { projectileManager.Remove(projectileId, ownerId); }

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

この場合は、他プレイヤーのオブジェクトが自身の発射した弾に当たったかを判定することになります。 ここで使用しているPhotonMessageInfoは特殊な引数で、RPCで実行するメソッドの引数の最後に追加すると、RPCを送受信する際に内部的に使われるデータが取得できるようになります。弾を発射したプレイヤーのIDは、PhotonMessageInfoのRPCを送信したプレイヤーのIDを使うことで、RPCで送信するデータを少し削減しています。
private void OnTriggerEnter2D(Collider2D collision) { if (!photonView.IsMine) { var projectile = collision.GetComponent<Projectile>(); if (projectile != null && projectile.OwnerId == PhotonNetwork.LocalPlayer.ActorNumber) { photonView.RPC(nameof(HitByProjectile), RpcTarget.All, projectile.Id); } } } [PunRPC] private void HitByProjectile(int projectileId, PhotonMessageInfo info) { projectileManager.Remove(projectileId, info.Sender.ActorNumber); }

サーバー時刻を活用しよう

他プレイヤーがRPCを送信し、自身がそのRPCを受信して実行するまでには、ネットワーク上の遅延が発生します。送信してから受信するまでにかかった時間を求めることができれば、その遅延を補正することができます。 ここで各プレイヤーのローカル時刻(System.DateTime.Now等)を利用することはできません。プレイヤー間でローカル時刻がずれていた場合には、実際とは全く異なる計算結果になってしまう可能性があるからです。
PhotonではPhotonNetwork.ServerTimestampから、ミリ秒単位で現在のサーバー時刻を取得できます。同じルームに参加しているプレイヤーは同じゲームサーバーに接続してるので、同じタイミングで取得した時刻はほぼ同じ値になります。
利用する際に注意しなければならないのは、取得できるサーバー時刻の値は定期的にオーバーフローが発生するということです。値はint型の最大値(2,147,483,647)を超えるとint型の最小値(-2,147,483,648)になって進み続けます。 以下のサンプルコードを見てください。サーバー時刻(オーバーフローする可能性がある値)を比較する時は、値を直接比較すると間違った結果になることがあるので、常に差分をとって比較する必要があります。
int a = 2147483647; int b = a + 100; // aより大きい値 // 直接比較する if (a < b) { Debug.Log("1"); // 表示されない } // 差分をとって大小比較する if (b - a > 0) { Debug.Log("2"); // 表示される }
サーバー時刻を取得するプロパティにはPhotonNetwork.Timeもありますが、内部的にはPhotonNetwork.ServerTimestampdouble型に変換しただけもので、あまり使い勝手が良くないので、この記事では使用しません。

弾を発射した時刻を送ろう

ここまでの弾は、RPCを受信して実行された時の座標から移動を開始していたため、ネットワーク上の遅延の分だけ座標がずれていました。弾を発射した時刻での座標と、弾を発射した時刻から現在の時刻までの経過時間から、現在の座標を計算できるようにすることで、全く遅延のない座標の同期が可能になります。
using UnityEngine; using Photon.Pun; public class Projectile : MonoBehaviour { private Vector3 origin; // 弾を発射した時刻での座標 private Vector3 velocity; private int timestamp; // 弾を発射した時刻 public int Id { get; private set; } public int OwnerId { get; private set; } public bool Equals(int id, int ownerId) => id == Id && ownerId == OwnerId; public bool IsActive => gameObject.activeSelf; public void Activate(int id, int ownerId, Vector3 origin, float angle, int timestamp) { Id = id; OwnerId = ownerId; this.origin = origin; velocity = 9f * new Vector3(Mathf.Cos(angle), Mathf.Sin(angle)); this.timestamp = timestamp; OnUpdate(); // transform.positionの初期値を決めるため、一度更新する gameObject.SetActive(true); } public void OnUpdate() { // 弾を発射した時刻から現在時刻までの経過時間を求める float elapsedTime = Mathf.Max(0f, unchecked(PhotonNetwork.ServerTimestamp - timestamp) / 1000f); // 弾を発射した時刻での座標・速度・経過時間から現在の座標を求める transform.position = origin + velocity * elapsedTime; } // 省略 }
using System.Collections.Generic; using UnityEngine; public class ProjectileManager : MonoBehaviour { // 省略 public void Fire(int id, int ownerId, Vector3 origin, float angle, int timestamp) { var projectile = (inactivePool.Count > 0) ? inactivePool.Pop() : Instantiate(projectilePrefab, transform); projectile.Activate(id, ownerId, origin, angle, timestamp); activeList.Add(projectile); } // 省略 }
プレイヤーのスクリプトで渡す弾自体のIDは、弾を発射した時刻を流用することで、RPCで送信するデータを削減しています。一度に複数の弾を発射するような場合には、弾ごとにIDを変える必要があるので注意してください。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks { // 省略 private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); if (Input.GetMouseButtonDown(0)) { var playerWorldPosition = transform.position; var mouseWorldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition); var dp = mouseWorldPosition - playerWorldPosition; float angle = Mathf.Atan2(dp.y, dp.x); photonView.RPC(nameof(FireProjectile), RpcTarget.All, transform.position, angle, PhotonNetwork.ServerTimestamp ); } } } [PunRPC] private void FireProjectile(Vector3 origin, float angle, int timestamp) { projectileManager.Fire(timestamp, photonView.OwnerActorNr, origin, angle, timestamp); } }

以下の★パートは中級者向けです。わからない部分は読み飛ばして、慣れた頃に読み返すのがオススメです。

★RPCで実行するメソッドをキャッシュしよう

デフォルトでは、ネットワークオブジェクトのスクリプトが動的に変更されても、RPCで適切なメソッドが実行されるようになっています。そのためRPCを実行するたびに、GetComponents<MonoBehaviour>()でスクリプトを取得し、リフレクションから該当するメソッドを探す処理が入ります。ネットワークオブジェクトのスクリプトを動的に変更しない場合は、完全に無駄な処理になってしまうため、PhotonNetworkからキャッシュを有効にするだけでパフォーマンスが改善します。
PhotonNetwork.UseRpcMonoBehaviourCache = true;

★RPCで実行するメソッド名について

ネットワーク上の通信では、文字列は文字数の分だけデータのサイズが増えるという問題があります。プレイヤー名やチャットの自由入力文などの文字列の通信が必須になるデータ以外では、文字列の使用を避ける、または可能な限り短い文字列で通信するのが望ましいです。
Photonでは、[PunRPC]属性がついたメソッドの名前をbyte型の値に変換するリストが自動的に作成され、それを内部的に使用することで、文字列(メソッド名)の通信を避ける仕組みになっています。PhotonServerSettingsから変換リストの更新や、ハッシュコードの確認などを行うことができます。
つまり、RPCで実行するメソッドの名前は長くてもパフォーマンスには影響がないということです。

次の記事

作成中です。 PUN2で始めるオンラインゲーム開発入門【その4】

o8que
Ghost - Programmer
3
Comments
o8que
4 days ago
Ghost - Programmer
「被弾処理を同期させよう」で、RPCでプレイヤーの色を変える処理の説明をしていましたが、途中参加したプレイヤーに色が同期されない問題があったので削除しました。被弾時にプレイヤーの色を変える処理については、その4で改めて説明します。
0