アマゾンバナーリンク

ディスプレイ広告

スポンサーリンク

超入門 Unityオンラインゲーム開発 PhotonPUN2 講座

2021年4月17日

こんにちは!ジェイです。今回はUnityを使ったことはあるけど、オンラインゲーム制作はした事がない人でも、順を追って理解できるような初心者から中級者を対象にした講座行います。

この記事は随時加筆していきますので、それを前提で読んでいただければ幸いです。

目次

記事内広告

初期設定

PhotonCloudを使うには初回に登録作業がありますので、初めて使う方は以下のサイトから、登録してアプリケーションIDを発行してもらう必要があります。最初に公式サイトで登録後にサインインしてください。

アプリケーションの作成

サインインしたら次にアプリケーションを作成します。まずは新しくアプリを作成するを選びます。

Photonの種別をPhotonPUNを選択して、アプリケーション名や説明やURLは好きな文を入れて構いません。

20CCUまで無料なので最初はこの設定にしましょう。そして、アプリケーションIDは次の項目で使うのでメモしておいてください。

Concurrent User (CCU)とは

ゲームのConcurrent Userとは同時にサーバに接続しているクライアントを指します。Photon Cloudアカウントの料金はCCUカウントが基準となります。

CCUはDaily Active Users (DAU/日別のアクティブなユーザ)やMonthly Active Users (MAU/月別のアクティブなユーザ)とは違います。ユーザが1日や月にプレイする時間は限られています (全てのユーザが毎日プレイするとは限らないので)。

アセットのインポート

Unityのプロジェクトを開いて、以下のアセットをインポートしましょう。

セットアップ

アセットのインポートが正常に終了すると、自動的に「PUN Wizard」が開きます。先ほど取得したアプリケーションIDを入力して「Setup Project」を押してください。

※もし自動で「PUN Wizard」が開かなかった場合は、メニューバーの「Window」→「Photon Unity Networking」→「PUN Wizard」から、手動で「PUN Wizard」を開くことができます。

PUN2の設定

セットアップが行われると、PUN2の設定ファイル(PhotonServerSettings)が生成されます。これで初期設定は完了です。以下の設定はしなくても、問題ありませんがしておくのをお勧めします。

ここのAPP id PUNでアプリケーションIDを入力しても構いません。

ロビー入室から同期までの流れ

これで準備完了です。オンライン上でCubeを動かすとこまでやります。まずは3DObject→Cubeを選択し、以下のスクリプトをアタッチしてPrefab化して、Resourcesフォルダの下においてください。

using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class CharaScript : MonoBehaviourPun
{
    void Update()
    {
        if(!photonView.IsMine)
        {
            return;
        }
        if (Input.GetKey("up"))
        {
            transform.position += transform.forward * 0.05f;
        }
        if (Input.GetKey("down"))
        {
            transform.position -= transform.forward * 0.05f;
        }
        if (Input.GetKey("right"))
        {
            transform.Rotate(0, 100 * Time.deltaTime, 0);
        }
        if (Input.GetKey("left"))
        {
            transform.Rotate(0, -100 * Time.deltaTime, 0);
        }
    }
}

SimplePun.csのStart関数ではPUNに接続し、入室した時に呼ばれるOnJoinedRoom関数で、PhotonNetwork.InstantiateでCube(Chara)を生成しています。(PhotonNetwork.Instantiateを使うにはPhotonViewをアタッチしている必要があります)

CreateEmptyで空のオブジェクトを作成し、SimplePun.csをアタッチしましょう。

using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class SimplePun : MonoBehaviourPunCallbacks
{
    void Start()
    {
        //旧バージョンでは引数必須でしたが、PUN2では不要です。
        PhotonNetwork.ConnectUsingSettings();
        if (string.IsNullOrEmpty(PhotonNetwork.NickName))
        {
            PhotonNetwork.NickName = "player" + Random.Range(1, 99999);
        }
    }
    void OnGUI()
    {
        //ログインの状態を画面上に出力
        GUILayout.Label(PhotonNetwork.NetworkClientState.ToString());
    }
    //ルームに入室前に呼び出される
    public override void OnConnectedToMaster()
    {
        // "room"という名前のルームに参加する(ルームが無ければ作成してから参加する)
        PhotonNetwork.JoinOrCreateRoom("room", new RoomOptions(), TypedLobby.Default);
    }
    //ルームに入室後に呼び出される
    public override void OnJoinedRoom()
    {
        //キャラクターを生成
        GameObject chara = PhotonNetwork.Instantiate("chara", new Vector3(0.0f, 0.5f, 0.0f), Quaternion.identity, 0);
        //自分だけが操作できるようにスクリプトを有効にする
        CharaScript charaScript = chara.GetComponent<CharaScript>();
        charaScript.enabled = true;
    }
}

CubeにAddComponentでPhotonView→PhotonTransformViewの順番でアタッチします。

実行結果

Photonのサーバーについて

Photonのサーバーには、ネームサーバーマスターサーバーゲームサーバーの3種類があります。まずネームサーバーへ接続し、ネームサーバーから適切なリージョン(国)のマスターサーバーへ転送して、マスターサーバーでプレイヤーのマッチメイキングを行い、ルームが作成されているゲームサーバーへ転送するのが基本的な流れです。プレイヤーは同じルームへ参加している他プレイヤーとのみ、データを送受信して同期処理を行うことができます。プレイヤーは1つのサーバーのみ接続できて、他のサーバーに同時に接続することはできません。

ネームサーバー

ネームサーバーはリージョン(国)ごとにあるサーバーで、通常は自分の近くの国のサーバーを使うのが、通信速度などで有利になります。このブログを見てる人は日本人が多いと思うので、JPに設定していれば特に問題ないでしょう。ネームサーバーについては、スクリプト内で接続する処理はありませんので、あまり気にする必要はありません。

マスターサーバー

PhotonPUN2ではPhotonNetwork.ConnectUsingSettings();を呼ぶことでマスターサーバーに接続できます。マスターサーバーでは、現在ゲームを遊んでいるプレイヤーの総数や、ゲームサーバーに作成されている全てのルームの情報を保持しています。

ここからロビーに参加して、ルームを作成したり既に存在するルームに参加することができます。プレイヤーはルームへ参加する時に、そのルームが作成されているゲームサーバーへ転送されます。そのタイミングでOnConnectedToMasterが呼ばれます。

ゲームサーバー

ルームは全てゲームサーバー内に作成されます。ルームへ参加して初めてプレイヤー同士で同期やプレイができます、ここでプレイヤーがルームから退出した時は、元のマスターサーバーへ再び転送されます。

今までの流れを整理すると以下の様になります。

  • リージョンを選択 : ネームサーバーを切断してマスターサーバーへ接続
  • ルームへ参加 : マスターサーバーを切断してゲームサーバーへ接続
  • ルームから退出 : ゲームサーバーを切断してマスターサーバーへ接続

