2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unity Photon Fusion入門 後編

Last updated at Posted at 2025-11-02

はじめに

この記事は,「Unity Photon Fusion入門 前編(サンプルプロジェクト付き)」の後編です.
解説するプロジェクトのゲームについて説明しているので,前編を見てからか,サンプルを動かしてから見てもらえると良いと思います!

今回はPhoton Fusion2の共有モードを扱います.

前編はこちら

ポイントについて

サンプルプロジェクトは見られないけど,重要なとこだけ知りたい!と言う方は,下記のポイント目次を使ってください.

ポイント1 スポーンの方法
ポイント2 マッチングエラーの取得
ポイント3 NetworkRunnerへの簡単なアクセス方法
ポイント4 PlayerIdについて
ポイント5 セッション中のシーン遷移
ポイント6 コールバックの解読
ポイント7 デスポーンできる人
ポイント8 遅延を感じさせないための工夫
ポイント9 NetworkTransformとNetworkRigidbodyの違い
ポイント10 ネットワークプロパティの代入

サンプルプロジェクトについて

Photonを解説するにあたり,実際に動く超シンプルなプロジェクトを公開しています.DLして,Unityで動かしてもらってからこの記事を読んでいただくと,より理解しやすいと思うので活用してみてください.
Unityroomでのプレイもできます(2人以上でないと遊べないので,ブラウザを複数起動して遊んでください).

Unityバージョンは,6000.0.48f1です.
Github: https://github.com/Keigo77/MultiMathGame
Unityroom: https://unityroom.com/games/samplemulti

前編で,Photonプロジェクトの実行方法(Photonの開発が初めての方向け),ゲーム内容の説明をしていますので,読んでみてください.

サンプルゲームのコード説明

前編で説明した画面の順に解説していきます.

タイトル画面

タイトル画面で使用しているスクリプトは2つです.

PlayerInfoManager.cs
プレイヤー情報の保存.
MatchingLauncher.cs
マッチング開始の処理.

PlayerInfoManager.cs

まずは,PlayerInfoManagerから.

PlayerInfoManager.cs
using UnityEngine;

public class PlayerInfoManager : MonoBehaviour
{
    public static string PlayerName { get; private set; }
    public static Color PlayerColor;

    /// <summary>
    /// タイトルシーンのユーザーネーム入力欄の中身を変更するごとに,ユーザーネームを更新.
    /// </summary>
    public void UserNameInputFieldOnValueChanged(string value)
    {
        PlayerName = value;
    }
}

プレイヤー名もプレイヤーの色も,シーンを跨いで保持させたいので,public staticで宣言しています.プレイヤー名入力のTextFieldのOnValueChangedには,UserNameInputFieldOnValueChanged関数を指定し,文字を入力するごとにプレイヤー名が保存されます.

MatchingLauncher.cs

次に,MatchingLauncherです.
これ以降,ファイル全体は折りたたみで表示します.

MatchingLauncherのコード全体
MatchingLauncher.cs
using Fusion;
using TMPro;
using UnityEngine;

public class MatchingLauncher : MonoBehaviour
{
    [SerializeField] private NetworkRunner _networkRunnerPrefab;
    [SerializeField] private TMP_InputField _roomNameInputField;
    [SerializeField] private int _waitRoomSceneIndex;

    void Awake()
    {
        Application.targetFrameRate = 60;
    }
    
    public async void RandomMatchButtonOnClick()
    {
        var networkRunner = Instantiate(_networkRunnerPrefab);
        
        // 共有モードのセッションに参加する
        var result = await networkRunner.StartGame(new StartGameArgs {
            GameMode = GameMode.Shared,
            PlayerCount = 4,
            Scene = SceneRef.FromIndex(_waitRoomSceneIndex)
        });
        
        // 結果をコンソールに出力する
        Debug.Log(result);
    }
    
    public async void PrivateMatchButtonOnClick()
    {
        var networkRunner = Instantiate(_networkRunnerPrefab);
        
        // 共有モードのセッションに参加する.同じパスワードを入力した人同士でしかマッチングしない.
        var result = await networkRunner.StartGame(new StartGameArgs {
            GameMode = GameMode.Shared,
            SessionName = _roomNameInputField.text,
            PlayerCount = 4,
            IsVisible = false,
            Scene = SceneRef.FromIndex(_waitRoomSceneIndex)
        });
        
        // 結果をコンソールに出力する
        Debug.Log(result);
    }
}

各マッチングボタンの処理は,下記の関数で行っています.

MatchingLauncher.cs
// ランダムマッチングの処理
public async void RandomMatchButtonOnClick()
{
    var networkRunner = Instantiate(_networkRunnerPrefab);
    
    // 共有モードのセッションに参加する
    var result = await networkRunner.StartGame(new StartGameArgs {
        GameMode = GameMode.Shared,
        PlayerCount = 4,
        Scene = SceneRef.FromIndex(_waitRoomSceneIndex)
    });
    
    // 結果をコンソールに出力する
    Debug.Log(result);
}

// プレイベートマッチングの処理
public async void PrivateMatchButtonOnClick()
{
    var networkRunner = Instantiate(_networkRunnerPrefab);
    
    // 共有モードのセッションに参加する.同じパスワードを入力した人同士でしかマッチングしない.
    var result = await networkRunner.StartGame(new StartGameArgs {
        GameMode = GameMode.Shared,
        SessionName = _roomNameInputField.text,
        PlayerCount = 4,
        IsVisible = false,
        Scene = SceneRef.FromIndex(_waitRoomSceneIndex)
    });
    
    // 結果をコンソールに出力する
    Debug.Log(result);
}

ポイントとしては,どちらのマッチングでもSceneを指定しています.
Sceneを指定すると,遷移先のシーンに配置してあるネットワークオブジェクト(NetworkObjectがアタッチされたオブジェクト)を自動でスポーンし,シーン遷移もしてくれます.この方法でスポーンさせたオブジェクトを,シーンオブジェクトといいます.

