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

![デモ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/56c16a2b-f56b-4188-bec0-9a53b7fce061.gif)

# はじめに

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

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

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

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

本記事では公式サンプルを元に、障害物生成型2Dアクションオンライン対戦ゲームの作り方の基礎を紹介します。

### 前提記事

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

# サンプルの解説

ベースとなる公式サンプルのRazor Madnessは、最大8プレイヤーまで参加できるプラットフォーマー（2Dアクション）レーシングゲームを謳っており、今回の要件に対して非常にマッチしています。

まずはこのサンプルについて、実装を追加するにあたって必要な部分を解説していきます。

https://doc.photonengine.com/ja-jp/fusion/current/game-samples/fusion-razor-madness

## ゲームの始め方

起動したらLobbyシーンを開きます。

起動画面からネットワークトポロジーを3つから選択、名前とルームナンバー（どちらも任意）を入力しOKを押下、接続処理を待ち右上のStartを押下するとゲームが始まります。

SinglePlayerはローカルモードで、ネットワークを介さずHostモードとほぼ同じ仕様で動作します。

Host/Joinはクライアントホスト型で、Hostは自分がホストとなりJoinしてくる他プレイヤーを待ち受けます。

Hostモードでは、接続が完了すると画面上にルームナンバーが表示されます。これを共有すると同一のセッションに接続することができます。

## ゲームのルール

ゲームはステージクリア制で、全員一斉にスタートしゴールを目指します。

登録済みのステージを順番に進め、全てクリアしたらまた1ステージ目から始まります。

先にゴールすると、そのプレイヤーは観戦モードになります。観戦モードでは自分のキャラクターを操作することができず、矢印キーでプレイヤーのカメラを使って残りのプレイヤーの視界で観戦することができます。

## 実装について

シーンの遷移はBuild SettingsのScene in Buildのindexの1以降全ての順繰り固定となっています。（画像では1から5）そのためステージを追加したり増減させる場合、この設定を変更する必要があります。

![ゲームシーンの遷移順](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/fddce5fb-b5ac-1ffb-4069-f7af413d79e4.png)

# 実装方法

サンプルはゲームのループがすでにできているため、本記事ではステージの改造と、クリック位置にオブジェクトを生成する改造を行います。

## ステージの改造

まずはステージの改造です。形状はそのままでも問題ありませんが、今回はテストをしやすくするため小さめのステージを作成します。

まずは雛形であるEmpty_Levelシーンをコピーします。

ステージはTilemap機能で作られており、FrontGroundは当たり判定のある壁、Detailsは装飾、Killはダメージ判定のあるトラップといった区別があります。

LevelPremadesオブジェクト以下にゲームルール用オブジェクトがありますが、主なものはRespawnPointはリスポーンポイント、StartWallは開始時に消える壁、Finishはクリア判定となっています。Tilemapでステージの形状を変更した場合はこれらの変更を忘れないように行います。

![改造後ステージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/3bda0b55-be4e-ba42-51c8-1c269c5e6b4d.png)

## オブジェクト生成

クリックした位置にトラップオブジェクトをスポーンさせる機能を作成します。

### 入力の処理

まずはクリックした位置を取得し変換をする必要があります。

今回はTilemap上でのグリッドに合わせた生成をしたいため、通常（Photon Fusionを利用しない）であれば以下のようなコードで座標を取得することができます。

```csharp
 var worldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
 var cellPosition = _tilemap.WorldToCell(worldPosition);
```

しかし、PhotonFusionでは状態変更権限を持つもの（サーバー型であればサーバー、クライアントホスト型であればホスト）がメソッドの実行を行います。そのため`Input.mousePosition`はたとえクライアントの自機であってもホストのマウスの座標を取得することになります。

そのため、入力権限を持ったオブジェクト（≒プレイヤーオブジェクト）のみで実行される`OnInput`コールバック内で`NetworkInput`に保存しておき、それを同期するという仕組みにする必要があります。これはキーボードなどの入力も同じです。

入力権限を持っている場合はHas Imput Authorityにチェックが入ります。
![入力権限を持ったオブジェクト](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/9f2c980a-6879-ae44-9f11-ae345f99ecef.png)

これを踏まえ、InputController.csには左クリックの取得と、クリック位置をTilemap座標に変換する処理を追加します。
座標変換を行わずクリック位置を同期するという考え方もあります。しかし今回の仕様ではカメラは常に1台しか存在せず各プレイヤーのカメラ位置はそのプレイヤーしか把握できないため、この段階で変換を行います。

