久々に PUN を使ったお話。
ドキュメントを読みながら組めば簡単に作れたよ、ぐらいのお話なので、
かつてのように PUN の深堀をしたわけではないですー。
この記事にはUnity2020.3.20f1を使用しています。
.Netのバージョン設定には.Net4.x を使用しています。
テスト用のオンラインマルチプレイヤー処理が欲しくてね
来月のデジゲー博に向けて、日々ゲームを作っていますよ。
現在作っているゲームはローカルマルチプレイヤーゲームなので、複数人で遊ぶことを想定しています。
やったー🙌
— ヤス (@yasu_tokushi) 2021年10月8日
PUN2でオンライン機能が組み込めたぞー❣️
これでマルチプレイのテストがしやすくなるはず。
動画では何も伝わらないだろうけどー。#デジゲー博 #ゲーム開発 #unity #gamedev pic.twitter.com/XTQkVeNqAq
ですがこのご時世、人が集まってテストするのは難しいですね。
オンラインでテストできるようにする必要があります。
とはいえテスト用なので、そんなに作業時間をかけたくない。
パパっと組み込みたい。
というわけで、PUN こと Photon Unity Networking の出番ですよ。
assetstore.unity.com
これでも僕は昔、 PUN をいろいろ調べてまとめたこともあるんですよ。
記事の日付を見たら、2016年。
月日が経ちすぎて、 PUNはバージョンが2になっておる!
もうあの日のまとめは役に立たんようだね……。
というわけで、Photonさんの公式ドキュメントでも見てみましょうか。
doc.photonengine.com
めちゃくちゃ気合が入っているドキュメントだ!
サンプルコードに名前空間や #region
をがっつり使っている!
ただコードをコピーペーストするのではなくて、1つ1つ自分の手でタイピングすることが大切です。そのほうが覚えられますから。 コメントの記述はとても簡単です。メソッドかプロパティの上に///とタイプすると、自動的にスクリプトエディターがタグなどの構造化されたコメントを生成します。
技術者を育てようとするスゴイ熱意を感じる
他にも PUN2 でいろいろ検索してみたところ、このような記事が。
zenn.dev
すごい綺麗に纏ってるー!
こんな高いレベルのドキュメントがいっぱいあるなんて!
そりゃ1週間ゲームジャムでも、ネットワークゲームが増えるわけだよ!
もう僕がPUNをまとめる機会はなさそうですね。
パパっとネットワーク接続からルームへの参加まで作っちゃう
このゲームのオンライン機能は、所詮テスト用ですからね。
マッチングやらなんやらは気にせずにパパっと作ってしまいましょう。
以下の流れで十分です。
- 接続する
- とりあえずどこかの部屋に入る
- 部屋が無ければ作る
コードを組んでいきましょうか。
using UnityEngine; using Photon.Pun; using Photon.Realtime; public class GameManager : MonoBehaviourPunCallbacks { private void Start() { Debug.Log("接続するよ。"); PhotonNetwork.ConnectUsingSettings(); } public override void OnConnectedToMaster() { Debug.Log("接続出来たよ!"); Debug.Log("部屋に入るよ!"); PhotonNetwork.JoinRandomRoom(); } public override void OnJoinRandomFailed(short aReturnCode, string aMessage) { Debug.LogWarning("部屋に入るに失敗したよ"); Debug.Log("仕方ないから部屋を作るよ!"); // 2人用の部屋 PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 2 }); } public override void OnCreatedRoom() { Debug.Log("部屋を作ったよ"); } public override void OnJoinedRoom() { Debug.Log("部屋に入ったよ"); } } // class GameManager
あっさり出来たました!
簡単。
あとはユーザーが集まるまでのStete処理をコルーチンを使ってパパっと作ってしまいましょう。
using UnityEngine; using Photon.Pun; using Photon.Realtime; using System.Collections; public class GameManager : MonoBehaviourPunCallbacks { private IEnumerator Start() { Debug.Log("接続するよ。"); PhotonNetwork.ConnectUsingSettings(); // 接続まで待つ yield return new WaitUntil(() => PhotonNetwork.IsConnected); // 部屋に入るまで待つ yield return new WaitUntil(() => PhotonNetwork.InRoom); // 最大人数(2人)になるまで待つ yield return new WaitUntil(() => PhotonNetwork.CurrentRoom.PlayerCount == PhotonNetwork.CurrentRoom.MaxPlayers); StartCoroutine(Main()); } private IEnumerator Main() { Debug.Log("ゲームを開始するよ"); while(true) { // ゲームループ yield return null; } } // 以下 PUN コールバック関数... }
これでユーザ数が揃ったらゲームが開始出来るようになりました。
データの同機にはカスタムプロパティを使う。
今回作っているゲームは、
入力受付時間に各ユーザーがコマンドを入力して、
その後コマンドを集計して、各キャラクターに適応するという流れです。
すなわちユーザーの入力情報さえ同期してしまえば、
各デバイスでゲームの同期が取ることが出来るのです。
オンライン化しやすいゲームで良かった。
その程度の情報量なので
PhotonTransformView や RPC を使って同期する必要もなさそう。
*カスタムプロパティ** を使うだけでいけそうな気がします。
PhotonView
を使うのも面倒だしね。
こちらの o8que さんの記事を参考にしましょう。 zenn.dev
えーと、なになに。
カスタムプロパティは、 Room と 各Player に Hashtable
型で設定できて、
各取得と設定は以下の通りなわけですね。
PhotonNetwork.CurrentRoom.CustomProperties
PhotonNetwork.CurrentRoom.SetCustomProperties()
PhotonNetwork.LocalPlayer.CustomProperties
PhotonNetwork.LocalPlayer.SetCustomProperties()
そしてカスタムプロパティは変更したら、以下のコールバックが呼ばれると。
OnRoomPropertiesUpdate()
OnPlayerPropertiesUpdate()
ではこれらカスタムプロパティを使って、まずはユーザー同士の状態の同期を取る処理を作ってみましょう。
ロジックとしては
- 各プレイヤーとルームに State を持たせる。
- 全プレイヤーの State が同じなったら、マスターがルームの State を更新する。
- ルームの State と プレイヤーの State が同じになったら、 その State の処理を実行。
- プレイヤーの State を更新する。
といった感じです。
using UnityEngine; using UnityEngine.UI; using Photon.Pun; using Photon.Realtime; using System.Collections; public class GameManager : MonoBehaviourPunCallbacks { private enum State : byte { Invalid, Ready, Input, Output, Reset, } private byte m_myState = (byte)State.Ready; private byte m_roomState = default; private ExitGames.Client.Photon.Hashtable m_otherProperties = new ExitGames.Client.Photon.Hashtable(); private IEnumerator Main() { Debug.Log("ゲームを開始するよ"); var hashTable = new ExitGames.Client.Photon.Hashtable(); while (true) { // 自分の希望Stateを更新して、部屋のStateも同じになったら実行する。 hashTable["state"] = m_myState; PhotonNetwork.LocalPlayer.SetCustomProperties(hashTable); yield return new WaitUntil(() => m_roomState == m_myState); switch ((State)m_myState) { case State.Ready: break; case State.Input: break; case State.Output: break; case State.Reset: break; default: break; } // 入力ラグのテスト用の時間。 yield return new WaitForSeconds(Random.Range(0.0f, 2.0f)); m_myState++; if (m_myState >= System.Enum.GetValues(typeof(State)).Length) m_myState = (byte)State.Ready; yield return null; } } public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged) { m_roomState = (byte)propertiesThatChanged["state"]; } public override void OnPlayerPropertiesUpdate(Player aTargetPlayer, ExitGames.Client.Photon.Hashtable someChangedProps) { if (PhotonNetwork.LocalPlayer.UserId != aTargetPlayer.UserId) m_otherProperties = someChangedProps; // マスターのみが // 全プレイヤーの希望Stateが同じになったとき部屋のStateを更新する。 if (PhotonNetwork.IsMasterClient) { if (!PhotonNetwork.LocalPlayer.CustomProperties.ContainsKey("state")) return; if (!m_otherProperties.ContainsKey("state")) return; byte myState = (byte)PhotonNetwork.LocalPlayer.CustomProperties["state"]; byte otherState = (byte)m_otherProperties["state"]; if (myState == otherState) { var hashTable = new ExitGames.Client.Photon.Hashtable(); hashTable["state"] = myState; PhotonNetwork.CurrentRoom.SetCustomProperties(hashTable); } } } }
狙い通りにできたけど、思いのほかコードがぐちゃぐちゃだなぁ。
Hashtable の管理がごちゃごちゃする原因ですよね。
自作クラスに変換しておきましょうか。
operator
を自作すればもっとすっきりするはずです。
試してみましょう。
private class RoomProperty { public State state { get; private set; } = State.Invalid; private const string locStateKey = "state"; public RoomProperty() { } public RoomProperty(State aState) { state = aState; } public RoomProperty(ExitGames.Client.Photon.Hashtable someProps) { if (someProps.ContainsKey(locStateKey)) state = (State)someProps[locStateKey]; } public static implicit operator RoomProperty(ExitGames.Client.Photon.Hashtable someProps) { return new RoomProperty(someProps); } public static implicit operator ExitGames.Client.Photon.Hashtable(RoomProperty aProperty) { var hashtable = new ExitGames.Client.Photon.Hashtable(); hashtable[locStateKey] = (byte)aProperty.state; return hashtable; } } private class PlayerProperty { public State state { get; private set; } = State.Invalid; private const string locStateKey = "state"; public PlayerProperty() { } public PlayerProperty(State aState) { state = aState; } public PlayerProperty(ExitGames.Client.Photon.Hashtable someProps) { if (someProps.ContainsKey(locStateKey)) state = (State)someProps[locStateKey]; } public static implicit operator PlayerProperty(ExitGames.Client.Photon.Hashtable someProps) { return new PlayerProperty(someProps); } public static implicit operator ExitGames.Client.Photon.Hashtable(PlayerProperty aProperty) { var hashtable = new ExitGames.Client.Photon.Hashtable(); hashtable[locStateKey] = (byte)aProperty.state; return hashtable; } public void NextState() { state++; if ((int)state >= System.Enum.GetValues(typeof(State)).Length) state = State.Ready; } }
implicit operator
を用いて、 Hashtable から 自作クラスに変換出来るようにしています。
暗黙的な変換が苦手な人は、 explicit
を使ってみてください。
今回はテスト用で手を抜きたいこともあり implicit
を使っています。
それでこのクラスを使って、先ほどの処理を整理すると。
public class GameManager : MonoBehaviourPunCallbacks { private PlayerProperty m_myProperty = new PlayerProperty(State.Ready); private PlayerProperty m_otherProperty = new PlayerProperty(State.Invalid); private RoomProperty m_roomProperty = new RoomProperty(); private IEnumerator Main() { Debug.Log("ゲームを開始するよ"); while (true) { PhotonNetwork.LocalPlayer.SetCustomProperties(m_myProperty); yield return new WaitUntil(() => m_myProperty.state == m_roomProperty.state); switch ((State)m_myProperty.state) { case State.Ready: break; case State.Input: break; case State.Output: break; case State.Reset: break; default: break; } // 入力ラグのテスト用の時間。 yield return new WaitForSeconds(Random.Range(0.0f, 2.0f)); m_myProperty.NextState(); yield return null; } } public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged) { m_roomProperty = propertiesThatChanged; } public override void OnPlayerPropertiesUpdate(Player aTargetPlayer, ExitGames.Client.Photon.Hashtable someChangedProps) { if (PhotonNetwork.LocalPlayer.UserId != aTargetPlayer.UserId) m_otherProperty = someChangedProps; // マスターのみが // 全プレイヤーの希望Stateが同じになったとき部屋のStateを更新する。 if (PhotonNetwork.IsMasterClient) { if (m_myProperty.state == m_otherProperty.state) { PhotonNetwork.CurrentRoom.SetCustomProperties(new RoomProperty(m_myProperty.state)); } } } }
うん、少しは整理出来た気がします。
あとは入力情報のパラメータも PlayerProperty
入れて同期を取ってみましょう。
using UnityEngine; using UnityEngine.UI; using Photon.Pun; using Photon.Realtime; using System.Collections; public class GameManager : MonoBehaviourPunCallbacks { private enum State : byte { Invalid, Ready, Input, Output, Reset, } private class RoomProperty { public State state { get; private set; } = State.Invalid; private const string locStateKey = "state"; public RoomProperty() { } public RoomProperty(State aState) { state = aState; } public RoomProperty(ExitGames.Client.Photon.Hashtable someProps) { if (someProps.ContainsKey(locStateKey)) state = (State)someProps[locStateKey]; } public static implicit operator RoomProperty(ExitGames.Client.Photon.Hashtable someProps) { return new RoomProperty(someProps); } public static implicit operator ExitGames.Client.Photon.Hashtable(RoomProperty aProperty) { var hashtable = new ExitGames.Client.Photon.Hashtable(); hashtable[locStateKey] = (byte)aProperty.state; return hashtable; } } private class PlayerProperty { public State state { get; private set; } = State.Invalid; public Vector3Int position { get; set; } private const string locStateKey = "state"; private const string locPosKey = "pos"; public PlayerProperty() { } public PlayerProperty(State aState) { state = aState; } public PlayerProperty(ExitGames.Client.Photon.Hashtable someProps) { if (someProps.ContainsKey(locStateKey)) state = (State)someProps[locStateKey]; if (someProps.ContainsKey(locPosKey)) { var bytes = (byte[])someProps[locPosKey]; position = new Vector3Int((sbyte)bytes[0], (sbyte)bytes[1], (sbyte)bytes[2]); } } public static implicit operator PlayerProperty(ExitGames.Client.Photon.Hashtable someProps) { return new PlayerProperty(someProps); } public static implicit operator ExitGames.Client.Photon.Hashtable(PlayerProperty aProperty) { var hashtable = new ExitGames.Client.Photon.Hashtable(); hashtable[locStateKey] = (byte)aProperty.state; byte[] posBytes = new byte[] { (byte)aProperty.position.x, (byte)aProperty.position.y, (byte)aProperty.position.z }; hashtable[locPosKey] = posBytes; return hashtable; } public void NextState() { state++; if ((int)state >= System.Enum.GetValues(typeof(State)).Length) state = State.Ready; } } [SerializeField] private Transform[] m_characters = default; private PlayerProperty m_myProperty = new PlayerProperty(State.Ready); private PlayerProperty m_otherProperty = new PlayerProperty(State.Invalid); private RoomProperty m_roomProperty = new RoomProperty(); private IEnumerator Start() { Debug.Log("接続するよ。"); PhotonNetwork.ConnectUsingSettings(); // 接続まで待つ yield return new WaitUntil(() => PhotonNetwork.IsConnected); // 部屋に入るまで待つ yield return new WaitUntil(() => PhotonNetwork.InRoom); // 最大人数(2人)になるまで待つ yield return new WaitUntil(() => PhotonNetwork.CurrentRoom.PlayerCount == PhotonNetwork.CurrentRoom.MaxPlayers); StartCoroutine(Main()); } private IEnumerator Main() { Debug.Log("ゲームを開始するよ"); while (true) { PhotonNetwork.LocalPlayer.SetCustomProperties(m_myProperty); yield return new WaitUntil(() => m_myProperty.state == m_roomProperty.state); switch ((State)m_myProperty.state) { case State.Ready: break; case State.Input: { var pos = Vector3Int.zero; pos.x = Random.Range(-5, 5); pos.z = Random.Range(-3, 3); m_myProperty.position = pos; PhotonNetwork.LocalPlayer.SetCustomProperties(m_myProperty); } break; case State.Output: { int myIndex = 0; int otherIndex = 1; if (!PhotonNetwork.IsMasterClient) { myIndex = 1; otherIndex = 0; } m_characters[myIndex].position = m_myProperty.position; m_characters[otherIndex].position = m_otherProperty.position; } break; case State.Reset: break; default: break; } // 入力ラグのテスト用の時間。 yield return new WaitForSeconds(Random.Range(0.0f, 0.5f)); m_myProperty.NextState(); yield return null; } } public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged) { m_roomProperty = propertiesThatChanged; } public override void OnPlayerPropertiesUpdate(Player aTargetPlayer, ExitGames.Client.Photon.Hashtable someChangedProps) { if (PhotonNetwork.LocalPlayer.UserId != aTargetPlayer.UserId) m_otherProperty = someChangedProps; // マスターのみが // 全プレイヤーの希望Stateが同じになったとき部屋のStateを更新する。 if (PhotonNetwork.IsMasterClient) { if (m_myProperty.state == m_otherProperty.state) { PhotonNetwork.CurrentRoom.SetCustomProperties(new RoomProperty(m_myProperty.state)); } } } public override void OnConnectedToMaster() { Debug.Log("接続出来たよ!"); Debug.Log("部屋に入るよ!"); PhotonNetwork.JoinRandomRoom(); } public override void OnJoinRandomFailed(short aReturnCode, string aMessage) { Debug.LogWarning("部屋に入るに失敗したよ"); Debug.Log("仕方ないから部屋を作るよ!"); // 2人用の部屋 PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 2 }); } public override void OnCreatedRoom() { Debug.Log("部屋を作ったよ"); } public override void OnJoinedRoom() { Debug.Log("部屋に入ったよ"); } } // class GameManager
Vector3Int
を sbyte
にしたり byte
にしたりと荒業も目立ちますが、とりあえず動作してくれました。
アクションゲームだと同期で頭を悩ますことが多いですが、シミュレーションゲームは比較的に同期はあっさり組み込めて助かりますね。
この調子でゲームをどんどん作っていかなくてはー!