同期

ルームへ参加したプレイヤーは、ゲームサーバー内の同じルームのプレイヤー同士でデータを送受信できるようになります。ここから、ルームプレイヤーの情報を利用したり、プレイヤーが生成したネットワークオブジェクトなどを使い、ゲームの同期処理を行います。

マスタークライアントは一番最初にルームを作成した人がなります。ルーム内のプレイヤー1人だけに実行させたい同期処理を行う場合に活用されます。

ネットワークオブジェクト

プレイヤーには固有のIDが1つだけ振り分けられていて、それによって識別されています。ViewIDはPhotonViewを持つゲームオブジェクトの数だけ存在して、ネットワークを通して生成や同期がなされます。これらをネットワークオブジェクトと呼びます。

ネットワークオブジェクトには所有権があり、最初はネットワークオブジェクトを生成したプレイヤーが所有者として紐づいています。この所有権をもったプレイヤーが退出するとそのネットワークオブジェクトは破棄されます。ルームに紐づく(所有者を持たない)ルームオブジェクトと呼ばれるネットワークオブジェクトを生成することもできます。こちらはルームが存在する限り破棄されません。

メッセージ数の制限

Photon Cloudには、1ルームの秒間メッセージ数500という制限があります。ここでカウントされるメッセージ数は、サーバーが送受信したメッセージ数の合計になります。詳しくは以下で解説してます。

プレイヤー

ローカルプレイヤー

プレイヤーの情報はPhotonNetwork.LocalPlayerで取得できます。なおルームに参加していない間でも、ローカルプレイヤーは取得できます。

プレイヤーのリストの取得

複数のプレイヤーの情報が必要な時はプレイヤーリストを取得します。

  • PhotonNetwork.PlayerList : ルーム内のプレイヤーオブジェクトの配列(自分を含む)を取得する
  • PhotonNetwork.PlayerListOthers : ルーム内のプレイヤーオブジェクトの配列(自分を含まない)を取得する

プレイヤーでよく使うフィールドとプロパティ

名前説明
player.ActorNumberプレイヤーのID
player.NickNameプレイヤー名
player.IsLocalローカルプレイヤーかどうか
player.IsMasterClientマスタークライアントかどうか
// ルーム内のプレイヤー全員のプレイヤー名とIDをコンソールに出力する
foreach (var player in PhotonNetwork.PlayerList)
{
    Debug.Log($"{player.NickName}({player.ActorNumber})");
}

ローカルプレイヤーの一部のプロパティは、PhotonNetworkのショートカットからも利用できます。

名前説明
PhotonNetwork.NickNameローカルプレイヤーの名前
PhotonNetwork.IsMasterClientローカルプレイヤーがマスタークライアントかどうか
// ローカルプレイヤーの名前を設定する
PhotonNetwork.NickName = "Player";
// ローカルプレイヤーがマスタークライアントかどうかを判定する
if (PhotonNetwork.IsMasterClient)
{
    Debug.Log("自身がマスタークライアントです");
}

プレイヤー参加・退出のコールバック

PhotonCloudのコールバックを使う時にはMonoBehaviourPunCallbacksを継承する必要があります。ここでは、プレイヤーが入退出した時に呼ばれるコールバックについて説明します。他プレイヤーのオブジェクトは、コールバックの引数として渡されます。

using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class PlayerCallbackSample : MonoBehaviourPunCallbacks
{
    // 他プレイヤーがルームへ参加した時に呼ばれるコールバック
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        Debug.Log($"{newPlayer.NickName}が参加しました");
    }

    // 他プレイヤーがルームから退出した時に呼ばれるコールバック
    public override void OnPlayerLeftRoom(Player otherPlayer) 
    {
        Debug.Log($"{otherPlayer.NickName}が退出しました");
    }
}

所有権

生成されたネットワークオブジェクトは所有権(Ownership)によって、プレイヤーと紐づきます。プレイヤーがネットワークオブジェクトを生成すると、そのプレイヤーは、ネットワークオブジェクトの生成者(Creator)となり、デフォルトの所有者(Owner)かつ管理者(Controller)となります。

  • 所有者 : ネットワークオブジェクトを所有しているプレイヤー
  • 生成者 : ネットワークオブジェクトを生成したプレイヤー
  • 管理者 : ネットワークオブジェクトを操作する権限を持つプレイヤー

MonoBehaviourPunを継承しているスクリプトは、photonViewプロパティから所有権を表す様々な情報を取得できます。

名前説明
photonView.IsMine自身(ローカルプレイヤー)が管理者かどうか
photonView.Owner所有者のプレイヤーオブジェクト
photonView.Controller管理者のプレイヤーオブジェクト
photonView.OwnerActorNr所有者のID(photonView.Owner.ActorNumberのショートカット)
photonView.CreatorActorNr生成者のID
photonView.ControllerActorNr管理者のID(photonView.Controller.ActorNumberのショーットカット)

ネットワークオブジェクトを扱う時の注意点として、ローカルプレイヤーが管理者かどうかを調べてからでないと、他のキャラクターを動かしてしまう可能性があるという事に注意してください。

using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class OwnershipSample : MonoBehaviourPun
{
    private void Start()
    {
        // 自身が管理者かどうかを判定する
        if (photonView.IsMine) 
        {
            // 所有者を取得する
            Player owner = photonView.Owner;
            // 所有者のプレイヤー名とIDをコンソールに出力する
            Debug.Log($"{owner.NickName}({photonView.OwnerActorNr})");
        }
    }
}

インスタンスの生成

ゲームオブジェクトを生成するには、PhotonViewコンポーネントを追加して、そのプレハブを「Resouces」に入れ、PhotonNetwork.Instantiate()を呼んで生成処理を行います。所有者が消えると生成したゲームオブジェクトが消えるので注意しましょう。

// "NetworkedObject"プレパブからネットワークオブジェクトを生成する(生成者と所有権が結びつく)
PhotonNetwork.Instantiate("NetworkedObject", Vector3.zero, Quaternion.identity);

InstantiateRoomObjectはルームからプレイヤーがいなくなるとオブジェクトが消えます。ルームオブジェクトはルームと紐づくので生成者はいないです。photonView.IsRoomViewでルームオブジェクトかどうか調べる事が出来ます。

ルームオブジェクトは生成者を持たないので、プレイヤーがルームから退出することによって自動的に破棄されることはありません。また、ルームオブジェクトの管理者はマスタークライアントになるので、マスタークライアント側でphotonView.IsMinetrueになります。