```InputController.cs
public class InputController : NetworkBehaviour, INetworkRunnerCallbacks
{
    [Networked]
    private NetworkButtons _prevData { get; set; }
    public NetworkButtons PrevButtons { get => _prevData; set => _prevData = value; }

    private Tilemap _tilemap;
    
    public override void Spawned()
    {
        if (Object.HasInputAuthority)
        {
            Runner.AddCallbacks(this);
        }
        
        _tilemap = GameObject.FindObjectOfType<Tilemap>();
    }

    public void OnInput(NetworkRunner runner, NetworkInput input)
    {
        InputData currentInput = new InputData();
        
        currentInput.Buttons.Set(InputButton.RESPAWN, Input.GetKey(KeyCode.R));
        currentInput.Buttons.Set(InputButton.JUMP, Input.GetKey(KeyCode.Space));
        currentInput.Buttons.Set(InputButton.LEFT, Input.GetKey(KeyCode.A));
        currentInput.Buttons.Set(InputButton.RIGHT, Input.GetKey(KeyCode.D));
        currentInput.Buttons.Set(InputButton.DOWN, Input.GetKey(KeyCode.S));
        currentInput.Buttons.Set(InputButton.LCLICK, Input.GetMouseButton(0));
        
        if (_tilemap != null)
        {
		    // マウスの位置をワールド座標に変換
            var worldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            // ワールド座標をTilemapのセル座標に変換
            var cellPosition = _tilemap.WorldToCell(worldPosition);
            currentInput.ClickedPosition = cellPosition;
        }
        
        input.Set(currentInput);
    }

	//略
}
```

また左クリックと最終的なオブジェクト生成位置としてのVector3を保存できるように、InputData構造体を修正します。

```InputData.cs
public struct InputData : INetworkInput
{
    [System.Flags]
    public enum InputButton
    {
        LEFT = 1 << 0,
        RIGHT = 1 << 1,
        DOWN = 1 << 2,
        RESPAWN = 1 << 3,
        JUMP = 1 << 4,
        LCLICK = 1 << 5,
    }
    
    public NetworkButtons Buttons;
    public Vector3 ClickedPosition;

    //略
}
```

### 生成の処理

次のObjectSpawner.csを作成し、Assets/Prefabs/Player/RigidBodyPlayer.prefabにアタッチします。

先程のInputControllerで同期したinput.ClickedPositionの場所にオブジェクトを生成する処理になります。

```ObjectSpawner.cs
public class ObjectSpawner : NetworkBehaviour
{
    private PlayerBehaviour _behaviour;
    private InputController _inputController;
    [SerializeField]
    private NetworkObject objectToSpawn;

    public override void Spawned()
    {
        _behaviour = GetBehaviour<PlayerBehaviour>();
        _inputController = GetBehaviour<InputController>();
    }
    
    public override void FixedUpdateNetwork()
    {
        if (GetInput<InputData>(out var input))
        {
            if (input.GetButtonPressed(_inputController.PrevButtons).IsSet(InputButton.LCLICK) && _behaviour.InputsAllowed)
            {
                if (objectToSpawn != null)
                {
                    Runner.Spawn(objectToSpawn, input.ClickedPosition, Quaternion.identity, Object.InputAuthority);
                }
            }
        }
    }
}
```

### 生成物について

トラップにはデフォルトのAssets/Prefabs/Obstacles/Saw_Blade.prefabを利用します。これを先程のObjectSpawnerコンポーネントのobjectToSpawnにアタッチします。

なおステージ中にあるLavaはコライダーを位置合わせしておいているだけなので、そのままでは使えません。

# デモ

以上の改造を行い動かしてみると、冒頭の動画ようなゲームが出来上がります。

![デモ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/56c16a2b-f56b-4188-bec0-9a53b7fce061.gif)

## 問題点

現在の状態ではゲーム中に好きなだけオブジェクトを置けてしまうため、設置タイミングや個数の制限をする必要があります。

![やりすぎ問題](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/eedbe76a-673c-33a9-4d25-df2c1d6d3e04.png)

またオブジェクトにバリエーションを持たせたり、プレイヤー同士で早いものがちで選択できるようにしたりといった要望もあると思います。

このあたりについては、次回以降の記事で紹介する予定です。