Photon Fusion for Unityで 障害物生成型2Dアクションオンライン対戦ゲームを作る～uGUI編

:::note warn
現在、Photon Fusionはv2がリリースされていますが、記事はPhoton Fusion v1に準拠して作成されていますのでご注意下さい。
これは記事の作成中に前触れもなく大型アップデートとなるv2が発表されたため、時間的に確認や対応ができなかったためです。
本記事のv2対応やv2そのものの情報については今後調査していく予定です。
:::


# はじめに

Photon Fusionアンバサダーのニム式です。

2Dアクションゲームにおいて、小さなステージのスタートからゴールへ移動するのを目的とするルールは非常に多く存在します。

その時間を競うオンラインレース要素、さらに各プレイヤーが任意の場所にオブジェクト生成するギミックを追加したものも存在します。

Photon Fusionを使うことで、こういったリアルタイムオンライン対戦ゲーム特有のギミックを追加することができます。

![デモ動画](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/02a36036-d3cf-f836-0378-b33f2d4406bc.gif)


以前の記事では公式サンプルを元に、ステージの改造やオブジェクトを生成する方法、その生成ルールの追加の仕方を紹介しました。

https://qiita.com/nimushiki/items/bd172b828a8a0f617a78

https://www.docswell.com/s/ni26mu/538D72-2023-12-10-192850

今回は、オブジェクトの生成をUI（uGUI）と連携する方法について紹介します。

### 前提記事

Photon Fusionの基本的な解説は以下の記事で行っていますので、そちらを参照下さい。

https://qiita.com/nimushiki/items/d6fe4f0cbe0437677fa8

### 動作確認環境

1.1.6	Fusion Razor Madness 1.1.6 Build 162

Windows 11 Home 22H2

Unity 2020.3.48f1

Fusion SDK 1.1.6 F Build 162

# UI作成の要件

今回は前項の動画に出てきている、リスポーン中に表示されるUIを作ります。UIはその中の一つをクリックすると対応するオブジェクトが生成され、マップ中に配置できるようにします。

なお、ネットワーク・トポロジーはクライアントホスト型を基準に実装しています。

# 重要な注意点

Photon Fusionでは同期する同期しないで処理が変わるため、何を同期するかについてきちんと考えておく必要があります。

基本的にUIの表示内容は同期しませんが、その操作については同期することが多くなります。

今回は「UIの表示・非表示は同期しない、UI要素の選択は同期する」必要があります。

![UCH3素材.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/5f383572-b660-221b-7cd2-f428c609d0fe.png)


それを実装するに当たって重要なポイントがいくつかあります。

### ButtonのOnclickedコールバックの結果を同期することができない

今回の仕様ではUIは各々で表示内容が異なる必要があるため、同期しないオブジェクトとして各プレイヤーキャラクターに対してCanvasを用意しています。

通常であれば、対応するButtonをクリックすればOnclickedコールバックが呼ばれ、設定された処理が行われます。

しかし、Photon FusionではOnclickedコールバックは同期してくれません。

クライアント側で対象のUIをクリックした場合でもその結果がホスト側には伝わらず、なかったことにされ同期しないという結果になります。

クライアント側で実行はされるため当然Debug.Logは出力されますが、結果は反映されないといった状態になり現状把握をややこしくさせるため注意が必要です。

![クリックが同期できない](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/3b023ebf-876c-fe6b-cc9e-4dbfb659b8cb.png)

マウスの位置とマウスのどのボタンを押下したかという情報はインプットとして同期が可能です。

今回は、事前にUI要素を別の同期オブジェクトに登録しておき、クリックしたマウスの位置の座標と対象のUI要素の重なりを判定することで、onClickコールバックのような動作を実現しています。

### 生成時のSetparentが無効になる

今回のUIは表示非表示の切り替えごとに中身の生成破棄を行います。そのため生成ごとにSetparentをする必要があります。

また各々のプレイヤーで違う内容を表示したいため1プレイヤーにつき１つUIを持っていますが、他のプレイヤーのUIは非表示にしておく必要があります。普通はStartやSpawnedのタイミングで（もしくはprefabで）SetActive(false)しておけば済みます。

