Realizza un gioco di auto multiplayer con PUN 2
Realizzare un gioco multiplayer in Unity è un compito complesso, ma fortunatamente diverse soluzioni semplificano il processo di sviluppo.
Una di queste soluzioni è Photon Network. Nello specifico, l'ultima versione della loro API chiamata PUN 2 si occupa dell'hosting del server e ti lascia libero di creare un gioco multiplayer nel modo che desideri.
In questo tutorial mostrerò come creare un semplice gioco di auto con sincronizzazione fisica utilizzando PUN 2.
Unity versione utilizzata in questo tutorial: Unity 2018.3.0f2 (64 bit)
Parte 1: configurazione del PUN 2
Il primo passo è scaricare un pacchetto PUN 2 da Asset Store. Contiene tutti gli script e i file necessari per l'integrazione multiplayer.
- Apri il tuo progetto Unity quindi vai su Asset Store: (Finestra -> Generale -> AssetStore) o premi Ctrl+9
- Cerca "PUN 2- Free" quindi fai clic sul primo risultato oppure fai clic qui
- Importa il pacchetto PUN 2 al termine del download
- Dopo che il pacchetto è stato importato, è necessario creare un ID app Photon, questa operazione viene eseguita sul loro sito Web: https://www.photonengine.com/
- Crea un nuovo account (o accedi al tuo account esistente)
- Vai alla pagina Applicazioni facendo clic sull'icona del profilo, quindi su "Your Applications" o segui questo collegamento: https://dashboard.photonengine.com/en-US/PublicCloud
- Nella pagina Applicazioni fare clic su "Create new app"
- Nella pagina di creazione, per Tipo fotone selezionare "Photon Realtime" e per Nome, digitare un nome qualsiasi, quindi fare clic "Create"
Come puoi vedere, l'applicazione utilizza per impostazione predefinita il piano gratuito. Puoi leggere ulteriori informazioni sui piani tariffari qui
- Una volta creata l'applicazione, copiare l'ID app situato sotto il nome dell'app
- Torna al tuo progetto Unity quindi vai su Finestra -> Photon Unity Networking -> PUN Wizard
- Nella procedura guidata PUN, fai clic su "Setup Project", incolla l'ID dell'app, quindi fai clic su "Setup Project"
Il PUN 2 è ora pronto!
Parte 2: Creazione di un gioco di auto multiplayer
1. Impostazione di una lobby
Iniziamo creando una scena della lobby che conterrà la logica della lobby (esplorazione delle stanze esistenti, creazione di nuove stanze, ecc.):
- Crea una nuova scena e chiamala "GameLobby"
- Nella scena "GameLobby" crea un nuovo GameObject e chiamalo "_GameLobby"
- Crea un nuovo script C# e chiamalo "PUN2_GameLobby", quindi collegalo all'oggetto "_GameLobby"
- Incolla il codice qui sotto all'interno dello script "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. Creazione di un prefabbricato per auto
Il prefabbricato per auto utilizzerà un semplice controller fisico.
- Crea un nuovo GameObject e chiamalo "CarRoot"
- Crea un nuovo cubo e spostalo all'interno dell'oggetto "CarRoot", quindi ingrandiscilo lungo gli assi Z e X
- Crea un nuovo GameObject e chiamalo "wfl" (abbreviazione di Wheel Front Left)
- Aggiungi il componente Wheel Collider all'oggetto "wfl" e imposta i valori dall'immagine seguente:
- Crea un nuovo GameObject, rinominalo in "WheelTransform" quindi spostalo all'interno dell'oggetto "wfl"
- Crea un nuovo cilindro, spostalo all'interno dell'oggetto "WheelTransform", quindi ruotalo e ridimensionalo finché non corrisponde alle dimensioni del Wheel Collider. Nel mio caso, la scala è (1, 0,17, 1)
- Infine, duplica l'oggetto "wfl" 3 volte per il resto delle ruote e rinomina ciascun oggetto rispettivamente in "wfr" (Ruota anteriore destra), "wrr" (Ruota posteriore destra) e "wrl" (Ruota posteriore sinistra)
- Crea un nuovo script, chiamalo "SC_CarController" quindi incolla al suo interno il codice seguente:
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;
}
}
}
- Allega lo script SC_CarController all'oggetto "CarRoot"
- Collega il componente Rigidbody all'oggetto "CarRoot" e modifica la sua massa su 1000
- Assegna le variabili della ruota in SC_CarController (Wheel collider per le prime 4 variabili e WheelTransform per il resto delle 4)
- Per la variabile Centro di massa crea un nuovo GameObject, chiamalo "CenterOfMass" e spostalo all'interno dell'oggetto "CarRoot"
- Posiziona l'oggetto "CenterOfMass" al centro e leggermente verso il basso, in questo modo:
- Infine, a scopo di test, sposta la fotocamera principale all'interno dell'oggetto "CarRoot" e puntala verso l'auto:
- Crea un nuovo script, chiamalo "PUN2_CarSync" quindi incolla al suo interno il codice seguente:
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);
}
}
}
}
}
- Allega lo script PUN2_CarSync all'oggetto "CarRoot"
- Collega il componente PhotonView all'oggetto "CarRoot"
- In PUN2_CarSync assegnare lo script SC_CarController all'array Local Scripts
- In PUN2_CarSync assegna la telecamera all'array Oggetti locali
- Assegna oggetti WheelTransform all'array Wheels
- Infine, assegna lo script PUN2_CarSync all'array Observed Components in Photon View
- Salva l'oggetto "CarRoot" in Prefab e posizionalo in una cartella chiamata Risorse (è necessario per poter generare oggetti sulla rete)
3. Creazione di un livello di gioco
Il livello di gioco è una scena che viene caricata dopo essere entrati nella stanza, dove si svolge tutta l'azione.
- Crea una nuova scena e chiamala "Playground" (o se vuoi mantenere un nome diverso, assicurati di cambiare il nome in questa riga PhotonNetwork.LoadLevel("Playground"); in PUN2_GameLobby.cs).
Nel mio caso, utilizzerò una scena semplice con un aereo e alcuni cubi:
- Crea un nuovo script e chiamalo PUN2_RoomController (questo script gestirà la logica all'interno della Room, come generare i giocatori, mostrare l'elenco dei giocatori, ecc.) quindi incollare il codice seguente al suo interno:
PUN2_RoomController.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");
}
}
- Crea un nuovo GameObject nella scena "Playground" e chiamalo "_RoomController"
- Allega uno script PUN2_RoomController all'oggetto _RoomController
- Assegna un prefabbricato per auto e uno SpawnPoint, quindi salva la scena
- Aggiungi sia le scene della GameLobby che quelle del parco giochi alle impostazioni di costruzione:
4. Realizzazione di una build di prova
Ora è il momento di creare una build e testarla:
Tutto funziona come previsto!