(通常の)ネットワークオブジェクト
ルームオブジェクト
所有者生成者(デフォルト)null(デフォルト)
生成者生成者null
管理者生成者(デフォルト)マスタークライアント(デフォルト)
// "NetworkedObject"プレパブからネットワークオブジェクトを生成する(ルームと所有権が結びつく)
PhotonNetwork.InstantiateRoomObject("NetworkedObject", Vector3.zero, Quaternion.identity);

InstantiateSceneObjectはSceneからプレイヤーがいなくなるとオブジェクトが消えます。

// "NetworkedObject"プレパブからネットワークオブジェクトを生成する(シーンと所有権が結びつく)
PhotonNetwork.InstantiateSceneObject("NetworkedObject", Vector3.zero, Quaternion.identity);

ちなみにResourcesフォルダを使わずにゲームオブジェクトを生成する方法もあります。

using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;

public class CPhotonPool : MonoBehaviourPunCallbacks, IPunPrefabPool
{
    public List<GameObject> PrefabList;

    public void Start()
    {
        // Poolの生成イベントを書き換える
        PhotonNetwork.PrefabPool = this;
    }

    public GameObject Instantiate(string prefabId, Vector3 position, Quaternion rotation)
    {
        Debug.Log(prefabId);
        foreach (var s in PrefabList)
        {
            if (s.name == prefabId)
            {
                var go = Instantiate(s, position, rotation);
                go.SetActive(false);
                return go;
            }
        }

        return null;
    }

    public void Destroy(GameObject go)
    {
        GameObject.Destroy(go);
    }
}
using Photon.Pun;
using UnityEngine;

// IPunPrefabPoolインターフェースを実装する
public class GamePlayerPrefabPool : MonoBehaviour, IPunPrefabPool
{
    [SerializeField]
    private GamePlayer gamePlayerPrefab = default;

    private void Start() {
        // ネットワークオブジェクトの生成・破棄を行う処理を、このクラスの処理に差し替える
        PhotonNetwork.PrefabPool = this;
    }

    GameObject IPunPrefabPool.Instantiate(string prefabId, Vector3 position, Quaternion rotation) {
        switch (prefabId) {
        case "GamePlayer":
            var player = Instantiate(gamePlayerPrefab, position, rotation);
            // 生成されたネットワークオブジェクトは非アクティブ状態で返す必要がある
            // (その後、PhotonNetworkの内部で正しく初期化されてから自動的にアクティブ状態に戻される)
            player.gameObject.SetActive(false);
            return player.gameObject;
        }
        return null;
    }

    void IPunPrefabPool.Destroy(GameObject gameObject) {
        Destroy(gameObject);
    }
}

オブジェクトプールの仕組みを使うと、頻繁にオブジェクトの生成・破棄を行う場合に、ネットワークオブジェクトを使い回せるようになるので、重いObject.Instanlateの処理の負荷を抑えることもできます。

using System.Collections.Generic;
using Photon.Pun;
using UnityEngine;

public class GamePlayerPrefabPool : MonoBehaviour, IPunPrefabPool
{
    [SerializeField]
    private GamePlayer gamePlayerPrefab = default;

    private Stack<GamePlayer> inactiveObjectPool = new Stack<GamePlayer>();

    private void Start() {
        PhotonNetwork.PrefabPool = this;
    }

    GameObject IPunPrefabPool.Instantiate(string prefabId, Vector3 position, Quaternion rotation) {
        switch (prefabId) {
        case "GamePlayer":
            GamePlayer player;
            if (inactiveObjectPool.Count > 0) {
                player = inactiveObjectPool.Pop();
                player.transform.SetPositionAndRotation(position, rotation);
            } else {
                player = Instantiate(gamePlayerPrefab, position, rotation);
                player.gameObject.SetActive(false);
            }
            return player.gameObject;
        }
        return null;
    }

    void IPunPrefabPool.Destroy(GameObject gameObject) {
        var player = gameObject.GetComponent<GamePlayer>();
        // PhotonNetworkの内部で既に非アクティブ状態にされているので、以下の処理は不要
        // player.gameObject.SetActive(false);
        inactiveObjectPool.Push(player);
    }
}

その際、使い回されるネットワークオブジェクトのスクリプトでUnityのイベント関数を使っている場合には、注意が必要です。オブジェクト生成後に一度しか呼ばれないAwake()Start()で初期化処理などを行っていると、オブジェクトが使い回された時に正しく初期化処理が行われない可能性があるからです。

public class GamePlayer : MonoBehaviourPunCallbacks
{
    private void Awake() {
        // Object.Instantiateの後に一度だけ必要な初期化処理を行う
    }

    private void Start() {
        // 生成後に一度だけ(OnEnableの後に)呼ばれる、ここで初期化処理を行う場合は要注意
    }

    public override void OnEnable() {
        base.OnEnable();

        // PhotonNetwork.Instantiateの生成処理後に必要な初期化処理を行う
    }

    public override void OnDisable() {
        base.OnDisable();

        // PhotonNetwork.Destroyの破棄処理前に必要な終了処理を行う
    }
}

それと、GameObject.Instantiateで生成したGameObjectには、PhotonViewが割り当てられていないので、PhotonNetwork.AllocateViewID()で割り当てなければViewIDが設定されないので、注意が必要です。

PUNは同期オブジェクトが1000個まで と決まっていまして、先ほどのスクリプトを動かし続けるとそのうちエラーを吐きます。

制限事項
Viewとプレイヤー

パフォーマンス上の理由で、PhotonNetwork APIがサポートするPhotonViewはプレイヤーあたり1000個まで、プレイヤー数は2,147,483人までです(これはハードウェアのサポート限界よりずっと多いのです!)。

Photon Unity Networking: 基本説明 より引用。

所有権の移譲

他プレイヤーが所有権を持つオブジェクトから、所有権を取得できるようにするには、まずPhotonViewの所有権オプションを変更しなければなりません。

所有権のオプションには以下の3つがあります。

所有権オプション説明
Fixed所有権は取得できず、生成者が常に所有権を持つ(デフォルト)
Takeover所有権を自由に取得できる
Request所有権を取得するためには、所有者の許可が必要になる

所有権を取得したいオブジェクトのphotonView.RequestOwnership()を呼ぶと所有権オプションが「TakeOver」の場合はすぐに所有権を獲得できます。「Request」の場合はオブジェクトが許可した時のみ所有権を獲得できます。

IPunOwnershipCallbacksインターフェイスを実装している場合は、所有権などのコールバックを受け取れるようになります。「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} に移譲されました");
    }
}

オブジェクトの所有者がルームから退出した際は、生成者に所有権が戻りますが、オブジェクトの生成者がルームから退出した際は、その所有権が移譲されているかどうかにかかわらずオブジェクトが自動的に削除されるため注意しましょう。

