Photon - PUN2 : RaiseEventのラッパー

「人はわかりあえる！　**だからコメントはあんまり書かなくていい！**」[^1]
とごねていたのですが、自分が書いたPhotonのRaiseEventのラッパーの使い方がみんなよくわからなかったっぽいので、反省して説明を書きます。
[PUN2](https://doc.photonengine.com/ja-jp/pun/v2/getting-started/pun-intro)[^2]です。PUN1でも要領は一緒なので適宜読み替えてください。

## RER / RES
継承元の`SingletonMonoBehaviour`は[みんな知ってるとこ](https://gist.github.com/tsubaki/e0406377a1b014754894)からコピペしてきたやつ。
[gistにも置いておきました](https://gist.github.com/nekomimi-daimao/919c7996424186a7c88ef93c377d1ab2)[^3]
これだけでわかる人は[使い方のコツ](#使い方のコツ)だけ読んでください。

```c#:RER/RES.cs
using System;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

/// <summary>
/// RaiseEventのラッパー.
///
/// イベントの足し方
/// ① enumを追加
/// ② Actionを追加
/// ③ RaiseEvent受信時のイベントをenumに従って振り分け
/// ④ RESに送信メソッドを追加
/// </summary>

// RaiseEventReceiver
public class RER : SingletonMonoBehaviour<RER>
{
    #region lifecycle

    public void OnEnable()
    {
        PhotonNetwork.NetworkingClient.EventReceived += OnEvent;
    }

    public void OnDisable()
    {
        PhotonNetwork.NetworkingClient.EventReceived -= OnEvent;
    }

    #endregion


    // ①
    // eventCode. 0~199。0は特殊な扱いのため1から始める
    public enum RaiseEventType : byte
    {
        SampleEvent = 1,
    }

    // ②
    public Action<string> OnSampleEvent;

    // ③
    public void OnEvent(EventData photonEvent)
    {
        var type = (RaiseEventType) Enum.ToObject(typeof(RaiseEventType), photonEvent.Code);
        Debug.Log("RaiseEvent Received. Type = " + type);
        switch (type)
        {
            case RaiseEventType.SampleEvent:
                OnSampleEvent?.Invoke(photonEvent.CustomData as string);
                break;
            default:
                return;
        }
    }
}


// RaiseEventSender
public static class RES
{
    // ④
    public static void SendSampleEvent(string message)
    {
        var raiseEventOptions = new RaiseEventOptions
        {
            Receivers = ReceiverGroup.All,
            CachingOption = EventCaching.AddToRoomCache,
        };
        PhotonNetwork.RaiseEvent((byte) RER.RaiseEventType.SampleEvent, message, raiseEventOptions, SendOptions.SendReliable);
    }
}
```
## 作った理由
RPCでいいじゃん、と思う人もいるかと思いますが、なんかやばいこと書いてあるのでRaiseEvent使ったほうがいいと思います。そもそもRPCめんどくさい。[^4]
<details><summary>やばいこと</summary><div>
> [最適化のヒント](https://doc.photonengine.com/ja-jp/pun/v2/gameplay/optimization)
> ターゲットGameObjectのあらゆるコンポーネントはRPCを実装することができるので、PUNはリフレクションを使用して適切なメソッドを見つけます。
</div></details>
RaiseEventってなに？　という人は[RPCとRaiseEvent](https://doc.photonengine.com/ja-jp/pun/v2/gameplay/rpcsandraiseevent)を読んでください。
公式ではイベントを受信する方法として以下2つを提示しています。

>> - IOnEventCallbackコールバック
>> - LoadBalancingClient.EventReceived

これをそのままやるとたいへんめんどくさいです。

- OnEnable・OnDisableの処理を書き忘れる
- EventCodeが被らないよう気をつける
- 1種類のイベントを複数箇所で受け取りたい場合、毎回パース処理を書く必要がある
- どこでイベントを送信してどこで受信しているか探し回る必要がある
- RaiseEventの仕様を説明しないとイベントを足してもらうことが困難
- (受信側/送信側)が(何が送られてくるのか/何を送ればいいのか)を知る必要があり密結合になる

## 使用例
実際にどう動くかを見たほうが早いと思うのでコード書きます。
### シーン構成
こんなシーンです。押したボタン毎にCubeの色が変わります。その色の変更が`RaiseEvent`を通じて相手に共有されます。
ちなみにですが`PhotonView`はこのシーン内に存在していません。[^5]
<img width="800" alt="スクリーンショット 2019-02-07 1.32.43.png" src="https://qiita-image-store.s3.amazonaws.com/0/146245/da6970e0-19ec-24b6-ecc0-a974d4e2f5e2.png">
GameManagerに以下の管理スクリプトを貼り付けます。
どこでもいいですが、`RER/RSR`も同じオブジェクトに貼り付けておきます。

```c#:PunSampleManager.cs
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;
using UnityEngine.UI;


public class PunSampleManager : MonoBehaviourPunCallbacks
{
    [SerializeField] private MeshRenderer _cubeMesh;

    [SerializeField] private Button _buttonRed;
    [SerializeField] private Button _buttonBlue;
    [SerializeField] private Button _buttonGreen;

    void Start()
    {
        RER.Instance.OnChangeColor += OnChangeColor;

        PhotonNetwork.GameVersion = Application.version;
        Debug.Log("connect");
        PhotonNetwork.ConnectUsingSettings();
    }

    public override void OnConnectedToMaster()
    {
        base.OnConnectedToMaster();
        Debug.Log("join");
        var roomOptions = new RoomOptions();
        PhotonNetwork.JoinOrCreateRoom("room", roomOptions, TypedLobby.Default);
    }

    public override void OnJoinedRoom()
    {
        base.OnJoinedRoom();
        Debug.Log("OnJoinedRoom");
        InitializeButtons();
    }

    private void InitializeButtons()
    {
        _buttonRed.interactable = true;
        _buttonBlue.interactable = true;
        _buttonGreen.interactable = true;

        _buttonRed.onClick.AddListener(() => RES.ChangeColor(CubeColor.Red));
        _buttonBlue.onClick.AddListener(() => RES.ChangeColor(CubeColor.Blue));
        _buttonGreen.onClick.AddListener(() => RES.ChangeColor(CubeColor.Green));
    }


    public enum CubeColor : int
    {
        Red,
        Blue,
        Green
    }

    private void OnChangeColor(CubeColor color)
    {
        Color change;
        switch (color)
        {
            case CubeColor.Red:
                change = Color.red;
                break;
            case CubeColor.Blue:
                change = Color.blue;
                break;
            case CubeColor.Green:
                change = Color.green;
                break;
            default:
                return;
        }

        _cubeMesh.material.color = change;
    }
}
```
```c#:RER/RSR_イベント追加版
using System;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;


/// <summary>
/// RaiseEventのラッパー.
///
/// イベントの足し方
/// ① enumを追加
/// ② Actionを追加
/// ③ RaiseEvent受信時のイベントをenumに従って振り分け
/// ④ RESに送信メソッドを追加
/// </summary>

// RaiseEventReceiver
public class RER : SingletonMonoBehaviour<RER>
{
    #region lifecycle

    public void OnEnable()
    {
        PhotonNetwork.NetworkingClient.EventReceived += OnEvent;
    }

    public void OnDisable()
    {
        PhotonNetwork.NetworkingClient.EventReceived -= OnEvent;
    }

    #endregion


    // ①
    // eventCode. 0~199。0は特殊な扱いのため1から始める
    public enum RaiseEventType : byte
    {
        SampleEvent = 1,
        ChangeColor,
    }

    // ②
    public Action<string> OnSampleEvent;
    public Action<PunSampleManager.CubeColor> OnChangeColor;


    // ③
    public void OnEvent(EventData photonEvent)
    {
        var type = (RaiseEventType) Enum.ToObject(typeof(RaiseEventType), photonEvent.Code);
        Debug.Log("RaiseEvent Received. Type = " + type);
        switch (type)
        {
            case RaiseEventType.ChangeColor:
                var color = (PunSampleManager.CubeColor) Enum.ToObject(typeof(PunSampleManager.CubeColor), photonEvent.CustomData);
                OnChangeColor?.Invoke(color);
                break;
            case RaiseEventType.SampleEvent:
            default:
                return;
        }
    }
}


// RaiseEventSender
public static class RES
{
    // ④
    public static void SendSampleEvent(string message)
    {
        var raiseEventOptions = new RaiseEventOptions
        {
            Receivers = ReceiverGroup.All,
            CachingOption = EventCaching.AddToRoomCache,
        };
        PhotonNetwork.RaiseEvent((byte) RER.RaiseEventType.SampleEvent, message, raiseEventOptions, SendOptions.SendReliable);
    }

    public static void ChangeColor(PunSampleManager.CubeColor cubeColor)
    {
        var content = (int) cubeColor;
        var raiseEventOptions = new RaiseEventOptions
        {
            Receivers = ReceiverGroup.All,
            CachingOption = EventCaching.AddToRoomCache,
        };
        PhotonNetwork.RaiseEvent((byte) RER.RaiseEventType.ChangeColor, content, raiseEventOptions, SendOptions.SendReliable);
    }
}
```


### 動作例
変化させた色が共有されます。
![changecolorL.gif](https://qiita-image-store.s3.amazonaws.com/0/146245/e97759e7-76b5-031f-e33f-a7e73b0648dd.gif)
`CachingOption = EventCaching.AddToRoomCache,`
Roomにキャッシュしているため、後から来た人にもRaiseEventが届いています。
![cachecolorL.gif](https://qiita-image-store.s3.amazonaws.com/0/146245/99f4c59f-148f-7ebe-d77a-fa7453b944c9.gif)
### 受信側追加
ボタンがどのように押されたかを見たいため、右側にログを出します。シーンはこんな感じ。
右の白いところに操作ログが文字で出ます。
<img width="613" alt="スクリーンショット 2019-02-12 23.35.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/146245/2e23caed-b992-fcc9-be72-6eba331d2fd8.png">

```c#:ColorLog.cs
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;

public class ColorLog : MonoBehaviour
{
    [SerializeField] private Text _log;

    private const int Limit = 10;
    private readonly Queue<PunSampleManager.CubeColor> _queue = new Queue<PunSampleManager.CubeColor>(Limit);

    // Start is called before the first frame update
    void Start()
    {
        RER.Instance.OnChangeColor += OnChangeColor;
    }


    private void OnChangeColor(PunSampleManager.CubeColor color)
    {
        _queue.Enqueue(color);
        if (_queue.Count > Limit)
        {
            _queue.Dequeue();
        }

        var stringBuilder = new StringBuilder(Limit);
        foreach (var cubeColor in _queue)
        {
            stringBuilder.AppendLine(cubeColor.ToString());
        }

        _log.text = stringBuilder.ToString();
    }
}
```

![logL.gif](https://qiita-image-store.s3.amazonaws.com/0/146245/529fab67-2840-feca-618c-5589b9727e73.gif)
`RER/RSR`（イベント送受信者）にも`PunSampleManager`（イベント受信側）にもいっさい変更を加えることなく、イベントを受信するクラスを増やすことができました。
### 送信側
せっかくなのでCubeを回転させましょう。

```c#:RER/RSR-イベント追加
/// ① enumを追加
public enum RaiseEventType : byte
{
    SampleEvent = 1,
    ChangeColor,
    RollCube,
}

/// ② Actionを追加
public Action OnSpinCube;

/// ③ RaiseEvent受信時のイベントをenumに従って振り分け
case RaiseEventType.RollCube:
    OnSpinCube?.Invoke();

/// ④ RESに送信メソッドを追加
public static void SpinCube()
{
    var raiseEventOptions = new RaiseEventOptions
    {
        Receivers = ReceiverGroup.All,
        CachingOption = EventCaching.DoNotCache,
    };
    PhotonNetwork.RaiseEvent((byte) RER.RaiseEventType.RollCube, null, raiseEventOptions, SendOptions.SendReliable);
}
```

```c#:CubeSpinner
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CubeSpinner : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        RER.Instance.OnSpinCube += () => { StartCoroutine(SpinCube()); };
    }

    private IEnumerator SpinCube()
    {
        var wait = new WaitForFixedUpdate();
        var defaultRotate = transform.rotation;
        var spinSpeed = new Vector3(0f, 0f, 10f);
        while (true)
        {
            transform.Rotate(spinSpeed);
            if (transform.rotation == defaultRotate)
            {
                yield break;
            }

            yield return wait;
        }
    }
}
```

![spinningL.gif](https://qiita-image-store.s3.amazonaws.com/0/146245/dd9e48b2-d9d9-8f21-d0bf-349669d5293d.gif)

回転を共有できました。
既存のCubeの色を変更する箇所には影響がありません。

## 使い方のコツ
#### イベントを発行したのが自クライアントか他のクライアントか意識しない
[送信側](#送信側)を例にすると、`RER/RES`でCubeの回転を他クライアントに送信した後に自分でCubeを回したりしない、ということ。イベント受信時に実行する操作はイベント受信時のみに行ってください。
来ないかもしれない？　遅いかもしれない？　
**PUN2を信じろ。**
#### 送信するデータのサイズに気をつける
objectはシリアライズできるものならなんでもいいです。PUN1の時代は一部のStringが文字化けしてたような気がしたので気をつけたほうがいいかも。
画像のような大きめのデータを送受信していて速度が気になる場合はMessagePackで圧縮かけると少しマシかもしれません。[^6]
パフォーマンスチューニングは百人百様なので各自がんばりましょう。
#### DontDestroyOnLoadしない
`Action`に登録されたオブジェクトのライフサイクルの管理めんどくさいすぎるので想定していません。`RER/RES`はシーンのオブジェクトとして使い捨てする運用でよいと思います。
それでも複数のシーン間で使いたいとかだったら、インスタンス破棄時に`OnDestroy`でリスナ解除とかUniRxでうまいことするとか。

## あとがき
`RaiseEventOptions`によるキャッシュの話は長くなりすぎるので割愛。ここ極めるとPUN2の自由度がぐっと上がります。
[インタレストグループ](https://doc.photonengine.com/ja-jp/pun/v2/gameplay/interestgroups)に対応するのもそう難しくないので需要がある人はてきとうに改造してください。

で、記事書き終わって気がついたんですけどコードにまったくコメント書いてなくて反省の色がねえなって思いました。
   
おしまい。


[^1]: [全ての人が刺身にタンポポを乗せなくてよくなる](https://note.mu/kotofurumiya/n/n31d401fce782)時は遠い……。余談ですがこの記事を書いた人ってすごく初学者にやさしく接した人ですよね。「他人がなにを考えてそうしているのか」を言語化するためには辛抱強く話を聞いてあげる必要があります。自分はそんなに優しくはできない。
[^2]: これ英語圏でも「パンツ」っぽく発音すると思うんですけどIT系ってむちむちのおっさんが多いですけどむちむちのおっさんたちが「パンツ」って言いまくることになると思うんですけど！！！！？！！？？！？！？
[^3]: 使ってみたかっただけ
[^4]: サンプルをチラ見して「は？　だっる」とか思って使ってなかったんですけど改めて読んだらほんっっとめんどくせえ！
[^5]: パフォーマンスを気にするならできるだけPhotonViewを削減すべし
[^6]: そも`RaiseEvent`はそういう用途で使うものではないです