Stwórz wieloosobową grę samochodową w PUN 2

Tworzenie gry wieloosobowej w Unity jest złożonym zadaniem, ale na szczęście kilka rozwiązań upraszcza proces tworzenia.

Jednym z takich rozwiązań jest Photon Network. W szczególności najnowsza wersja ich interfejsu API o nazwie PUN 2 zajmuje się hostingiem serwerów i pozostawia swobodę tworzenia gry wieloosobowej tak, jak chcesz.

W tym poradniku pokażę jak stworzyć prostą grę samochodową z synchronizacją fizyki za pomocą PUN 2.

Unity wersja używana w tym samouczku: Unity 2018.3.0f2 (64-bitowa)

Część 1: Konfigurowanie PUN 2

Pierwszym krokiem jest pobranie pakietu PUN 2 z Asset Store. Zawiera wszystkie skrypty i pliki potrzebne do integracji gry wieloosobowej.

  • Otwórz swój projekt Unity, a następnie przejdź do Asset Store: (Okno -> Ogólne -> AssetStore) lub naciśnij Ctrl+9
  • Wyszukaj "PUN 2- Free", a następnie kliknij pierwszy wynik lub kliknij tutaj
  • Zaimportuj pakiet PUN 2 po zakończeniu pobierania

  • Po zaimportowaniu pakietu musisz utworzyć identyfikator aplikacji Photon, można to zrobić na ich stronie internetowej: https://www.photonengine.com/
  • Utwórz nowe konto (lub zaloguj się na istniejące konto)
  • Przejdź do strony aplikacji, klikając ikonę profilu, a następnie "Your Applications" lub kliknij ten link: https://dashboard.photonengine.com/en-US/PublicCloud
  • Na stronie Aplikacje kliknij "Create new app"

  • Na stronie tworzenia w polu Typ fotonu wybierz "Photon Realtime", a w polu Nazwa wpisz dowolną nazwę i kliknij "Create"

Jak widać, aplikacja domyślnie korzysta z planu Free. Więcej informacji na temat planów cenowych znajdziesz tutaj

  • Po utworzeniu aplikacji skopiuj identyfikator aplikacji znajdujący się pod nazwą aplikacji

  • Wróć do swojego projektu Unity, a następnie przejdź do Okno -> Photon Unity Sieć -> Kreator PUN
  • W kreatorze PUN kliknij "Setup Project", wklej identyfikator aplikacji, a następnie kliknij "Setup Project"

PUN 2 jest już gotowy!

Część 2: Tworzenie wieloosobowej gry samochodowej

1. Konfiguracja lobby

Zacznijmy od stworzenia sceny Lobby, która będzie zawierać logikę Lobby (przeglądanie istniejących pokoi, tworzenie nowych itp.):

  • Utwórz nową scenę i nazwij ją "GameLobby"
  • W scenie "GameLobby" utwórz nowy obiekt GameObject i nazwij go "_GameLobby"
  • Utwórz nowy skrypt C# i nadaj mu nazwę "PUN2_GameLobby", a następnie dołącz go do obiektu "_GameLobby"
  • Wklej poniższy kod do skryptu "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. Tworzenie prefabrykatu samochodu

Prefabrykat samochodu będzie korzystał z prostego kontrolera fizycznego.

  • Utwórz nowy obiekt GameObject i wywołaj go "CarRoot"
  • Utwórz nową kostkę i przesuń ją wewnątrz obiektu "CarRoot", a następnie powiększ ją wzdłuż osi Z i X

  • Utwórz nowy obiekt GameObject i nazwij go "wfl" (skrót od Wheel Front Left)
  • Dodaj komponent Wheel Collider do obiektu "wfl" i ustaw wartości z poniższego obrazka:

  • Utwórz nowy obiekt GameObject, zmień jego nazwę na "WheelTransform", a następnie przenieś go do obiektu "wfl"
  • Utwórz nowy cylinder, przesuń go do obiektu "WheelTransform", a następnie obróć i zmniejsz go, aż będzie odpowiadał wymiarom Wheel Collider. W moim przypadku skala to (1, 0,17, 1)

  • Na koniec zduplikuj obiekt "wfl" 3 razy dla pozostałych kół i zmień nazwę każdego obiektu odpowiednio na "wfr" (przednie prawe koło), "wrr" (tylne koło prawe) i "wrl" (tylne koło lewe).

  • Utwórz nowy skrypt, nadaj mu nazwę "SC_CarController" i wklej w nim poniższy kod:

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;
        }
    }
}
  • Dołącz skrypt SC_CarController do obiektu "CarRoot"
  • Dołącz komponent Rigidbody do obiektu "CarRoot" i zmień jego masę na 1000
  • Przypisz zmienne koła w SC_CarController (Wheel Collider dla pierwszych 4 zmiennych i WheelTransform dla pozostałych 4)

  • Dla zmiennej Środek masy utwórz nowy obiekt GameObject, nadaj mu nazwę "CenterOfMass" i przenieś go do obiektu "CarRoot"
  • Umieść obiekt "CenterOfMass" na środku i nieco w dół, jak poniżej:

  • Na koniec, dla celów testowych, przesuń główną kamerę do obiektu "CarRoot" i skieruj ją na samochód:

  • Utwórz nowy skrypt, nadaj mu nazwę "PUN2_CarSync" i wklej w nim poniższy kod:

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);
                }
            }
        }
    }
}
  • Dołącz skrypt PUN2_CarSync do obiektu "CarRoot"
  • Dołącz komponent PhotonView do obiektu "CarRoot"
  • W PUN2_CarSync przypisz skrypt SC_CarController do tablicy Local Scripts
  • W PUN2_CarSync przypisz kamerę do tablicy obiektów lokalnych
  • Przypisz obiekty WheelTransform do tablicy Wheels
  • Na koniec przypisz skrypt PUN2_CarSync do tablicy Observed Components w Photon View
  • Zapisz obiekt "CarRoot" w Prefab i umieść go w folderze o nazwie Resources (jest to potrzebne, aby móc odradzać obiekty przez sieć)

3. Tworzenie poziomu gry

Poziom Gry to Scena, która ładuje się po wejściu do Pokoju, gdzie dzieje się cała akcja.

  • Utwórz nową Scenę i nazwij ją "Playground" (lub jeśli chcesz zachować inną nazwę, pamiętaj o zmianie nazwy w tej linii PhotonNetwork.LoadLevel("Playground"); w PUN2_GameLobby.cs).

W moim przypadku posłużę się prostą sceną z samolotem i kostkami:

  • Utwórz nowy skrypt i nadaj mu nazwę PUN2_RoomController (ten skrypt zajmie się logiką panującą w Pokoju, np. spawnowaniem graczy, wyświetlaniem listy graczy itp.), a następnie wklej w nim poniższy kod:

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");
    }
}
  • Utwórz nowy obiekt GameObject w scenie "Playground" i nazwij go "_RoomController"
  • Dołącz skrypt PUN2_RoomController do obiektu _RoomController
  • Przypisz prefabrykat samochodu i punkty spawnowania, a następnie zapisz scenę

  • Dodaj sceny GameLobby i Playground do ustawień kompilacji:

4. Tworzenie kompilacji testowej

Teraz czas na wykonanie kompilacji i przetestowanie jej:

Sharp Coder Odtwarzacz wideo

Wszystko działa zgodnie z oczekiwaniami!