シーンオブジェクトは通常は所有者を持ちませんが、通常のネットワークオブジェクトと同じように、所有権を移譲できます。所有者を持たない間は、マスタークライアントが管理者になっていて、photonView.IsMineで判別もできます。マスタークライアントがルームから退出した際は、次に部屋に入った順番で、マスタークライントが管理者になります。所有権を移譲した後に所有者がルームから退出した際は、所有者を持たない状態に戻りマスタークライアントが管理者になります。

プレイヤーとネットワークオブジェクトの効率的な管理

PhotonNetwork.PlayerListPhothonNetwork.OtherPlayerListアクセスするたびに配列のコピーを返します。取得した配列の要素数が変わることはありませんが、頻繁にアクセスする場合には、パフォーマンスの低下を招く場合があります。

// ルーム内のネットワークオブジェクトの名前とIDをコンソールに出力する
foreach (var photonView in PhotonNetwork.PhotonViewCollection)
{
    Debug.Log($"{photonView.gameObject.name}({photonView.ViewID})");
}

上記の様になってしまうので、ネットワークオブジェクトを管理する自作クラスを定義するのがよいでしょう。

例えば以下のようにアバターをコントロールするクラスを作成します。

using System.Collections.Generic;
using UnityEngine;

public class AvatarContainer : MonoBehaviour
{
    private List<AvatarContainerChild> avatarList = new List<AvatarContainerChild>();

    public AvatarContainerChild this[int index] => avatarList[index];
    public int Count => avatarList.Count;

    private void OnTransformChildrenChanged() {
        avatarList.Clear();
        foreach (Transform child in transform) {
            avatarList.Add(child.GetComponent<AvatarContainerChild>());
        }
    }
}

アネットワークオブジェクトには、それら管理するオブジェクトの子要素にするスクリプトを追加しましょう。すると、アバターを管理するオブジェクトでOnTransformChildenChanged()が呼ばれて、アバターのリストが更新されます。この時にOnwerプロパティを定義しておけば、そのクラスのリストは、プレイヤーのリストとしても使えるようになります。

using Photon.Pun;
using Photon.Realtime;

public class AvatarContainerChild : MonoBehaviourPunCallbacks
{
    public Player Owner => photonView.Owner;

    public override void OnEnable() {
        base.OnEnable();

        var container = FindObjectOfType<AvatarContainer>();
        transform.SetParent(container.transform);
    }
}

オブジェクト同期

PhotonCloudPUN2ではオブジェクトを同期するのに、大きくわけて3つの同期方法があります。1つ目がIPunObservableインターフェイスを実装して、IPunObservable.OnPhotonSeriallizeview()が呼ぶ方法です。この方法では毎ループ同期するので、負荷が高いです。

void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    if (stream.IsWriting) 
    {
        // 自身が所有するオブジェクトのデータを送信する
        stream.SendNext(transform.localPosition);
        stream.SendNext(transform.localRotation);
        stream.SendNext(transform.localScale);
    } 
    else
    {
        // 他プレイヤーが所有するオブジェクトのデータを受信する
        transform.localPosition = (Vector3)stream.ReceiveNext();
        transform.localRotation = (Quaternion)stream.ReceiveNext();
        transform.localScale = (Vector3)stream.ReceiveNext();
    }
}

座標やアニメーションの同期

これらに関しては、あらかじめPUN2に用意されているので、わざわざスクリプトを書かなくても座標やアニメーションの同期が可能になります。

PhotonTransformView

PhotonTransformViewはTransformの同期を行います。PhotonViewを追加した後にPhotonTransformViewを追加するだけで動作してくれます。

PhotonTransformViewClassic

前のバージョンのPUNのPhotonTransformViewと同じような仕様になってます。現在のバージョンであるPUN2との違いは、細かい設定が色々とできる事です。特にきちんと設定すれば同期の制度がかなりあがるので、覚えておいた方が良いでしょう。

PhotonAnimatorView

これはアニメーションを同期するコンポーネントです。

以上の3つの詳しい説明は以下でしているので参考にしてください。

同期の頻度の調整

PhotonNetwork.sendRateとPhotonNetwork.sendRateOnSerializeで動機頻度を設定できます。

PhotonNetwork.sendRate
PhotonNetworkがパケットを、1秒に何度送信するかを定義します。(デフォルト値は15)
パケットを減らすほど、オーバーヘッドも減りますが、遅延が増加します。 sendRateを50に設定すると、1秒に50パケットを作りあげます。ターゲットプラットフォームを気をつけてください。モバイルネットワークは比較的遅く、信頼性も低くなります。

PhotonNetwork.sendRateOnSerialize
OnPhotonSerializeがPhotonViewに、1秒何度呼ばれるかを定義します。
PhotonNetwork.sendRateと関連させて、この値を決めてください。OnPhotonSerializeは更新情報と、送信されるメッセージを作成します。 レートを低くすると負荷も低くできますが、ラグが増加するでしょう。

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

RPCのターゲットの種類

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

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

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

RpcTarget.AllVisaServerを指定することで、RPCを送信するプレイヤー自身も通信を介してメソッドを実行できます。ルーム内のプレイヤー全員のRPCが、送信された(サーバーが受信した)順番で実行されることが保証されるようになるので、実行順序が決まっている場合に有効です。

また、RpcTarget.AllBufferedを指定すると、RPCがサーバーに保存されて、RPCが送信された後にルームへ途中参加したプレイヤーでもRPCが実行されるようになります。例えば、武器を装備したり、弾痕を残したりする処理を同期したい場合に使えます。

ただし、保存されたRPCの数が多くなると、ルームへ途中参加するプレイヤー側で大量の通信と処理が発生する可能性があるので、使用には注意が必要です。PhotonNetwork.RemoveRPCs(photonView);で蓄積されたRPCを消去できるので、必要なくなったタイミングで消去しておいた方がよいでしょう。

特別な引数

RPCで実行するメソッドの引数の最後にPhotonMessageInfoを追加すると、送信者の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}");
    }
}

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

カスタムプロパティ

カスタムプロパティには部屋単位で同期するルームプロパティとプレイヤー単位で同期するプレイヤープロパティがあります。ルームプロパティは入室前と入室後にも設定できますが、プレイヤープロパティは入室後にしか設定できません。

MonoBehaviourPunCallbacksを継承しているスクリプトは、カスタムプロパティが更新された時のコールバックを受け取ることができます。コールバックの引数で受け取れるHashtableには、更新されたペアのみが追加されています。

using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class CustomPropertiesCallbacksSample : MonoBehaviourPunCallbacks
{
    public override void OnPlayerPropertiesUpdate(Player targetPlayer, Hashtable changedProps)
    {
        // カスタムプロパティが更新されたプレイヤーのプレイヤー名とIDをコンソールに出力する
        Debug.Log($"{targetPlayer.NickName}({targetPlayer.ActorNumber})");

        // 更新されたプレイヤーのカスタムプロパティのペアをコンソールに出力する
        foreach (var prop in changedProps) {
            Debug.Log($"{prop.Key}: {prop.Value}");
        }
    }

    public override void OnRoomPropertiesUpdate(Hashtable propertiesThatChanged) 
    {
        // 更新されたルームのカスタムプロパティのペアをコンソールに出力する
        foreach (var prop in propertiesThatChanged)
        {
            Debug.Log($"{prop.Key}: {prop.Value}");
        }
    }
}

