Unity

【Unityでオンラインゲーム】Photonサーバーを用いたルーム・ロビーの作り方【即使えるコードも公開】

本記事内には、アフィリエイトリンクを含む場合があります

Unityでは、個人でもオンライン対戦ゲームを作ることができます。

特に、ここでは”Photon”というサーバーを用いる方法を紹介します。

原理をしっかり学ぶ前に、
まずは実装したい!という方の助けになるかと思います。

1.作るモノ

サーバーにログインし、ルーム作成とルーム一覧をみる機能を実装する方法です。

触って試してみてください↓

このようなログインとマッチングシステムは、
オンライン対戦ゲームでは、なくてはならないものですよね?

わざわざシーン移動せず、Panelを切り替えて行います。
ゲームの流れは次のような形です。

1 ニックネームを決めてログイン

特定のデータを保存しながら遊ぶゲームもありますが、
ここでは、毎回ニックネームを決めて遊ぶ、シンプルなゲームを想定します。

ニックネームを決めて、ログインボタンをクリック。

2 遊ぶルームを決める

ルームとは、ゲームシーンを共有するグループです。

Photonサーバーを無料で使う場合、同時接続数(CCU)の上限は20です。
これはルームでなく、ロビー全体の人数を指します。
ユーザーが増えるようであれば有料プランを検討しましょう。

ルームを決めるのに、ここで3つの選択肢を与えます。

●ルーム作成
●ランダムプレイ(ルームがなければ、自動ルーム作成)

とにかくすぐに遊びたい人向け(俗にいうクイックプレイ)
●ルーム一覧
すでに知り合いがルームを作っている場合などは、ここからルームを探します。

3 ゲームをはじめる

ルームでは、その部屋に入るのが早い人がルームホストとなります。
ルームホストがゲームをスタートさせることができます。

 

それでは、Scriptの内容を中心に、
ロビー・ルーム・ゲームスタートまでを作っていきます。

2.宣言と必要なUIを作る

第一に、Projectファイルにて、LobbySceneという名のSceneを作ります。

「LobbyManager.cs」のC#scriptを作成し、空のGameObjectにアタッチします。

Scriptでは、まず下図のように必要なモノを宣言するので、
それを参考に必要なUIオブジェクト(TextやPanelなど)をシーンに作成します。

*Prefabは別途作成したテンプレのようなオブジェクトです。
次の要素をもつオブジェクトを作り、Resourcesフォルダに入れておきます。

シーンに配置するオブジェクト、LobbyManagerのInspectorは次のようになります。

3.処理をひたすらコーディング

シーンに必要なオブジェクトを作成したら、
Scriptに各種処理をコーディングしていきます。

