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:

Sharp Coder Videospelare

Allt fungerar som förväntat!

Föreslagna artiklar
Skapa ett multiplayer-spel i Unity med PUN 2
Synkronisera stela kroppar över nätverk med PUN 2
Bygga nätverksspel för flera spelare i Unity
Photon Network (Classic) Nybörjarguide
Multiplayer-datakomprimering och bitmanipulation
Unity Lägger till flerspelarchatt till PUN 2-rummen
Handledning för Unity Online Leaderboard