ポイント1 スポーンの方法

ネットワークオブジェクトをスポーンする方法は2つあります.
方法1. NetworkRunner.Spawn()でスポーンさせる
方法2. NetworkRunner経由でシーンをロードし,遷移先のネットワークオブジェクトをスポーンさせる.(後ほど解説しますが,セッション作成後でもこの方法が使えます.)

方法2がかなり便利で,早く知っておきたかったです...
どちらの方法を使うかは,スポーンさせたいネットワークオブジェクトの特徴によって使い分けます.
ここで,個人的な使い分け方を紹介します.

<方法1: NetworkRunner.Spawn()>

・オブジェクトの状態権限を,ホスト以外に持たせるとき
方法2でスポーンさせたオブジェクトの状態権限はホストになるため,状態権限をホスト以外に持たせたければ方法1でスポーンさせるしかありません.(他プレイヤーが状態権限をリクエストし,譲渡する場合は方法2でも良いと思います.)
・オブジェクトを動的にスポーンさせたいとき
方法2は,シーン遷移と同時にスポーンさせるため,シーン遷移後の特定のタイミングでスポーンさせたい場合は方法1になります.

<方法2: NetworkRunner経由でシーンをロード>

・シーン遷移後すぐに,確実に取得したいオブジェクトのとき
後ほども詳しく記載しますが,例えばGameManagerです.あるオブジェクトAが,Start関数内でGameManagerを使いたいとします.このとき,シーン遷移が終わってからGameManagerをスポーンさせていると(方法1),Start関数実行までにスポーンが間に合わず,オブジェクトAがFind系関数でGameManager取得しようとしても,nullを返されることがあります.
例えばですが,最初からGameManagerをシーンに配置し,オブジェクトAにアサインしておく.それから方法2でシーン遷移することで,GameManagerをノーコードでスポーンさせられるし,オブジェクトAも確実にGameManagerを参照できます.
・シーン内に1つだけ存在していればいいオブジェクトのとき
これもGameManagerなどが当てはまるかなと思います.方法1でこれを実現しようとすると,シーン遷移後に,ホストだけがGameManagerをスポーンさせるという処理が必要になります.
なので,シーン上に1つあればいいオブジェクトの場合は,方法2が有効です.

ポイント2 マッチングエラーの取得

どちらのマッチング処理も,最後にresultを表示しています.

MatchingLauncher.cs
Debug.Log(result)

「reslut.Ok」で,セッションの作成 or 参加が問題なく行えたかを,bool型で取得することができます.
例えば,インターネットに接続していない状態だとfalseが返されます.

待機所画面

待機所画面にあるスクリプトは1つです.

WaitRoom.cs
プレイヤーの入室処理・バトル画面への遷移処理・退室処理.

なお,WaitRoomはシーンオブジェクトなので,シーン遷移時に自動でスポーンします.

これ以降のファイルの全体コードは,折りたたみで表示し,説明したい関数のみ掲載します.

WaitRoom.cs

