Unity猫本のサンプルゲームをPhotonでオンライン対戦ゲーム化してみた

Photonのマルコポーロチュートリアルが完了したので、取得した知識を用いて読み終わっていた「Unity5の教科書 2D&3Dスマートフォンゲーム入門講座」のサンプルゲームをオンライン対戦ゲームに改造してみた。

# 想定読者
- Photonのマルコポーロチュートリアルを実施済みであること。
    - https://doc.photonengine.com/ja/pun/current/tutorials/tutorial-marco-polo
**※どうやら現在落ちてる模様。。。**
- 「Unity5の教科書 2D&3Dスマートフォンゲーム入門講座」のChapter6まで完了していること。
    - https://www.amazon.co.jp/exec/obidos/ASIN/4797386797/

※なお、公式のチュートリアルは***メンテされておらず、記載の通りでは動作しない箇所が多々あるため***「チュートリアルやってみた」系な記事と見比べつつ実施するのがおすすめ。私はこちらにお世話になりました。
[script life 千夜一夜 プログラミング別館](http://scriptlife.hacca.jp/contents/programming/category/unity/photon/)

# 目次
おおまなかな流れは下記の通り。

- [Photon接続設定](#photon接続設定)
- [プレイヤーキャラクターの同期](#プレイヤーキャラクターの同期)
- [プレイヤーキャラクターの位置同期](#プレイヤーキャラクターの位置同期)
- [プレイヤーキャラクターのアニメーション同期](#プレイヤーキャラクターのアニメーション同期)
- [プレイヤーコントローラースクリプトのその他修正](#プレイヤーコントローラースクリプトのその他修正)
- [ゲームクリア時の制御](#ゲームクリア時の制御)


# Photon接続設定
ここはほぼチュートリアル通りなので詳しい説明は割愛。
PUNセットアップウィザードによるAppIdの設定などは完了していることを前提として、空のゲームオブジェクトを作成し、ロビーへの入室処理を含む下記ソースをアタッチするのみ。

```csharp:RandomMatchMaker.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon;

public class RandomMatchMaker : Photon.PunBehaviour {

  // Use this for initialization
  void Start()
  {
    PhotonNetwork.ConnectUsingSettings("0.1");
    PhotonNetwork.logLevel = PhotonLogLevel.Full;
  }

  void OnGUI()
  {
    GUILayout.Label(PhotonNetwork.connectionStateDetailed.ToString());
  }

  public override void OnJoinedLobby()
  {
    PhotonNetwork.JoinRandomRoom();
  }

  public override void OnPhotonRandomJoinFailed(object[] codeAndMsg)
  {
    PhotonNetwork.CreateRoom(null);
  }
}
```

# プレイヤーキャラクターの同期
プレイヤーキャラクターを同期対象とするためにはPhotonNetwork.Instantiateを用いてPrefabから生成する必要がある。サンプルではプレイヤーキャラクターを事前に配置する方式となっているのでまずはそこから直していく。

## Prefabを作成
ゲームオブジェクト「cat」をベースに「catPrefab」を作成する。
Prefab作成後、ゲームオブジェクト「cat」は削除しておく。

## プレイヤーキャラクターを生成
RandomMatchMaker.csにOnJoinedRoomメソッド(部屋に入室した際に呼ばれる)を追加し、PhotonNetwork.Instantiateを用いてプレイヤーキャラクターを生成する。

スポーン位置はY軸を底の雲を考慮した固定値に、X軸をランダム(雲から落ちない程度)にした。この辺は適当でOK。

```csharp:RandomMatchMaker.cs
public override void OnJoinedRoom()
{
  Vector3 spawnPosition = new Vector3(Random.Range(-2, 2), -4.395f, 0);
  PhotonNetwork.Instantiate("catPrefab", spawnPosition, Quaternion.identity, 0);
}
```

## キャラクターの同期を確認
同期を確認するといっても、現段階ではゲーム画面に自分と相手の2人のプレイヤーが出現することを確認するのみとなる。当然ながらオンラインゲームなので複数人でアプリを起動しないと動作の確認ができない。下記方法で確認を行う。

- 適当な場所にビルド出力したアプリと、Unityで起動したアプリの二つを同時起動する。（別ファイル名で複数ビルド出力する等でももちろんOK）
- 片方を起動し、もう片方を起動した際に相手プレイヤーが現れるかを確認する。


# プレイヤーキャラクターの位置同期
位置の同期はPhotonTransformViewクラスを用いた方法で行う。Photonで提供されている本クラスを同期したいオブジェクトのコンポーネントに追加することで、プログラムをほとんど書かずに位置情報を同期できる。

- PhotonViewクラスを先程作成したcatPrefabのコンポーネントに追加
- PhotonTransformViewクラスを同様にcatPrefabのコンポーネントに追加
    - `Synchronize Position`をチェック
- PhotonViewクラスの`Observed Componets`に上記で追加したコンポーネントであるPhotonTransformViewクラスを追加

## プレイヤーコントローラースクリプトの修正
動作確認を行うと、自分のキャラクターを動かした際に相手キャラクターまで同様に動いてしまっている。
これはプレイヤーコントローラースクリプトによる動作の制御が、自分のキャラクターだけではなく相手のキャラクターにも適用されてしまっているのが原因である。

PhotonViewクラスに用意された`isMine`メソッドを使用して自分のキャラクターかどうかを判定することができるので、ボタン入力によって移動やジャンプさせる処理を本メソッドの分岐で囲むことで自分のキャラクターのみ動作するようにすることができる。

```csharp:PlayerController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour {

  PhotonView myPhotonView;

  void Start()
  {
    this.myPhotonView = GetComponent<PhotonView>();
  }

  void Update()
  {
    if (this.myPhotonView.isMine)
    {
      // 移動、ジャンプ処理
    }
  }
}
```

## 相手キャラクターのワープ対策
相手キャラクターも動作してしまう問題は解消されたが、相手キャラクターの位置が頻繁にワープしてしまう。この事象はネットワークゲーム作成における大きな課題の一つで、みなさん様々な工夫をして対応している模様。
筆者は一旦下記の方法で落ち着いたが、より良い方法や定石などがあればご教示いただきたい。

### PhotonTransformViewクラスの同期オプション調整
一般的には下記記事で紹介されているPhotonTransformViewクラスの同期オプションに`Synchronize Values`を選択した上で、`PhotonTransformView.SetSynchronizedValues`を用いて`Rigidbody2D.velocity`(速度)を同期する方法でスムーズに動作するにようなのだが、実際に試したところ本ゲームでは変わらずワープが頻繁に発生して安定しなかった。
[【PhotonCloud】 PhotonTransformViewでTransformの同期を行う](https://qiita.com/toRisouP/items/10af1e0edbfc171e2f62)

いろいろな試行錯誤の末、下記方法でワープが発生しないようになった。

- 上記記事と同様に`Synchronize Values`を選択する
- `PhotonTransformView.SetSynchronizedValues`を用いた速度の同期は行わない。（なお、`SetSynchronizedValues`を呼び出さずともtransformのpositionが同期されているのはPhotonライブラリ内のPhotonTransformViewPositionControlクラス内にて確認済み）
- `PhotornTransformView`の同期オプションはデフォルト値含め下記の通り。キャラクターの向きも同期したいので、ついでにScaleも対象にしておく。
![cap01.png](https://qiita-image-store.s3.amazonaws.com/0/40094/ef30cc03-6c74-2d15-64f2-5529d89ec551.png)

`Synchronize Values`の他に`Estimated Speed`や`Fixed Speed`という同期方法もあるが、いずれもワープは解消されなかった。（そもそも`Fixed Speed`はジャンプを行うなど等速でない動きを行う本ゲームには不向きであるのは明確だが）

### 同期の頻度の調整
PhotonTransformViewの同期オプション調整とは別に同期頻度の調整も行った。なお、この対応は諸刃の剣で、同期頻度を高くするとことでよりスムーズに同期させることが可能になるが、通信量が増えることにより帯域を圧迫してしまう。（特にモバイル環境は厳しい。。）よって最後の手段と考えるべきとのこと。

具体的にはRandomMatchMakerクラスの`Start`メソッドにて`PhotonNetwork.sendRate`および`PhotonNetwork.sendRateOnSerialize`に対して同期頻度を設定する。なお、本パラメータで設定しているのは1秒間に送信するパケット数である。(デフォルト値は15)
少しずつ増やして動作を確認していった結果、60でワープが発生せず安定して動作するようになった。

```csharp:RandomMatchMaker.cs
  // Use this for initialization
  void Start()
  {
    PhotonNetwork.ConnectUsingSettings("0.1");
    PhotonNetwork.logLevel = PhotonLogLevel.Full;
    PhotonNetwork.sendRate = 60;
    PhotonNetwork.sendRateOnSerialize = 60;
  }
```

# プレイヤーキャラクターのアニメーション同期
キャラクターの位置がスムーズに同期する状態となったが、ジャンプした際のアニメーションが同期されていないのでこちらも対応が必要になる。

## PhotonAnimatorViewクラスによる同期は動作せず。。
Photonには先ほど位置同期のために使用した`PhotonTransformView`の他に、アニメーション同期を行うための`PhotonAnimatorView`というクラスが用意されており、本クラスを同期したいオブジェクトのコンポーネントに追加することで、プログラムをほとんど書かずにアニメーションを同期できる。。。はずなのだが実際に試したところ同期されなかった。

調べたところ`PhotonAnimatorView`はTriggerの同期には対応しておらず、アニメーションの切り替えにTriggerを用いている本サンプルでは上記の方法ではアニメーションの同期はされない模様。古い記事ではあるが、こちら以降の新しい情報を見つけられなかった。
[【PhotonCloud】 PhotonAnimatorViewでAnimatorの同期を行う](https://qiita.com/toRisouP/items/f3811c221a0c2ad4546a)

公式ドキュメントに下記の記載もあり普通に同期されそうなのだが。
>トリガーパラメータを使用する場合、トリガを設定するコンポーネントがPhotonAnimatorViewより、GameObjectのコンポーネントのスタック上で高い位置にあることを確認してください トリガーは、1つのフレームの間のみ、trueとして起動されます。

## 解決策
当初アニメーションの切り替えにTriggerではなくBoolを用いることも検討したのだが、Triggerの場合に自動で行われるこの次フレームにてOFFにするという制御を自前で書く方法が分からず断念。下記記事の内容を上手く取り込めれば行けそうだったが、実現できなかった。

[【Unity】 AnimatorのTriggerをBoolを使って再現する](https://qiita.com/toRisouP/items/72c8950b53f79fadc4af)

最終的にアニメーション自体は現状のままTriggerで制御し（自前で次フレームにてOFFする必要無し）、ジャンプしたという情報をBoolで同期するという方法を取ることで解決した。詳細は下記の通り。例によってより良い方法があればご教示いただきたい。

- PlayerControllerクラスでジャンプ判定用フラグを保持。ジャンプボタン押下時にONにする。

```csharp:PlayerController.cs(修正前)
public class PlayerController : MonoBehaviour {
  void Update () {
　　　　　　　　〜省略〜
    // ジャンプする
    if (Input.GetKey(KeyCode.Space) && this.rigid2D.velocity.y == 0) {
      // ジャンプアニメーションに切り替えるトリガーをセットする
      this.animator.SetTrigger("JumpTrigger");
      // 上方向に力をかけてジャンプさせる
      this.rigid2D.AddForce (transform.up * this.jumpForce);
    }
    〜省略〜
  }
}
```

```csharp:PlayerController.cs(修正後)
public class PlayerController : MonoBehaviour {
　　　　〜省略〜
  // ジャンプ判定用フラグ
  public bool jumpFlg;
　　　　　〜省略〜

  void Update () {
　　　　　　　　〜省略〜
    // ジャンプする
    if (Input.GetKey(KeyCode.Space) && this.rigid2D.velocity.y == 0) {
      // ジャンプ判定用フラグをON
      this.jumpFlg = true;
      // ジャンプアニメーションに切り替えるTriggerをセットする（自分の操作キャラクター用）
      this.animator.SetTrigger("JumpTrigger");
      // 上方向に力をかけてジャンプさせる
      this.rigid2D.AddForce (transform.up * this.jumpForce);
    }
　　　　　　　　〜省略〜
  }
}
```

- 前述のジャンプ判定用フラグの値を同期するためのクラス(名前は適当でOKだがNetworkCharactorクラスとした)を作成し、PhotonTransformViewクラスと同様にcatPrefabのコンポーネントに追加、PhotonViewクラスの`Observed Componets`への追加を行う。

```csharp:NetworkCharactor.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon;

public class NetworkCharactor : Photon.MonoBehaviour {

  void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
  {
    // 送信処理
    if (stream.isWriting)
    {
      bool jumpFlg = GetComponent<PlayerController>().jumpFlg;
      if (jumpFlg)
      {
        Debug.Log("Send JumpFlg: " + jumpFlg);
      }
      // 送信
      stream.SendNext(jumpFlg);
      // 送信後にジャンプ判定用フラグをOFFにする。
      GetComponent<PlayerController>().jumpFlg = false;
    }
    // 受信処理
    else
    {
      // 受信
      bool jumpFlg = (bool)stream.ReceiveNext();
      if (jumpFlg)
      {
        // ジャンプアニメーションに切り替えるTriggerをセットする（相手キャラクター用）
        GetComponent<Animator>().SetTrigger("JumpTrigger");
        Debug.Log("Recieve JumpFlg: " + jumpFlg);
      }
    }
  }
}
```

`OnPhotonSerializeView`メソッドについて簡単に説明する。これはPhotonEngineから定期的（※前章で設定した同期頻度に準ずる）に呼び出されるコールバック関数で、同期したい情報の送信/受信処理を記載することができる。

ジャンプ判定フラグがON以外の場合も送信しているのが一見無駄なように見えるが、この様にしないとstream.ReceiveNext()で取得した際に、送信数と受信数が合致しないことにより下記エラーが発生してしまうので注意。

```
IndexOutOfRangeException: Array index is out of range.
PhotonStream.ReceiveNext () (at Assets/Photon Unity Networking/Plugins/PhotonNetwork/PhotonClasses.cs:1066)
NetworkCharactor.OnPhotonSerializeView (.PhotonStream stream, PhotonMessageInfo info) (at Assets/Scripts/NetworkCharactor.cs:23)
```

# プレイヤーコントローラースクリプトのその他修正
ここまででの動作確認中にいくつか不備を発見したたため追加で対応する。

## 操作ボタンの入力がプレイヤキャラクターに反映されない
稀にジャンプや移動ボタンの入力がプレイヤーキャラクターに反映されない事象が発生した。ボタン入力制御を`Update`に、Rigidbody2Dによるキャラクター移動制御を`FixedUpdate`に分割することで対応。詳細については下記を参照。

- [UpdateとFixedUpdateの使い分け](http://mediamonster.blog.fc2.com/blog-entry-22.html)
- [【Unity】Update()とFixedUpdate()には使い分けがあった!?](http://www.clrmemory.com/unity/proper-update-fixedupdate/)

## 相手キャラクターの歩行アニメーションが止まらない
歩行アニメーションの速度はキャラクターのX軸の速度を基に設定を行なっているが、前述の通りRigidbody2D.velocity(速度)を同期していないことにより正しく動作していなかった。前フレームとのX軸位置の差分から速度を算出し、その値を設定することで解決した。

## プレイヤーコントローラースクリプト完成
上記もろもろを反映したプレイヤーコントローラースクリプトの全体は下記の通り。

```csharp:PlayerController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class PlayerController : MonoBehaviour {

  Rigidbody2D rigid2D;
  Animator animator;
  PhotonView myPhotonView;
  float jumpForce = 680.0f;
  float walkForce = 30.0f;
  float maxWalkSpeed = 2.0f;

  public bool jumpFlg;
  Vector3 latestPos;
  // プレイヤーの進行方向入力キー
  int directionKey = 0;
  bool inputJump = false;

  // Use this for initialization
  void Start()
  {
    this.rigid2D = GetComponent<Rigidbody2D>();
    this.animator = GetComponent<Animator>();
    this.myPhotonView = GetComponent<PhotonView>();
  }

  // Update is called once per frame
  void Update()
  {
    // プレイヤーの進行方向を取得
    directionKey = 0;
    if (Input.GetKey(KeyCode.RightArrow))
    {
      directionKey = 1;
    }
    if (Input.GetKey(KeyCode.LeftArrow))
    {
      directionKey = -1;
    }

    // 動く方向に応じて反転
    if (directionKey != 0)
    {
      transform.localScale = new Vector3 (directionKey, 1, 1);
    }

    // ジャンプキーの入力
    if (Input.GetKeyDown(KeyCode.Space)) {
      inputJump = true; 
    }
  }

  void FixedUpdate()
  {
    // プレイヤーの速度
    // float speedx = Mathf.Abs(this.rigid2D.velocity.x);
    float speedx = Mathf.Abs((transform.position.x - latestPos.x) / Time.deltaTime);
    latestPos = transform.position;

    if (this.myPhotonView.isMine)
    {
      // ジャンプする
      if (inputJump && this.rigid2D.velocity.y == 0)
      {
        // ジャンプアニメーションに切り替えるフラグをONにする
        this.jumpFlg = true;
        this.animator.SetTrigger("JumpTrigger");
        // 上方向に力をかけてジャンプさせる
        this.rigid2D.AddForce (transform.up * this.jumpForce);
        inputJump = false;
      }

      // スピード制限
      if (speedx < this.maxWalkSpeed)
      {
        this.rigid2D.AddForce (transform.right * directionKey * this.walkForce);
      }

    }

    // アニメーション速度設定(0の場合はアニメーション無しとなる)
    if (this.rigid2D.velocity.y == 0)
    {
      // 歩行中のアニメーション速度。プレイヤーの速度に応じてアニメーション速度を変更。
      this.animator.speed = speedx / 2.0f;
    }
    else
    {
      // ジャンプ中のアニメーション速度。プレイヤーの速度とは無関係に固定値。
      this.animator.speed = 1.0f;
    }

    // 画面外に出た場合はスタート地点に戻す
    if (transform.position.y < -10)
    {
      // 加速度を初期化
      this.rigid2D.velocity = Vector2.zero;
      // 初期位置を設定
      transform.position = new Vector3(Random.Range(-2, 2), -4.395f, 0);
    }
  }

  // ゴールに到達
  void OnTriggerEnter2D(Collider2D other) {
    Debug.Log("ゴール");
    SceneManager.LoadScene ("ClearScene");
  }
  
}
```

# カメラのプレイヤー追従処理を修正
現在の状態ではカメラがプレイヤーキャラクターを正しく追従しないので修正する。原因と対応内容については下記の通り。

## 正常に動作しない原因
- 現在はカメラ用コントローラースクリプトにて`GameObject.Find`の引数に`"cat"`を指定してプレイヤーオブジェクトを取得しているが、catPrefabからプレイヤーオブジェクトを作成した場合、対象のオブジェクト名は`"catPrefab(clone)"`となる(cloneはUnityにより自動で付与される)。そのためオブジェクト名が一致せず取得できない。
- 操作キャラクターに加え、相手キャラクターのオブジェクト名も同様に`"catPrefab(clone)"`となるため、`GameObject.Find`の引数を`"catPrefab(clone)"`に修正するだけでは対象を一意に判定できない。

## 対応内容
- RandomeMatchMakerクラスでのキャラクター生成時に`"Player"`というタグを設定する処理を追加。
- `GameObject.Find`の代わりに`GameObject.FindGameObjectWithTag`を使用して取得。自分の操作キャラクターにのみ`"Player"`が付いているため追従すべきオブジェクトを正常に取得することができるようになる。

## スクリプト修正内容
```csharp:RandomMatchMaker.cs
public override void OnJoinedRoom()
{
  Vector3 spawnPosition = new Vector3(Random.Range(-2, 2), -4.395f, 0);
  PhotonNetwork.Instantiate("catPrefab", spawnPosition, Quaternion.identity, 0);
  // 操作キャラクターであることを判定するためにタグ付け
  player.tag = "Player";
}
```

```csharp:CameraController.cs
// Update is called once per frame
void Update () {
  // キャラクターを設定
  if (this.player == null)
  {
    // タグ名からキャラクターオブジェクトを取得
    this.player = GameObject.FindGameObjectWithTag("Player");
    return;
  }

  Vector3 playerPos = this.player.transform.position;

  transform.position = new Vector3(
      transform.position.x,
      playerPos.y,
      transform.position.z);
}
```

# ゲームクリア時の制御
最後にゲームクリア時の制御をオンライン対戦用に修正する。
具体的にはいずれかのプレイヤーがゴールに辿り着いたタイミングで全プレイヤーをゲームクリア画面に遷移させ、Photon接続を切断する。ゲームクリア画面でタップすると再度Photon接続から開始するという動作に修正する。

ソースの修正内容としてはClearDirectorクラスの`Start`にて`PhotonNetwork.Disconnect`を呼び出すのみでOK。
PlayerControllerクラスの`OnTriggerEnter2D`内の処理は自分のプレイヤーか相手プレイヤーかに関わらずハンドリングされるので、どちらかがクリアすれば同タイミングにてクリアシーンに遷移し、同時にPhoton接続が解除される。

```ClearDirector.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class ClearDirector : MonoBehaviour {
  
  void Start() {
    // クリア画面遷移前にPhotonNetworkとの通信を切る
    PhotonNetwork.Disconnect();
  }

  void Update () {
    if (Input.GetMouseButtonDown(0)) {
      SceneManager.LoadScene("GameScene");
    }
  }
  
}
```

# Let's Play!!
完成したゲームをプレイした動画がこちら。
見栄えが良いので4人同時プレイとしたが、起動端末が1つなので同時には動かせず。。雰囲気が伝わればと。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">プレイ動画 <a href="https://t.co/ZkPrCt5lK6">pic.twitter.com/ZkPrCt5lK6</a></p>&mdash; いりや (@iriya0624) <a href="https://twitter.com/iriya0624/status/955957825359851520?ref_src=twsrc%5Etfw">2018年1月24日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>