カスタムプロパティの取得

カスタムプロパティの値はPlayer(Room)のCustomPropertiesから取得できます。値はobject型でしか取得できませんが、C#のis演算子の型変換を使って、適切な型にキャストできるかの判定と、キャストできるならその結果を変数で受け取るという2つの処理をスマートに記述できます。普通のキャストもできますが、値が設定されてないとエラーになるので注意してください。

int stageId = (PhotonNetwork.CurrentRoom.CustomProperties["StageId"] is int value) ? value : 0;

拡張メソッドで使いやすくする

カスタムプロパティは自由な値を設定できる反面、間違った文字列(誤字・脱字)や、間違って想定と違うデータ型の値を設定してもコンパイルエラーが発生しないため、そのままで使うと、実行時のエラーを起こしやすくなってしまう危険があります。

カスタムプロパティを安全に使う方法の一つとして、カスタムプロパティ用の拡張メソッドを定義して、拡張メソッド経由でカスタムプロパティを扱うようにするのがオススメです。

using ExitGames.Client.Photon;
using Photon.Realtime;

public static class PlayerPropertiesExtensions
{
    private const string ScoreKey = "Score";
    private const string MessageKey = "Message";

    private static readonly Hashtable propsToSet = new Hashtable();

    // プレイヤーのスコアを取得する
    public static int GetScore(this Player player) {
        return (player.CustomProperties[ScoreKey] is int score) ? score : 0;
    }

    // プレイヤーのメッセージを取得する
    public static string GetMessage(this Player player) {
        return (player.CustomProperties[MessageKey] is string message) ? message : string.Empty;
    }

    // プレイヤーのスコアを設定する
    public static void SetScore(this Player player, int score) {
        propsToSet[ScoreKey] = score;
        player.SetCustomProperties(propsToSet);
        propsToSet.Clear();
    }

    // プレイヤーのメッセージを設定する
    public static void SetMessage(this Player player, string message) {
        propsToSet[MessageKey] = message;
        player.SetCustomProperties(propsToSet);
        propsToSet.Clear();
    }
}


拡張メソッドを定義しておけば、カスタムプロパティの値の取得や設定を行うたびに、キーの文字列を直接に指定したりする必要がなくなります。

- int score = (PhotonNetwork.LocalPlayer.CustomProperties["Score"] is int value) ? value : 0;
+ int score = PhotonNetwork.LocalPlayer.GetScore();
- var hastable = new ExitGames.Client.Photon.Hashtable();
- hastable["Message"] = "こんにちは";
- PhotonNetwork.LocalPlayer.SetCustomProperties(hastable);
+ PhotonNetwork.LocalPlayer.SetMessage("こんにちは");

ルームプロパティ

ルームに入る前の使い方

using System.Runtime.InteropServices;
using UnityEngine.SceneManagement;
using Photon.Pun;
using Photon.Realtime;
using Hashtable = ExitGames.Client.Photon.Hashtable;

public class CGameController : MonoBehaviourPunCallbacks
{
    // 一番最初に部屋に参加しようとすると失敗するので、失敗した時の処理(マスタークライアント)
    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        RoomOptions roomOptions = new RoomOptions() { MaxPlayers = 20 };
        int stage_valiue = 0;
        string[] str = { "Game", "Candy", "Stage", "Fight" };
        int len = Mathf.Clamp(stage_valiue, 0, str.Length - 1);
        roomOptions.CustomRoomProperties = new ExitGames.Client.Photon.Hashtable()
        {
                { "RoomCreator",PhotonNetwork.NickName },
                { "StageName",str[len] },
                { "TimeText",0 },
                { "Timer",0 },
                { "TeamMemberText1", string.Empty },
                { "TeamMemberText2", string.Empty },
                { "TeamScoreText1", 0 },
                { "TeamScoreText2", 0 },
                { "ResultText", string.Empty },
        };
        //ロビーにカスタムプロパティの情報を表示させる
        roomOptions.CustomRoomPropertiesForLobby = new string[]
        {
                "RoomCreator","StageName","TimeText","Timer","TeamMemberText1","TeamMemberText2","TeamScoreText1","TeamScoreText2", "ResultText"
        };
        PhotonNetwork.CreateRoom(null, roomOptions, null);
    }
}

ルーム入室後の使い方

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System;
using Random = UnityEngine.Random;
using System.Runtime.InteropServices;
using UnityEngine.SceneManagement;
using Photon.Pun;
using Photon.Realtime;
using Hashtable = ExitGames.Client.Photon.Hashtable;    
public class CGameController : MonoBehaviourPunCallbacks
{
    void Update()
    {
        if (!PhotonNetwork.IsConnectedAndReady || !PhotonNetwork.InRoom) return;

        if (!Finished)
        {
            Hashtable output_param = new Hashtable();
            if (output_param != null)
            {
                if (PhotonNetwork.CurrentRoom != null && PhotonNetwork.CurrentRoom.CustomProperties != null)
                {
                    output_param = PhotonNetwork.CurrentRoom.CustomProperties;
                }
            }
            if (PhotonNetwork.IsMasterClient)
            {
                string answer = string.Empty;
                System.TimeSpan t = System.TimeSpan.FromMinutes(Timer);

                answer = string.Format("{0:D2}:{1:D2}",
                                t.Hours,
                                t.Minutes);

                Timer -= Time.deltaTime;
                TimerText.text = answer;

                Hashtable input_param = new Hashtable();
                if (input_param != null)
                {
                    input_param["Timer"] = Timer;
                    input_param["TimerText"] = TimerText.text;
                }

                string player_name1 = string.Empty;
                string player_name2 = string.Empty;

                foreach (var player in PhotonNetwork.PlayerList)
                {
                    if (player.CustomProperties == null)
                    {
                        continue;
                    }
                    if (player.CustomProperties["TeamID"] == null)
                    {
                        continue;
                    }

                    if ((int)player.CustomProperties["TeamID"] == 0)
                    {
                        player_name1 += player.NickName + "\n";
                    }
                    else if ((int)player.CustomProperties["TeamID"] == 1)
                    {
                        player_name2 += player.NickName + "\n";
                    }
                }
                TeamMemberText1.text = player_name1;
                TeamMemberText2.text = player_name2;
                if (input_param != null)
                {
                    input_param["TeamMemberText1"] = player_name1;
                    input_param["TeamMemberText2"] = player_name2;
                    if (PhotonNetwork.CurrentRoom != null)
                    {
                        PhotonNetwork.CurrentRoom.SetCustomProperties(input_param);
                    }
                }
            }
            else
            {
                if (output_param != null)
                {
                    if (output_param["Timer"] != null)
                    {
                        Timer = (float)output_param["Timer"];
                    }
                    if (output_param["TimerText"] != null)
                    {
                        TimerText.text = output_param["TimerText"].ToString();
                    }
                    if (output_param["TeamMemberText1"] != null)
                    {
                        TeamMemberText1.text = output_param["TeamMemberText1"].ToString();
                    }
                    if (output_param["TeamMemberText2"] != null)
                    {
                        TeamMemberText2.text = output_param["TeamMemberText2"].ToString();
                    }
                }
            }
            if (output_param != null)
            {
                if (output_param["TeamScoreText1"] != null)
                {
                    if (output_param["TeamScoreText2"] != null)
                    {
                        TeamScoreText1.text = output_param["TeamScoreText1"].ToString();
                        TeamScoreText2.text = output_param["TeamScoreText2"].ToString();
                    }
                }
            }

            if (Timer < 0)
            {
                photonView.RPC("FinishGame", RpcTarget.All);
            }
        }
    }
}

