はじめに
Unityでネットワーク構築をする際はPhoton社の提供しているSDKを使う方が多いかと思います。その中でもPUN2を使用している方が多く、技術記事も散見されます。しかし、Fusion2については、SDKが新しいこともあって未だ技術記事が少ないです。将来性の観点から今回はFusion2で対戦ゲームを作っていきます。ホスト・クライアントモードを使用します。
以下の流れを実装していきます。
ロビー入室
↓
ルーム選択・入室
↓
全員準備完了でゲームスタート
環境
・Unity Editor Version 2022.3.60f1
・Fusion Version 2.0.1
対象の方
・チーム戦のネット対戦ゲームを作りたい方
・Fusion2をある程度触ってみた方
1. Fusion2導入
まずはFusion2のSDKをプロジェクトにインポートしていきます。
下記URLからダウンロードし、インポートしてください。ダウンロードにはサインインが必要となります。(無料)
ダウンロードしたら、サイトにUnityへの導入手順が載ってあるので。それを参考に進めてください。一度チュートリアルを完了しておくとなお良いですね。
2. ロビー入室
ロビー入室を実装していきます。ここでいうロビーとはホテルのロビーと一緒で、全部屋の状態を確認することができます。今回だと、何個のルームがあるのか、何人入っているのかなどを確認します。以下のような「Matchmaker.cs」ファイルを作成してください。このクラスではロビー入室・ルーム入室を管理します。
using System;
using System.Collections.Generic;
using Fusion;
using Fusion.Sockets;
using UnityEngine;
public class Matchmaker : MonoBehaviour, INetworkRunnerCallbacks
{
public static Matchmaker Instance { get; private set; }
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
#region INetworkRunnerCallbacks
//INetworkRunnerCallbacksのインターフェースを実装してください。
#endregion
}
私はシングルトンを多用したい派なので、今回はこのスタンスに付き合ってください。シングルトンについては割愛しますm(_ _)m
「INetworkRunnerCallbacks」を継承することで、ネット周りに関する様々なコールバックを受け取ることができます。ex)入室時、退出時、シャットダウン時、etc,,,
今後も多用するので、Photonの技術資料で概要を抑えてください。
Editorにもどり、ヒエラルキーに空のオブジェクトを作成、名前を「InterfaceManager」としてください。その名の通り、UI周りを一括管理させるオブジェクトに仕立てます。「InterfaceManager」に「Matchmaker.cs」アタッチしてください。

下準備が完了したので、「Matchmaker.cs」にロビー入室処理を書いていきます。以下を追加します。
public NetworkRunner Runner { get; private set; }
[SerializeField] private NetworkRunner runnerPrefab;
void Start()
{
StartCoroutine(JoinLobbyRoutine());
}
IEnumerator JoinLobbyRoutine()
{
Runner = Instantiate(runnerPrefab);
Runner.AddCallbacks(this);
Task<StartGameResult> task = Runner.JoinSessionLobby(SessionLobby.ClientServer);
while (!task.IsCompleted)
{
yield return null;
}
StartGameResult result = task.Result;
if (result.Ok)
{
Debug.Log("Connected to lobby.");
}
else
{
Debug.Log("Failed to connect to lobby.");
}
}
スタートと同時にロビー入室処理を走らせます。ここで「NetworkRunner」と呼ばれる、ネット対戦構築における最重要クラスが登場します。「NetworkRunner」を用いてロビー・ルームの入退室などを管理します。「NetworkRunner」はヒエラルキーに最初から置いてもよいのですが、今回はプレハブからインスタンス化するようにします。ではプレハブを作成します。空のオブジェクトに「NetworkRunner」と名付け以下のコンポーネントをアタッチ後、プレハブ化させてください。そしてプレハブを「Matchmaker.cs」にアタッチしてください。