(#region~#endregionで区切ります)

Unity Methods

まずは、通常のUnity Methodsです。


Start()で初めの画面を表示、
その時点のルームやプレイヤー情報をDictionaryというListに格納させてます。
AutomaticallySyncSceneで、MasterClient(ルームホスト)のロードするゲームシーンをルーム内の他Client(ユーザー)に共有されるようになります。

UI Callbacks

UI Callbacksでは基本的に、UIのButtonで呼ぶメソッドを記載してます。
その際に、どのような処理をさせるかを記載します。

OnLoginButtonClicked()は、「ログイン」ボタンで呼び出します。
入力されたニックネームをPhotonNetworkに送り、設定します。
入力がない場合、エラー(invalid)を返します。

OnCreateRoomButtonClicked()は、「ルーム作成」ボタンで呼び出します。
ルーム名が入力されなければ、Room●●●(1000~10000の番号が入る)として、
最大プレイ人数MaxPlayers(数字のみ)をroomOptionsに送信。
PhotonNetworkサーバー上に、roomNameとroomOptionsをもったルームを作成します。

OnCancelButtonClicked()は、「キャンセル」ボタンで呼び出します。
1つ前のGameOptions(3つの選択肢のある)画面のみをActiveにして、戻ります。

 

OnShowRoomListButtonClicked()は「ルームリスト」ボタンで呼び出します。
ロビーにいなければロビーに入り、ルームリストの画面をActiveにします。

OnBackButtonClicked()は「戻る」ボタンで呼び出します。
ロビーから出て、前の画面をActiveにします。

OnLeaveGameButtonClicked()は「退室」ボタンで呼び出します。
ルームを出ます。

OnJoinRandomGameButtonClicked()は「ランダムプレイ」ボタンで呼び出します。
画面を切り替え、ランダムに取得されたルームに入る、
または、自動でルーム作成の処理(PhotonNetwork.JoinRandomRoom)が行われます。

OnStartGameButtonClicked()は「ゲームスタート」ボタンで呼び出します。
押した人がルームホスト(MasterClient)であること確認し、
設定の”GemeScene”をロードします。

Photon Callbacks

本丸といえようPhoton Callbacksです!

サーバーに情報を送信すると、自動的に実行されるメソッドであり、
そのときに処理してほしい内容を記載します。

Debug.Logを置くことで、そのメソッドの実行有無を確認でき、
エラーの原因を知れるようになります。
(ゲーム開発が終わったら、デバッグは除きましょう)

少し長くなりますが、テンプレみたいなものだと思って、
意味を理解して、必要な箇所のみアレンジして使えばよいでしょう^^

OnConnected()でネットへの接続、
OnConnectedToMaster()でPhotonサーバーへの接続がなされたことを確認できます。
(Debug.Logが読まれたならば)
LocalPlayer.NickNameは、自分が入力したニックネームの情報です。

サーバー接続と同時に、GameOptionsの画面を表示させます。

OnCreatedRoom()でルームが作られたことを確認します。

 

OnJoinedRoom()は、ルームに入ったときに読まれるメソッドです。
このときにルーム内のPanelをActiveにし、
自身がルームホスト(MasterClient)であれば、startGameButton(「ゲームスタート」ボタン)が表示されるようになります。

roomInfoText.textに、"ルーム名"、"現在のプレイ人数&最大プレイ人数"という情報を渡し、表示させます。

次に、ルーム内にいる各プレイヤーに対して
playerListPrefab(プレイヤーカードみたいなもの)を生成して
playerGameObjectという変数に入れ、
playerListContentを配下に並べられ、表示されます。

さらに、各プレイヤーのActorNumber(ルームに入ると割り当てられるID)が読まれ、
自分(LocalPlayer)であるかどうかを判別し、PlayerIndicatorの表示を判断させます(この機能は別になくても困らない^^;)。
ルームにいるプレイヤーをActorNumberという番号と紐付けて、記憶します。

最後にplayerGameObjectsというListに、これらActorNumerとplayerGameObjectをセットで格納しておきます。

 

OnPlayerEnterRoom()は、すでに自分がルームにいて、他のプレイヤーが入室したときに呼ばれます。
OnJoinedRoom()と同様、
roomInfo.textの更新、playerListObjectの生成と整列、ActorNumberによる自分の識別、Listへの格納を行います。
後からプレイヤー入室しても、ルームホスト(MasterClient)は変わらないので、「ゲームスタート」ボタンの表示有無に関する処理はありません。

OnPlayerLeftRoom()は、ルームから自分以外のプレイヤーが退室したときに呼ばれます。
roomInfo.textの更新、退室したプレイヤー(otherplayer)をListから削除します。
ここで、自分がルームホストであるかのチェックをさせます。

 


OnLeftRoom()は、自分が退室したときに呼ばれます。
GameOptionsの画面(Panel)をアクティブに、
playerListGameObjectsという配列データは削除され、空になります(どこかに入室したときにまた更新される)。

 


OnRoomListUpdate()は、ルームを一望できるロビーに入ったとき(JoinLobby)に呼ばれます。
現存するルーム、つまり
RoomInfoというクラス型のroomListという名のListに格納されているroom)を
1つずつ検証して有効なルームを精査し、
cachedRoomListというListに格納されていきます

さらに、cachedRoomListに格納されている各roomに対して、
roomListEntryPrefab(これまたカードみたいなもの)を生成、roomListEntryGameObjectという変数に入れ、
roomListParentGameObjectを配下に、並べられます。

それから、roomEntryGameObjectに、roomがもつ情報を反映させていきます。
最後に、roomListGameObjectsというListに格納されます。

Listが3つも登場するので、混乱するかもしれません^^;
イメージは次のようなものです。

全roomをもつList(roomList)
 → 有効なroomのList(cachedRoomList)
   → roomの情報が反映されたオブジェクトのList(roomListGameObjects)

 


OnLeftLobby()は、自分がロビーを出たときに呼ばれます。
ルーム一覧の情報は破棄され、
cachedRoomListに格納された情報も削除されます。

OnJoinRandomFailed()は、現存するルームに入室できなかったときに呼ばれます。
「ルーム作成」ボタンで呼び出したときと同じようにルーム作成の処理が行われますが、
ここでは設定画面をとばし、
コードの内容(ルーム名:Room ●●●●、最大プレイ人数:20)でルームが即作成されるようにしています。