WaitRoom.csのコード全体
WaitRoom.cs
using System;
using System.Collections.Generic;
using Fusion;
using Fusion.Sockets;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class WaitRoom : NetworkBehaviour, INetworkRunnerCallbacks
{
    public static int JoinedPlayer = 1;
    [SerializeField] private NetworkPrefabRef _playerPrefab;
    [SerializeField] private Color[] _playerColors;
    [SerializeField] private Button _startButton;

    // GameLauncherでSceneを指定することで,自動でこのオブジェクト(NetworkObject)がスポーンする.
    public override void Spawned()
    {
        // NetworkBehaviourを継承していれば,RunnerでNetworkRunnerにアクセス可能.
        Runner.AddCallbacks(this);

        // ホストなら,スタートボタンを表示する.
        if (Runner.IsSharedModeMasterClient)
        {
            _startButton.gameObject.SetActive(true);
        }
        
        // プレイヤーのスポーン
        Runner.Spawn(_playerPrefab, onBeforeSpawned: (_, playerObj) =>
        {
            PlayerController playerController = playerObj.GetComponent<PlayerController>();
            playerController.PlayerName = PlayerInfoManager.PlayerName;

            // プレイヤーの色の更新.現在部屋にいるプレイヤーの数を取得し,入ってきた順番で色を決定する.(だが,このままでは数人が出入りすると,同じ色になる.)
            playerController.PlayerColor = _playerColors[Runner.LocalPlayer.PlayerId % 4 - 1];
            
            // Mainシーンでも同じ色を使いたいため,色を保存する.
            PlayerInfoManager.PlayerColor = _playerColors[Runner.LocalPlayer.PlayerId % 4 - 1];
        });
        
        // プレイヤーの入室を許可する.
        Runner.SessionInfo.IsOpen = true;
        CheckCanStartGame();
    }
    
    public void StartButtonOnClick()
    {
        // IsOpenをfalseにしてからMainに行かないと,プレイ中にMainシーンに人が入って来れるようになる.
        Runner.SessionInfo.IsOpen = false;
        JoinedPlayer = Runner.SessionInfo.PlayerCount;
        Runner.LoadScene("Main");
        // SceneManager.LoadScene("Main");だと,押した人しかシーン遷移しなかつ,遷移先のネットワークオブジェクトがスポーンされない..
    }
    
    public void BackButtonOnClick()
    {
        Runner.Shutdown();
        SceneManager.LoadScene("Home");
    }

    private void OnDisable()
    {
        // OnDisableのタイミングでRemoveCallbacksすると,MainでPlayerOnLeftが呼ばれなくなる.
        // StartButtonOnClickでRemoveCallbacksすると,呼ばれてしまう.=>ホストしかコールバックを解除していないから.
        if (Runner != null)
        {
            Runner.RemoveCallbacks(this);
            Debug.Log("Remove Callbacks");
        }
    }

    private void CheckCanStartGame()
    {
        // ホストかつ,部屋に2人以上いるなら,スタートボタンを押せるようにする.
        if (Runner.IsSharedModeMasterClient && Runner.SessionInfo.PlayerCount >= 2)
        {
            _startButton.interactable = true;
        }
    }
    
    
    
    // -----------------INetworkRunnerCallbacks-------------------------

    void INetworkRunnerCallbacks.OnPlayerJoined(NetworkRunner runner, PlayerRef player)
    {
        CheckCanStartGame();
    }

    // ここでrunnerでなく,Runnerを使うと,Runnerはnullなのでエラーが出る
    void INetworkRunnerCallbacks.OnPlayerLeft(NetworkRunner runner, PlayerRef player)
    {
        Debug.Log(player);
        if (runner.IsSharedModeMasterClient && runner.SessionInfo.PlayerCount < 2)
        {
            _startButton.interactable = false;
        }
    }
    
    void INetworkRunnerCallbacks.OnObjectExitAOI(NetworkRunner runner, NetworkObject obj, PlayerRef player) {}
    void INetworkRunnerCallbacks.OnObjectEnterAOI(NetworkRunner runner, NetworkObject obj, PlayerRef player) {}
    void INetworkRunnerCallbacks.OnInput(NetworkRunner runner, NetworkInput input) {}
    void INetworkRunnerCallbacks.OnInputMissing(NetworkRunner runner, PlayerRef player, NetworkInput input) {}
    void INetworkRunnerCallbacks.OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) {}
    void INetworkRunnerCallbacks.OnConnectedToServer(NetworkRunner runner) {}
    void INetworkRunnerCallbacks.OnDisconnectedFromServer(NetworkRunner runner, NetDisconnectReason reason) {}
    void INetworkRunnerCallbacks.OnConnectRequest(NetworkRunner runner, NetworkRunnerCallbackArgs.ConnectRequest request, byte[] token) {}
    void INetworkRunnerCallbacks.OnConnectFailed(NetworkRunner runner, NetAddress remoteAddress, NetConnectFailedReason reason) {}
    void INetworkRunnerCallbacks.OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) {}
    void INetworkRunnerCallbacks.OnSessionListUpdated(NetworkRunner runner, List<SessionInfo> sessionList) {}
    void INetworkRunnerCallbacks.OnCustomAuthenticationResponse(NetworkRunner runner, Dictionary<string, object> data) {}
    void INetworkRunnerCallbacks.OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken) {}
    void INetworkRunnerCallbacks.OnReliableDataReceived(NetworkRunner runner, PlayerRef player, ReliableKey key, ArraySegment<byte> data) {}
    void INetworkRunnerCallbacks.OnReliableDataProgress(NetworkRunner runner, PlayerRef player, ReliableKey key, float progress) {}
    void INetworkRunnerCallbacks.OnSceneLoadDone(NetworkRunner runner) {}
    void INetworkRunnerCallbacks.OnSceneLoadStart(NetworkRunner runner) {}
}

まず,プレイヤーをスポーンさせるSpawned関数から説明します.

WaitRoom.cs
    public override void Spawned()
    {
        // NetworkBehaviourを継承していれば,RunnerでNetworkRunnerにアクセス可能.
        Runner.AddCallbacks(this);

        // ホストなら,スタートボタンを表示する.
        if (Runner.IsSharedModeMasterClient)
        {
            _startButton.gameObject.SetActive(true);
        }
        
        // プレイヤーのスポーン
        Runner.Spawn(_playerPrefab, onBeforeSpawned: (_, playerObj) =>
        {
            PlayerController playerController = playerObj.GetComponent<PlayerController>();
            playerController.PlayerName = PlayerInfoManager.PlayerName;

            // プレイヤーの色の更新.現在部屋にいるプレイヤーの数を取得し,入ってきた順番で色を決定する.(だが,このままでは数人が出入りすると,同じ色になる.)
            playerController.PlayerColor = _playerColors[Runner.LocalPlayer.PlayerId % 4 - 1];
            
            // Mainシーンでも同じ色を使いたいため,色を保存する.
            PlayerInfoManager.PlayerColor = _playerColors[Runner.LocalPlayer.PlayerId % 4 - 1];
        });
        
        // プレイヤーの入室を許可する.
        Runner.SessionInfo.IsOpen = true;
        CheckCanStartGame();
    }

WaitRoomでは,INetworkRunnerCallbacksを使用したいので,AddCallbackしています.

WaitRoom.cs
Runner.AddCallbacks(this);

各コールバックについては,下記サイトのリファレンスで確認することができます.

ポイント3 NetworkRunnerへの簡単なアクセス方法

ちなみに,NetworkBehaviourを継承しているクラスであれば,「Runner」だけでNetworkRunnerにアクセスすることができます.便利!

その後,ホストのみにスタートボタンを表示させます.

WaitRoom.cs
// ホストなら,スタートボタンを表示する.
if (Runner.IsSharedModeMasterClient)
{
    _startButton.gameObject.SetActive(true);
}

そしてプレーヤーをスポーンさせます.

WaitRoom.cs
// プレイヤーのスポーン
Runner.Spawn(_playerPrefab, onBeforeSpawned: (_, playerObj) =>
{
    PlayerController playerController = playerObj.GetComponent<PlayerController>();
    playerController.PlayerName = PlayerInfoManager.PlayerName;

    // プレイヤーの色の更新.現在部屋にいるプレイヤーの数を取得し,入ってきた順番で色を決定する.(だが,このままでは数人が出入りすると,同じ色になる.)
    playerController.PlayerColor = _playerColors[Runner.LocalPlayer.PlayerId % 4 - 1];
    
    // Mainシーンでも同じ色を使いたいため,色を保存する.
    PlayerInfoManager.PlayerColor = _playerColors[Runner.LocalPlayer.PlayerId % 4 - 1];
});