同期方法の選び方

オブジェクト同期

  • ネットワークオブジェクトのインスタンス(PhotonView)単位で通信が行われる
  • ネットワーク上で同期されるUpdate関数のように使うことができる
  • 通信する頻度が多いデータを同期することに向いているが、そのために通信量が多くなりがち
  • 基本的に自動で通信を繰り返すため、特定のタイミングでのみ通信するような用途には向かない
  • (プロトコルがUDP、PhotonVIewの監視オプションで「Unreliable~」を設定した場合)
    到達保証(送信したデータが確実に受信される保証)がないため、重要なデータの同期には使えない

自動で定期的にデータを送受信し続けることに最適化されたRPCとみなせます。リアルタイムで動き回るオブジェクトの座標・向き・アニメーションのパラメーター・UIの値など、更新頻度が多い表示周りのデータの通信に最適です。到達保証されてるわけではないので、部分的に情報が抜け落ちてしまうと困るデータを通信する際には、RPCを使うようにしてください。

表示周りのデータの通信に最適といっても、例えばターン制シミュレーションゲームのように、数十秒に一回程度しか座標の移動が発生しない、かつ座標の移動は確実に同期されてほしい場合は、RPCやカスタムプロパティを使った方がよいこともあります。

RPC

  • ネットワークオブジェクトのインスタンス(PhotonView)単位で通信が行われる
  • ネットワーク上で同期される関数のように使うことができる
  • (RpcTarget.Allなどで普通に実行した場合)
    汎用的に使えるが、途中参加したプレイヤーには同期されない
  • (RpcTarget.AllViaServerなどでサーバー経由で実行した場合)
    順序保証(実行される順番の保証)がされるため、先着順位を決めたりすることができる
  • (RpcTarget.AllBufferedなどでRPCをバッファリングして実行した場合)
    途中参加したプレイヤーにも同期されるデータの履歴が作れるが、大量の通信と処理が発生する可能性がある

最も汎用的に使える同期方法で、どれを選んだらよいか迷ったなら、とりあえずRPCにしてみるのも悪くありません。通信したいデータが、オブジェクト同期にもカスタムプロパティにも適さないようなら、間違いなくRPCです。

各プレイヤーの入力・当たり判定・ゲーム進行関連の通知・その他イベントの処理など、様々な用途の通信に活用できます。もし途中参加したプレイヤーにもデータを同期したいなら、まずはカスタムプロパティで通信できないか検討しましょう。

RPCのバッファリングは、例えばチャットや重要な情報などのログを途中参加したプレイヤーでも見られるようにするには便利ですが、履歴が不要なデータの同期では無駄な通信と処理が発生してしまうので使用は控えましょう。

カスタムプロパティ

  • プレイヤー(Player)またはルーム(Room)単位で通信が行われる
  • ネットワーク上で同期される変数のように使うことができる
  • 途中参加したプレイヤーにもデータが同期される
  • 文字列のキーを含む必要があるため、その分だけデータのサイズが大きくなりがち
  • 同じ値を複数のプレイヤーが同時に更新を試みると、並行処理に関連する問題が発生することがある

途中参加したプレイヤーにデータを同期する最も簡単な方法です。プレイヤーの状態(スコアやライフなど)・マップ上のアイテム・ルームの共有情報など、必要になった時にいつでも値を取得できるようにしたいデータの通信に適しています。

特に更新頻度が少ないデータの通信には最適です。必要なら更新頻度が多いデータの通信に使っても問題はありませんが、文字列のキーを含む分だけデータサイズが大きいので、一秒間で何度も頻繁に更新されるようなデータなら、オブジェクト同期で通信できれば、通信量を削減できるかもしれません。

Photonでサポートされているデータ型

RPCで渡せる引数やOnPhotonSerializeView()で送受信できる型一覧です。

上記でサポートされていない型でも、独自にシリアライズとデシリアライズの処理を実装すれば、カスタムタイプの型を渡す事ができます。

Color型のシリアライズとデシリアライズ

Color型のシリアライズ処理を行うには、RGBA値がfloat型なので、float型1つ4バイト×4で合計16バイト必要です。それに対して、color32型のRGBA値はbyte型なので、byte型1つ1バイト×4で合計4バイトで通信量を削減することができます。

using ExitGames.Client.Photon;
using UnityEngine;

public static class ColorSerializer
{
    private static readonly byte[] bufferColor = new byte[4];

    // カスタムタイプを登録するメソッド(起動時に一度だけ呼び出す)
    public static void Register() 
    {
        PhotonPeer.RegisterType(typeof(Color), 1, SerializeColor, DeserializeColor);
    }

    // Color型をバイト列に変換して送信データに書き込むメソッド
    private static short SerializeColor(StreamBuffer outStream, object customObject) 
    {
        Color32 color = (Color)customObject;
        lock (bufferColor) {
            bufferColor[0] = color.r;
            bufferColor[1] = color.g;
            bufferColor[2] = color.b;
            bufferColor[3] = color.a;
            outStream.Write(bufferColor, 0, 4);
        }
        return 4; // 書き込んだバイト数を返す
    }

    // 受信データからバイト列を読み込んでColor型に変換するメソッド
    private static object DeserializeColor(StreamBuffer inStream, short length) 
    {
        Color32 color = new Color32();
        lock (bufferColor) 
        {
            inStream.Read(bufferColor, 0, 4);
            color.r = bufferColor[0];
            color.g = bufferColor[1];
            color.b = bufferColor[2];
            color.a = bufferColor[3];
        }
        return (Color)color;
    }
}

