📝
[Unity6]Photon Fusionのサーバーモードでエアホッケー的な物を作ってみたい(部屋分け〜ゲームサイクル3/3)
はじめに
前回ではエアホッケー部分の移動や判定を実装したので、今回は部屋分けとゲームサイクルの実装をします。
事前準備
- 前回分のスクリプトの更新をします。
-
ServerScene
とGameScene
の他にServerSceneManager
とTitleScene
を用意します。 -
UIBuilder
を使用して、UIを作成します。
Player.cs
- サーバーでの移動だけではなく、クライアント側でも見た目だけの移動を行うようにしました。
-
Player
の空のオブジェクトの子供になるようにしました。
using UnityEngine;
using Fusion;
using System.Linq;
/// <summary>
/// プレイヤー
/// </summary>
public class Player : NetworkBehaviour
{
[SerializeField]
[Header("リジットボディ")]
Rigidbody2D rigidbody2d;
[Networked]
[SerializeField]
[Header("プレイヤーのインデックス")]
int playerIndex { get; set; } = 0;
// 予測位置
private Vector2 predictedPosition;
// ネットワークランナー
private NetworkRunner networkRunner;
/// <summary>
/// RPCの初期化
/// </summary>
[Rpc]
public void RpcInit()
{
// サーバーじゃないなら
if (!NetworkRunnerLauncher.Instance.isServer)
{
// ネットワークランナーを保存
networkRunner = NetworkRunnerLauncher.Instance.runner;
// 物理挙動をシミュレートする
networkRunner.SetIsSimulated(gameObject.GetComponent<NetworkObject>(), false);
}
// サーバーなら
else
{
// ネットワークランナーを保存
networkRunner = NetworkRunnerLauncher.Instance.runner;
// 物理挙動をシミュレートする
networkRunner.SetIsSimulated(gameObject.GetComponent<NetworkObject>(), true);
}
// 予測位置の初期化を追加
predictedPosition = transform.position;
// playerParentがあったら
GameObject playerParent = GameObject.Find("Player");
if (playerParent != null)
{
// playerParentが見つかった場合、親を設定
transform.SetParent(playerParent.transform);
}
// なかったら
else
{
Debug.LogError("Playerの親が見つかりませんでした。");
}
// プレイヤー1なら
if (playerIndex == 1)
{
// 親の変更で位置とスケールがおかしくなるので修正
transform.localScale = new Vector3(0.5f, 2.5f, 1.0f);
transform.position = new Vector3(-8.0f, 0.0f, 0.0f);
}
// プレイヤー2なら
else if (playerIndex == 2)
{
// 親の変更で位置とスケールがおかしくなるので修正
transform.localScale = new Vector3(0.5f, 2.5f, 1.0f);
transform.position = new Vector3(8.0f, 0.0f, 0.0f);
}
}
/// <summary>
/// PhotonFixedUpdate
/// </summary>
public override void FixedUpdateNetwork()
{
if (networkRunner != null)
{
if (networkRunner.IsServer)
{
// サーバー側での物理演算による移動
ServerMove();
}
else if (Object.HasInputAuthority)
{
// クライアント側での予測移動
ClientPredictMove();
}
}
}
/// <summary>
/// サーバーでの移動
/// </summary>
void ServerMove()
{
// プレイヤーの入力データを取得、できなかったら処理しない
if (networkRunner.TryGetInputForPlayer(Object.InputAuthority, out PlayerInputData inputData))
{
// 移動速度
float moveSpeed = 5f;
// 入力に応じて移動計算
Vector2 moveDir = new Vector2(inputData.horizontal, inputData.vertical);
Vector2 newPos = rigidbody2d.position + moveDir * moveSpeed * networkRunner.DeltaTime;
// リジットボディで移動
rigidbody2d.MovePosition(newPos);
}
}
/// <summary>
/// クライアントでの移動予測
/// </summary>
private void ClientPredictMove()
{
// プレイヤーの入力データを取得、できなかったら処理しない
if (networkRunner.TryGetInputForPlayer(Object.InputAuthority, out PlayerInputData inputData))
{
// 移動速度
float moveSpeed = 5f;
// 入力に応じて移動計算
Vector2 moveDir = new Vector2(inputData.horizontal, inputData.vertical);
// 入力がある場合のみ予測位置を更新
if (moveDir != Vector2.zero)
{
predictedPosition += moveDir * moveSpeed * networkRunner.DeltaTime;
}
// 位置を更新
transform.position = predictedPosition;
}
}
/// <summary>
/// サーバーからの位置更新を受信したとき
/// </summary>
public override void Render()
{
if (networkRunner != null)
{
// クライアントで権限があれば
if (!networkRunner.IsServer && Object.HasInputAuthority)
{
// サーバーの正しい位置を取得(NetworkBehaviourが持つ同期済みの位置)
Vector2 networkPosition = Object.transform.position;
// 予測位置との差が大きい場合は補正
if (Vector2.Distance(networkPosition, predictedPosition) > 0.5f)
{
predictedPosition = networkPosition;
transform.position = networkPosition;
}
}
}
}
/// <summary>
/// プレイヤーのインデックス設定
/// </summary>
/// <param name="index">プレイヤーのインデックス</param>
[Rpc]
public void RpcSetPlayerIndex(int index)
{
// プレイヤーのインデックスが設定されていなかったら
if (playerIndex == 0)
{
playerIndex = index;
}
}
}
Ball.cs
-
Spawned
からRpc
属性のRpcInit
を使用するように変更しました。 -
Ball
の空のオブジェクトの子供になるようにしました。
using UnityEngine;
using Fusion;
using UniRx;
using System.Linq;
/// <summary>
/// ボール
/// </summary>
public class Ball : NetworkBehaviour
{
[SerializeField]
[Header("リジットボディ")]
Rigidbody2D rigidbody2d;
// ネットワークランナー
private NetworkRunner networkRunner;
/// <summary>
/// RPCの初期化
/// </summary>
[Rpc]
public void RpcInit()
{
// ballParentがあったら
GameObject ballParent = GameObject.Find("Ball");
if (ballParent != null)
{
// ballParentが見つかった場合、親を設定
transform.SetParent(ballParent.transform);
}
// なかったら
else
{
Debug.LogError("Playerの親が見つかりませんでした。");
}
// サーバーじゃないなら
if (!NetworkRunnerLauncher.Instance.isServer)
{
// ネットワークランナーを保存
networkRunner = NetworkRunnerLauncher.Instance.runner;
// 物理挙動をシミュレートする
networkRunner.SetIsSimulated(gameObject.GetComponent<NetworkObject>(), false);
}
// サーバーなら
else
{
// ネットワークランナーを保存
networkRunner = NetworkRunnerLauncher.Instance.runner;
// 物理挙動をシミュレートする
networkRunner.SetIsSimulated(gameObject.GetComponent<NetworkObject>(), true);
// 右側に押し出す
rigidbody2d.AddForce(new Vector2(5.0f, 0.0f), ForceMode2D.Impulse);
}
}
/// <summary>
/// トリガーイベント
/// </summary>
/// <param name="collision"></param>
private void OnTriggerEnter2D(Collider2D collision)
{
// サーバーなら
if (networkRunner.IsServer)
{
// どっちかのプレイヤーに加点
if (collision.tag == "Player_1")
{
NetworkRunnerLauncher.Instance.scoreManager.GetComponent<ScoreManager>().RpcAddScore(1, 1);
}
else if(collision.tag == "Player_2")
{
NetworkRunnerLauncher.Instance.scoreManager.GetComponent<ScoreManager>().RpcAddScore(0, 1);
}
// 3秒後に処理
Observable.Timer(System.TimeSpan.FromSeconds(3)).Subscribe(_ =>
{
// 終了してなかったら
if (!NetworkRunnerLauncher.Instance.scoreManager.GetComponent<ScoreManager>().isEnd)
{
// 初期位置にする
rigidbody2d.position = Vector2.zero;
// 速度をリセット
rigidbody2d.linearVelocity = Vector2.zero;
// 右側に押し出す
rigidbody2d.AddForce(Vector2.right * 5.0f, ForceMode2D.Impulse);
}
}).AddTo(this);
}
}
}
ServerScene
- 全て空のオブジェクトで
Player
,Ball
,UI
のオブジェクトを作成します。 - イベントシステムやライトを削除して、オーディオリスナーを削除します。
GameScene
- サーバーと同じく全て空のオブジェクトで
Player
,Ball
,UI
のオブジェクトを作成します。
ServerSceneManager
- 新規シーンを作成して、カメラ/イベントシステム/ライトのみのシーンにします。
- 後に作成する
ServerSceneManager
をアタッチします。
TitleScene
- タイトル画面からゲーム画面に遷移するだけの画面です。
-
UIDocument
でボタンを配置して押下するとGameScene
に遷移する実装のみです。 - 後に作成する
Title
をアタッチします。
部屋分け/ゲームサイクル処理
- 部屋分け
- 1NetWorkRunnerが1部屋となっていて、1PC1サーバーか1PC複数実行ファイル起動等方法はある中で1シーン1部屋の構成にしました。
- サーバー側はServerScene
ではなくServerSceneManager
シーンで実行をします。 - ゲームサイクル
- UIはUIToolKit
を使用して、UIBuilder
で組み立てています。
-TitleScene
から始まり、ボタンを押下するとGameScene
に遷移しプレイヤーが集まるとゲームが開始されます。
- どちらかが指定されている点数を獲得するとリザルトが表示され、TitleScene
に遷移します。
ClientRunnerLogic.cs
- リザルト後にサーバーから抜けて、タイトルに遷移する関数を作成します。
// using省略
/// <summary>
/// クライアントのランナーロジック
/// </summary>
public static class ClientRunnerLogic
{
~~~~~~~~~~~~~~~~~~~~~~~~OnInputLogic省略~~~~~~~~~~~~~~~~~~~~~~~~
/// <summary>
/// 切断ロジック
/// </summary>
/// <param name="runner"></param>
public static void OnShutdownLogic(NetworkRunner runner)
{
// 接続を切る
runner.Shutdown();
// タイトルに遷移
SceneManager.LoadSceneAsync("TitleScene");
}
}
Player.cs
-
RpcInit
の最後に1行追加します。
-GameObject.Find("MainUIDocument(Clone)").GetComponent<ScoreManager>().SetPlayerIndex(playerIndex);
を追加します。
Title.cs
-
UIBuilder
で組み立てたUIからボタンを取得して、ボタン押下時にGameScene
に遷移するようにしています。
using UnityEngine;
using Fusion;
using System.Linq;
using UnityEngine.UIElements;
using UnityEngine.SceneManagement;
/// <summary>
/// タイトル
/// </summary>
public class Title : MonoBehaviour
{
[SerializeField]
[Header("メインのUIDocument")]
UIDocument UIDocument;
private void Start()
{
// UIDocumentのルートVisualElement を取得
VisualElement root = UIDocument.rootVisualElement;
// ボタンを取得
Button startButton = root.Q<Button>("StartButton");
// ボタンの挙動を設定
startButton.clicked += () =>
{
// ゲームに遷移
SceneManager.LoadSceneAsync("GameScene");
};
}
}
NetworkRunnerLauncher.cd
-
disconnectNum
,scoreManagerPrefab
,scoreManager
を追加します。 - サーバー処理の
Start
の最後にscoreManager = runner.Spawn(scoreManagerPrefab);
を追加します。 - 接続の切断関数(
RpcDisconnect
)を追加します。 - サーバー側
OnPlayerLeft
を使用してサーバーの停止を行います。
//using省略
/// <summary>
/// ネットワークランナーランチャー
/// </summary>
public class NetworkRunnerLauncher : MonoBehaviour, INetworkRunnerCallbacks
{
[SerializeField]
[Header("スコアマネージャープレハブ")]
NetworkObject scoreManagerPrefab;
[Header("生成したスコアマネージャー")]
public NetworkObject scoreManager;
~~~~~~~~~~~~~~~~~~~~~~~~他の変数~~~~~~~~~~~~~~~~~~~~~~~~
// 切断人数
private int disconnectNum;
// インスタンス
private static NetworkRunnerLauncher instance;
// インスタンス
public static NetworkRunnerLauncher Instance
{
get
{
if (instance == null)
{
instance = FindFirstObjectByType<NetworkRunnerLauncher>();
}
return instance;
}
}
/// <summary>
/// 初期化
/// </summary>
private async void Start()
{
// コールバックを受け取る
runner.AddCallbacks(this);
// サーバーなら
if (isServer)
{
// ルームIDを取得
roomId = ServerSceneManager.Instance.GetRoomNum();
// デフォルトのシーン
NetworkSceneInfo sceneInfo = default(NetworkSceneInfo);
// サーバーモードで起動
StartGameArgs startGameArgs = new StartGameArgs()
{
GameMode = GameMode.Server, // サーバーモードで動作
Scene = sceneInfo, // default の NetworkSceneInfo を設定
SessionProperties = new Dictionary<string, SessionProperty> // セッション検索のフィルター
{
{ "GameType", 1 }
},
EnableClientSessionCreation = true, // 新しいセッションを作成できるか
PlayerCount = 2, // プレイヤーの上限
IsOpen = true, // 参加可能かどうか
IsVisible = true, // セッションを公開するか
MatchmakingMode = MatchmakingMode.FillRoom // どのようにマッチングさせるか
};
// サーバーでゲームを開始
StartGameResult result = await runner.StartGame(startGameArgs);
// リザルトがOKじゃなかったら
if (!result.Ok)
{
Debug.LogError($"サーバー起動に失敗: {result.ShutdownReason}");
}
// リザルトがOKなら
else
{
Debug.Log("サーバーが正常に起動しました。");
}
// スコアマネージャーをスポーン
scoreManager = runner.Spawn(scoreManagerPrefab);
}
// クライアントなら
else
{
// クライアント接続する場合
StartGameArgs startGameArgs = new StartGameArgs()
{
GameMode = GameMode.Client, // クライアントモード
SessionProperties = new Dictionary<string, SessionProperty> // セッション検索のフィルター
{
{ "GameType", 1 }
},
MatchmakingMode = MatchmakingMode.FillRoom // どのようにマッチングさせるか
};
// クライアントを開始
StartGameResult result = await runner.StartGame(startGameArgs);
// リザルトがOKじゃなかったら
if (!result.Ok)
{
Debug.LogError($"サーバー接続失敗: {result.ShutdownReason}");
}
// リザルトがOKなら
else
{
Debug.Log("サーバーに接続できました。");
}
}
}
/// <summary>
/// 接続切断
/// </summary>
[Rpc]
public void RpcDisconnect()
{
// クライアントなら
if (!runner.IsServer)
{
// クライアントの切断ロジック
ClientRunnerLogic.OnShutdownLogic(runner);
}
}
~~~~~~~~~~~~~~~~~~~~~~~~他のコールバック~~~~~~~~~~~~~~~~~~~~~~~~
/// <summary>
/// プレイヤーが切断
/// </summary>
/// <param name="networkRunner"></param>
/// <param name="playerRef"></param>
public void OnPlayerLeft(NetworkRunner networkRunner, PlayerRef playerRef)
{
disconnectNum++;
// サーバーなら
if (runner.IsServer && disconnectNum >= 2)
{
// クライアントの切断ロジック
ServerRunnerLogic.OnShutdownLogic(runner, roomId);
}
}
~~~~~~~~~~~~~~~~~~~~~~~~他のコールバック~~~~~~~~~~~~~~~~~~~~~~~~
}
ScoreManager.cs
- スコアの加算/表示を行うクラスです。
- UIの空のオブジェクトの子供になるように設定しました。
- スコアが指定した値を超えると、UIを切り替えて接続の切断を行います。
using System.Collections.Generic;
using Fusion;
using UniRx;
using UnityEngine;
using UnityEngine.UIElements;
/// <summary>
/// スコアマネージャー
/// </summary>
public class ScoreManager : NetworkBehaviour
{
[SerializeField]
[Header("メインのUIDocument")]
UIDocument UIDocument;
// 終了したかどうか
public bool isEnd = false;
// プレイヤースコア値リスト
private List<int> playerScoreList = new List<int>();
// プレイヤーのスコアラベルリスト
private List<Label> playerScoreLabel = new List<Label>();
// 結果のラベル
private Label resultLabel;
// プレイヤーのインデックス
private int playerIndex;
/// <summary>
/// 初期化
/// </summary>
private void Start()
{
// uiParentがあったら
GameObject uiParent = GameObject.Find("UI");
if (uiParent != null)
{
// playerParentが見つかった場合、親を設定
transform.SetParent(uiParent.transform);
}
// なかったら
else
{
Debug.LogError("UIDocumentの親が見つかりませんでした。");
}
}
/// <summary>
/// 初期化
/// </summary>
[Rpc]
public void RpcInit()
{
// UIDocumentのルートVisualElement を取得
VisualElement root = UIDocument.rootVisualElement;
// プレイヤーのスコアラベルを取得
playerScoreLabel.Add(root.Q<Label>("PlayerScore_0"));
playerScoreLabel.Add(root.Q<Label>("PlayerScore_1"));
resultLabel = root.Q<Label>("ResultLabel");
// プレイヤーのスコア値を初期化
playerScoreList.Add(0);
playerScoreList.Add(0);
// プレイヤーのスコアを初期表示
playerScoreLabel[0].text = playerScoreList[0].ToString();
playerScoreLabel[1].text = playerScoreList[1].ToString();
}
/// <summary>
/// スコアを加算してLabelを更新する
/// </summary>
/// <param name="playerIndex">プレイヤーのインデックス</param>
/// <param name="addValue">加算するスコア</param>
[Rpc]
public void RpcAddScore(int playerIndex, int addValue)
{
// スコアを更新
playerScoreList[playerIndex] += addValue;
playerScoreLabel[playerIndex].text = playerScoreList[playerIndex].ToString();
// 10点以上取ったら
if (playerScoreList[playerIndex] >= 1)
{
// スコアを非表示にする
VisualElement root = UIDocument.rootVisualElement;
root.Q<VisualElement>("Score").style.display = DisplayStyle.None;
// リザルトを表示する
if (playerIndex == (this.playerIndex - 1))
{
resultLabel.text = "Winner!";
}
else
{
resultLabel.text = "You lose";
}
root.Q<VisualElement>("Result").style.display = DisplayStyle.Flex;
isEnd = true;
// 2秒後に処理
Observable.Timer(System.TimeSpan.FromSeconds(2)).Subscribe(_ =>
{
// 切断
NetworkRunnerLauncher.Instance.RpcDisconnect();
}).AddTo(this);
}
}
/// <summary>
/// プレイヤーインデックスの設定
/// </summary>
/// <param name="index"></param>
public void SetPlayerIndex(int index)
{
// プレイヤーのインデックスが設定されていなかったら
if (playerIndex == 0)
{
playerIndex = index;
}
}
}
ServerRunnerLogic.cs
- 接続の切断行う関数を追加しました。
- 2人目が接続してきた際に新規の部屋を作成するようにしました。
using UnityEngine;
using Fusion;
using System.Linq;
using System.Collections.Generic;
/// <summary>
/// サーバーのランナーロジック
/// </summary>
public static class ServerRunnerLogic
{
/// <summary>
/// プレイヤー接続ロジック
/// </summary>
/// <param name="networkRunner">ネットワークランナー</param>
/// <param name="playerPrefab">プレイヤープレハブ</param>
/// <param name="ballPrefab">ボールプレハブ</param>
/// <param name="playerRef">プレイヤー情報</param>
public static void JoinPlayerLogic(NetworkRunner networkRunner, NetworkObject playerPrefab, NetworkObject ballPrefab, PlayerRef playerRef)
{
Debug.Log($"SessionInfo:\n[\nName:{networkRunner.SessionInfo.Name}\nPlayerCount:{networkRunner.SessionInfo.PlayerCount}\nProperties:{networkRunner.SessionInfo.Properties}\n]");
// 現在のプレイヤー数を取得
int playerCount = networkRunner.ActivePlayers.Count();
Debug.Log("playerCount" + playerCount.ToString());
// プレイヤーのネットワークオブジェクト
NetworkObject player = null;
// プレイヤー生成
player = networkRunner.Spawn(playerPrefab, new Vector3(0.0f, 0.0f, 0.0f), Quaternion.identity, playerRef);
// プレイヤーが生成されていたら
if (player != null)
{
// インデックスを設定
player.GetComponent<Player>().RpcSetPlayerIndex(playerCount);
// プレイヤーを初期化
player.GetComponent<Player>().RpcInit();
}
// 2人目のプレイヤー
if (playerCount == 2)
{
// ボール生成
NetworkObject ball = networkRunner.Spawn(ballPrefab, new Vector3(0.0f, 0.0f, 0.0f), Quaternion.identity);
// 玉の初期化
ball.GetComponent<Ball>().RpcInit();
// スコアを初期化
NetworkRunnerLauncher.Instance.scoreManager.GetComponent<ScoreManager>().RpcInit();
// 新規の部屋を作成
ServerSceneManager.Instance.CreateRoom();
// 重複する名前の更新
ServerSceneManager.Instance.UpdateName();
}
}
/// <summary>
/// 接続ロジック
/// </summary>
/// <param name="runner"></param>
/// <param name="roomId"></param>
public static void OnShutdownLogic(NetworkRunner runner, int roomId)
{
// 接続を切る
runner.Shutdown();
// シーンを閉じる
ServerSceneManager.Instance.CloseScene(roomId);
}
}
ServerSceneManager.cs
- 各部屋を管理する処理です。
- serverSceneNameのシーンを読み込み1部屋として扱います。
- LocalPhysicsMode.Physics2Dでシーンを読み込み、判定をシーン事に分けます。
- Physics2Dにすると、自動で物理演算が効かなくなったのでFixedUpdate内で手動更新をしています。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class ServerSceneManager : MonoBehaviour
{
[SerializeField]
[Header("サーバーシーン名")]
string serverSceneName;
// シーン事のPhysicsScene2Dリスト
private List<Scene> sceneList = new List<Scene>();
// ルーム数
private int roomNum = 0;
// インスタンス
private static ServerSceneManager instance;
// インスタンス
public static ServerSceneManager Instance
{
get
{
if (instance == null)
{
instance = FindFirstObjectByType<ServerSceneManager>();
}
return instance;
}
}
/// <summary>
/// 初期化
/// </summary>
private void Start()
{
CreateRoom();
}
/// <summary>
/// 定期更新
/// </summary>
void FixedUpdate()
{
// customPhysicsScene内の物理シミュレーションを手動で進める
sceneList.ForEach(scene =>
{
// シーンが有効かどうか
if (scene.IsValid() && scene.isLoaded)
{
scene.GetPhysicsScene2D().Simulate(Time.fixedDeltaTime);
}
});
}
/// <summary>
/// 新しい物理シーンを作成する
/// </summary>
/// <returns></returns>
public void CreateRoom()
{
// 用意したシーンserverSceneNameをAdditiveかつLocalPhysicsMode.Physics2Dで読み込む
LoadSceneParameters loadParams = new LoadSceneParameters(
LoadSceneMode.Additive,
LocalPhysicsMode.Physics2D
);
// シーンを読み込んでリストに保存
Scene loadedScene = SceneManager.LoadScene(serverSceneName, loadParams);
sceneList.Add(loadedScene);
roomNum++;
}
/// <summary>
/// 重複する名前の更新
/// </summary>
public void UpdateName()
{
FindUpdateName("Player");
FindUpdateName("UI");
FindUpdateName("Ball");
}
/// <summary>
/// nameのオブジェクトを検索して名前を更新
/// </summary>
/// <param name="name"></param>
private void FindUpdateName(string name)
{
GameObject updateObj = GameObject.Find(name);
if (updateObj != null)
{
updateObj.name = $"{name}_{roomNum}";
}
}
/// <summary>
/// ルーム数を取得
/// </summary>
/// <returns></returns>
public int GetRoomNum()
{
return roomNum;
}
/// <summary>
/// シーンを閉じる
/// </summary>
/// <param name="roomId"></param>
public void CloseScene(int roomId)
{
SceneManager.UnloadSceneAsync(sceneList[roomId - 1]);
}
}
実行結果
ゲームを開始すると設定したポイント分(1ポイント)の得点になり次第リザルト画面UIが表示されタイトルに戻されるようになり、ゲームが何回でも遊べるようになっています。
1回目は左のプレイヤー、2回目は右のプレイヤーとなっています。
(複数部屋は重たすぎて録画できませんでした)
わかった事
- 複数部屋を立てるのには今回の方法だとサーバーの負担がかなりあった
- サーバー側に全て物理シュミュレーションを任せるのはよくなさそう
- まだラグが目につくのでラグ補完の部分をどうにかしないといけない
Discussion