PUN 2 を使用してマルチプレイヤー車ゲームを作成する
Unity でマルチプレイヤー ゲームを作成するのは複雑な作業ですが、幸いなことに、いくつかのソリューションにより開発プロセスが簡素化されます。
そのようなソリューションの 1 つが Photon Network です。具体的には、PUN 2 と呼ばれる API の最新リリースがサーバーのホスティングを担当し、自由にマルチプレイヤー ゲームを好きなように作成できるようにします。
このチュートリアルでは、PUN 2 を使用して物理同期を備えたシンプルな自動車ゲームを作成する方法を示します。
Unity このチュートリアルで使用されるバージョン: Unity 2018.3.0f2 (64 ビット)
パート 1: PUN 2 のセットアップ
最初のステップは、Asset Store から PUN 2 パッケージをダウンロードすることです。これには、マルチプレイヤーの統合に必要なすべてのスクリプトとファイルが含まれています。
- Unity プロジェクトを開き、Asset Store に移動します: (ウィンドウ -> 一般 -> アセットストア) または Ctrl+9 を押します。
- "PUN 2- Free" を検索して最初の結果をクリックするか、ここをクリックしてください
- ダウンロードが完了したら、PUN 2 パッケージをインポートします。
- パッケージをインポートした後、Photon App ID を作成する必要があります。これは Web サイト (https://www.photonengine.com/) で行われます。
- 新しいアカウントを作成します (または既存のアカウントにログインします)
- プロフィール アイコンをクリックして [アプリケーション] ページに移動し、["Your Applications"] をクリックするか、次のリンクをクリックします: https://dashboard.photonengine.com/en-US/PublicCloud
- 「アプリケーション」ページで、 をクリックします。 "Create new app"
- 作成ページで、Photon Type に "Photon Realtime" を選択し、Name に任意の名前を入力して、 "Create"
ご覧のとおり、アプリケーションはデフォルトで無料プランに設定されています。料金プラン の詳細については、こちらをご覧ください。
- アプリケーションが作成されたら、アプリ名の下にあるアプリ ID をコピーします。
- Unity プロジェクトに戻り、[ウィンドウ] -> [Photon Unity ネットワーク] -> [PUN ウィザード] に移動します。
- PUN ウィザードで "Setup Project" をクリックし、アプリ ID を貼り付けて、 "Setup Project"
PUN 2 の準備が整いました。
パート 2: マルチプレイヤー車ゲームの作成
1. ロビーのセットアップ
まず、ロビー ロジック (既存のルームの参照、新しいルームの作成など) を含むロビー シーンを作成します。
- 新しいシーンを作成して呼び出します "GameLobby"
- "GameLobby" シーンで新しいゲームオブジェクトを作成し、それを呼び出します "_GameLobby"
- 新しい C# スクリプトを作成し、"PUN2_GameLobby" という名前を付けて、"_GameLobby" オブジェクトにアタッチします。
- 以下のコードを "PUN2_GameLobby" スクリプト内に貼り付けます。
PUN2_GameLobby.cs
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class PUN2_GameLobby : MonoBehaviourPunCallbacks
{
//Our player name
string playerName = "Player 1";
//Users are separated from each other by gameversion (which allows you to make breaking changes).
string gameVersion = "1.0";
//The list of created rooms
List<RoomInfo> createdRooms = new List<RoomInfo>();
//Use this name when creating a Room
string roomName = "Room 1";
Vector2 roomListScroll = Vector2.zero;
bool joiningRoom = false;
// Use this for initialization
void Start()
{
//Initialize Player name
playerName = "Player " + Random.Range(111, 999);
//This makes sure we can use PhotonNetwork.LoadLevel() on the master client and all clients in the same room sync their level automatically
PhotonNetwork.AutomaticallySyncScene = true;
if (!PhotonNetwork.IsConnected)
{
//Set the App version before connecting
PhotonNetwork.PhotonServerSettings.AppSettings.AppVersion = gameVersion;
PhotonNetwork.PhotonServerSettings.AppSettings.FixedRegion = "eu";
// Connect to the photon master-server. We use the settings saved in PhotonServerSettings (a .asset file in this project)
PhotonNetwork.ConnectUsingSettings();
}
}
public override void OnDisconnected(DisconnectCause cause)
{
Debug.Log("OnFailedToConnectToPhoton. StatusCode: " + cause.ToString() + " ServerAddress: " + PhotonNetwork.ServerAddress);
}
public override void OnConnectedToMaster()
{
Debug.Log("OnConnectedToMaster");
//After we connected to Master server, join the Lobby
PhotonNetwork.JoinLobby(TypedLobby.Default);
}
public override void OnRoomListUpdate(List<RoomInfo> roomList)
{
Debug.Log("We have received the Room list");
//After this callback, update the room list
createdRooms = roomList;
}
void OnGUI()
{
GUI.Window(0, new Rect(Screen.width / 2 - 450, Screen.height / 2 - 200, 900, 400), LobbyWindow, "Lobby");
}
void LobbyWindow(int index)
{
//Connection Status and Room creation Button
GUILayout.BeginHorizontal();
GUILayout.Label("Status: " + PhotonNetwork.NetworkClientState);
if (joiningRoom || !PhotonNetwork.IsConnected || PhotonNetwork.NetworkClientState != ClientState.JoinedLobby)
{
GUI.enabled = false;
}
GUILayout.FlexibleSpace();
//Room name text field
roomName = GUILayout.TextField(roomName, GUILayout.Width(250));
if (GUILayout.Button("Create Room", GUILayout.Width(125)))
{
if (roomName != "")
{
joiningRoom = true;
RoomOptions roomOptions = new RoomOptions();
roomOptions.IsOpen = true;
roomOptions.IsVisible = true;
roomOptions.MaxPlayers = (byte)10; //Set any number
PhotonNetwork.JoinOrCreateRoom(roomName, roomOptions, TypedLobby.Default);
}
}
GUILayout.EndHorizontal();
//Scroll through available rooms
roomListScroll = GUILayout.BeginScrollView(roomListScroll, true, true);
if (createdRooms.Count == 0)
{
GUILayout.Label("No Rooms were created yet...");
}
else
{
for (int i = 0; i < createdRooms.Count; i++)
{
GUILayout.BeginHorizontal("box");
GUILayout.Label(createdRooms[i].Name, GUILayout.Width(400));
GUILayout.Label(createdRooms[i].PlayerCount + "/" + createdRooms[i].MaxPlayers);
GUILayout.FlexibleSpace();
if (GUILayout.Button("Join Room"))
{
joiningRoom = true;
//Set our Player name
PhotonNetwork.NickName = playerName;
//Join the Room
PhotonNetwork.JoinRoom(createdRooms[i].Name);
}
GUILayout.EndHorizontal();
}
}
GUILayout.EndScrollView();
//Set player name and Refresh Room button
GUILayout.BeginHorizontal();
GUILayout.Label("Player Name: ", GUILayout.Width(85));
//Player name text field
playerName = GUILayout.TextField(playerName, GUILayout.Width(250));
GUILayout.FlexibleSpace();
GUI.enabled = (PhotonNetwork.NetworkClientState == ClientState.JoinedLobby || PhotonNetwork.NetworkClientState == ClientState.Disconnected) && !joiningRoom;
if (GUILayout.Button("Refresh", GUILayout.Width(100)))
{
if (PhotonNetwork.IsConnected)
{
//Re-join Lobby to get the latest Room list
PhotonNetwork.JoinLobby(TypedLobby.Default);
}
else
{
//We are not connected, estabilish a new connection
PhotonNetwork.ConnectUsingSettings();
}
}
GUILayout.EndHorizontal();
if (joiningRoom)
{
GUI.enabled = true;
GUI.Label(new Rect(900 / 2 - 50, 400 / 2 - 10, 100, 20), "Connecting...");
}
}
public override void OnCreateRoomFailed(short returnCode, string message)
{
Debug.Log("OnCreateRoomFailed got called. This can happen if the room exists (even if not visible). Try another room name.");
joiningRoom = false;
}
public override void OnJoinRoomFailed(short returnCode, string message)
{
Debug.Log("OnJoinRoomFailed got called. This can happen if the room is not existing or full or closed.");
joiningRoom = false;
}
public override void OnJoinRandomFailed(short returnCode, string message)
{
Debug.Log("OnJoinRandomFailed got called. This can happen if the room is not existing or full or closed.");
joiningRoom = false;
}
public override void OnCreatedRoom()
{
Debug.Log("OnCreatedRoom");
//Set our player name
PhotonNetwork.NickName = playerName;
//Load the Scene called Playground (Make sure it's added to build settings)
PhotonNetwork.LoadLevel("Playground");
}
public override void OnJoinedRoom()
{
Debug.Log("OnJoinedRoom");
}
}
2. 車のプレハブの作成
Car プレハブは単純な物理コントローラーを使用します。
- 新しいゲームオブジェクトを作成して呼び出します "CarRoot"
- 新しい Cube を作成し、"CarRoot" オブジェクト内に移動して、Z 軸と X 軸に沿ってスケールアップします。
- 新しいゲームオブジェクトを作成し、"wfl" (Wheel Front Left の略語) という名前を付けます。
- Wheel Collider コンポーネントを オブジェクトに "wfl" オブジェクトに追加し、以下の画像の値を設定します。
- 新しいゲームオブジェクトを作成し、名前を "WheelTransform" に変更して、"wfl" オブジェクト内に移動します。
- 新しい円柱を作成し、"WheelTransform" オブジェクト内に移動し、ホイール コライダーの寸法と一致するまで回転および縮小します。私の場合、スケールは (1, 0.17, 1) です。
- 最後に、残りのホイールに対して "wfl" オブジェクトを 3 回複製し、各オブジェクトの名前をそれぞれ "wfr" (ホイール前部右)、"wrr" (ホイール後部右)、および "wrl" (ホイール後部左) に変更します。
- 新しいスクリプトを作成し、"SC_CarController" という名前を付け、その中に以下のコードを貼り付けます。
SC_CarController.cs
using UnityEngine;
using System.Collections;
public class SC_CarController : MonoBehaviour
{
public WheelCollider WheelFL;
public WheelCollider WheelFR;
public WheelCollider WheelRL;
public WheelCollider WheelRR;
public Transform WheelFLTrans;
public Transform WheelFRTrans;
public Transform WheelRLTrans;
public Transform WheelRRTrans;
public float steeringAngle = 45;
public float maxTorque = 1000;
public float maxBrakeTorque = 500;
public Transform centerOfMass;
float gravity = 9.8f;
bool braked = false;
Rigidbody rb;
void Start()
{
rb = GetComponent<Rigidbody>();
rb.centerOfMass = centerOfMass.transform.localPosition;
}
void FixedUpdate()
{
if (!braked)
{
WheelFL.brakeTorque = 0;
WheelFR.brakeTorque = 0;
WheelRL.brakeTorque = 0;
WheelRR.brakeTorque = 0;
}
//Speed of car, Car will move as you will provide the input to it.
WheelRR.motorTorque = maxTorque * Input.GetAxis("Vertical");
WheelRL.motorTorque = maxTorque * Input.GetAxis("Vertical");
//Changing car direction
//Here we are changing the steer angle of the front tyres of the car so that we can change the car direction.
WheelFL.steerAngle = steeringAngle * Input.GetAxis("Horizontal");
WheelFR.steerAngle = steeringAngle * Input.GetAxis("Horizontal");
}
void Update()
{
HandBrake();
//For tyre rotate
WheelFLTrans.Rotate(WheelFL.rpm / 60 * 360 * Time.deltaTime, 0, 0);
WheelFRTrans.Rotate(WheelFR.rpm / 60 * 360 * Time.deltaTime, 0, 0);
WheelRLTrans.Rotate(WheelRL.rpm / 60 * 360 * Time.deltaTime, 0, 0);
WheelRRTrans.Rotate(WheelRL.rpm / 60 * 360 * Time.deltaTime, 0, 0);
//Changing tyre direction
Vector3 temp = WheelFLTrans.localEulerAngles;
Vector3 temp1 = WheelFRTrans.localEulerAngles;
temp.y = WheelFL.steerAngle - (WheelFLTrans.localEulerAngles.z);
WheelFLTrans.localEulerAngles = temp;
temp1.y = WheelFR.steerAngle - WheelFRTrans.localEulerAngles.z;
WheelFRTrans.localEulerAngles = temp1;
}
void HandBrake()
{
//Debug.Log("brakes " + braked);
if (Input.GetButton("Jump"))
{
braked = true;
}
else
{
braked = false;
}
if (braked)
{
WheelRL.brakeTorque = maxBrakeTorque * 20;//0000;
WheelRR.brakeTorque = maxBrakeTorque * 20;//0000;
WheelRL.motorTorque = 0;
WheelRR.motorTorque = 0;
}
}
}
- SC_CarController スクリプトを "CarRoot" オブジェクトにアタッチします
- Rigidbody コンポーネントを "CarRoot" オブジェクトにアタッチし、その質量を 1000 に変更します
- SC_CarController でホイール変数を割り当てます (最初の 4 つの変数には ホイール コライダー、残りの 4 つは WheelTransform)
- Center of Mass 変数については、新しいゲームオブジェクトを作成し、"CenterOfMass" という名前を付け、"CarRoot" オブジェクト内に移動します。
- 次のように、"CenterOfMass" オブジェクトを中央の少し下に配置します。
- 最後に、テスト目的で、メイン カメラを "CarRoot" オブジェクト内に移動し、車に向けます。
- 新しいスクリプトを作成し、"PUN2_CarSync" という名前を付け、その中に以下のコードを貼り付けます。
PUN2_CarSync.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
public class PUN2_CarSync : MonoBehaviourPun, IPunObservable
{
public MonoBehaviour[] localScripts; //Scripts that should only be enabled for the local player (Ex. Car controller)
public GameObject[] localObjects; //Objects that should only be active for the local player (Ex. Camera)
public Transform[] wheels; //Car wheel transforms
Rigidbody r;
// Values that will be synced over network
Vector3 latestPos;
Quaternion latestRot;
Vector3 latestVelocity;
Vector3 latestAngularVelocity;
Quaternion[] wheelRotations = new Quaternion[0];
// Lag compensation
float currentTime = 0;
double currentPacketTime = 0;
double lastPacketTime = 0;
Vector3 positionAtLastPacket = Vector3.zero;
Quaternion rotationAtLastPacket = Quaternion.identity;
Vector3 velocityAtLastPacket = Vector3.zero;
Vector3 angularVelocityAtLastPacket = Vector3.zero;
// Use this for initialization
void Awake()
{
r = GetComponent<Rigidbody>();
r.isKinematic = !photonView.IsMine;
for (int i = 0; i < localScripts.Length; i++)
{
localScripts[i].enabled = photonView.IsMine;
}
for (int i = 0; i < localObjects.Length; i++)
{
localObjects[i].SetActive(photonView.IsMine);
}
}
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
// We own this player: send the others our data
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
stream.SendNext(r.velocity);
stream.SendNext(r.angularVelocity);
wheelRotations = new Quaternion[wheels.Length];
for(int i = 0; i < wheels.Length; i++)
{
wheelRotations[i] = wheels[i].localRotation;
}
stream.SendNext(wheelRotations);
}
else
{
// Network player, receive data
latestPos = (Vector3)stream.ReceiveNext();
latestRot = (Quaternion)stream.ReceiveNext();
latestVelocity = (Vector3)stream.ReceiveNext();
latestAngularVelocity = (Vector3)stream.ReceiveNext();
wheelRotations = (Quaternion[])stream.ReceiveNext();
// Lag compensation
currentTime = 0.0f;
lastPacketTime = currentPacketTime;
currentPacketTime = info.SentServerTime;
positionAtLastPacket = transform.position;
rotationAtLastPacket = transform.rotation;
velocityAtLastPacket = r.velocity;
angularVelocityAtLastPacket = r.angularVelocity;
}
}
// Update is called once per frame
void Update()
{
if (!photonView.IsMine)
{
// Lag compensation
double timeToReachGoal = currentPacketTime - lastPacketTime;
currentTime += Time.deltaTime;
// Update car position and velocity
transform.position = Vector3.Lerp(positionAtLastPacket, latestPos, (float)(currentTime / timeToReachGoal));
transform.rotation = Quaternion.Lerp(rotationAtLastPacket, latestRot, (float)(currentTime / timeToReachGoal));
r.velocity = Vector3.Lerp(velocityAtLastPacket, latestVelocity, (float)(currentTime / timeToReachGoal));
r.angularVelocity = Vector3.Lerp(angularVelocityAtLastPacket, latestAngularVelocity, (float)(currentTime / timeToReachGoal));
//Apply wheel rotation
if(wheelRotations.Length == wheels.Length)
{
for (int i = 0; i < wheelRotations.Length; i++)
{
wheels[i].localRotation = Quaternion.Lerp(wheels[i].localRotation, wheelRotations[i], Time.deltaTime * 6.5f);
}
}
}
}
}
- PUN2_CarSync スクリプトを "CarRoot" オブジェクトにアタッチします
- PhotonView コンポーネントを "CarRoot" オブジェクトにアタッチします
- PUN2_CarSync で、SC_CarController スクリプトを Local Scripts 配列に割り当てます。
- PUN2_CarSync でカメラをローカル オブジェクト配列に割り当てます
- WheelTransform オブジェクトを Wheel 配列に割り当てる
- 最後に、PUN2_CarSync スクリプトを Photon View の Observed Components 配列に割り当てます。
- "CarRoot" オブジェクトを Prefab に保存し、Resources というフォルダーに配置します (これは、ネットワーク上でオブジェクトを生成できるようにするために必要です)。
3. ゲームレベルの作成
ゲーム レベルは、ルームに参加した後に読み込まれるシーンであり、すべてのアクションが発生します。
- 新しいシーンを作成し、"Playground" という名前を付けます (または、別の名前を保持したい場合は、PUN2_GameLobby.cs のこの行 PhotonNetwork.LoadLevel("Playground"); の名前を必ず変更してください)。
私の場合、平面といくつかの立方体を含む単純なシーンを使用します。
- 新しいスクリプトを作成し、 PUN2_RoomController という名前を付けます (このスクリプトは、プレーヤーの生成、プレーヤー リストの表示など、ルーム内のロジックを処理します)。その中に以下のコードを貼り付けます。
PUN2_ルームコントローラー.cs
using UnityEngine;
using Photon.Pun;
public class PUN2_RoomController : MonoBehaviourPunCallbacks
{
//Player instance prefab, must be located in the Resources folder
public GameObject playerPrefab;
//Player spawn point
public Transform[] spawnPoints;
// Use this for initialization
void Start()
{
//In case we started this demo with the wrong scene being active, simply load the menu scene
if (PhotonNetwork.CurrentRoom == null)
{
Debug.Log("Is not in the room, returning back to Lobby");
UnityEngine.SceneManagement.SceneManager.LoadScene("GameLobby");
return;
}
//We're in a room. spawn a character for the local player. it gets synced by using PhotonNetwork.Instantiate
PhotonNetwork.Instantiate(playerPrefab.name, spawnPoints[Random.Range(0, spawnPoints.Length - 1)].position, spawnPoints[Random.Range(0, spawnPoints.Length - 1)].rotation, 0);
}
void OnGUI()
{
if (PhotonNetwork.CurrentRoom == null)
return;
//Leave this Room
if (GUI.Button(new Rect(5, 5, 125, 25), "Leave Room"))
{
PhotonNetwork.LeaveRoom();
}
//Show the Room name
GUI.Label(new Rect(135, 5, 200, 25), PhotonNetwork.CurrentRoom.Name);
//Show the list of the players connected to this Room
for (int i = 0; i < PhotonNetwork.PlayerList.Length; i++)
{
//Show if this player is a Master Client. There can only be one Master Client per Room so use this to define the authoritative logic etc.)
string isMasterClient = (PhotonNetwork.PlayerList[i].IsMasterClient ? ": MasterClient" : "");
GUI.Label(new Rect(5, 35 + 30 * i, 200, 25), PhotonNetwork.PlayerList[i].NickName + isMasterClient);
}
}
public override void OnLeftRoom()
{
//We have left the Room, return back to the GameLobby
UnityEngine.SceneManagement.SceneManager.LoadScene("GameLobby");
}
}
- "Playground" シーンに新しいゲームオブジェクトを作成し、それを呼び出します "_RoomController"
- PUN2_RoomController スクリプトを _RoomController オブジェクトにアタッチします。
- Car プレハブと SpawnPoints を割り当てて、シーンを保存します
- GameLobby シーンと Playground シーンの両方をビルド設定に追加します。
4. テストビルドの作成
次に、ビルドを作成してテストします。
すべてが期待どおりに機能します。