Vector2Int型のシリアライズとデシリアライズ

Vector2Int型のシリアライズ処理を行うには、x,y値がint型なので、int型1つ4バイト×2で合計8バイト必要です。またint,short,float型の値をバイト列に書き込めるProtocol.Serializeを使っています。

using ExitGames.Client.Photon;
using UnityEngine;

public static class Vector2IntSerializer
{
    public static readonly byte[] bufferColor = new byte[4];
    public static readonly byte[] bufferVector2Int = new byte[8];

    public static void Register()
    {
        PhotonPeer.RegisterType(typeof(Color), 1, SerializeColor, DeserializeColor);
        PhotonPeer.RegisterType(typeof(Vector2Int), 2, SerializeVector2Int, DeserializeVector2Int);
    }

    // 省略

    private static short SerializeVector2Int(StreamBuffer outStream, object customObject) 
    {
        Vector2Int v = (Vector2Int)customObject;
        int index = 0;
        lock (bufferVector2Int) 
        {
            Protocol.Serialize(v.x, bufferVector2Int, ref index);
            Protocol.Serialize(v.y, bufferVector2Int, ref index);
            outStream.Write(bufferVector2Int, 0, index);
        }
        return (short)index;
    }

    private static object DeserializeVector2Int(StreamBuffer inStream, short length)
    {
        int x, y;
        int index = 0;
        lock (bufferVector2Int) 
        {
            inStream.Read(bufferVector2Int, 0, length);
            Protocol.Deserialize(out x, bufferVector2Int, ref index);
            Protocol.Deserialize(out y, bufferVector2Int, ref index);
        }
        return new Vector2Int(x, y);
    }
}

組み込み型のシリアライズとデシリアライズ

上記のパターンでは、int,short,floatしかサポートされていないので、それ以外の型をバイト列に変換する時は独自に実装する必要があります。ここではbyte型のシリアライズとデシリアライズを定義します。

public static partial class MyProtocol
{
    public static void Serialize(byte value, byte[] target, ref int offset)
    {
        target[offset] = value;
        offset++;
    }

    public static void Deserialize(out byte value, byte[] source, ref int offset)
    {
        value = source[offset];
        offset++;
    }
}

double型など、2バイト以上のデータ型をバイト列へ変換する際には、BitConverterクラスを使います。BitConverterは自身の環境のバイトオーダーでしか処理を行えないため、IPAdressクラスのバイトオーダーを変換するメソッドを使って、異なるバイトオーダーの環境の間でも正しく通信できるようにする必要があります。

using System;
using System.Net;

public static partial class MyProtocol
{
    private const int SizeDouble = sizeof(double);

    public static void Serialize(double value, byte[] target, ref int offset)
    {
        long host = BitConverter.DoubleToInt64Bits(value);
        long network = IPAddress.HostToNetworkOrder(host);
        byte[] bytes = BitConverter.GetBytes(network);
        Buffer.BlockCopy(bytes, 0, target, offset, SizeDouble);
        offset += SizeDouble;
    }

    public static void Deserialize(out double value, byte[] source, ref int offset)
    {
        long host = BitConverter.ToInt64(source, offset);
        long network = IPAddress.NetworkToHostOrder(host);
        value = BitConverter.Int64BitsToDouble(network);
        offset += SizeDouble;
    }
}

string型は、送信する文字数によってサイズが変わります。可変長のデータは、バイト列の最初の1~4バイトにサイズを入れて、バイト列からサイズを取得できるようにしましょう。以下のコードでは、長い文字列は通信しない想定で、最初の1バイトのみにサイズを入れるようにしてるので、最大255バイト分の文字列が通信できます。

using System.Text;
using UnityEngine;

public static partial class MyProtocol
{
    // UTF-8でエンコード・デコードできない文字は空文字に置き換える設定にしておく
    private static readonly Encoding encoding = Encoding.GetEncoding(
        "utf-8",
        new EncoderReplacementFallback(string.Empty),
        new DecoderReplacementFallback(string.Empty)
    );

    public static void Serialize(string value, byte[] target, ref int offset)
    {
        int byteCount = encoding.GetBytes(value, 0, value.Length, target, offset + 1);
        byte size = (byte)Mathf.Min(byteCount, byte.MaxValue);
        target[offset] = size;
        offset += size + 1;
    }

    public static void Deserialize(out string value, byte[] source, ref int offset)
    {
        byte size = source[offset];
        value = encoding.GetString(source, offset + 1, size);
        offset += size + 1;
    }
}

コールバック関数

ある条件がそろった時に自動的に呼ばれる関数の一覧です。

using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using System.Collections.Generic;
using UnityEngine;
 
public class NetworkManager : MonoBehaviourPunCallbacks
{
 // Photonに接続した時
    public override void OnConnected()
    {
        Debug.Log("OnConnected");
 
        // ニックネームを付ける
        SetMyNickName("Knohhoso");
    }
 
    // Photonから切断された時
    public override void OnDisconnected(DisconnectCause cause)
    {
        Debug.Log("OnDisconnected");
    }
 
    // マスターサーバーに接続した時
    public override void OnConnectedToMaster()
    {
        Debug.Log("OnConnectedToMaster");
 
        // ロビーに入る
        JoinLobby();
    }
 
    // ロビーに入った時
    public override void OnJoinedLobby()
    {
        Debug.Log("OnJoinedLobby");
    }
 
    // ロビーから出た時
    public override void OnLeftLobby()
    {
        Debug.Log("OnLeftLobby");
    }
 
    // 部屋を作成した時
    public override void OnCreatedRoom()
    {
        Debug.Log("OnCreatedRoom");
    }
 
    // 部屋の作成に失敗した時
    public override void OnCreateRoomFailed(short returnCode, string message)
    {
        Debug.Log("OnCreateRoomFailed");
    }
 
    // 部屋に入室した時
    public override void OnJoinedRoom()
    {
        Debug.Log("OnJoinedRoom");
    }
 
