Notifications
Article
PUN2で始めるオンラインゲーム開発入門【その5】
Published a year ago
4.1 K
1
ロビーを利用してうまくマッチングできるようにしよう
【その1】|【その2】|【その3】|【その4】|【その5】

最適な同期方法を選ぼう

  1. オブジェクト同期(その2)
  2. RPC(その3)
  3. カスタムプロパティ(その4)
これまでの記事では、3つの同期方法について紹介してきました。Photonを使ったオンラインゲーム開発では、基本的にはこの中から用途・目的に合わせて適切な同期方法を選ぶことになります。以下ではそれぞれの特徴を比較して、どれを選択すべきかの基本的な指針をまとめていますので、一つの目安として参考にしてください。

オブジェクト同期

  • ネットワークオブジェクトのインスタンス(PhotonView)単位で通信が行われる
  • ネットワーク上で同期されるUpdate関数のように使うことができる
  • 通信する頻度が多いデータを同期することに向いているが、そのために通信量が多くなりがち
  • 基本的に自動で通信を繰り返すため、特定のタイミングでのみ通信するような用途には向かない
  • (プロトコルがUDP、PhotonViewの監視オプションで「Unreliable~」を設定した場合) 到達保証(送信したデータが確実に受信される保証)がないため、重要なデータの同期には使えない
自動で定期的にデータを送受信し続けることに最適化されたRPCとみなせます。リアルタイムで動き回るオブジェクトの座標・向き・アニメーションのパラメーター・UIの値など、更新頻度が多い表示周りのデータの通信に最適です。 到達保証がない(ことがある)ので、部分的に情報が抜け落ちてしまうと困るデータを通信する際には、RPCを使うようにしてください。表示周りのデータの通信に最適といっても、例えばターン制シミュレーションの駒(ユニット)のような、数秒~数十秒に一回程度しか座標の移動が発生しない、かつ座標の移動は確実に同期されてほしい場合は、RPCやカスタムプロパティを使った方がよいこともあります。

RPC

  • ネットワークオブジェクトのインスタンス(PhotonView)単位で通信が行われる
  • ネットワーク上で同期される関数のように使うことができる
  • RpcTarget.Allなどで普通に実行した場合) 汎用的に使えるが、途中参加したプレイヤーには同期されない
  • RpcTarget.AllViaServerなどでサーバー経由で実行した場合) 順序保証(実行される順番の保証)がされるため、先着順位を決めたりすることができる
  • (RpcTarget.AllBufferedなどでRPCをバッファリングして実行した場合) 途中参加したプレイヤーにも同期されるデータの履歴が作れるが、大量の通信と処理が発生する可能性がある
最も汎用的に使える同期方法で、どれを選んだらよいか迷ったなら、とりあえずRPCにしてみるのも悪くありません。通信したいデータが、オブジェクト同期にもカスタムプロパティにも適さないようなら、間違いなくRPCです。各プレイヤーの入力・当たり判定・ゲーム進行関連の通知・その他イベントの処理など、様々な用途の通信に活用できます。 もし途中参加したプレイヤーにもデータを同期したいなら、まずはカスタムプロパティで通信できないか検討しましょう。RPCのバッファリングは、例えばチャットや重要な情報などのログを途中参加したプレイヤーでも見られるようにするには便利ですが、履歴が不要なデータの同期では無駄な通信と処理が発生してしまうので使用は控えましょう。

カスタムプロパティ

  • プレイヤー(Player)またはルーム(Room)単位で通信が行われる
  • ネットワーク上で同期される変数のように使うことができる
  • 途中参加したプレイヤーにもデータが同期される
  • 文字列のキーを含む必要があるため、その分だけデータのサイズが大きくなりがち
  • 同じ値を複数のプレイヤーが同時に更新を試みると、並行処理に関連する問題が発生することがある
