Tạo trò chơi ô tô nhiều người chơi với PUN 2

Tạo trò chơi nhiều người chơi trong Unity là một nhiệm vụ phức tạp nhưng may mắn thay, có một số giải pháp giúp đơn giản hóa quá trình phát triển.

Một giải pháp như vậy là Photon Network. Cụ thể, bản phát hành API mới nhất của họ có tên PUN 2 đảm nhiệm việc lưu trữ máy chủ và cho phép bạn tự do tạo trò chơi nhiều người chơi theo cách bạn muốn.

Trong hướng dẫn này, tôi sẽ hướng dẫn cách tạo một trò chơi ô tô đơn giản với tính năng đồng bộ hóa vật lý bằng PUN 2.

Unity phiên bản được sử dụng trong hướng dẫn này: Unity 2018.3.0f2 (64-bit)

Phần 1: Thiết lập PUN 2

Bước đầu tiên là tải xuống gói PUN 2 từ Asset Store. Nó chứa tất cả các tập lệnh và tệp cần thiết để tích hợp nhiều người chơi.

  • Mở dự án Unity của bạn sau đó đi tới Asset Store: (Window -> General -> AssetStore) hoặc nhấn Ctrl+9
  • Tìm kiếm "PUN 2- Free" rồi nhấp vào kết quả đầu tiên hoặc bấm vào đây
  • Nhập gói PUN 2 sau khi quá trình tải xuống hoàn tất

  • Sau khi gói được nhập, bạn cần tạo ID ứng dụng Photon, việc này được thực hiện trên trang web của họ: https://www.photonengine.com/
  • Tạo một tài khoản mới (hoặc đăng nhập vào tài khoản hiện có của bạn)
  • Đi tới trang Ứng dụng bằng cách nhấp vào biểu tượng hồ sơ rồi "Your Applications" hoặc theo liên kết sau: https://dashboard.photonengine.com/en-US/PublicCloud
  • Trên trang Ứng dụng, nhấp vào "Create new app"

  • Trên trang tạo, phần Photon Type chọn "Photon Realtime" và phần Name, nhập tên bất kỳ rồi nhấp vào "Create"

Như bạn có thể thấy, Ứng dụng mặc định ở gói Miễn phí. Bạn có thể đọc thêm về Gói giá tại đây

  • Sau khi Ứng dụng được tạo, hãy sao chép ID ứng dụng nằm dưới Tên ứng dụng

  • Quay lại dự án Unity của bạn sau đó vào Window -> Photon Unity Networking -> PUN Wizard
  • Trong Trình hướng dẫn PUN, nhấp vào "Setup Project", dán ID ứng dụng của bạn rồi nhấp vào "Setup Project"

PUN 2 hiện đã sẵn sàng!

Phần 2: Tạo trò chơi ô tô nhiều người chơi

1. Thiết lập tiền sảnh

Hãy bắt đầu bằng cách tạo cảnh Sảnh sẽ chứa logic Sảnh (Duyệt các phòng hiện có, tạo phòng mới, v.v.):

  • Tạo một Cảnh mới và gọi nó "GameLobby"
  • Trong cảnh "GameLobby", tạo một GameObject mới và gọi nó "_GameLobby"
  • Tạo một tập lệnh C# mới và gọi nó là "PUN2_GameLobby" sau đó đính kèm nó vào đối tượng "_GameLobby"
  • Dán mã bên dưới vào tập lệnh "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. Tạo nhà lắp ghép ô tô