プレイヤースポーン時に,PlayerController.PlayerNameというネットワークプロパティに,タイトル画面で入力した自分の名前を代入します.これで,同じ部屋のプレイヤーに自分の名前を共有できます.
PlayerControllerのコードはこちら
その後,プレイヤーの色を決定します.今回はRunner.LocalPlayer.PlayerIdを使って,色を決定しています.

ポイント4 PlayerIdについて

Runner.LocalPlayer.PlayerIdは,入室した順に番号が割り振られますが,退出したプレイヤーを含んだ順で番号が割り振られるので,注意しましょう.
下記例で言うと,CのIDが2ではなく,3になることに注意しましょう.
例)
Aが入室(ID = 1) → Bが入室(ID = 2) → Bが退室 → Cが入室(ID = 3)

次に,コールバック処理を見てみましょう.

WaitRoom.cs
private void CheckCanStartGame()
{
    // ホストかつ,部屋に2人以上いるなら,スタートボタンを押せるようにする.
    if (Runner.IsSharedModeMasterClient && Runner.SessionInfo.PlayerCount >= 2)
    {
        _startButton.interactable = true;
    }
}

// -----------------INetworkRunnerCallbacks-------------------------

void INetworkRunnerCallbacks.OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
    CheckCanStartGame();
}

void INetworkRunnerCallbacks.OnPlayerLeft(NetworkRunner runner, PlayerRef player)
{
    Debug.Log($"{player}が退室しました.");
    if (runner.IsSharedModeMasterClient && runner.SessionInfo.PlayerCount < 2)
    {
        _startButton.interactable = false;
    }
}

OnPlayerJoinedは,新しいプレイヤーが入室してくる度に実行します.
2人以上プレイヤーがいたら,Startボタンを押せるようにしたいので,プレイヤーが入室したタイミングでStartボタンを押せるようにするかを,ホストだけがチェックします.

OnPlayerLeftは,プレイヤーが退室する度に実行されます.
このときにも,Startボタンが押せるかどうかチェックします.

次に,BackボタンとStartボタンの処理を見ます.

WaitRoom.cs
public void StartButtonOnClick()
{
    // IsOpenをfalseにしてからMainに行かないと,プレイ中にMainシーンに人が入って来れるようになる.
    Runner.SessionInfo.IsOpen = false;
    JoinedPlayer = Runner.SessionInfo.PlayerCount;
    Runner.LoadScene("Main");
    // SceneManager.LoadScene("Main");だと,押した人しかシーン遷移しなかつ,遷移先のネットワークオブジェクトがスポーンされない..
}

public void BackButtonOnClick()
{
    Runner.Shutdown();
    SceneManager.LoadScene("Home");
}

Startボタン挿下時,SessionInfo.IsOpenをfalseにします.これをfalseにしてからバトルを開始しないと,バトル開始後にプレイヤーが入室できるようになってしまいます.

そしていよいよバトルシーンに遷移します.

ポイント5 セッション中のシーン遷移

先ほど,ポイント1でオブジェクトのスポーン方法は2種類あると書きました.シーン遷移時にスポーンさせる方法2がありましたが,あれはセッション作成時でなくても使用できます!

WaitRoom.cs
Runner.LoadScene("Main");

のように,Runner.LoadSceneを使うことで,Mainシーンのネットワークオブジェクトを自動でスポーンさせることができます.激推しポイントです!!
さらに,部屋内のプレイヤー全員をシーン遷移させることができるのもポイントです.

Runner.LoadSceneは,ホストのみが実行できます.
ホスト以外が実行すると,
「InvalidOperationException: The runner does not have the scene authority」
のエラーが発生するので,ホスト以外が実行できないようにifやRpcを使いましょう.

最後に,コールバックの解読です.

WaitRoom.cs
private void OnDisable()
{
    if (Runner != null)
    {
        Runner.RemoveCallbacks(this);
        Debug.Log("Remove Callbacks");
    }
}

ポイント6 コールバックの解読

別のシーンに遷移するタイミングで,プレイヤー全員がコールバックを解除した方が良いです.
なぜなら,解除しないと次のシーン(Main)でプレイヤーが退出した際,WaitRoomのOnPlayerLeftが呼ばれます.つまり,Mainシーンには存在しないスクリプトのコールバックが呼ばれてしまうためです.

バトル画面

バトル画面では,5つのスクリプトが使われています.

PlayerSpawner.cs
プレイヤーのスポーンと,プレイヤーが全員集まったかをチェックする.
DamagePoint.cs
前編で説明した,黄色いひし形の処理.プレイヤーが触れると,そのプレイヤー以外の全員にダメージを与える.
DamagePointGenerator.cs
一定時間おきに,黄色いひし形をスポーンさせる.
PlayerController.cs
プレイヤー関連の処理.
GameManager.cs
勝敗判定を行う.

PlayerSpawner.cs

まず,PlayerSpawnerから説明します.

PlayerSpawnerのコード全体
PlayerSpawner
using Fusion;
using UnityEngine;

public class PlayerSpawner : NetworkBehaviour
{
    [SerializeField] private NetworkPrefabRef _playerPrefab;
    [SerializeField] private GameManager _gameManager;
    
    public override void Spawned()
    {
        var playerAvatar = Runner.Spawn(_playerPrefab, onBeforeSpawned: (_, playerObj) =>
        {
            PlayerController playerController = playerObj.GetComponent<PlayerController>();
            playerController.PlayerName = PlayerInfoManager.PlayerName;
            playerController.PlayerColor = PlayerInfoManager.PlayerColor;
            playerController.Init(_gameManager);
        });
            
        // 自分自身のプレイヤーオブジェクトを設定する.(他のプレイヤーが,簡単に他Playerを取得できるようになる)
        Runner.SetPlayerObject(Runner.LocalPlayer, playerAvatar);
        PlayerCountCheck();
    }