途中参加したプレイヤーにデータを同期する最も簡単な方法です。プレイヤーの状態(スコアやライフなど)・マップ上のアイテム・ルームの共有情報など、必要になった時にいつでも値を取得できるようにしたいデータの通信に適しています。特に更新頻度が少ないデータの通信には最適です。必要なら更新頻度が多いデータの通信に使っても問題はありませんが、文字列のキーを含む分だけデータサイズが大きいので、一秒間で何度も頻繁に更新されるようなデータなら、オブジェクト同期で通信できれば、通信量を削減できるかもしれません。

効率的にプレイヤーを管理しよう

オンラインゲームでは様々なタイミングでプレイヤーが途中参加したり途中退出したりする可能性があるので、プレイヤーの数が変動した際にはどのような処理を行うのか、あらかじめ想定して開発を進める必要があります。そのためには、同じルームに参加しているプレイヤーを適切に管理するための機能を知っておくことが重要です。

他プレイヤーが参加・退出した時のコールバック

MonoBehaviourPunCallbacksを継承しているスクリプトは、自身が参加しているルームに他プレイヤーが参加・退出した時のコールバックを受け取ることができます。他プレイヤーの情報は、コールバックの引数として渡されます。
// 他プレイヤーが参加した時に呼ばれるコールバック public override void OnPlayerEnteredRoom(Player player) { Debug.Log(player.NickName + "が参加しました"); } // 他プレイヤーが退出した時に呼ばれるコールバック public override void OnPlayerLeftRoom(Player player) { Debug.Log(player.NickName + "が退出しました"); }

同じルームに参加しているプレイヤーの取得

ルームに参加しているプレイヤーの情報は、PhotonNetworkの配列にアクセスすることで取得できます。
Player[] allPlayers = PhotonNetwork.PlayerList; // プレイヤーの配列(自身を含む) Player[] otherPlayers = PhotonNetwork.PlayerListOthers; // プレイヤーの配列(自身を含まない) PhotonView[] photonViews = PhotonNetwork.PhotonViews; // ネットワークオブジェクトの配列
注意点として、これらのプロパティはアクセスするたびに配列のコピーを作成して返します。取得した配列の要素数が途中で意図せずに変わることはありませんが、頻繁にアクセスする際はパフォーマンス上の問題が発生する可能性があります。 また、ネットワークオブジェクトの配列は、複数の種類のネットワークオブジェクトを生成して使うような場合には、種類別に分ける処理が必要になることもあるため、あまり使い勝手は良くありません。

独自のネットワークオブジェクト管理クラスを作成しよう