Nhà lắp ghép ô tô sẽ sử dụng bộ điều khiển vật lý đơn giản.

  • Tạo một GameObject mới và gọi nó "CarRoot"
  • Tạo một Khối lập phương mới và di chuyển nó vào bên trong đối tượng "CarRoot", sau đó phóng to nó dọc theo trục Z và X

  • Tạo một GameObject mới và đặt tên là "wfl" (viết tắt của Wheel Front Left)
  • Thêm thành phần Wheel Collider vào đối tượng "wfl" và đặt các giá trị từ hình ảnh bên dưới:

  • Tạo một GameObject mới, đổi tên nó thành "WheelTransform" sau đó di chuyển nó vào trong đối tượng "wfl"
  • Tạo một Hình trụ mới, di chuyển nó vào bên trong đối tượng "WheelTransform" sau đó xoay và thu nhỏ nó xuống cho đến khi nó khớp với kích thước của Wheel Collider. Trong trường hợp của tôi, tỷ lệ là (1, 0,17, 1)

  • Cuối cùng, nhân đôi đối tượng "wfl" 3 lần cho các bánh xe còn lại và đổi tên từng đối tượng lần lượt thành "wfr" (Bánh xe phía trước bên phải), "wrr" (Bánh xe phía sau bên phải) và "wrl" (Bánh xe phía sau bên trái)

  • Tạo một tập lệnh mới, đặt tên là "SC_CarController" rồi dán đoạn mã bên dưới vào trong tập lệnh đó:

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;
        }
    }
}
  • Đính kèm tập lệnh SC_CarController vào đối tượng "CarRoot"
  • Gắn thành phần Rigidbody vào đối tượng "CarRoot" và thay đổi khối lượng của nó thành 1000
  • Gán các biến bánh xe trong SC_CarController (Bộ va chạm bánh xe cho 4 biến đầu tiên và WheelTransform cho 4 biến còn lại)

  • Đối với biến Center of Mass, hãy tạo một GameObject mới, gọi nó là "CenterOfMass" và di chuyển nó vào trong đối tượng "CarRoot"
  • Đặt đối tượng "CenterOfMass" ở giữa và hơi hướng xuống dưới, như thế này:

  • Cuối cùng, với mục đích thử nghiệm, hãy di chuyển Camera chính bên trong đối tượng "CarRoot" và hướng nó vào ô tô:

  • Tạo một tập lệnh mới, đặt tên là "PUN2_CarSync" rồi dán đoạn mã bên dưới vào trong tập lệnh đó:

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);
                }
            }
        }
    }
}
  • Đính kèm tập lệnh PUN2_CarSync vào đối tượng "CarRoot"
  • Đính kèm thành phần PhotonView vào đối tượng "CarRoot"
  • Trong PUN2_CarSync gán tập lệnh SC_CarController cho mảng Tập lệnh cục bộ
  • Trong PUN2_CarSync gán Camera cho mảng Đối tượng cục bộ
  • Gán các đối tượng WheelTransform vào mảng Wheels
  • Cuối cùng, Gán tập lệnh PUN2_CarSync cho mảng Thành phần được quan sát trong Chế độ xem Photon
  • Lưu đối tượng "CarRoot" vào Prefab và đặt nó vào thư mục có tên Tài nguyên (điều này là cần thiết để có thể sinh ra các đối tượng qua mạng)

3. Tạo cấp độ trò chơi

Cấp độ trò chơi là Cảnh được tải sau khi tham gia Phòng, nơi tất cả hành động diễn ra.

  • Tạo một Cảnh mới và gọi nó là "Playground" (Hoặc nếu bạn muốn giữ một tên khác, hãy nhớ đổi tên ở dòng này PhotonNetwork.LoadLevel("Playground"); tại PUN2_GameLobby.cs).

Trong trường hợp của tôi, tôi sẽ sử dụng một cảnh đơn giản với một chiếc máy bay và một số hình khối:

  • Tạo một tập lệnh mới và gọi nó là PUN2_RoomController (Tập lệnh này sẽ xử lý logic bên trong Phòng, như sinh ra người chơi, hiển thị danh sách người chơi, v.v.), sau đó dán mã bên dưới vào bên trong nó:

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");
    }
}
  • Tạo một GameObject mới trong cảnh "Playground" và gọi nó "_RoomController"
  • Đính kèm tập lệnh PUN2_RoomController vào đối tượng _RoomController
  • Chỉ định một prefab Xe hơi và một SpawnPoints sau đó lưu Cảnh

  • Thêm cả Cảnh GameLobby và Sân chơi vào cài đặt Bản dựng:

4. Tạo bản dựng thử nghiệm

Bây giờ là lúc tạo bản dựng và thử nghiệm nó:

Sharp Coder Trình phát video

Mọi thứ hoạt động như mong đợi!