    // 特定の部屋への入室に失敗した時
    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        Debug.Log("OnJoinRoomFailed");
    }
 
    // ランダムな部屋への入室に失敗した時
    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        Debug.Log("OnJoinRandomFailed");
    }
 
    // 部屋から退室した時
    public override void OnLeftRoom()
    {
        Debug.Log("OnLeftRoom");
    }
 
    // 他のプレイヤーが入室してきた時
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        Debug.Log("OnPlayerEnteredRoom");
    }
 
    // 他のプレイヤーが退室した時
    public override void OnPlayerLeftRoom(Player otherPlayer)
    {
        Debug.Log("OnPlayerLeftRoom");
    }
 
    // マスタークライアントが変わった時
    public override void OnMasterClientSwitched(Player newMasterClient)
    {
        Debug.Log("OnMasterClientSwitched");
    }
 
    // ロビーに更新があった時
    public override void OnLobbyStatisticsUpdate(List<TypedLobbyInfo> lobbyStatistics)
    {
        Debug.Log("OnLobbyStatisticsUpdate");
    }
 
 
    // ルームリストに更新があった時
    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        Debug.Log("OnRoomListUpdate");
    }
 
    // ルームプロパティが更新された時
    public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        Debug.Log("OnRoomPropertiesUpdate");
    }
 
    // プレイヤープロパティが更新された時
    public override void OnPlayerPropertiesUpdate(Player target, ExitGames.Client.Photon.Hashtable changedProps)
    {
        Debug.Log("OnPlayerPropertiesUpdate");
    }
 
    // フレンドリストに更新があった時
    public override void OnFriendListUpdate(List<FriendInfo> friendList)
    {
        Debug.Log("OnFriendListUpdate");
    }
 
    // 地域リストを受け取った時
    public override void OnRegionListReceived(RegionHandler regionHandler)
    {
        Debug.Log("OnRegionListReceived");
    }
 
    // WebRpcのレスポンスがあった時
    public override void OnWebRpcResponse(OperationResponse response)
    {
        Debug.Log("OnWebRpcResponse");
    }
 
    // カスタム認証のレスポンスがあった時
    public override void OnCustomAuthenticationResponse(Dictionary<string, object> data)
    {
        Debug.Log("OnCustomAuthenticationResponse");
    }
 
    // カスタム認証が失敗した時
    public override void OnCustomAuthenticationFailed(string debugMessage)
    {
        Debug.Log("OnCustomAuthenticationFailed");
    }
}

シーン間の移動

通常時は部屋に入ってから、PhotonNetwork.LoadLevelでMainシーンへと移動します。

// 入室時に呼ばれるコールバック
public override void OnJoinedRoom()
{
     //Mainシーンへ遷移
     PhotonNetwork.LoadLevel("Main");
}

PhotonNetwork.AutomaticallySyncScene=trueを設定すると、部屋を作ったマスタークライアントと同じシーンへ他のクライアントに移動させることができます。その場合は、OnCreatedRoom()で部屋を作った時のみ、Mainシーンへと移動します。

//ルーム作成時の処理(作成者しか実行されない)
public override void OnCreatedRoom()
{
     //Mainシーンへ遷移
     PhotonNetwork.LoadLevel("Main");
}

サーバー時刻

サーバー時刻の取得

現在のサーバー時刻はPhotonNetwork.ServerTimestampからミリ秒で取得できます。同じルームに参加しているプレイヤーのサーバー時刻は共有されます。

int currentTime = PhotonNetwork.ServerTimestamp;

サーバー時刻の比較

PhotonNetwork.ServerTimestampから取得できるサーバー時刻の値は、定期的にオーバーフローが発生しますint型の最大値(21474836472147483647)を超えると、int型の最小値(-2147483648−2147483648)になって進み続け、また最大値を超えたら最小値に戻ることを繰り返しています。

そのためサーバー時刻を比較する場合は、値同士を直接比較すると間違った結果になることがあるので、かならず差分をとって比較する必要があります

- if (PhotonNetwork.ServerTimestamp > endTime) {
+ if (unchecked(PhotonNetwork.ServerTimestamp - endTime) > 0) {
      Debug.Log("終了時刻を過ぎました");
  }

必須ではありませんが、サーバー時刻の加算や減算では、C#のunchecked演算子を使って明示的にオーバーフローを無視しておくと、不要な警告やエラーなどを抑えられることがあります。

マッチメイキング

短時間でプレイヤー同士をマッチさせたいなら、PhotonNetwork.JoinRandomOrCreateRoom()を使用すると良いでしょう。既にルームが存在するなら参加して、まだ存在しないなら、部屋を作ります。

開発用GUIコンポーネント

PUN2には、現在のネットワーク状態を可視化するPhotonStatsGuiコンポーネント、回線が不安定なプレイヤーの動作をテストしたりするPhotonLagSimulationGuiコンポーネントがあらかじめ用意されています。

PunとPun2の違い

Photon.MonoBehaviour

これは一番最初に困るところ。MonoBehaviourPunMonoBehaviourPunCallbacksを使えばOKです。

public override void OnJoinedLobby()

ロビーに接続する時に使う関数。Auto Join LobbyはPUN2になって廃止されたので、代わりにpublic override void OnConnectedToMaster()を使えます。PUN2からは、ロビーに入らなくても部屋には入れるので、むしろ必要ないならロビーに入らないで入室する方がよいです。

PhotonNetwork.GetRoomList()

Photon.GetRoomListはなくなったので、代わりにILobbyCallbacks.OnRoomListUpdate(List<RoomInfo> roomList)をコールバックからルームのリストを取得できます。その時にConnectUsingSettingsで接続済みでなおかつ、ロビーに入っていて、ルームに入っていない状態の時のみ取得できます。

PhotonNetwor.inRoomとか、photonView.isMineとかのis,inシリーズ

これらの対処は簡単で、iを大文字にするだけで対応してくれます。PhotonMessageInfoの要素のsenderSenderに、IDUserIDに変更。sender.IDSender.UserIdに変更することで対応できます。また ownerOwnerに代わってます 。

PhotonNetwork.ConnectUsingSettings(_gameVersion);

引数の_gameVersionを消すだけで解決します。

PhotonNetwork.playerName

これはplayerNameをNickNameに変えれば、解決します。
PhotonNetwork.playerPhotonNetwork.LocalPlayerで代用ができます。

photonView.RPC(“chat",Targets.All,~省略~);

Pun2だとTargets.Allがなくなったようです。RpcTarget.Allに変えることで対処できます。

PhotonNetwork.player.ID;

これはint型が返されますがPhotonNetwork.LocalPlayer.UserId;はstring型が返されます。

using Photon.Pun;
using Photon.Realtime;
の上の2つを追加するのも忘れない様にしましょう。

PhotonNetwork.playerList

PhotonNetwork.PlayerListに変更になり、取得できる型名がPhotonPlayerからPlayerになりました。

PhotonNetwork.connetedAndReady

PhotonNetwork.IsConnectedAndReadyになりました。

以上PunとPun2の違いで気づいた点について、まとめてみました。 以下に詳しく書いてあるのでよかったら参考にしてください。

PunからPun2に移行する場合は以下を参考にしてください。

アイコンは星宮あきさんからお借りしました。

PhotonCloudPUN2更新記録

バージョン2.30 リリース日 2021年4月14日

PhotonCloudPUN2で3DActionGame開発

ここまで終わったら実践で3Dアクションゲームを作ってみましょう!

PhotonCloudPUN2でチャット機能の実装

チャット機能を実装したい時はこちら

ルームとシーン間の移動

ルームを一旦抜けて別のルームとシーンに移動するサンプルです。

+4