上記のPhotonNetworkの配列アクセスの扱いづらさを解消するためには、例えば以下のような、ネットワークオブジェクトのリストを管理する独自クラス(GamePlayerManager)を作成するのがオススメです。
using System.Collections.Generic; using UnityEngine; public class GamePlayerManager : MonoBehaviour { private List<GamePlayer> playerList = new List<GamePlayer>(); public GamePlayer this[int index] => playerList[index]; public int Count => playerList.Count; private void OnTransformChildrenChanged() { // 子要素が変わったら、ネットワークオブジェクトのリストを更新する playerList.Clear(); foreach (Transform child in transform) { playerList.Add(child.GetComponent<GamePlayer>()); } } }
プレイヤーのネットワークオブジェクト(GamePlayer)は、生成されたらGamePlayerManagerの子要素に設定しましょう。GamePlayerManager側ではOnTransformChildrenChanged()が呼ばれるので、そこでネットワークオブジェクトのリストを更新します。GamePlayer.Ownerを定義すれば、ネットワークオブジェクトのリストはプレイヤーのリストとしても使えます。
using Photon.Pun; using Photon.Realtime; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks { public Player Owner => photonView.Owner; private void Awake() { var gamePlayerManager = GameObject.FindWithTag("GamePlayerManager").GetComponent<GamePlayerManager>(); transform.SetParent(gamePlayerManager.transform); } // 省略 }

プレイヤー同士のマッチング方法を決めよう

これまでは、既にマッチングが完了している(同じルームに参加している)ことを前提にして、プレイヤー間の同期方法について紹介してきました。ここでは、その前提となっていた様々なマッチングの方法について詳しく紹介します。

ルーム固定型のマッチング

ルームの数や種類があらかじめ決まっている場合は、PhotonNetwork.JoinOrCreateRoom()が便利です。指定した名前のルームが既に作成されていたら参加し、作成されていなかったら作成してから参加することができます。
PhotonNetwork.JoinOrCreateRoom("Room1", new RoomOptions() { MaxPlayers = 4 }, TypedLobby.Default);

ルーム選択型のマッチング

各プレイヤーが自由にルームを作成したり、作成されたルームに参加したりする場合は、Photonのロビー機能を使います。マスターサーバーへの接続が成功した後にPhotonNetwork.JoinLobby()でロビーに参加すると、MonoBehaviourPunCallbacksを継承しているスクリプトは、ルームリストの更新をコールバックで受け取ることができるようになります。コールバックの引数には、変更(追加・更新・削除)があったルームの情報が渡されるので、それを元にUIへ反映させる流れになります。
// マスターサーバーへの接続が成功したら、ロビーに参加する public override void OnConnectedToMaster() { PhotonNetwork.JoinLobby(); }
以下の画像とコードは、ルーム選択リスト(RoomListView)とそのリスト要素(RoomListEntry)の実装例になります。
ルーム選択リストはスクロールビューです。ルームリストの更新をコールバックで受け取り、受け取ったルーム情報を元にしてリスト要素を変更する処理を行っています。
using System.Collections.Generic; using Photon.Pun; using Photon.Realtime; using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(ScrollRect))] public class RoomListView : MonoBehaviourPunCallbacks { [SerializeField] private RoomListEntry roomListEntryPrefab = default; // RoomListEntryプレハブの参照 private ScrollRect scrollRect; private Dictionary<string, RoomListEntry> activeEntries = new Dictionary<string, RoomListEntry>(); private Stack<RoomListEntry> inactiveEntries = new Stack<RoomListEntry>(); private void Awake() { scrollRect = GetComponent<ScrollRect>(); } // ルームリストが更新された時に呼ばれるコールバック public override void OnRoomListUpdate(List<RoomInfo> roomList) { foreach (var info in roomList) { RoomListEntry entry; if (activeEntries.TryGetValue(info.Name, out entry)) { if (!info.RemovedFromList) { // リスト要素を更新する entry.Activate(info); } else { // リスト要素を削除する activeEntries.Remove(info.Name); entry.Deactivate(); inactiveEntries.Push(entry); } } else if (!info.RemovedFromList) { // リスト要素を追加する entry = (inactiveEntries.Count > 0) ? inactiveEntries.Pop().SetAsLastSibling() : Instantiate(roomListEntryPrefab, scrollRect.content); entry.Activate(info); activeEntries.Add(info.Name, entry); } } } }
リスト要素はボタンになっています。クリックされたらPhotonNetwork.JoinRoom()でルーム選択リストから渡されたルーム名のルームへの参加を試みる処理を行っています。
using Photon.Pun; using Photon.Realtime; using TMPro; using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(RectTransform))] [RequireComponent(typeof(Button))] public class RoomListEntry : MonoBehaviour { [SerializeField] private TextMeshProUGUI nameLabel = default; [SerializeField] private TextMeshProUGUI messageLabel = default; [SerializeField] private TextMeshProUGUI playerCounter = default; private RectTransform rectTransform; private Button button; private string roomName; private void Awake() { rectTransform = GetComponent<RectTransform>(); button = GetComponent<Button>(); } private void Start() { // リスト要素がクリックされたら、対応したルーム名のルームに参加する button.onClick.AddListener(() => PhotonNetwork.JoinRoom(roomName)); } public void Activate(RoomInfo info) { roomName = info.Name; nameLabel.text = (string)info.CustomProperties["DisplayName"]; messageLabel.text = (string)info.CustomProperties["Message"]; playerCounter.SetText("{0}/{1}", info.PlayerCount, info.MaxPlayers); // ルームの参加人数が満員でない時だけ、クリックできるようにする button.interactable = (info.PlayerCount < info.MaxPlayers); gameObject.SetActive(true); } public void Deactivate() { gameObject.SetActive(false); } public RoomListEntry SetAsLastSibling() { rectTransform.SetAsLastSibling(); return this; } }

ルームの作成

通常のルーム作成にはPhotonNetwork.CreateRoom()を使います。PhotonNetwork.JoinOrCreateRoom()とは異なり、同じルーム名のルームが既に作成されていたら、ルーム作成は失敗してそのまま終わります。ルーム名にnull""を渡すと、自動的にユニークなルーム名が生成されるので、ルーム作成の失敗をなるべく回避したい場合に使えます。
PhotonNetwork.CreateRoom( null, // 自動的にユニークなルーム名を生成する new RoomOptions() { MaxPlayers = 4, CustomRoomProperties = new ExitGames.Client.Photon.Hashtable() { { "DisplayName", $"{PhotonNetwork.NickName}の部屋" }, { "Message", "誰でも参加OK!" } }, CustomRoomPropertiesForLobby = new[] { "DisplayName", "Message" } } );
MonoBehaviourPunCallbacksを継承しているスクリプトは、ルーム作成関連のコールバックを受け取ることができます。 ルームの作成が成功した時には、その直後に作成したルームへ自動的に参加する流れになるため、コールバックを活用する機会は少ないかもしれません。ルームの作成が失敗した時には、エラーメッセージを表示したりすることに利用できます。
// ルームの作成が成功した時に呼ばれるコールバック public override void OnCreatedRoom() { Debug.Log("ルーム作成に成功しました"); } // ルームの作成が失敗した時に呼ばれるコールバック public override void OnCreateRoomFailed(short returnCode, string message) { Debug.Log($"ルーム作成に失敗しました: {message}"); }

プライベートマッチング

ルーム作成時にRoomOptions.IsVisiblefalseに設定すると、ロビーのルームリストの更新のコールバックで通知されない非公開設定のルームを作成できます。ルームへ参加するには、PhotonNetwork.JoinRoom()で正確なルーム名を指定する必要があるため、実質的にルーム名を知っているプレイヤーだけが参加できるプライベートルームになります。
public void CreatePrivateRoom(string roomName) { PhotonNetwork.CreateRoom( roomName, new RoomOptions() { MaxPlayers = 4, IsVisible = false } ); }
既に作成されたルームに参加しているプレイヤーは、Room.IsVisibleで後から公開設定を変更することもできます。
PhotonNetwork.CurrentRoom.IsVisible = true;

ルームの参加許可設定

Room.IsOpenでルーム参加許可設定を変更できます。例えば、試合が始まった後にプレイヤーの途中参加を止めたい時にfalseに設定することで、ルームの定員に空きがあっても、他プレイヤーがルームに参加することはできなくなります。
public override void OnJoinedRoom() { // 自身がルームに参加した時に満員になったら、以降そのルームを参加拒否設定にする if (PhotonNetwork.CurrentRoom.PlayerCount == PhotonNetwork.CurrentRoom.MaxPlayers) { PhotonNetwork.CurrentRoom.IsOpen = false; } }

ランダムマッチング

PhotonNetwork.JoinRandomRoom()で、既に作成されているルームの中の一つにランダムで参加することができます。参加人数が満員・非公開設定・参加拒否設定のうち一つでも当てはまるルームは、ランダムマッチングの対象から除外されます
PhotonNetwork.JoinRandomRoom();
MonoBehaviourPunCallbacksを継承しているスクリプトは、ルーム参加関連のコールバックを受け取ることができます。 ランダムマッチングが失敗した時に呼ばれるコールバックは、(ルーム名を指定する)通常のマッチングが失敗した時に呼ばれるコールバックとは別に用意されています。大抵これが呼ばれるのは、ランダムで参加できるルームが存在しない場合が多いため、自動で新しいルームを作成する処理を入れておけば、エラーメッセージなどを表示せずに済みます。
// マッチングが成功した時に呼ばれるコールバック public override void OnJoinedRoom() { Debug.Log("ルームに参加しました"); } // 通常のマッチングが失敗した時に呼ばれるコールバック public override void OnJoinRoomFailed(short returnCode, string message) { Debug.Log($"ルーム参加に失敗しました: {message}"); } // ランダムマッチングが失敗した時に呼ばれるコールバック public override void OnJoinRandomFailed(short returnCode, string message) { // ランダムに参加できるルームが存在しないなら、新しいルームを作成する PhotonNetwork.CreateRoom(null); }

ルームのフィルタリング(スキルベースマッチング)

PhotonNetwork.JoinRandomRoom()は、引数にマッチング条件として、カスタムプロパティと定員を指定することができます。
例えば、ルームを作成するプレイヤーはPhotonNetwork.CreateRoom()で自身のランクをルームのカスタムプロパティに設定し、ルームに参加するプレイヤーはPhotonNetwork.JoinRandomRoom()で自身のランクをマッチング条件で指定すると、ルームを作成したプレイヤーと同じランクのプレイヤーだけが参加できるスキルベースマッチングを実現できます。
public void CreateRoomWith(int rank) { // 引数で指定したランクが設定されていて、かつ定員が4人に設定されているルームを作成する PhotonNetwork.CreateRoom( null, new RoomOptions() { MaxPlayers = 4, CustomRoomProperties = new ExitGames.Client.Photon.Hashtable() { { "Rank", rank } } } ); }
public void JoinRoomWith(int rank) { // カスタムプロパティに引数と同じランクが設定されていて、かつ定員が4人に設定されているルームに参加する PhotonNetwork.JoinRandomRoom( new ExitGames.Client.Photon.Hashtable() { { "Rank", rank } }, 4 ); }
引数のカスタムプロパティをnull、または定員を0にすることで、片方を「条件指定無し」にすることもできます。
// 定員が4人に設定されているルームに参加する(カスタムプロパティの条件指定無し) PhotonNetwork.JoinRandomRoom(null, 4);

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

★所有権とシーンオブジェクトについて

ネットワークオブジェクトには所有権(Ownership)が存在します。所有権を持つプレイヤーは所有者(Owner)と呼ばれます。また、インスタンスを生成したプレイヤーは生成者(Creator)と呼ばれ、所有者とは区別されます。さらに、所有者は自動的に管理者(Controller)となり、オブジェクト同期でデータを送信する処理を行ったり、PhotonNetwork.Destroy()でインスタンスを削除したりすることができます。
// ネットワークオブジェクトの所有者ID・生成者ID・管理者IDをコンソールに表示する Debug.Log($"{photonView.OwnerActorNr} {photonView.CreatorActorNr} {photonView.ControllerActorNr}");
デフォルトでは、インスタンスの生成者が所有者かつ管理者となり、その所有権を移譲することはできません。

所有権の取得

他プレイヤーが所有権を持つインスタンスから、所有権を取得(自分自身へ所有権を移譲)できるようにするには、まずPhotonViewの所有権オプションを変更しておく必要があります。
  1. 「Fixed」 - 所有権は取得できず、生成者が常に所有権を持つ(デフォルト)
  2. 「Takeover」 - 所有権を自由に取得できる
  3. 「Request」 - 所有権を取得するためには、所有者の許可が必要になる
所有権を取得したいインスタンスでPhotonView.RequestOwnership()を呼び出すと、「Takeover」オプションを選択した時はすぐに所有権を取得し、「Request」オプションを選択した時は所有権のリクエストが行われます。
photonView.RequestOwnership();
IPunOwnershipCallbacksインターフェースを実装しているスクリプトは、所有権関連のコールバックを受け取ることができます。(MonoBehaviourPunCallbacksを継承してもコールバックは受け取れないので注意してください) 「Request」オプションを選択した時は、IPunOwnershipCallbacks.OnOwnershipRequest()に自身が所有権を持つインスタンスで所有権のリクエストが行われた際の処理(許可/拒否)を実装することで、所有権を移譲できるようになります。
// 所有権のリクエストが行われた時に呼ばれるコールバック void IPunOwnershipCallbacks.OnOwnershipRequest(PhotonView targetView, Player requestingPlayer) { // 自身が所有権を持つインスタンスで所有権のリクエストが行われたら、常に許可して所有権を移譲する if (targetView.IsMine && targetView.ViewID == photonView.ViewID) { bool acceptsRequest = true; if (acceptsRequest) { targetView.TransferOwnership(requestingPlayer); } else { // リクエストを拒否する場合は、何もしない } } } // 所有権の移譲が行われた時に呼ばれるコールバック void IPunOwnershipCallbacks.OnOwnershipTransfered(PhotonView targetView, Player previousOwner) { if (targetView.ViewID == photonView.ViewID) { string id = targetView.ViewID.ToString(); string p1 = previousOwner.NickName; string p2 = targetView.Owner.NickName; Debug.Log($"ViewID {id} の所有権が {p1} から {p2} に移譲されました"); } }
インスタンスの所有者がルームから退出した際は、生成者に所有権が戻りますが、インスタンスの生成者がルームから退出した際は、その所有権が移譲されているかどうかにかかわらずインスタンスが自動的に削除されるため注意しましょう。

シーンオブジェクト

マスタークライアントは、シーンオブジェクト(SceneObject)と呼ばれるルームに紐づいたネットワークオブジェクトを生成できます。インスタンスは生成者と所有者を持たないため、インスタンスが自動的に削除されることはありません。
PhotonNetwork.InstantiateSceneObject("SceneObject", Vector3.zero, Quaternion.identity);
また、ネットワークオブジェクトはあらかじめシーンに配置すると、自動的にシーンオブジェクトとして生成されます
シーンオブジェクトはデフォルトで所有者を持ちませんが、通常のネットワークオブジェクトと同じように、所有権を移譲できます。所有者を持たない間は、マスタークライアントが管理者になっていて、PhotonView.IsMineで判別もできます。 マスタークライアントがルームから退出した際は、次に割り当てられたマスタークライントが管理者になります。所有権を移譲した後に所有者がルームから退出した際は、所有者を持たない状態に戻りマスタークライアントが管理者になります。

★排他制御でアイテムデュープ対策をしよう

オンラインゲーム開発では、しばしば並行処理に関連する問題に遭遇します。例えばステージ上にアイテムが落ちていて、プレイヤーはそのアイテムに触ると取得できる(取得されたアイテムはステージ上から消える)とします。2人以上のプレイヤーがほぼ同時にアイテムに触れた時、正しく排他制御が行われないと望ましい結果にならない可能性があります。
  • 最初にアイテムに触れたプレイヤーがアイテムの取得に成功する(望ましい結果)
  • 両方のプレイヤーがアイテムの取得に成功する(アイテムが複製される問題が発生する)
  • 両方のプレイヤーがアイテムの取得に失敗する(アイテムが消滅する問題が発生する)
  • 最後にアイテムに触れたプレイヤーがアイテムの取得に成功する(不公平な結果)
排他制御はサーバー側のロジックで処理できれば理想ですが、Photon Cloudを使う場合は、サーバー側の処理を入れることができないので、Photonのクライアント側で用意されている機能の中でうまく実装する必要があります。

サーバー経由のRPCを使う

アイテムを取得するRPCを定義して、サーバー経由で呼び出すことでRPCが実行される順序が保証されるようになります。最初に実行されたRPCの送信者IDが自身のプレイヤーIDなら、アイテムの取得処理を行いましょう。
using Photon.Pun; using UnityEngine; public class GameItem : MonoBehaviourPunCallbacks { private bool isAvailable; // アイテムが取得可能かどうか public void Spawn() { isAvailable = true; } public void TryGetItem() { photonView.RPC(nameof(RPCTryGetItem), RpcTarget.AllViaServer); } [PunRPC] private void RPCTryGetItem(PhotonMessageInfo info) { if (isAvailable) { isAvailable = false; if (info.Sender.ActorNumber == PhotonNetwork.LocalPlayer.ActorNumber) { Debug.Log("アイテムの取得に成功しました"); } } else { // 既にアイテムが取得済みなら、何もしない } } }
この方法は実装が簡単で、アイテムの取得が失敗した時の検知も容易です。しかしその反面、アイテムの取得が失敗した時の検知が不要な場合には、何も行わないRPCを実行するための無駄な通信が発生することになります。

カスタムプロパティの条件付き更新を使う

カスタムプロパティは設定時の引数に条件を指定することができます。ゲームサーバーは、条件付き更新を受信した時点での、条件に指定した値と、それに対応したカスタムプロパティの現在値とを比較します。そして、その値が一致した場合にのみ、カスタムプロパティが更新され、同じルームに参加しているプレイヤー全員への送信・同期が行われます。
ルームのカスタムプロパティで、アイテムIDをキーとしてその所有者IDを値とするペアを設定します。アイテムを取得する際は、アイテムの所有者IDが0である(所有者を持たない)ことを条件にして、所有者IDの値を自身のプレイヤーIDで更新します。最初に所有者IDを更新したプレイヤーの更新のみが成功するので、そこでアイテムの取得処理を行いましょう。
using ExitGames.Client.Photon; using Photon.Pun; using UnityEngine; public class GameItem : MonoBehaviourPunCallbacks { private string id; // アイテムID public void Spawn() { PhotonNetwork.CurrentRoom.SetCustomProperties( new Hashtable() { { id, 0 } } ); } public void TryGetItem() { PhotonNetwork.CurrentRoom.SetCustomProperties( new Hashtable() { { id, PhotonNetwork.LocalPlayer.ActorNumber } }, new Hashtable() { { id, 0 } } ); } public override void OnRoomPropertiesUpdate(Hashtable propertiesThatChanged) { foreach (var entry in propertiesThatChanged) { string k = (string)entry.Key; int v = (int)entry.Value; if (k == id && v == PhotonNetwork.LocalPlayer.ActorNumber) { Debug.Log("アイテムの取得に成功しました"); } } } }
条件付き更新は、失敗すると他プレイヤーへの送信・同期が何も行われないため、無駄な通信を抑えることができますが、そのために失敗した時は何のコールバックを受け取ることもできません。内部的には、条件付き更新が失敗したプレイヤーにはエラーが返ってきているので、必要なら独自のコールバックを作成することは可能です。
以下のコードでは、MonoBehaviourPunCallbacksOnPropertiesUpdateFailed()コールバックを追加しています。このクラスを継承することで、条件付き更新が失敗した時のコールバックを受け取ることができるようになります。ただし、どの更新が失敗したのか等の詳細な情報は全くわからないため、その判別をしたいならサーバー経由のRPCを使う方がよいでしょう。
using ExitGames.Client.Photon; using Photon.Pun; using Photon.Realtime; public class CustomMonoBehaviourPunCallbacks : MonoBehaviourPunCallbacks { public override void OnEnable() { base.OnEnable(); PhotonNetwork.NetworkingClient.OpResponseReceived += OnOpResponseReceived; } public override void OnDisable() { base.OnDisable(); PhotonNetwork.NetworkingClient.OpResponseReceived -= OnOpResponseReceived; } private void OnOpResponseReceived(OperationResponse response) { if (response.OperationCode == OperationCode.SetProperties && response.ReturnCode == ErrorCode.InvalidOperation) { OnPropertiesUpdateFailed(response.DebugMessage); } } public virtual void OnPropertiesUpdateFailed(string message) {} }

★ネットワークカリングで通信量を削減しよう

基本的にオブジェクト同期やRPCは同じルームへ参加しているプレイヤー全員と同期が行われるため、必ずしも全員に送信する必要がないデータを同期する場合には、無駄な通信が発生してしまうことになります。これは、サーバー側のロジックで適切なプレイヤーのみに送信できれば理想ですが、Photon Cloudを使う場合には、サーバー側の処理を入れることができないので、その簡易的な代替としてインタレストグループ(Interest Groups)という機能を利用できます。

インタレストグループの受信設定

プレイヤーはPhotonNetwork.SetInterestGroups()で、受信対象のグループの追加と削除を行うことができます。グループIDはbyte型の値で、1255の範囲で複数選択できます。グループID0は、ルームへ参加しているプレイヤー全員が自動的に受信対象として追加されるデフォルトのグループで、設定を変更(削除)することはできません。
PhotonNetwork.SetInterestGroups( new byte[] { 1, 2, 3 }, // グループID1~3を受信対象から削除する new byte[] { 4, 5, 6 } // グループID4~6を受信対象に追加する );
引数をnullにすると「グループ指定無し」、Array.Empty<byte>()にすると「全てのグループを指定」になります。内部的に引数のグループIDの配列は、HashSet<byte>型コレクションで整理されてから送信されるので、仮にグループIDが重複しても問題はありません。追加と削除の配列の間でグループIDが重複した場合は、追加が優先されます。
// 全てのグループを受信対象から削除する PhotonNetwork.SetInterestGroups(Array.Empty<byte>(), null); // グループID1~3を受信対象に追加し、それ以外の全てのグループを受信対象から削除する PhotonNetwork.SetInterestGroups(Array.Empty<byte>(), new byte[] { 1, 2, 3 }); // グループID4~6を受信対象に追加し、グループID1~3を受信対象から削除する PhotonNetwork.SetInterestGroups(new byte[] { 1, 2, 3, 4, 5 }, new byte[] { 4, 5, 6, 4, 5, 6 });

インタレストグループの送信設定

オブジェクト同期やRPCは、送信される(送信データを作成される)時にPhotonView.Groupで指定されているグループIDが送信対象に設定されます。グループID0を指定すると、ルームへ参加しているプレイヤー全員に送信することになります。(PhotonView.Groupは、送信時にグループIDを設定するためだけの値で、変更することで通信は発生しません)
// グループID1を受信対象に追加しているプレイヤーのみで、オブジェクト同期が行われる void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { photonView.Group = 1; stream.SendNext(transform.position); } else { transform.position = (Vector3)stream.ReceiveNext(); } }
// グループID1を受信対象に追加しているプレイヤーのみで、RPCが実行される public void FireProjectile(float angle) { photonView.Group = 1; photonView.RPC(nameof(RPCFireProjectile), RpcTarget.All, angle); }
ネットワークオブジェクトを生成する際にグループIDを指定することもできます。グループIDを指定して生成されるインスタンスは、後からインタレストグループの受信設定を変更しても、自動的に生成・削除されることはありません。
// グループID1を受信対象に追加しているプレイヤーのみで、ネットワークオブジェクトのインスタンスが生成される PhotonNetwork.Instantiate("NetworkedObject", Vector3.zero, Quaternion.identity, 1);

実装例

1km四方のマップを、100m四方のエリアに分割して、それぞれにグループID1100を割り当てます。プレイヤーは座標を更新するたびに、自身がいるエリアのグループIDを計算して、変更があれば更新するようにします。これで、同じエリアにいるプレイヤー同士のみが座標の更新を同期するようになります。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks { private void Update() { if (photonView.IsMine) { var direction = new Vector3(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv); var areaPosition = new Vector2Int( Mathf.Clamp(Mathf.FloorToInt((transform.position.x + 500f) / 100f), 0, 9), Mathf.Clamp(Mathf.FloorToInt((transform.position.y + 500f) / 100f), 0, 9) ); byte currentGroup = (byte)(areaPosition.x + 10 * areaPosition.y + 1); if (currentGroup != photonView.Group) { PhotonNetwork.SetInterestGroups( new byte[] { photonView.Group }, new byte[] { currentGroup } ); photonView.Group = currentGroup; } } } }
これはサンプル用の単純な例です。実用的には、エリアの境界で急に同期が途切れないように周囲のエリアのグループIDを受信対象に含めたり、もっとちゃんとした空間分割でグループIDを割り当てたりする必要があるでしょう。さらにその場合には、最大255グループが制限になってしまうこともあり、その中で調整しなければなりません。

次の記事

PhotonやPUN2についてより詳しく知りたい場合には、公式のドキュメントやフォーラムをチェックしてみましょう。
https://doc.photonengine.com/ja-jp/pun/current/getting-started/pun-intro https://doc-api.photonengine.com/en/pun/v2/index.html https://forum.photonengine.com/ https://forum.unity.com/threads/photon-unity-networking.101734/
o8que
Ghost - Programmer
5
Comments
越天涯咫尺
5 months ago
为啥会有日语教程
0