しかし、これは仕様かはわかりませんが、そのタイミングでGameobjectが非アクティブ（親だけ非アクティブでも同じ）になっているとSetparentが失敗してしまいます。

そのため、オブジェクトではなく大本のCanvas自体をオンオフする必要があります。

![UIの構造](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/f4efb77b-ad42-a54c-0704-1e94fa791b28.png)

本題からは外れますが、同期オブジェクト（NetworkObjectがついたもの）でSetparentする場合は子にNetwork Transform Anchorコンポーネントをアタッチしておく必要があります。

# 実装

以上の内容を踏まえた、UI周りの実装について紹介していきます。

それ以外については、以前の記事とスライドをご参照下さい。

https://qiita.com/nimushiki/items/bd172b828a8a0f617a78

https://www.docswell.com/s/ni26mu/538D72-2023-12-10-192850

## UIの設定

まずはUIを初期設定する処理です。

`TrapUIManager.cs`の`FixedUpdateNetwork`ではゲームのステートを監視し、状態が切り替わったタイミングでUIの設定を行います。

`ResetTrapScreen`では不要になったUIオブジェクトの破棄を行います。

`SpawnRandomUIItem`では`objectToSpawnList`に登録されたUIオブジェクトの生成が行われます。

生成したオブジェクトはクリック判定のときに参照することになるため`spawnedObjectList`に登録しますが、`NetworkLinkedList`を使い同期できるようにします。

```TrapUIManager.cs
[Networked, Capacity(6)]
NetworkLinkedList<NetworkObject> spawnedObjectList { get; }

public override void FixedUpdateNetwork()
{
    currentValue = _behaviour.isRespawning() == false && _levelBehaviour.StartTimerIsExpired(Object.Runner);
    if (previousValue != currentValue)
    {
        if(currentValue)
        {
            ResetTrapScreen();
        }
        else
        {
            SpawnRandomUIItem();
        }
        previousValue = currentValue;
    }
		//略
}

private void ResetTrapScreen()
{
    foreach (var target in spawnedObjectList)
    {
        Runner.Despawn(target);
    }
    spawnedObjectList.Clear();
    trapSpawned = false;
}

private void SpawnRandomUIItem()
{
    for (int i = 0; i < uiCount; i++)
    {
        var obj = objectToSpawnList[Random.Range(0,objectToSpawnList.Count)];
        if (obj != null)
        {
             Runner.Spawn(obj,obj.transform.position,Quaternion.identity,Object.InputAuthority,Init);

             void Init(NetworkRunner runner, NetworkObject spawnedObject)
             {
                 spawnedObject.transform.SetParent(_trapIconArea);
                 spawnedObjectList.Add(spawnedObject);
             }
        }
    }
}
```

## UIの表示コントロール

次はUIの表示機能です。UIは各プレイヤー毎に表示を変化させる、つまり表示を同期しない必要があります。

そのため表示非表示を反映する機能については前述のとおり、同期を行わないオブジェクトである大本のCanvasをオンオフすることで実現します。

表示非表示の切り替えは以下のようにトラップの生成破棄フラグ`trapSpawned`で行います。フラグは生成の`CheckOverlap`と破棄の`ResetTrapScreen`で更新します。

また自分以外のプレイヤーの場合は表示する必要がないので、`Object.HasInputAuthority`で先に判定します。今回はプレイヤーオブジェクトの子にこの`TrapUIManager.cs`がついているので、自分の場合はtrue、自分以外の場合はfalseになります。

```TrapUIManager.cs
[SerializeField] private Canvas _trapUICanvas;
public override void FixedUpdateNetwork()
{
		//略

    if (Object.HasInputAuthority)
    {
        if (trapSpawned)
        {
            _trapUICanvas.enabled = false;
        }
        else
        {
            _trapUICanvas.enabled =
                !(_behaviour.isRespawning() == false && _levelBehaviour.StartTimerIsExpired(Object.Runner));
        }
    }
    else
    {
        _trapUICanvas.enabled = false;
    }
}
```

## トラップオブジェクトの生成

次はUIをクリックしてトラップを生成する処理です。

オフラインゲームの場合であればonClickコールバックを起点にする形で問題ありませんが、前述の通りその実行と結果は同期されません。