    private void PlayerCountCheck()
    {
        if (WaitRoom.JoinedPlayer == Runner.SessionInfo.PlayerCount)
        {
            _gameManager.RpcStartGame();
        }
    }
}

PlayerSpawnerはシーンオブジェクトなので,遷移と同時にスポーンします.
では,Spawned関数から見てみましょう.

PlayerSpawner.cs
public override void Spawned()
{
    var playerAvatar = Runner.Spawn(_playerPrefab, onBeforeSpawned: (_, playerObj) =>
    {
        PlayerController playerController = playerObj.GetComponent<PlayerController>();
        playerController.PlayerName = PlayerInfoManager.PlayerName;
        playerController.PlayerColor = PlayerInfoManager.PlayerColor;
        playerController.Init(_gameManager);
    });
        
    // 自分自身のプレイヤーオブジェクトを設定する.(他のプレイヤーが,簡単に他Playerを取得できるようになる)
    Runner.SetPlayerObject(Runner.LocalPlayer, playerAvatar);
    PlayerCountCheck();
}

WaitRoom.csの処理と似ていますが,異なる点はプレイヤーにGameManagerを渡している点です.onBeforeSpawnedのタイミングで渡すことで,PlayerControllerでも安全に渡すことができています.

このInit()では,本人のアバターでしか実行されていません.別のプレイヤーのGameManagerを使おうとするとnullエラーが出るので,GameManagerの関数は本人が実行するようにしましょう.

PlayerSpawner.cs
// 自分自身のプレイヤーオブジェクトを設定する.(他のプレイヤーが,簡単に他Playerを取得できるようになる)
Runner.SetPlayerObject(Runner.LocalPlayer, playerAvatar);

その後,スポーンさせたプレイヤーオブジェクトを「PlayerObject」として設定します.PlayerObjectに設定すると,他のプレイヤーが自分以外のプレイヤーオブジェクト
(= PlayerController)を取得することができます.後ほど詳しく見てみます.

PlayerSpawner.cs
private void PlayerCountCheck()
{
    if (WaitRoom.JoinedPlayer == Runner.SessionInfo.PlayerCount)
    {
        _gameManager.RpcStartGame();
    }
}

SetPlayerObjectの後は,PlayerCountCheck()を行います.
WaitRoomシーンでStartボタンを挿下した瞬間の人数を参加人数として設定してあるので,Mainシーンに全員入ってきたか確認します.全員が入ってきてから,ゲームを開始します.

DamagePoint.cs

次に,DamagePointの説明をします.
なお,DamagePointのオブジェクト(黄色いひし形)には,「NetworkTransform」のコンポーネントがついています.これにより,誰かが黄色いひし形をスポーンさせるだけで全プレイヤーに位置情報が共有され,同じ位置に生成されます.

DamagePointのコード全体
DamagePoint.cs
using Fusion;
using UnityEngine;

public class DamagePoint : NetworkBehaviour
{
    private NetworkObject _networkObject;
    
    public override void Spawned()
    {
        _networkObject = this.GetComponent<NetworkObject>();
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag("Player"))
        {
            // デスポーンまでに遅延があり,2回触れることもできるため,誰かが触れたら即座に非表示にする.
            this.gameObject.SetActive(false);
            
            if (collision.gameObject.GetComponent<NetworkObject>().HasStateAuthority)
            {
                foreach (var player in Runner.ActivePlayers) {
                    if (Runner.TryGetPlayerObject(player, out var playerObj) && player != Runner.LocalPlayer)
                    {
                        playerObj.GetComponent<PlayerController>().RpcDamage();
                    }
                }
            
                RpcDespawn();
            }
        }
    }

    /// <summary>
    /// このオブジェクトを生成したホストしかでスポーンさせることができないので,デスポーンをホストに依頼する.
    /// </summary>
    [Rpc(RpcSources.All, RpcTargets.StateAuthority)]
    private void RpcDespawn()
    {
        Runner.Despawn(_networkObject);
    }
}

プレイヤーがDamagePoint(黄色のひし形)に触れたら,他のプレイヤー全員にダメージを与える処理を見てみましょう.

DamagePoint.cs
private void OnTriggerEnter2D(Collider2D collision)
{
    if (collision.CompareTag("Player"))
    {
        // デスポーンまでに遅延があり,複数回触れることもできてしまうため,誰かが触れたら即座に非表示にする.
        this.gameObject.SetActive(false);

        // 触れたプレイヤーが自分なら(触れたプレイヤーの状態権限所持者が自分なら)
        if (collision.gameObject.GetComponent<NetworkObject>().HasStateAuthority)
        {
            // 自分以外のプレイヤーオブジェクトを取得し,ダメージを与える.
            foreach (var player in Runner.ActivePlayers) {
                if (Runner.TryGetPlayerObject(player, out var playerObj) && player != Runner.LocalPlayer)
                {
                    playerObj.GetComponent<PlayerController>().RpcDamage();
                }
            }
        
            RpcDespawn();
        }
    }
}

まず,DamagePointに触れたオブジェクトが,プレイヤーかどうか判定します.
プレイヤーの場合は,即座にオブジェクトを非表示にします(後ほど解説).そして,触れたプレイヤーが自分だった場合,プレイヤー全員のPlayerControllerを取得し,自分以外のプレイヤーにダメージを与えています.
ダメージを与え終わったら,このオブジェクトの状態権限を持ったホストにデスポーンを依頼します.

ポイント7 デスポーンできる人

オブジェクトの状態権限を持ったプレイヤーしか,そのオブジェクトをデスポーンさせることができません.

DamagePoint.cs
/// <summary>
/// このオブジェクトを生成したホストしかでスポーンさせることができないので,デスポーンをホストに依頼する.
/// </summary>
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
private void RpcDespawn()
{
    Runner.Despawn(_networkObject);
}

プレイヤー全員がデスポーンを依頼する可能性があり,かつ依頼されるのはデスポーンを行えるホストだけなので,[Rpc(RpcSources.All, RpcTargets.StateAuthority)]にしています.