この状態でエディターを再生すると、コンソールに「Connected to lobby.」と表示され、無事ロビーに入室できたかと思います。
3. ルーム表示
次に、ロビー入室した際に入室可能なルームを表示させましょう。よくあるネット対戦ゲームでは、作成者(ホスト)がルームを作成し、入室者(クライアント)は作成されたルームのリストから選ぶ感じでしょうか。ですが今回はルーム作成のフローを省略し、決め打ちで3つのルームを表示させ、そこにプレイヤーが入る形にします。つまり、最初に入る人がホストで、続いて入る人がクライアントとなります。
以下の流れで実装します。
ロビー入室
↓
画面にルーム入室用ボタンを3つ生成
↓
任意のルーム入室用ボタンを押したら、そのルームへ入室
では、ルーム入室用ボタンのプレハブを作成します。「SesssionItemUI.cs」を作成し、プレハブにアタッチさせておきます。このプレハブは以下の要素を含みます。
・入室用ボタン
・ルーム名
・入室可能人数
・現在の入室人数

using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class SessionItemUI : MonoBehaviour
{
[SerializeField] private TMP_Text sessionNameLabel;
[SerializeField] private TMP_Text playerCount;
[SerializeField] private Button joinButton;
[HideInInspector] public string sessionName = null;
public void Init(string sessionName, int players, int maxPlayers, bool isOpen)
{
joinButton.interactable = isOpen;
joinButton.onClick.AddListener(Join);
sessionNameLabel.text = this.sessionName = sessionName;
playerCount.text = $"{players}/{maxPlayers}";
}
public void Join()
{
//ルーム入室処理(後述)
}
}
Init()でルーム入室用ボタンの各種パラメータを反映させるコードです。入室不可の場合はボタンを非アクティブにして押せないようにする処理もしてます。
次にこのプレハブを生成する処理を「Matchmaker.cs」に記述していきます。以下を追加します。
public class Matchmaker : MonoBehaviour, INetworkRunnerCallbacks
{
[SerializeField] private FixedSessionInfo[] sessionInfos;
[SerializeField] private SessionItemUI sessionItemPrefab;
readonly List<SessionItemUI> sessionItems = new List<SessionItemUI>();
SessionItemUI GetSessionItem(int i)
{
return sessionItems.ElementAtOrDefault(i) ?? TrackItem(Instantiate(sessionItemPrefab, sessionItemHolder));
}
SessionItemUI TrackItem(SessionItemUI item)
{
sessionItems.Add(item);
return item;
}
SessionItemUI GetSessionItem(string sessionname)
{
return sessionItems.FirstOrDefault(item => item.sessionName == sessionname) ?? TrackItem(Instantiate(sessionItemPrefab, sessionItemHolder));
}
public void OnSessionListUpdated(NetworkRunner runner, List<SessionInfo> sessionList)
{
// 現在存在するセッションに対してUIアイテムを設定・更新
for (int i = 0; i < sessionInfos.Length; i++)
{
SessionInfo sessionInfo = sessionList.FirstOrDefault(item => item.Name == sessionInfos[i].Name) ?? null;
if (sessionInfo != null)
{
GetSessionItem(i).Init(sessionInfo.Name, sessionInfo.PlayerCount, sessionInfo.MaxPlayers, sessionInfo.IsOpen);
}
else
{
GetSessionItem(sessionInfos[i].Name).Init(sessionInfos[i].Name, 0, sessionInfos[i].MaxPlayers, true);
}
}
}
}
[System.Serializable]
public class FixedSessionInfo
{
public string Name;
public int MaxPlayers;
}
ここで「NetworkRunnerCallbacks」の一つ、OnSessionListUpdated()が登場します。このコールバック関数はルーム内で入退室などが行われた際に呼び出されます。また、ロビー入室時にも一回呼び出されます。変化のあったルームはSessionInfo型で受け取ることができ、プレイヤー数などの情報が入っています。その情報をもとに先ほど作成した「SessionItemUI.cs」を初期化してます。ルームのパラメータは「sessionInfos」で事前に設定する必要があります。ではエディターで「Matchmaker.cs」に各種パラメータを設定しましょう。SessionItemHolderはルーム入室用ボタンが生成される場所を指します。今回は「Horizontal Layout Group」をアタッチして整列させてます。

この状態で再生すると以下の画像のようになるはずです。これでルームの表示は出来ました。

4.1 ルーム入室
さて、最難関のルーム入室処理です。ややこしいですが頑張りましょう。先ほど作成したルーム入室用ボタンを押した際に任意のルームに入室できるように「SessionItemUI.cs」「Matchmaker.cs」に処理を追加していきます。
public void Join()
{
StartCoroutine(Matchmaker.Instance.HostSessionRoutine(sessionName));
}
[SerializeField] private NetworkObject managerPrefab;
public IEnumerator HostSessionRoutine(string sessionName)
{
Runner.GetComponent<NetworkEvents>().PlayerJoined.AddListener((runner, player) =>
{
if (runner.IsServer && runner.LocalPlayer == player)
{
runner.Spawn(managerPrefab);
}
});
Task<StartGameResult> task = Runner.StartGame(new StartGameArgs()
{
GameMode = GameMode.AutoHostOrClient,
SessionName = sessionName,
SceneManager = Runner.GetComponent<INetworkSceneManager>(),
PlayerCount = sessionInfos.FirstOrDefault(info => info.Name == sessionName)?.MaxPlayers ?? 6,,
});
while (!task.IsCompleted)
{
yield return null;
}
StartGameResult result = task.Result;
if (result.Ok)
{
Debug.Log($"Connected to {sessionName}.");
//ルーム選択画面の非表示
//チームセレクト用パネルの表示
}
else
{
Debug.Log($"Failed to connect to {sessionName}.");
}
}
※UIの表示非表示は各自の方法でお願いします。(SetActive()は非推奨)
Runner.StartGame()がルーム入室処理の根幹です。StartGameArgsで指定したパラメータによってルームを作成・入室を行います。GameMode = GameMode.AutoHostOrClient にすることで最初に入った人をHost、次からはClientに自動で割り当てることができます。
ここで新しく「NetworkObject」クラスが登場します。これは簡単に説明すると、様々なものをネットワーク上で同期させるためのクラスです。例えばこのクラスがアタッチされているリンゴのオブジェクトは、ほかのクライアントでも生成されるようになります。しかし、注意点として、「NetworkObject」を生成できるのはホストのみである点です。先ほどの「Mathmaker.cs」のコードを見ると、
if (runner.IsServer && runner.LocalPlayer == player)
{
runner.Spawn(managerPrefab);
}
と記述されています。このRunner.IsServerの部分がホストという意味です。そして、ホストであれば、「NetworkObjerct」のmanagerPrefabをスポーンさせています。(「NetworkObjerct」はSpawn()で生成します)つまり、managerPrefabは全クライアント間で同期されるオブジェクトになります。ではmanagerPrefabについて、作成しながら説明していきます。
空のオブジェクトに「NetworkManager」と名付け、「PlayerRegistry.cs」を作成してアタッチし、プレハブ化させてください。「NetworkObject」コンポーネントも忘れずに。そして「Matchmaker.cs」にアタッチしてください。

ひとまずはこれでオッケーです。「NetoworkManager」は後々ゲームマネージャー的なクラスを付けていきます。「PlayerRegistry.cs」は入室しているプレイヤーの情報を格納しておくクラスです。適宜このクラスからプレイヤーを参照していきます。
この状態でビルドして、Editorと、アプリの二つのクライアントでテストしてみてください。ここまでうまくいっていれば、問題なく入室できると思います。確認方法としては、最初にアプリ側で任意のルームに入ると、Editor側の画面ではルーム入室用ボタンの表示人数が1人になっていると思います。そして、ヒエラルキー上ではNetworkRunnerのパラメータでPlayer2という表記を見つけることができれば成功しています。
4.2 プレイヤー情報の設定
さて、ここまででの実装では、クライアントが同じ場所で集合してる”だけ”です。相手の名前も顔も分かっていません。ここからは自分の情報をほかの人に送信したり、ほかの人の情報を受信したり、、という処理を実装していきます。まず、自分の情報を持つ「PlayerObject.cs」というクラスを作成していきます。「PlayerObject.cs」は言わば自分の分身で、AさんがBさんの情報が欲しいときはBさんの「PlayerOBject.cs」を参照します。同期させるわけですから、「NetworkObject」のコンポーネントは必須ですね。ではそのプレハブを作成していきます。空のオブジェクトに「PlayerObject」と名付け「NetworkObject」「PlayerOBject.cs」をアタッチ後、プレハブ化させてください。

次に「PlayerObject」をスポーンさせていきます。「NetworkRunner」プレハブを開き、新しく「PlayerSpawner.cs」を作成してアタッチしてください。中身は以下です。
using Fusion;
public class PlayerSpawner : SimulationBehaviour, IPlayerJoined
{
[SerializeField] private NetworkObject playerObject;
public void PlayerJoined(PlayerRef player)
{
if (Runner.IsServer)
{
Runner.Spawn(playerObject, inputAuthority: player);
}
}
}

「PlayerSpawner.cs」はとてもシンプルで、ホストだったら「PalyerObject」をスポーンさせる処理のみです。ここでもPlayerJoined()というNetworkRunnerCallbacksの一つが使われています。ただし、「PlayerSpawner.cs」はNetworkRunnerコンポーネントと同じオブジェクト内にあるため、コールバック関数を明示的に登録する必要はありません。「Matchmaker.cs」では明示的にRunner.AddCallbacks(this)と記述しています。PlayerJoinedはプレイヤーが入室したときに発火するコールバック関数で、自分が入室したときも呼び出されます。したがって、ルーム入室時に「PlayerObject」が随時スポーンされます。再生してみると、「PlayerObject」がヒエラルキー上で見えるかと思います。
では「PlayerObject」にプレイヤーの情報を持たせていきます。ここで留意するべき点として、ネットワーク同期される情報についてもホストしか変更ができません。したがって、名前を変更したい際もホストにお願いして変更する必要があるというわけです。
さて、ここから本当にややこしくなります。「PlayerObject」にPlayerの情報が格納されていることは分かったと思いますが、どのようにして参照・変更を行うかが分からないと思います。そこで、先ほどチラッと触れた「PlayerRegistry.cs」の出番です。この「Playerregistry.cs」に全プレイヤーの「PlayerObject」を格納しておけば、そこから簡単に参照を行えるわけです。ではまず「PlayerObject」を「PlayerRegsitry」に登録する方法を説明します。一気にコードを貼り付けます。
using System;
using Fusion;
public class PlayerObject : NetworkBehaviour
{
public static PlayerObject Local { get; private set; }
[Networked]
public PlayerRef Ref { get; set; }
[Networked]
public byte Index { get; set; } = 255;
[Networked]
public string Nickname { get; set; }
public void Server_Init(PlayerRef pRef, byte index)
{
Ref = pRef;
Index = index;
}
public override void Spawned()
{
if (Object.HasStateAuthority)
{
PlayerRegistry.Server_Add(Runner, Object.InputAuthority, this);
}
if (Object.HasInputAuthority)
{
Local = this;
var random = new Random();
var nickName = $"Player{random.Next(1000, 9999)}";
Rpc_SetNickname(nickName);
}
if (Object.InputAuthority == PlayerRef.None) return;
PlayerRegistry.PlayerJoined(Object.InputAuthority);
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
public void Rpc_SetNickname(string nick)
{
Nickname = nick;
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Helpers.Linq;
using Fusion;
using Fusion.Sockets;
using UnityEngine;
public class PlayerRegistry : NetworkBehaviour, INetworkRunnerCallbacks
{
[Networked, Capacity(CAPACITY)]
NetworkDictionary<PlayerRef, PlayerObject> ObjectByRef { get; }
void Awake()
{
Instance = this;
}
public override void Spawned()
{
Instance = this;
Runner.AddCallbacks(this);
DontDestroyOnLoad(gameObject);
}
bool GetAvailable(out byte index)
{
if (ObjectByRef.Count == 0)
{
index = 0;
return true;
}
else if (ObjectByRef.Count == CAPACITY)
{
index = default;
return false;
}
byte[] indices = ObjectByRef.OrderBy(kvp => kvp.Value.Index).Select(kvp => kvp.Value.Index).ToArray();
for (int i = 0; i < indices.Length - 1; i++)
{
if (indices[i + 1] > indices[i] + 1)
{
index = (byte)(indices[i] + 1);
return true;
}
}
index = (byte)(indices[indices.Length - 1] + 1);
return true;
}
public static void Server_Add(NetworkRunner runner, PlayerRef pRef, PlayerObject pObj)
{
Debug.Assert(runner.IsServer);
if (pRef == PlayerRef.None) return;
if (Instance.GetAvailable(out byte index))
{
Instance.ObjectByRef.Add(pRef, pObj);
DontDestroyOnLoad(pObj.gameObject);
pObj.Server_Init(pRef, index);
}
else
{
Debug.LogWarning($"Unable to register player {pRef}", pObj);
}
}
#NetworkRunnerCallbacks
}
はい、、、。これだけだと何が何だかわからないかと思います。順を追って説明します。まず「NetworkObject」であれば、「NetworkBehavior」というクラスを継承できます。おかげでSpawned()メソッドをオーバーライドすることができます。Spawned()メソッドは自分がスポーンされた際に呼ばれるメソッドで、MonoBehaviourでいうところのStart()メソッドに該当します。ですので、「NetoworkObject」ではStart()の代わりにSpawned()が使われます。
Spawned()メソッドは全クライアントで呼び出されます。「NetworkObject」はネット同期されるので、全クライアントでスポーンしている状態にあり、個々でSpawned()が走っているのです。そうすると、自分の情報を自分の「PlayerObject.cs」にのみ反映させるためには、自分の「PlayerObject」であるかどうか判別する必要があります。その判別方法が
if (Object.HasStateAuthority)
{
//===
}
if (Object.HasInputAuthority)
{
//===
}
この部分です。「Object」とは自分自身の「NetworkObject」のことで、HasStateAuthorityとHasInputAuthorityはそれぞれ「状態権限を持っているか」、「入力権限を持っているか」です。状態権限は必ずホストが持っています。入力権限はスポーン時に設定します。「PlayerSpawner.cs」で
Runner.Spawn(playerObject, inputAuthority: player);
のように設定していましたね。ですので、Object.HasInputAuthority == trueの時、それは自分の「PlayerObject」であることが分かるのです。「PlayerObject.cs」では自分のオブジェクトの場合に名前を設定している処理が書かれています。ここでまた新しくRPCという概念が出てきます。データベースをよく触る人はなじみ深いかと思いますが、簡単に言うと、リモートでメソッドを呼び出す際に使用します。今回だと名前を変更するメソッドを自分の端末ではなく、ホストの端末で呼び出しています。見方としては、RpcSources.InputAuthority、RpcTargets.StateAuthorityとあるので、「自分」が「ホスト側」でRpc_SetNickname()を呼び出しているという事になります。なぜこのようにするのかというと、以前話したように、ネットワーク同期のプロパティはホストでしか変更できないからです。ここでいうとNicknameなどが該当します。Nicknameには定義する際に[Networked]という属性がついています。この[Networked]属性がついたプロパティは全てネット同期されます。型には縛りがありますが、大抵大丈夫です。これによりB君が自分の名前を変更したいときに、ホストであるA君の端末上でB君の名前を変更すると、B君の端末で変更が反映されるわけです。ややこしすぎる。
ではホスト側でスポーンした際はどのような処理が入るでしょうか。if (Object.HasStateAuthority)の部分ですね。ここでは「PlayerRegistry.cs」に「PlayerObject」を登録しようとしています。ここからは「PlayerRegistry.cs」を見ていきます。PlayerRegistry.Server_Add()が呼び出されています。Server_Add()では引数で渡された「PlayerObject」を登録する作業を行っています。すでに登録済みの「PlayerObject」たちを加味して、該当するインデックスを割り当て、そのインデックスをServer_Init()により「PlayerObject」のプロパティへ反映しています。この一連の処理で、「PlayerRegistry.cs」のObjectByRefにクラアント全員分の情報が格納されました。[Networked]属性が付与されているので、全クラアントから参照可能です。ここで、PlayerRefとは、プレイヤーを一意に特定できるクラスのことです。ですから、
ObjectByRef[B君のPlayerRef].Nickname
と記述すればB君の名前を取得できるわけです。
他にも説明不足なコードがありますが、これで、プレイヤー同士で入った順番と、名前はわかる状態になりました。この状態でビルドし、エディターとアプリで同じルームに入ってみてください。ヒエラルキーから、クラアント全員分の情報(インデックス、名前)が分かるかと思います。
4.3 チーム選択
ここからは発展的な内容になります。チーム対戦ではなく、個人戦であれば以上の処理で大体は実装できます。チーム対戦では、「PlayerObject.cs」にチームとチームごとの順番の情報が追加で必要になります。また、ここで観戦者かどうかの情報も入れていきます。この実装にはUIのハンドリングが出てきます。これは人それぞれ実装方法が異なると思うので参考程度に見てください。私はAnimationをつかってパネルの表示非表示を行います。「InterfaceManager」オブジェクトに「InterfaceManager.cs」を作成しアタッチします。「InterfaceManager.cs」ではUIの操作を一括管理するもので、私はパネルごとのAnimatorを保持させておくことで任意のタイミングで表示・非表示を出来るようにします。その際に「ResoucesManager.cs」というデータの保管庫的なものもついでに作成しておきます。「ResousesManager.cs」はヒエラルキーに空のオブジェクト「ResourcesManage」を作成し、そこにアタッチします。
using UnityEngine;
public class InterfaceManager : MonoBehaviour
{
#region Singleton
public static InterfaceManager Instance { get; private set; }
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
#endregion
public Animator LobbyAnim; //ロビー内パネルのアニメーター
public Animator TeamSelectAnim; //チーム選択パネルのアニメーター
public Animator RoomAnim; //ルーム内パネルのアニメーター
}
using UnityEngine;
public class ResourcesManager : MonoBehaviour
{
#region Singleton
public static ResourcesManager Instance { get; private set; }
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
#endregion
public static readonly string PANEL_IN = "Panel In";
public static readonly string PANEL_OUT = "Panel Out";
}
中身はシンプルです。AnimatorController内のクリップ名を「ResourcesManager.cs」に記述することで、確実に呼び出すことができるような形にしてます。以下がAnimatorControllerです。パネルを上から表示させ、上に隠すようなアニメーションです。

少し脱線しましたが、チーム選択処理を実装していきます。「TeamSelectScreenUI.cs」というチーム選択を管理するスクリプトを作成し、チーム選択用パネルのオブジェクトなどにアタッチしてください。私は以下のようなチーム選択用パネルを作成し、そこにアタッチしました。チームは白チームと赤チームを用意し、観戦でも入れるような仕組みにしました。

実際の処理を書いていきます。チーム選択ボタンを押したときに、自分の「PlayerObject.cs」の情報を変更したいですね。しかし、ホスト側でしかネット同期されるプロパティは変更出来ないのでした。そこでRPCを使い、ホスト側に変更させる方法を取ります。以下を追加します。
public class TeamSelectScreenUI : MonoBehaviour
{
//ヒエラルキーでボタンにアタッチしておく
public void OnPlayerTeamSelected(string teamName)
{
PlayerObject.ETeam team = (PlayerObject.ETeam)System.Enum.Parse(typeof(PlayerObject.ETeam), teamName);
//チーム選択画面を非表示////InterfaceManager.Instance.TeamSelectAnim.Play(ResourcesManager.PANEL_OUT);
//ルーム画面を表示////InterfaceManager.Instance.RoomAnim.Play(ResourcesManager.PANEL_IN);
PlayerObject.Local.Rpc_SetTeam(team);
}
//ヒエラルキーでボタンにアタッチしておく
public void OnSpectatorSelected()
{
//チーム選択画面を非表示//InterfaceManager.Instance.TeamSelectAnim.Play(ResourcesManager.PANEL_OUT);
//ルーム画面を表示////InterfaceManager.Instance.RoomAnim.Play(ResourcesManager.PANEL_IN);
PlayerObject.Local.Rpc_ToggleSpectate();
}
}
public enum ETeam
{
White,
Red,
None
}
[Networked]
public ETeam Team { get; set; }
[Networked]
public bool IsSpectator { get; set; } = false;
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
public void Rpc_SetTeam(ETeam team)
{
Team = team;
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
public void Rpc_ToggleSpectate()
{
IsSpectator = !IsSpectator;
}
これで、ボタンを押せばそのチームに入ることができます。「TeamSelectScreen.cs」のメソッドをボタンに登録するときは、引数に該当するチームの名前を正しく入れてください。
ex) 白チーム→White
さて、ここまでで以下の流れが実装できていると思います。
ロビー入室(ロビー画面)
↓
ルーム選択
↓
チーム選択画面
↓
チーム選択
↓
ルーム画面(まだ空)
ルーム画面での「PlayerObject.cs」の値には選んだチームが割り当てられていると思います。