作例
こちら( Photon BoltでPhysicsのボールは動くのか - tanaka's Programming Memo )の記事の内容が古くなっていたので、2019/7/28現在の最新場である1.2.9に対応させて書き直しました。
目次
Photon Boltとは
マルチプレイヤー ゲームのネットワーク部分を担当してくれるUnity用のネットワークエンジンです(ビジュアルスクリプティング のBoltの話ではございません!! )Exit Gamesが提供しているネットワークエンジン の一つで、最近流行っている人が集まって対戦するようなリアルタイムネットワークゲーム を開発するのに向いています。
詳しくは以下の公式サイトや、アセットストア をどうぞ。
www.photonengine.com
注意点!
Photon Boltの最大の注意点の一つは、現時点でWebGL に対応していない!! ということだと思います。1週間ゲームジャムとかでは使えませんのでご注意を!PUNの方は使えます。
目的
学園祭のような人が集まる場所で、同じ教室内のLAN環境で、エアホッケー のネットワーク対戦ゲームを動かしたい というのが今回の出発点です。ビルドターゲットはWindows やmac などのPCです。最初のマッチング時にPhotonクラウド を通す必要があるのでインターネット接続は必要 です。
エアホッケー ならRigidbody
のvelocity
を同期できれば簡単に作れそうです。しかし、Photon Boltにはvelocity
を同期するためのState 設定がありません。そこで、Command を使うことで対応させました。その構築手順です。
前提
はじめよう を読んで、Photon Bolt Free のインストールと概要を確認しておくのが望ましいです。
ブログではUnity2018.4.4f1 と2019.1.5 で動作確認しました。ブログ執筆時のPhoton BoltのUnityのサポートバージョンは2017.4.26以降です。
考え方
Photon BoltでPC間で同期したいデータは、State を作って、それにデータを割り当てます。そこにRigidbody のvelocity とかを同期する設定があればいいのですがありません。UNetにはあったので油断していました。
Photon Boltでは、Commands を使って、クライアントからホストに操作情報を送らせて、サーバーでまとめて制御した結果をクライアントに戻して反映させる、という仕組みがあります。ざっくりこんな感じ。
(public) RollerBallBoltChart | Sketchboard
1カ所でまとめて物理シミュレーションを行うので操作に対する反応は少し遅れますが、全てのプレイヤーの動きを一致させることができます。
今回は、Commands でマウスのX-Yの移動量を送信して、プレイヤーの座標を結果(Result)として返すようにしました。
プロジェクトを作成
まずはProjectを作成します。作業中、指示通りにやっているのに動かない場合は、Unityを閉じてプロジェクトを開き直すとか、Unityが2017.4以降かを確認してください。
アカウントの入手とApp IDの取得
Sign In | Photon Engine を開いて、Photonアカウントの作成と、Bolt用のアプリを作成して、App IDを生成してください。
フィールドを読み込む
作例用のプレイヤーやフィールドのオブジェクトを用意しました。まずはそれらを組み込みます。
新規にPhotonBoltPhysics
などの名前でUnityのプロジェクトを作成してください。Unityのバージョンは2017.4以降の安定版がよいでしょう。このブログではUnity2018.4.4を使っています
こちら を開いてください
最新版のPhotonBoltPhysics@v?.?.?.unitypackage
をクリックしてダウンロードします
ダウンロードが完了したら、UnityのProject ウィンドウにドラッグ&ドロップして、インポートします
以上で、ゲーム用のオブジェクトが読み込まれます。Scenes フォルダー内のGame シーンをダブルクリックすると、フィールドとプレイヤー2つ、ボールが確認できます。
Gameシーン
Photon Boltの初期設定
Photon Boltは、同時接続20接続(マッチング後にサーバーを利用しなくなったアカウントも含みます)などの制約以内であれば、無料で利用できます。開発中や、今回のような試用の場合はFree版でいけます。
エラーが出るので、直すために設定をします。
Bolt メニューがあるか確認します。見当たらなかったら、Unityを閉じてプロジェクトを開きなおします
Bolt メニューからWizard を選んで、ウィンドウが開いたらNext をクリックします
PhotonのWebページで作成したApp ID をコピーして、Photon Bolt App ID or Email 欄に入力します
変なリージョンに行かれると接続ができなかったりしたので、テスト時はRegion を[jp] Japan :: Tokyo にしておきます
NAT Punchthrough Enabled はチェックを入れたままにしておきます
以上選択したらNext
Sample を有効にすると、公式のサンプルを見ることができます。今回は不要なので、そのままNext をクリックします
Done をクリックします
Bolt Setup Complete ダイアログが表示されます。コンパイル が必要なので、Yes をクリックします
以上でエラーが解消されます。エラーがまだ表示されていたら、Console ウィンドウのClear ボタンをクリックすれば消えます。
サーバーとクライアントのどちらでログインするかを決めるためのボタンとサーバー接続のコードを実装したMenu シーンを作成します。
デフォルトで作成されるSampleScene があったら、名前をMenu に変更します
Unity2017だとシーンはないので、New Scene で新しいシーンを作成して、名前をMenu にします
作成したMenu シーンをダブルクリックして切り替えます
Hierarchy ウィンドウのMain Camera をクリックして選択します
Inspector ウィンドウのAdd Component をクリックして、New script で新しいスクリプト を作成して、名前をMenu
にします
作成したMenu スクリプト をダブルクリックして開きます
以下のスクリプト に置き換えます。Photon Bolt アセットのGettingStarted サンプルに含まれているMenu.cs を元にして、シーン名をGame
に変更したものです
using System;
using System.Collections;
using System.Collections.Generic;
using Bolt.Matchmaking;
using UdpKit;
using UnityEngine;
namespace Bolt.Samples.GettingStarted
{
public class Menu : Bolt.GlobalEventListener
{
bool ShowGui = true ;
private void Awake()
{
Application.targetFrameRate = 60 ;
BoltLauncher.SetUdpPlatform(new PhotonPlatform());
}
void OnGUI()
{
if (!ShowGui) { return ; }
GUILayout.BeginArea(new Rect(10 , 10 , Screen.width - 20 , Screen.height - 20 ));
if (GUILayout.Button("Start Server" , GUILayout.ExpandWidth(true ), GUILayout.ExpandHeight(true )))
{
BoltLauncher.StartServer();
}
if (GUILayout.Button("Start Client" , GUILayout.ExpandWidth(true ), GUILayout.ExpandHeight(true )))
{
BoltLauncher.StartClient();
}
GUILayout.EndArea();
}
public override void BoltStartBegin()
{
ShowGui = false ;
}
public override void BoltStartDone()
{
if (BoltNetwork.IsServer)
{
string matchName = Guid.NewGuid().ToString();
BoltMatchmaking.CreateSession(
sessionID: matchName,
sceneToLoad: "Game"
);
}
}
public override void SessionListUpdated(Map<Guid, UdpSession> sessionList)
{
Debug.LogFormat("Session list updated: {0} total sessions" , sessionList.Count);
foreach (var session in sessionList)
{
UdpSession photonSession = session.Value as UdpSession;
if (photonSession.Source == UdpSessionSource.Photon)
{
BoltNetwork.Connect(photonSession);
}
}
}
}
}
Playすると、以下のようにメニューが表示されます。
Menu画面
まだ設定などが足りないので、ボタンを押してもエラーになります。Playを停止して設定を続けます。
ビルド設定
チャプター1 | Photon Engine を参考にビルド設定をします。
File メニューからBuild Settings を選択します
Project メニューからMenu シーンをドラッグして、Scenes In Build 欄にドロップします
同様に、Game シーンもドラッグ&ドラッグでScenes In Build 欄に追加します
ビルドシーン
Player Settings... ボタンをクリックします
Resolution and Presentation 欄をクリックして開いて、以下を設定します
Fullscreen Mode をWindows に設定
Default Screen Width を640
に設定
Default Screen Height を360
に設定
Run In Background にチェックを入れます
Display Resolution Dialog をDISABLED にします
以上は開発用の設定です。開発が完了したら適宜変更してください。
Build Settings ウィンドウを閉じます
Bolt メニューから、Compile Assembly を選択します。これをやらないと、シーンがBoltに認識されないので動きません
以上設定したらPlay して、Start Server ボタンをクリックしてください。Gameシーンに切り替わります。
Gameシーン
Gameシーンの設定を全くしていないので操作はできません。停止して、Gameシーンの実装をします。
Gameシーンを作る
ネットワークに接続して、Gameシーンに切り替えることができたので、Gameシーン本体の実装を始めます。
Stateの作成
Boltでネットワークに登場させるゲームオブジェクトは、同期するデータを定義したState を持ちます。今回はTransform だけ同期します。そのためのTransformState
を作成します。
Bolt メニューからAssets を選択します
Bolt Assets ウィンドウの何もない部分を右クリックして、New State を選択します
以下の通り設定します
TransformStateの作成
設定したら、Bolt Editor ウィンドウは閉じます
利用するプロパティはTransform
型のTransform
だけです。
Space はデフォルトがLocal だったのでそのままにしました。今回は使っていませんが、State やCommand などの設定にCompression (圧縮)の項目があって、そこで値の範囲を制限して送信するデータ量を減らすことができます。Space をLocal にしておくことで、設定した範囲を越える移動は親のオブジェクト側で行うような工夫ができそうです。
捕捉: クライアントの動きを滑らかにする設定(補間とNetwork Rate)
Smoothing Algorithm はNone のままにしました。Interpolation(内挿)とExterpolation(外挿)の違いは以下にあります。
Interpolation vs. Extrapolation | Photon Engine
ざっくりと、Interpolation は過去のデータを利用して動きを滑らかに見せます。過去の場所の間に補間するので、突飛な場所に移動することはありません。ただし、少し古い座標に表示されることになります。
Exterpolation は過去の動きの傾向から、次の座標を予想して動かします。レスポンスは速いですが、予想が外れた場合はいたことがない座標に移動してしまいます。
今回のようにマウス操作&Physicsベースだと、動きの予想がしずらいのでExterpolation での予測精度があまりよくありませんでした。また、Interpolation は遅延が気になります。
ということで、None のままにして補間はしないことにしました。そのままではガタついて見えてしまいますが、これはState の送受信の頻度が原因です。最後の方の設定の調整 のところで調整します。
Commandの作成
今回の肝であるクライアントからマウスの移動量を送り、座標を返してもらうためのCommand を作成します。
Bolt Assets ウィンドウの余白を右クリックして、New Command を選択します
以下のように設定します
RollerBallBoltCommandの作成
以上設定したら、Bolt Editor ウィンドウと、Bolt Assets ウィンドウを閉じます
入力のMouse はVector 型で、動かす時に楽をするためにX とZ で送ることにしました。
結果はXYZ 全て利用するVector 型で、そのままローカル座標に放り込みます。効果がよくわかりませんでしたが、なんとなく奇麗に動いている気がするのでSmooth Corrections (スムーズに補正する)にチェックを入れました。あとは以前の設定にもあったのでTeleport Threshold に10
を設定しました。
ボールプレハブの作成
ゲームオブジェクトをネット対応させます。ネットワーク上で共有するオブジェクトにはBoltEntity
スクリプト をアタッチして、ネットワークに接続した時にBoltNetwork.Instantiate()
で生成します。そして、State のデータをオブジェクトのプロパティー と関連付けます。
Project ウィンドウから、Game シーンをダブルクリックしてシーンを切り替えます
Project ウィンドウのRollerBallBolt > Prefabs フォルダーを開いて、Ball プレハブをクリックして選択します
Inspector ウィンドウでOpen Prefab ボタンをクリックします(Open Prefab ボタンがない場合はそのまま編集します)
Inspector ウィンドウのAdd Component ボタンをクリックして、Bolt Entiny を検索などして見つけてアタッチします
アタッチした直後はエラーになります。以下を設定します。
Ball プレハブを再び開いて、State 欄をITransformState にします
Stateを設定
Bolt メニューからCompile Assembly を選択してコンパイル します
以上でエラーがなくなります。
その他の設定も確認して、以下のようにします。Tag はBall を予め割り当てています
Ball Inspector
Inspector ウィンドウからAdd Component > New script を選択して、Ball
という名前のスクリプト を作成します
Ball.cs
は以下の通りです。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace PhotonBoltPhysics
{
public class Ball : Bolt.EntityBehaviour<ITransformState>
{
< summary >
</ summary >
public override void Attached()
{
state.SetTransforms(state.Transform, transform);
}
}
}
アタッチされた時にtransform
をState のTransform に割り当てているだけです。
プレイヤーの作成
プレイヤーも同様にネットワーク対応させていきます。
以下は設定済みです。
Player1 、Player2 とも、Tag はPlayer
Sphere Collider をアタッチして、良い場所と大きさに調整
Rigidbody をアタッチして、Use Gravity を外し、Collision Detection をContinuous Dynamic 、Freeze Position のY にチェック、Freeze Rotation を全てチェック
ネットワーク対応させるために、以下を設定します。
Project ウィンドウからPlayer1 プレハブをクリックして選択します
Inspector ウィンドウにOpen Prefab ボタンがあったらクリックします
Inspector ウィンドウのAdd Component ボタンをクリックして、Bolt Entity を選択してアタッチします
Inspector ウィンドウのState をITransformState に設定します
Bolt メニューからCompile Assembly を選択して実行したら、Player1 プレハブを開き直します
新規スクリプト を作成して、Player
の名前にします
Player.cs
スクリプト の中身は以下の通りです。
using System.Collections;
using System.Collections.Generic;
using Bolt;
using UnityEngine;
namespace PhotonBoltPhysics
{
public class Player : Bolt.EntityBehaviour<ITransformState>
{
[Tooltip("送られてきた操作を速度に変換するレート。Mouse XとMouse Yは-1~1に正規化されているので、動作環境の解像度は考えなくて構いません。" ), SerializeField]
float input2Speed = 20f ;
Rigidbody rb;
float _x;
float _y;
#region System Loop
private void Awake()
{
rb = GetComponent<Rigidbody>();
}
private void Update()
{
PollMouse();
}
#endregion System Loop
#region Bolt Events
< summary >
</ summary >
public override void Attached()
{
state.SetTransforms(state.Transform, transform);
}
< summary >
</ summary >
public override void SimulateController()
{
IRollerBallBoltCommandInput input = RollerBallBoltCommand.Create();
Vector3 data = new Vector3(_x, 0 , _y);
input.Mouse = data;
entity.QueueInput(input);
}
< summary >
</ summary >
< param name ="command" > </ param >
< param name ="resetState" > </ param >
public override void ExecuteCommand(Command command, bool resetState)
{
RollerBallBoltCommand cmd = (RollerBallBoltCommand)command;
if (resetState)
{
transform.localPosition = cmd.Result.Position;
}
else
{
rb.velocity = cmd.Input.Mouse * input2Speed;
cmd.Result.Position = transform.localPosition;
}
}
#endregion Bolt Events
#region My Methods
void PollMouse()
{
_x = Input.GetAxis("Mouse X" );
_y = Input.GetAxis("Mouse Y" );
}
#endregion My Methods
}
}
Advencedチュートリアル をベースに改造したものです。ポイントは以下の通りです。
Bolt.EntityBehaviour<ITransformState>
を継承します
Update()
でマウスの移動量を記録しておきます
アタッチされた時に、transform
をState のTransform に割り当てるコードをAttached()
メソッドに実装します
SimulateController()
は、操作をシミュレートする際にBoltから呼び出されるメソッドです。ここで、Update()
で記録しておいたマウスの移動量をRollerBallBoltCommand のInput のMouse に設定して、QueueInput()
で登録します
ExecuteCommand(command, resetState)
に、コマンドを実行するための処理を実装します。command
は送受信するためのRollerBallBoltCommand
のインスタンス 、resetState
はサーバーかクライアントのどちらで動いているかを表していて、true
の時はコントローラー(クライアント)側の処理、つまり、送られてきた結果を自分のローカル座標に反映させます。false
の時は、オーナー(サーバー)側の処理で、QueueInput()
で蓄えられた入力データを速度に反映させて、オブジェクトを動かし、結果をcommandに渡します
Player2の作成
同様にPlayer2も設定します。以下、ざっとした手順です。
Player2 プレハブを開きます
Bolt Entity をアタッチ
作成済みのPlayer スクリプト をアタッチ
State をITransformState に設定
Bolt メニューからCompile Assembly を選択
以上でプレイヤーの設定完了です。
フィールド
枠や地面などのフィールドは、ネットワークで共有する必要はないのでそのまま配置しておけばOKです。
オブジェクトを配置する機能の作成
公式のチュートリアル では、ランダムに座標を設定したりしてスクリプト 側で座標を作っていましたが、最初から登場させたい場所にゲームオブジェクトを配置して、そこにInstantiateした方が楽だろうということで作った機能です。
新規に空のGame Objectを作成して、NetworkSceneManager
などの名前にします
新しいスクリプト を作成して、NetworkSceneManager
などの名前にします
スクリプト は以下の通りです。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace PhotonBoltPhysics
{
public struct OBJECT_RECORD
{
public int id;
public Vector3 position;
}
public class NetworkSceneManager : MonoBehaviour
{
public static NetworkSceneManager Instance
{
get;
private set;
}
public List<OBJECT_RECORD> playerPositions {
get;
private set;
}
public OBJECT_RECORD ballPosition
{
get;
private set;
}
private void Awake()
{
Instance = this ;
playerPositions = new List<OBJECT_RECORD>();
GameObject[] gos = GameObject.FindGameObjectsWithTag("Player" );
OBJECT_RECORD or;
foreach (GameObject go in gos)
{
or.id = go.GetComponent<BoltEntity>().PrefabId.Value;
or.position = go.transform.position;
playerPositions.Add(or);
Destroy(go);
}
GameObject ball = GameObject.FindGameObjectWithTag("Ball" );
or.id = ball.GetComponent<BoltEntity>().PrefabId.Value;
or.position = ball.transform.position;
ballPosition = or;
Destroy(ball);
}
< summary >
</ summary >
< param name ="id" > </ param >
< returns > </ returns >
public Vector3 GetPlayerPosition(int id)
{
foreach (OBJECT_RECORD or in playerPositions)
{
if (or.id == id)
{
return or.position;
}
}
return Vector3.zero;
}
}
}
Awake()
で、Player とBall のタグが付いたオブジェクトを検索して、プレハブIDと座標を記録したらDestroyします。サーバーでオブジェクトを生成する際に、プレハブIDで記録しておいた座標を検索して、その座標にゲームオブジェクトを配置しています。
最後に、シーンが始まった時に、プレイヤーやボールを生成するためのオブジェクトとスクリプト です。これは、ゲームオブジェクトにはアタッチしません。新規にスクリプト を作成したら、PhotonBoltPhysicsServerCallbacks
などの名前にします。コードは以下の通りです。
using UnityEngine;
namespace PhotonBoltPhysics
{
[BoltGlobalBehaviour(BoltNetworkModes.Server, "Game" )]
public class PhotonBoltPhysicsServerCallbacks : Bolt.GlobalEventListener
{
< summary >
</ summary >
< param name ="map" ></ param >
public override void SceneLoadLocalDone(string map)
{
BoltEntity be = BoltNetwork.Instantiate(BoltPrefabs.Player1);
be.transform.position = NetworkSceneManager.Instance.GetPlayerPosition(be.PrefabId.Value);
be.TakeControl();
be = BoltNetwork.Instantiate(BoltPrefabs.Ball);
be.transform.position = NetworkSceneManager.Instance.ballPosition.position;
}
< summary >
</ summary >
< param name ="connection" ></ param >
public override void SceneLoadRemoteDone(BoltConnection connection)
{
BoltEntity be = BoltNetwork.Instantiate(BoltPrefabs.Player2);
be.transform.position = NetworkSceneManager.Instance.GetPlayerPosition(be.PrefabId.Value);
be.AssignControl(connection);
}
}
}
クラスは、Bolt.GlobalEventListener
を継承します。
[BoltGlobalBehaviour(BoltNetworkModes.Server, "Game" )]
上記の属性により、このクラスはサーバー 、かつ、Game シーンのみで実行されます。
SceneLoadLocalDone()
は、サーバーのGame
シーンが準備できた時にサーバーで実行するコードです。Player1 とBall を生成して、Player1 はサーバー自身が制御するのでTakeControl()
を呼び出します。
SceneLoadRemoteDone()
は、クライアントのGame
シーンが準備できた時にサーバーで実行するコードです。Player2 を生成して、操作はクライアントが担当するので、AssignControl()
で接続先(connection
)に割り当てます。
制作過程は、おおよそ以上の通りです。
動作確認
Photon Boltでは、開発を楽にするために実行形式のファイルを自動的にビルドして、ホストとクライアントで起動してくれる機能があります。それを使ってGameシーンをネットワークで実行してみます。
Bolt メニューからScenes を選択します
Bolt Scenes ウィンドウで、Clients 欄を1
にします。これで、クライアントアプリが1つ起動するようになります
Game と書いてある欄の右のDebug Start をクリックします
Save するかの確認ダイアログが表示されたら、Save をクリックします
ビルドが始まって、しばらく待つとアプリが1つ起動します。Unityでサーバー、クライアントアプリでクライアントが動きます。マウスを動かすと、アクティブなウィンドウのプレイヤーが操作できます。
完成!!
設定の調整
Photon Bolt の設定がデフォルトのままなので、クライアント側の動きがガタガタで、オブジェクトに雷マークが表示されています。これらを直すための設定は以下の通りです。
Window メニューから、Bolt > Settings を選択します
Network Rate を1
にします
これで、状態の同期を1フレームごとに行うようになるので、クライアントの動きがスムーズになります
ネット環境によっては、動きがおかしくなることがあるので、その時は2
にしたり3
に戻したり調整してみてください
必要に応じて、Miscellanceous 欄の以下のチェックを外します
Show Debug Info
オブジェクトに表示されるBoltアイコンが消えます
Show Help Buttons
Visible By Default
本当に完成!!
まとめ
Physicsで制御するゲームオブジェクトを、Photon Boltを使ってネットワーク上で共有することができました。
UNetに比べると手順はやや多い印象ですが、動いたあとの安心感があります。サービス停止の心配ないし...。
エアホッケー やブロック崩し 、ピンボール 、コインプッシャーなど、Physicsを使って簡単に作れるタイプのゲームをネットワーク対応させるのに、今回の手法は有用と思います。
WebGL が使えたらなぁ...と願いつつ。
最後の最後に: Physicsの同期について
Photon Unity Networking(PUN)では、PhysicsのRigidbodyのvelocityを同期するためのクラスPhotonRigidbodyView
があります。
Photon Unity Networking: PhotonRigidbodyView Class Reference
Photon Boltでも、State
にvelocity
用のVector3
を追加して速度の同期を試みることはできます。ただ、このAPI リファレンスの解説にある通り、Rigidbodyのvelocityを同期するだけではネットワーク間のオブジェクトの動きが変わってしまう可能性があります。
今回のように、物体の衝突による動きが直接ゲーム性に関わる場合、PC間の挙動の違いでボールの飛ぶ方向が変わるのは困ります。座標を送って補正したとしても、動きが不自然になることが予想されます。そのため、Commands で操作を送ってサーバーがまとめて処理する方式にしました。
Rigidbodyのvelocityを使えば、各PC上で座標計算ができるので動きを綺麗にすることができます。弾などの単純な軌道で、衝突時に跳ね返る必要がないようなオブジェクトの場合は、Rigidbodyのvelocityを同期する手法は使えると思います。どのような動きをさせたいかで採用する技術が変化してくるので、研究のし甲斐があると思います。今回と同じ内容のものを、PUNのvelocity同期で実装したらどうなるかも見てみたいところです。
参考URL
www.photonengine.com
assetstore.unity.com