このOnTriggerEnter2D関数は,どのプレイヤーが黄色いひし形に触れても,全員実行されることに注意してください.そのため,自分自身が触れたのかどうかを途中で判定しています.

ポイント8 遅延を感じさせないための工夫

誰かが黄色いひし形に触れると,すぐにオブジェクトを非表示にしています.

DamagePoint.cs
// デスポーンまでに遅延があり,2回触れることもできるため,誰かが触れたら即座に非表示にする.
this.gameObject.SetActive(false);

これは,ホストがデスポーンしてくれるまでに遅延があるためです.非表示にしないと,ホスト以外の画面には黄色いオブジェクトが数秒残ってしまい,1度に何度も触ることができてしまいます.試してみたい方は,この行をコメントアウトして実行してみると良いでしょう.
このように,できるだけプレイヤーに遅延を感じさせないかつ,バグが起こらない設計をするようにしましょう.

DamagePointGenerator.cs

次に,DamagePointGeneratorの説明をします.
DamagePointGeneratorはシーンオブジェクトなので,遷移と同時にスポーンします.

DamagePointGeneratorのコード全体
DamagePointGenerator.cs
using Fusion;
using UnityEngine;

public class DamagePointGenerator : NetworkBehaviour
{
    [SerializeField] private NetworkPrefabRef _calcPointPrefab;
    [SerializeField] private float _generateSpan;
    private int _startTick;     // 開始時ティック
    [SerializeField] private GameManager _gameManager;
    
    public override void Spawned() {
        if (HasStateAuthority) {
            // スポーン時の現在ティックを、開始ティックとして設定する
            _startTick = Runner.Tick;
        }
    }

    public override void Render() {
        
        if (!HasStateAuthority) { return; }
        
        float elapsedTime = (Runner.Tick - _startTick) * Runner.DeltaTime;
        if (elapsedTime >= _generateSpan && _gameManager.IsGameStarted)
        {
            // 経過時間のリセット.elapsedTime=0としても,Render内では実行されない.
            _startTick = Runner.Tick;
            
            var randX = Random.Range(-8f, 8f);
            var randY = Random.Range(-3f, 4f);

            // NetworkTransformを付けて,Positionを共有する.
            Runner.Spawn(_calcPointPrefab, new Vector2(randX, randY));
        }
    }
}

DamagePointGenerator.cs
public override void Spawned() {
    if (HasStateAuthority) {
        // スポーン時の現在ティックを、開始ティックとして設定する
        _startTick = Runner.Tick;
    }
}

public override void Render() {
    
    if (!HasStateAuthority) { return; }
    
    float elapsedTime = (Runner.Tick - _startTick) * Runner.DeltaTime;
    if (elapsedTime >= _generateSpan && _gameManager.IsGameStarted)
    {
        // 経過時間のリセット.elapsedTime=0としても,Render内では実行されない.
        _startTick = Runner.Tick;
        
        var randX = Random.Range(-8f, 8f);
        var randY = Random.Range(-3f, 4f);

        // NetworkTransformを付けて,Positionを共有する.
        Runner.Spawn(_calcPointPrefab, new Vector2(randX, randY));
    }
}

o8que様の記事にもあるように,Time.deltaTimeなどのローカル環境で時間を測るとプレイヤー間でズレが生じ,不具合を引き起こす可能性があります.そのため,今回使用している https://zenn.dev/o8que/books/photon-fusion/viewer/tick-timer の方法で時間の処理を行うようにしましょう.

DamagePointGenerator.cs
if (!HasStateAuthority) { return; }

とありますが,ホスト以外もスポーン処理を行うと,黄色いひし形が一定時間ごとにプレイヤーの数分生成されてしまいます(プレイヤーが4人いたら,4個生成されてしまう).なので,ホスト以外はスポーン処理を行わないようにしています.

PlayerController.cs

次に,PlayerControllerの説明をします.
PlayerControllerが付いているPlayerオブジェクトには,「NetworkRigidbody」コンポーネントがついています.

ポイント9 NetworkTransformとNetworkRigidbodyの違い

オブジェクトの位置情報を共有したいときは,NetworkTransformコンポーネントを付けます.しかし,そのオブジェクトが加速度やAddForceで動いている場合は,NetworkRigidbodyコンポーネントで位置情報を共有するようにしましょう.
加速度移動のオブジェクトにNetworkTransformコンポーネントを付けると,オブジェクトが正常に動かなくなります.(おそらく負荷の問題.)

各コンポーネントを付けたときの比較動画:
https://x.com/Keig_game/status/1964251751324012879

なお,NetworkRigidbodyを使うには,公式アドオンの「Fusion Physics」をインポートする必要があります.デフォルトでは使えないので注意してください.

↓Fusion Physicsのダウンロード(サインインしていないとダウンロードできません.)