Private Methods

サーバーの情報をもってきて、ローカルで呼ぶメソッドです。


OnJoinRoomButtonClicked()は、「Join」ボタンで呼び出します。
Photonoサーバーにおけるロビーからルームに移動する一連のメソッドを呼びます。

ClearRoomListView()は、Photon CallbacsのOnLeftLobby()で呼ばれます。
ルームを一覧に表示させていたroomListGameObjectsの中身を削除します。
(OnLeftLobby()では、cachedRoomListは削除される)

Public Methods

各種コードより、飛ばれるメソッドをここに記載します。


ActivePanel()は、画面切替を操作させるものです。
呼び出す際は、ActivatePanel(~.name)というように記載します。

初期画面(Login_UI_Panel.name)
ログイン後画面(GameOptions_UI_Panel.name)
ルーム作成画面(CreateRoom_UI_Panel.name)
ルーム内画面(InsideRoom_UI_Panel.name)
ロビー画面(RoomList_UI_Panel.name)
ランダムルーム検索中画面(JoinRandomRoom_UI_Panel.name)

 

以上
これはあくまで、私個人の理解に基づくものであることをご留意ください。

私がプロフェッショナルでないからこそ、
初心者にもわかりやすい説明になったかな?と思いたいのですが・・・^^;

サーバーに関して独学は、とても難しいですね。

4.コピペ用コード

以下は、コピペして使うのにご活用ください↓

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Photon.Pun;
using Photon.Realtime;

public class LobbyManager : MonoBehaviourPunCallbacks
{
    [Header("Connection Status")]
    public Text connectionStatusText;

    [Header("Login UI Panel")]
    public InputField playerNameInput;
    public GameObject Login_UI_Panel;


    [Header("Game Options UI Panel")]
    public GameObject GameOptions_UI_Panel;
    
    [Header("Create Room UI Panel")]
    public GameObject CreateRoom_UI_Panel;
    public InputField roomNameInputField;
    public InputField maxPlayerInputField;
    
    [Header("Inside Room UI Panel")]
    public GameObject InsideRoom_UI_Panel;
    public Text roomInfoText;
    public GameObject playerListPrefab;
    public GameObject playerListContent;
    public GameObject startGameButton;

    [Header("Room List UI Panel")]
    public GameObject RoomList_UI_Panel;
    public GameObject roomListEntryPrefab;
    public GameObject roomListParentGameObject;
    
    [Header("Join Random Room UI Panel")]
    public GameObject JoinRandomRoom_UI_Panel;

    private Dictionary<string, RoomInfo> cachedRoomList; 
    private Dictionary<string, GameObject> roomListGameObjects; 
    private Dictionary<int, GameObject> playerListGameObjects; 

    #region Unity Methods

    // Start is called before the first frame update
    private void Start()
    {    
        // Login_UI_Panel.SetActive(true);
        // GameOptions_UI_Panel.SetActive(false);
        ActivatePanel(Login_UI_Panel.name);

        cachedRoomList = new Dictionary<string, RoomInfo>();
        roomListGameObjects = new Dictionary<string, GameObject>();

        PhotonNetwork.AutomaticallySyncScene = true; 
    }

    // Update is called once per frame
    private void Update()
    {
        connectionStatusText.text = "Connection status: " + PhotonNetwork.NetworkClientState;
    }

    #endregion

    #region UI Callbacks
    public void OnLoginButtonClicked()
    {
        string playerName = playerNameInput.text;
        if(!string.IsNullOrEmpty(playerName))
        {
            PhotonNetwork.LocalPlayer.NickName = playerName;
            PhotonNetwork.ConnectUsingSettings();

        }
        else
        {
            Debug.Log("PlayerName is invalid!");
        }

    }

    public void OnCreateRoomButtonClicked()
    {
        string roomName = roomNameInputField.text;

        if(string.IsNullOrEmpty(roomName))
        {
            roomName = "Room " + Random.Range(1000,10000);
        }

        RoomOptions roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = (byte)int.Parse(maxPlayerInputField.text);

        PhotonNetwork.CreateRoom(roomName,roomOptions);
    }

    public void OnCancelButtonClicked()
    {
        ActivatePanel(GameOptions_UI_Panel.name);
    }

    public void OnShowRoomListButtonClicked()
    {
        if(!PhotonNetwork.InLobby)
        {
            PhotonNetwork.JoinLobby();
        }

        ActivatePanel(RoomList_UI_Panel.name);
    }