そこで代替手段として、`ObjectSpawner.cs`でゲームのステートとクリック判定を取得、`TrapUIManager.cs`の`CheckOverlap`でその位置にあるUI子要素探し、対応するトラップを返して生成します。

```ObjectSpawner.cs
public override void Spawned()
{
    _behaviour = GetBehaviour<PlayerBehaviour>();
    _inputController = GetBehaviour<InputController>();
    _levelBehaviour = FindObjectOfType<LevelBehaviour>();
    _trapUIManager = FindObjectOfType<TrapUIManager>();
}

public override void FixedUpdateNetwork()
{
    if (GetInput<InputData>(out var input))
    {
        if (input.GetButtonPressed(_inputController.PrevButtons).IsSet(InputButton.LCLICK))
        {
            if (_behaviour.isRespawning() || _levelBehaviour.StartTimerIsExpired(Runner) == false)
            {
                var target = _trapUIManager.CheckOverlap(input.MousePosition);
                if (target != null)
                {
                    Runner.Spawn(target, input.MousePosition, Quaternion.identity, Object.InputAuthority);
                }
            }
        }
    }
}
```

```TrapUIManager.cs
public NetworkObject CheckOverlap(Vector3 mousePosition)
{
    NetworkObject result = null;
    
    if (spawnedObjectList.Count == 0 || trapSpawned) return null;
    
    var pos = Camera.main.WorldToScreenPoint(mousePosition);
    foreach (var value in spawnedObjectList)
    {
        if (RectTransformUtility.RectangleContainsScreenPoint(value.GetComponent<RectTransform>(), pos))
        {
            result = value.GetComponent<TrapUIItem>().GetTrapObject();
        }
    }

    if (result != null)trapSpawned = true;
    return result;
}
```

```TrapUIItem.cs
//個別のトラップにアタッチ
//trapObjectは生成したいPrefabを設定
public NetworkObject GetTrapObject()
{
    return trapObject;
}
```

![UI子要素](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/6842fe3e-15f0-803e-f173-ac877a223d9f.png)

## トラップの設置

最後にトラップの設置機能です。

前項で生成したトラップは、マウスへの追従と設置機能を持ちます。また設置できるかどうかと設置済みかどうかをビジュアル化するため、SpriteRenderer.colorで色を変化させています。


```TrapController.cs
public override void Spawned()
{
    isFollowing = true;
    _collider = GetComponent<Collider2D>();
    _collider.enabled = false;
    _spriteRenderer = GetComponentInChildren<SpriteRenderer>();
    _tilemap = FindObjectsOfType<Tilemap>().First(x => x.name == "Background");
    _inputController = Runner.GetPlayerObject(Object.InputAuthority).GetBehaviour<PlayerData>().Instance.GetBehaviour<InputController>();
}

public override void FixedUpdateNetwork()
{
    if (GetInput<InputData>(out var input))
    {
        if (isFollowing)
        {
            //マウスに追従＆マスにスナップ
            var cellPosition = _tilemap.WorldToCell(input.MousePosition);
            transform.position = cellPosition + new Vector3(0.5f,0.5f,0);
            
            //設置不可の範囲では色を変える
            if (_tilemap.GetTile(cellPosition) == null)
            {
                _spriteRenderer.color = Color.red;
            }
            else
            {
                _spriteRenderer.color = Color.blue;
                
                //クリックしたら配置
                //設置不可領域でクリックしたら無効
                if (input.GetButtonPressed(_inputController.PrevButtons).IsSet(InputButton.LCLICK))
                {
                    isFollowing = false;
                    _collider.enabled = true;
                    _spriteRenderer.color = Color.white;
                }
            }
        }
    }
}
```
# まとめ
UI操作の結果を同期するという、やってそうでやってなかった、かつよく使われるそうな内容を紹介しました。同期するものとしないものを跨いだ処理のため、非常にややこしくなる可能性があります。（デバッグログは表示されるが同期されない、という状況に非常に頭を悩ませました）
未検証ですが、Canvasを一本化したり同期するのをオブジェクトではなくIDにしたりと、もう少し簡素にする方法があるかもしれません。