Gör ett bilspel för flera spelare med PUN 2
Att göra ett flerspelarspel i Unity är en komplex uppgift, men som tur är förenklar flera lösningar utvecklingsprocessen.
En sådan lösning är Photon Network. Närmare bestämt tar den senaste versionen av deras API som heter PUN 2 hand om serverhosting och låter dig göra ett multiplayer-spel som du vill.
I den här handledningen kommer jag att visa hur man skapar ett enkelt bilspel med fysiksynkronisering med PUN 2.
Unity version som används i denna handledning: Unity 2018.3.0f2 (64-bitars)
Del 1: Ställa in PUN 2
Det första steget är att ladda ner ett PUN 2-paket från Asset Store. Den innehåller alla skript och filer som krävs för integration med flera spelare.
- Öppna ditt Unity-projekt och gå sedan till Asset Store: (Fönster -> Allmänt -> AssetStore) eller tryck på Ctrl+9
- Sök efter "PUN 2- Free" klicka sedan på det första resultatet eller klicka här
- Importera PUN 2-paketet efter att nedladdningen är klar
- Efter att paketet har importerats måste du skapa ett Photon App ID, detta görs på deras hemsida: https://www.photonengine.com/
- Skapa ett nytt konto (eller logga in på ditt befintliga konto)
- Gå till applikationssidan genom att klicka på profilikonen och sedan "Your Applications" eller följ denna länk: https://dashboard.photonengine.com/en-US/PublicCloud
- Klicka på sidan Applikationer "Create new app"
- På sidan för att skapa, för Photon Type, välj "Photon Realtime" och för Namn, skriv valfritt namn och klicka sedan "Create"
Som du kan se använder applikationen gratisplanen som standard. Du kan läsa mer om prisplaner här
- När applikationen har skapats kopierar du app-ID:t under appnamnet
- Gå tillbaka till ditt Unity-projekt och gå sedan till Fönster -> Foton Unity Nätverk -> PUN Wizard
- Klicka på "Setup Project" i PUN Wizard, klistra in ditt app-ID och klicka sedan "Setup Project"
PUN 2 är nu klar!
Del 2: Skapa ett bilspel för flera spelare
1. Skapa en lobby
Låt oss börja med att skapa en lobbyscen som kommer att innehålla lobbylogik (bläddra i befintliga rum, skapa nya rum, etc.):
- Skapa en ny scen och kalla den "GameLobby"
- Skapa ett nytt GameObject i "GameLobby"-scenen och anrop det "_GameLobby"
- Skapa ett nytt C#-skript och kalla det "PUN2_GameLobby" och bifoga det sedan till "_GameLobby"-objektet
- Klistra in koden nedan i "PUN2_GameLobby"-skriptet
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. Skapa en bilprefab
Bilprefab kommer att använda en enkel fysikkontroller.
- Skapa ett nytt GameObject och anropa det "CarRoot"
- Skapa en ny kub och flytta den inuti "CarRoot"-objektet och skala sedan upp den längs Z- och X-axeln
- Skapa ett nytt GameObject och döp det "wfl" (förkortning för Wheel Front Left)
- Lägg till Wheel Collider-komponenten till "wfl"-objektet och ställ in värdena från bilden nedan:
- Skapa ett nytt GameObject, byt namn på det till "WheelTransform" och flytta det sedan in i "wfl"-objektet
- Skapa en ny cylinder, flytta den inuti "WheelTransform"-objektet och rotera sedan och skala ner den tills den matchar Wheel Collider-dimensionerna. I mitt fall är skalan (1, 0,17, 1)
- Slutligen, duplicera "wfl"-objektet 3 gånger för resten av hjulen och byt namn på varje objekt till "wfr" (Hjul fram höger), "wrr" (hjul bak höger) respektive "wrl" (hjul bak vänster)
- Skapa ett nytt skript, kalla det "SC_CarController" och klistra sedan in koden nedan i det:
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;
}
}
}
- Bifoga SC_CarController-skriptet till "CarRoot"-objektet
- Fäst Rigidbody-komponenten till "CarRoot"-objektet och ändra dess massa till 1000
- Tilldela hjulvariablerna i SC_CarController (Wheel Collider för de första 4 variablerna och WheelTransform för resten av de 4)
- För Center of Mass-variabeln skapa ett nytt GameObject, kalla det "CenterOfMass" och flytta det inuti "CarRoot"-objektet
- Placera "CenterOfMass"-objektet i mitten och något nedåt, så här:
- För teständamål, flytta slutligen huvudkameran inuti "CarRoot"-objektet och rikta den mot bilen:
- Skapa ett nytt skript, kalla det "PUN2_CarSync" och klistra sedan in koden nedan i det:
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);
}
}
}
}
}
- Bifoga PUN2_CarSync-skriptet till "CarRoot"-objektet
- Fäst PhotonView-komponenten till "CarRoot"-objektet
- I PUN2_CarSync tilldela SC_CarController-skriptet till arrayen Local Scripts
- I PUN2_CarSync tilldela kameran till Local Objects array
- Tilldela WheelTransform-objekt till Wheels-arrayen
- Tilldela slutligen PUN2_CarSync-skriptet till arrayen Observed Components i Photon View
- Spara "CarRoot"-objektet i Prefab och placera det i en mapp som heter Resources (detta behövs för att kunna skapa objekt över nätverket)
3. Skapa en spelnivå
Game Level är en scen som laddas efter att ha gått med i rummet, där all action sker.
- Skapa en ny scen och kalla den "Playground" (Eller om du vill behålla ett annat namn, se till att ändra namnet på den här raden PhotonNetwork.LoadLevel("Playground"); på PUN2_GameLobby.cs).
I mitt fall kommer jag att använda en enkel scen med ett plan och några kuber:
- Skapa ett nytt skript och kalla det PUN2_RoomController (Detta skript kommer att hantera logiken i rummet, som att skapa spelarna, visa spelarlistan, etc.) och klistra sedan in koden nedan i det:
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");
}
}
- Skapa ett nytt GameObject i "Playground"-scenen och anrop det "_RoomController"
- Bifoga ett PUN2_RoomController-skript till _RoomController-objektet
- Tilldela en bilprefab och en SpawnPoints och spara sedan scenen
- Lägg till både GameLobby och Playground Scenes till Build-inställningarna:
4. Att göra ett testbygge
Nu är det dags att bygga och testa den:
Allt fungerar som förväntat!