    public void OnBackButtonClicked()
    {
        if(PhotonNetwork.InLobby)
        {
            PhotonNetwork.LeaveLobby();
        }
        ActivatePanel(GameOptions_UI_Panel.name);
    }

    public void OnLeaveGameButtonClicked()
    {
        PhotonNetwork.LeaveRoom();
    }

    public void OnJoinRandomRoomButtonClicked()
    {
        ActivatePanel(JoinRandomRoom_UI_Panel.name);
        PhotonNetwork.JoinRandomRoom();
    }

    public void OnStartGameButtonClicked()
    {
        if(PhotonNetwork.IsMasterClient)
        {
            PhotonNetwork.LoadLevel("GameScene");
        }
    }


    #endregion

    #region Photon Callbacks
    public override void OnConnected()
    {
        Debug.Log("Connected to Internet");
    }
    public override void OnConnectedToMaster()
    {
        Debug.Log(PhotonNetwork.LocalPlayer.NickName + "is connected to Photon");
        ActivatePanel(GameOptions_UI_Panel.name);

    }

    public override void OnCreatedRoom()
    {
        Debug.Log(PhotonNetwork.CurrentRoom.Name + " is created.");
    }

    public override void OnJoinedRoom()
    {
        Debug.Log(PhotonNetwork.LocalPlayer.NickName + " joined to " + PhotonNetwork.CurrentRoom.Name);
        ActivatePanel(InsideRoom_UI_Panel.name);

        if(PhotonNetwork.LocalPlayer.IsMasterClient)
        {
            startGameButton.SetActive(true);
        }
        else
        {
            startGameButton.SetActive(false);
        }

        roomInfoText.text = "Room name: " + PhotonNetwork.CurrentRoom.Name + " " + "Players/Max.players:" + PhotonNetwork.CurrentRoom.PlayerCount + "/" + PhotonNetwork.CurrentRoom.MaxPlayers;

        if(playerListGameObjects == null)
        {
            playerListGameObjects = new Dictionary<int, GameObject>();
        }

        foreach(Player player in PhotonNetwork.PlayerList)
        {
            GameObject playerListGameObject = Instantiate(playerListPrefab);
            playerListGameObject.transform.SetParent(playerListContent.transform);
            playerListGameObject.transform.localScale = Vector3.one;

            playerListGameObject.transform.Find("PlayerNameText").GetComponent<Text>().text = player.NickName;
            if(player.ActorNumber == PhotonNetwork.LocalPlayer.ActorNumber)
            {
                playerListGameObject.transform.Find("PlayerIndicator").gameObject.SetActive(true);
            }
            else
            {
                playerListGameObject.transform.Find("PlayerIndicator").gameObject.SetActive(false);

            }
            playerListGameObjects.Add(player.ActorNumber, playerListGameObject);
        }

    }

    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        roomInfoText.text = "Room name: " + PhotonNetwork.CurrentRoom.Name + " " + "Players/Max.players:" + PhotonNetwork.CurrentRoom.PlayerCount + "/" + PhotonNetwork.CurrentRoom.MaxPlayers;

        GameObject playerListGameObject = Instantiate(playerListPrefab);
        playerListGameObject.transform.SetParent(playerListContent.transform);
        playerListGameObject.transform.localScale = Vector3.one;