PlayerControllerのコード全体
PlayerController.cs
using Fusion;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class PlayerController : NetworkBehaviour
{
    private int _playerHp = 10;
    private int _playerMaxHp;
    [SerializeField] private Image _greenHpGauge;
    [Networked] public NetworkString<_8> PlayerName { get; set; }
    [Networked] public Color PlayerColor { get; set; }
    [SerializeField] private float _moveSpeed;
    [SerializeField] private float _jumpForce;
    [SerializeField] private TextMeshPro _playerNameText;
    private Rigidbody2D _rigidbody;
    private NetworkObject _networkObject;
    private GameManager _gameManager;
    
    private const float CASTRADIUS = 0.2f;
    private const float CASTDISTANCE = 0.5f;


    public void Init(GameManager gameManager)
    {
        _gameManager = gameManager;
    }

    public override void Spawned()
    {
        _networkObject = this.GetComponent<NetworkObject>();
        _rigidbody = this.GetComponent<Rigidbody2D>();
        _playerMaxHp = _playerHp;
        
        // プレイヤー名・色の更新
        _playerNameText.text = PlayerName.Value;
        this.GetComponent<SpriteRenderer>().color = PlayerColor;
    }
    
    // FixedUpdateNetworkだと,スペースキーが反応しないことがある.
    public override void Render()
    {
        // 地面についている間だけジャンプ可能.
        bool isTouchingGround = Physics2D.CircleCast(transform.position, CASTRADIUS, Vector2.down, CASTDISTANCE, LayerMask.GetMask("Ground"));
        
        if (Input.GetKeyDown(KeyCode.Space) && isTouchingGround)
        {
            _rigidbody.AddForce(Vector2.up * _jumpForce, ForceMode2D.Impulse);
        }
        
        if (Input.GetKey(KeyCode.A))
        {
            _rigidbody.linearVelocityX = -_moveSpeed;
        } 
        else if (Input.GetKey(KeyCode.D))
        {
            _rigidbody.linearVelocityX = _moveSpeed;
        }
        else
        {
            _rigidbody.linearVelocityX = 0f;
        }
    }

    [Rpc(RpcSources.Proxies, RpcTargets.All)]
    public void RpcDamage()
    {
        _playerHp -= 4;
        _greenHpGauge.fillAmount = _playerHp / (float)_playerMaxHp;

        if (_playerHp <= 0 && HasStateAuthority)
        {
            _gameManager.RpcSendDeath(Runner.LocalPlayer);
            Runner.Despawn(_networkObject);
            Debug.Log("despawn");
        }
    }
    
    /// <summary>
    /// Unityエディター上に,CircleCastの範囲を表示する
    /// </summary>
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.yellow;
        Vector3 origin = transform.position;
        Vector3 endPoint = origin + (Vector3)Vector2.down * CASTDISTANCE;
        Gizmos.DrawWireSphere(endPoint, CASTRADIUS);
    }
}

上の関数から見ていきます.
まず,Init関数です.

PlayerController.cs
public void Init(GameManager gameManager)
{
    _gameManager = gameManager;
}

これは,PlayerSpawnerのonBeforeSpawnedで呼ばれる関数であり,GameManagerを受け取ります.そのプレイヤー本人しか実行できないことに注意してください.

次にSpawned関数です.

PlayerController.cs
    public override void Spawned()
    {
        _networkObject = this.GetComponent<NetworkObject>();
        _rigidbody = this.GetComponent<Rigidbody2D>();
        _playerMaxHp = _playerHp;
        
        // プレイヤー名・色の更新
        _playerNameText.text = PlayerName.Value;
        this.GetComponent<SpriteRenderer>().color = PlayerColor;
    }

PlayrSpawnerで代入したPlayerNameとPlayerColorを画面に反映させます.この2つの変数はネットワークプロパティなので,全員の画面で共有されます.

最後はRpcDamageです.

PlayerController.cs
[Rpc(RpcSources.Proxies, RpcTargets.All)]
public void RpcDamage()
{
    _playerHp -= 4;
    _greenHpGauge.fillAmount = _playerHp / (float)_playerMaxHp;

    if (_playerHp <= 0 && HasStateAuthority)
    {
        _gameManager.RpcSendDeath(Runner.LocalPlayer);
        Runner.Despawn(_networkObject);
        Debug.Log("despawn");
    }
}

ダメージを喰らう際に,DamagePointから実行されます.
HPを4減らし,HPゲージを更新.hpが0以下なら,GameManagerにこれを通知し,プレイヤーオブジェクトをデスポーンさせます.
DamagePointと同様,自分が状態権限を持ったオブジェクトしかデスポーンできないので,プレイヤー本人のみがデスポーンさせるようにif分岐しています.

Rpcですが,自分で自分にダメージを与えることはないため,自分以外のプレイヤーがRpcDamageを実行します.そして,HPゲージは全員の画面に反映させたいので,[Rpc(RpcSources.Proxies, RpcTargets.All)]にしてあります.

GameManager.cs

最後に,GameManagerの説明をします.
GameManagerはシーンオブジェクトなので,遷移と同時にスポーンします.

GameManagerのコード全体
GameManager.cs
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using Fusion;
using TMPro;
using UnityEngine;

public class GameManager : NetworkBehaviour
{
    [Networked] public NetworkBool IsGameStarted { get; private set; } = false;
    [SerializeField] private TextMeshProUGUI _resultText;
    private List<PlayerRef> _deathPlayers = new List<PlayerRef>();

    // UniTask
    private CancellationToken _token;

    public override void Spawned()
    {
        _token = this.GetCancellationTokenOnDestroy();
    }

    [Rpc(RpcSources.All, RpcTargets.StateAuthority)]
    public void RpcStartGame()
    {
        IsGameStarted = true;
    }
    
    [Rpc(RpcSources.All, RpcTargets.All)]
    public void RpcSendDeath(PlayerRef deathPlayer)
    {
        _deathPlayers.Add(deathPlayer);
        
        if (_deathPlayers.Count == Runner.SessionInfo.PlayerCount - 1)
        {
            if (_deathPlayers.Contains(Runner.LocalPlayer))
            {
                _resultText.text = "You Lose...";
            }
            else
            {
                _resultText.text = "You Win!";
            }
            
            _resultText.gameObject.SetActive(true);
            MoveWaitRoom().Forget();
        }
    }

    private async UniTaskVoid MoveWaitRoom()
    {
        if (HasStateAuthority)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(1.5f), cancellationToken: _token);
            Runner.LoadScene("WaitRoom");
        }
    }
}

上から順に説明します.
まず,RpcStartGame関数です.

GameManager.cs
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
public void RpcStartGame()
{
    IsGameStarted = true;
}

RpcStartGameは,IsGameStartedをtrueに変えることで「DamagePointを一定時間ごとにスポーンさせる処理」を開始させます.
この関数は,PlayerSpawnerから実行されます.

ネットワークプロパティであるIsGameStartedを変更するのは,GameManagerの状態権限所持者であるホストです.なので,[Rpc(RpcSources.All, RpcTargets.StateAuthority)]にしています.

ポイント10 ネットワークプロパティの代入

ネットワークプロパティは,状態権限所持者が変更するようにしましょう.
状態権限所持者以外が変更してもプレイヤー間で共有されません.変更したプレイヤーの画面でのみ変更されます.

ちなみに...
ネットワークプロパティは,privateで宣言しても問題ありません.また,get; set;にprivateをつけても使うことができます.

sample
[Networked] public int i { get; set; } = 0;
// privateでもOK
[Networked] private int i { get; set; } = 0;
[Networked] public int i { get; private set; } = 0;

次に,RpcSendDeath関数です.

GameManager
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcSendDeath(PlayerRef deathPlayer)
{
    _deathPlayers.Add(deathPlayer);
    
    if (_deathPlayers.Count == Runner.SessionInfo.PlayerCount - 1)
    {
        if (_deathPlayers.Contains(Runner.LocalPlayer))
        {
            _resultText.text = "You Lose...";
        }
        else
        {
            _resultText.text = "You Win!";
        }
        
        _resultText.gameObject.SetActive(true);
        MoveWaitRoom().Forget();
    }
}

この関数は,PlayerContollerでHPが0以下になったプレイヤーから実行されます.
倒れたプレイヤーは_deathPlayersリストに追加され,リストの要素数が「参加者 - 1 ( = 生存者が1人)」になったらWin!かLose...を表示するという処理を行っています.
_deathPlayersの共有はもちろん,プレイヤーごとにテキスト表示を行う必要があるため,RpcTargets.Allにしています.

_deathPlayersをネットワークプロパティにする方法もありますが,結局_deathPlayersリストへの要素追加をホストに依頼する関数が別途必要になります.そうなると関数の数が増えるため,今回はネットワークプロパティにしていません.

最後はMoveWaitRoomです.

GameManager
private async UniTaskVoid MoveWaitRoom()
{
    if (HasStateAuthority)
    {
        await UniTask.Delay(TimeSpan.FromSeconds(1.5f), cancellationToken: _token);
        Runner.LoadScene("WaitRoom");
    }
}

生存者が1人になったらGameManagerから実行される関数です.
UniTaskについて詳しくは触れませんが,1.5秒待ったら,NetworkRunnerを経由してシーンをロードします.
大事なことなのでもう一度.Runner.LoadSceneはホストしか実行できませんが,ホストが実行すれば,プレイヤー全員がシーン遷移します.

以上でサンプルプロジェクトの説明を終わります!!!!
ここからはおまけです.

UniTaskとRpc関数

Photonを使ったプロジェクトでも,UniTaskは通常通り動作します.
ですが,注意すべき点はRpc関数の返り値が,「void」か「RpcInvokeInfo」である点です.
Rpc関数内でawaitを使うのであればasync voidを使う必要がありますが,例外(キャンセルも含む)が発生した際,コンソールにエラーとして表示されます.try-catchを使用していれば表示されませんが,awaitごとにtry-catchを書いていたら可読性は下がるでしょう.
そのため,awaitを使う関数をForgetで実行するなどして,Rpc関数内でのawaitの使用は極力避けるようにしましょう.

↓async voidについての記事

「UniTask  async void」と探していたせいで思うような記事が見つからず,私もasync voidのデメリットが分かっていませんでした.(AIも正しいのか分からないし...)
UniTaskはTaskからできているので,「Task  async void」と検索したら,求めていた知識が得られました.

PhotonでDOTweenを使う方法

DOTween,有名ですよね.ですが,スポーンさせたオブジェクトにDOTweenを使うと,動かないのです.
少し設定を加える必要があります.

解決策1

SetUpdate(UpdateType.Late)オプションを付けます.

sample
transform.DOLocalMove(new Vector3(...), 0.5f).SetUpdate(UpdateType.Late);

解決策2

DOTweenの設定を変更します.
Unityの画面上部タブ「Tools」→「Demigiant」→「DOTween Utility Panel」→パネル上部タブ「Preference」→ 「Update Type」をNormalから「Late」に変更.
ただし,この設定をすると全てのDOTweenに適用されるので注意です.

ネットワーク同期のタイミングで,DOTweenで設定した座標が元の座標で上書きされていることが原因のようです.なので,DOTweenのタイミングを,ネットワーク同期とずらすことでDOTweenが動作するようになりました.

参考サイト:

AnimationEvent

NetworkMecanimAnimatorを使うことで,オブジェクトのアニメーション再生状況を全員と共有することができます.このとき,アニメーションEventに設定した関数も全員の画面で実行されます.なので,実質Rpc関数のような動きをします.

Is Master Client Object

通常,あるプレイヤーが退室すると,そのプレイヤーが状態権限所持者であるオブジェクトもデスポーンします.
今回でいうと,ホストが退室してWairRoomやGameManagerがデスポーンしてしまうと,ホスト権は他プレイヤーに移りますが,ゲームの進行が不可になります.
これを防ぐために,ホスト退室してもデスポーンせず,新しいホストに状態権限を持たせる設定があります.これがIs Master Client Objectです.
アタッチした「NetworkObject」スクリプトに「Is Master Client Object」というチェック項目があるので,これにチェックを打てば設定完了です.

まとめ

以上がPhotonFusion入門でしたが,いかがだったでしょうか.
オンラインゲームは開発こそ大変ですが,開発していくうちにまだまだ学べそうなことがたくさんありそうです.この記事が,皆さんのPhoton開発の役に立てれば嬉しいです.

何か質問・ご指摘・感想などあれば,Qiitaのコメントか,私のXでリプ・DMを投げて欲しいです!
私の学びにもなるので,遠慮なくコメントいただけると嬉しいです.

Xアカウント:https://x.com/Keig_game

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?