        playerListGameObject.transform.Find("PlayerNameText").GetComponent<Text>().text = newPlayer.NickName;
        if(newPlayer.ActorNumber == PhotonNetwork.LocalPlayer.ActorNumber) //ActorNumber=出入りで変わる識別番号
        {
            playerListGameObject.transform.Find("PlayerIndicator").gameObject.SetActive(true);
        }
        else
        {
            playerListGameObject.transform.Find("PlayerIndicator").gameObject.SetActive(false);
        }
        playerListGameObjects.Add(newPlayer.ActorNumber, playerListGameObject);

    }

    public override void OnPlayerLeftRoom(Player otherPlayer)
    {
        roomInfoText.text = "Room name: " + PhotonNetwork.CurrentRoom.Name + " " + "Players/Max.players:" + PhotonNetwork.CurrentRoom.PlayerCount + "/" + PhotonNetwork.CurrentRoom.MaxPlayers;

        Destroy(playerListGameObjects[otherPlayer.ActorNumber].gameObject);
        playerListGameObjects.Remove(otherPlayer.ActorNumber);

        if(PhotonNetwork.LocalPlayer.IsMasterClient)
        {
            startGameButton.SetActive(true);
        }
    }

    public override void OnLeftRoom() 
    {
        ActivatePanel(GameOptions_UI_Panel.name);
        foreach(GameObject playerListGameObject in playerListGameObjects.Values)
        {
            Destroy(playerListGameObject);
        }
        playerListGameObjects.Clear();
        playerListGameObjects = null;
    }

    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        ClearRoomListView();

        foreach(RoomInfo room in roomList)
        {
            Debug.Log(room.Name);
            if(!room.IsOpen || !room.IsVisible || room.RemovedFromList) 
            {
                if(cachedRoomList.ContainsKey(room.Name))
                {
                    cachedRoomList.Remove(room.Name);
                }
            }
            else
            {
                if(cachedRoomList.ContainsKey(room.Name)) 
                {
                    cachedRoomList[room.Name] = room;
                }
                else
                {
                    cachedRoomList.Add(room.Name, room);
                }
            }
        }

        foreach (RoomInfo room in cachedRoomList.Values)
        {
            GameObject roomListEntryGameObject = Instantiate(roomListEntryPrefab);
            roomListEntryGameObject.transform.SetParent(roomListParentGameObject.transform);
            roomListEntryGameObject.transform.localScale = Vector3.one;

            roomListEntryGameObject.transform.Find("RoomNameText").GetComponent<Text>().text = room.Name;
            roomListEntryGameObject.transform.Find("RoomPlayersText").GetComponent<Text>().text = room.PlayerCount + " / " + room.MaxPlayers;
            roomListEntryGameObject.transform.Find("JoinRoomButton").GetComponent<Button>().onClick.AddListener(()=> OnJoinRoomButtonClicked(room.Name));

            roomListGameObjects.Add(room.Name, roomListEntryGameObject);

        }
    }

    public override void OnLeftLobby()
    {
        ClearRoomListView();
        cachedRoomList.Clear();
    }

    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        Debug.Log(message);

        string roomName = "Room " + Random.Range(1000,10000); 

        RoomOptions roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 20;

        PhotonNetwork.CreateRoom(roomName,roomOptions);
    }

    #endregion

    #region Private Methods
    void OnJoinRoomButtonClicked(string _roomName)
    {
        if(PhotonNetwork.InLobby)
        {
            PhotonNetwork.LeaveLobby();
        }
        PhotonNetwork.JoinRoom(_roomName);

    }
    void ClearRoomListView()
    {
        foreach(var roomListGameObject in roomListGameObjects.Values)
        {
            Destroy(roomListGameObject);
        }

        roomListGameObjects.Clear();
    }
    #endregion

    #region  Public Methods
    public void ActivatePanel(string panelToBeActivated)
    {
        Login_UI_Panel.SetActive(panelToBeActivated.Equals(Login_UI_Panel.name));
        GameOptions_UI_Panel.SetActive(panelToBeActivated.Equals(GameOptions_UI_Panel.name));
        CreateRoom_UI_Panel.SetActive(panelToBeActivated.Equals(CreateRoom_UI_Panel.name));
        InsideRoom_UI_Panel.SetActive(panelToBeActivated.Equals(InsideRoom_UI_Panel.name));
        RoomList_UI_Panel.SetActive(panelToBeActivated.Equals(RoomList_UI_Panel.name));
        JoinRandomRoom_UI_Panel.SetActive(panelToBeActivated.Equals(JoinRandomRoom_UI_Panel.name));

    }
    #endregion


}

 

InputFieldで、
ニックネームやルームの日本語入力
プレイ人数設定での半角数字のみを受け付けるようにする方法は
次の記事で紹介しております。

【UnityでWebGL問題】InputFieldで日本語入力を可能にする&スマホでの入力を可能にする方法今回は、"WebGL"でゲームをビルドしたときに起こる InputFieldに関する問題の解決方法を紹介します。 WebGLに生...

特にWebGLでゲームを出力した場合、
日本語に関してさまざまな問題が発生するのです。

ま、日本語をそもそも使わなければよい話なんですけどね^^;

ABOUT ME
いなも@システマライフハッカー
”仙豆”を開発することを夢見て、健康食品会社で働いていたものの、2016年に出会ったロシアの武術”システマ”こそ、その糸口があると感銘し、勝手にシステマ普及活動を始める。 一方で、クリエイティブなモノ作りが好きで、DX社会で楽しみを見出せる"Unity”を活かして、”スマートかつ快適な暮らし”のヒントを発信している。

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA