اصنع لعبة سيارات متعددة اللاعبين باستخدام PUN 2

يعد إنشاء لعبة متعددة اللاعبين في Unity مهمة معقدة، ولكن لحسن الحظ هناك العديد من الحلول التي تعمل على تبسيط عملية التطوير.

أحد هذه الحلول هو Photon Network. على وجه التحديد، يعتني الإصدار الأخير من واجهة برمجة التطبيقات (API) الخاصة بهم والمسمى PUN 2 باستضافة الخادم ويترك لك الحرية في إنشاء لعبة متعددة اللاعبين بالطريقة التي تريدها.

في هذا البرنامج التعليمي، سأعرض كيفية إنشاء لعبة سيارات بسيطة مع مزامنة فيزيائية باستخدام PUN 2.

Unity الإصدار المستخدم في هذا البرنامج التعليمي: Unity 2018.3.0f2 (64 بت)

الجزء 1: إعداد PUN 2

الخطوة الأولى هي تنزيل حزمة PUN 2 من ملف Asset Store. أنه يحتوي على كافة البرامج النصية والملفات المطلوبة للتكامل متعددة اللاعبين.

  • افتح مشروعك Unity ثم انتقل إلى Asset Store: (نافذة -> عام -> AssetStore) أو اضغط على Ctrl+9
  • ابحث عن "PUN 2- Free" ثم اضغط على النتيجة الأولى أو اضغط هنا
  • قم باستيراد حزمة PUN 2 بعد انتهاء التنزيل

  • بعد استيراد الحزمة، تحتاج إلى إنشاء معرف تطبيق Photon، ويتم ذلك على موقع الويب الخاص بهم: https://www.photonengine.com/
  • إنشاء حساب جديد (أو تسجيل الدخول إلى حسابك الحالي)
  • انتقل إلى صفحة التطبيقات بالضغط على أيقونة الملف الشخصي ثم "Your Applications" أو اتبع هذا الرابط: https://dashboard.photonengine.com/en-US/PublicCloud
  • في صفحة التطبيقات، انقر فوق "Create new app"

  • في صفحة الإنشاء، بالنسبة لـ Photon Type، حدد "Photon Realtime" وبالنسبة للاسم، اكتب أي اسم ثم انقر فوق "Create"

كما ترون، يتم تعيين التطبيق افتراضيًا على الخطة المجانية. يمكنك قراءة المزيد عن خطط التسعير هنا

  • بمجرد إنشاء التطبيق، انسخ معرف التطبيق الموجود أسفل اسم التطبيق

  • ارجع إلى مشروعك Unity ثم انتقل إلى Window -> Photon Unity Networking -> PUN Wizard
  • في معالج PUN، انقر فوق "Setup Project"، ثم الصق معرف التطبيق الخاص بك، ثم انقر فوق "Setup Project"

PUN 2 جاهز الآن!

الجزء الثاني: إنشاء لعبة سيارات متعددة اللاعبين

1. إعداد اللوبي

لنبدأ بإنشاء مشهد الردهة الذي سيحتوي على منطق الردهة (استعراض الغرف الموجودة، وإنشاء غرف جديدة، وما إلى ذلك):

  • قم بإنشاء مشهد جديد وسميه "GameLobby"
  • في المشهد "GameLobby" قم بإنشاء GameObject جديد وقم بتسميته "_GameLobby"
  • أنشئ نصًا جديدًا لـ C# وأطلق عليه اسم "PUN2_GameLobby" ثم قم بإرفاقه بالكائن "_GameLobby"
  • الصق الكود أدناه داخل البرنامج النصي "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. إنشاء سيارة جاهزة

سوف تستخدم السيارة الجاهزة وحدة تحكم فيزيائية بسيطة.

  • قم بإنشاء GameObject جديد وقم بتسميته "CarRoot"
  • قم بإنشاء مكعب جديد وحركه داخل الكائن "CarRoot" ثم قم بتوسيع نطاقه على طول المحورين Z وX

  • قم بإنشاء GameObject جديد وقم بتسميته "wfl" (اختصار لـ Wheel Front Left)
  • قم بإضافة مكون Wheel Collider إلى الكائن "wfl" وقم بتعيين القيم من الصورة أدناه:

  • قم بإنشاء كائن GameObject جديد، وأعد تسميته إلى "WheelTransform" ثم انقله داخل الكائن "wfl"
  • أنشئ أسطوانة جديدة، وحركها داخل الكائن "WheelTransform" ثم قم بتدويرها وصغر حجمها حتى تتطابق مع أبعاد Wheel Collider. في حالتي، المقياس هو (1، 0.17، 1)

  • أخيرًا، قم بتكرار الكائن "wfl" 3 مرات لبقية العجلات وأعد تسمية كل كائن إلى "wfr" (العجلة الأمامية اليمنى)، "wrr" (العجلة الخلفية اليمنى)، و "wrl" (العجلة الخلفية اليسرى) على التوالي

  • أنشئ سكريبت جديد وسميه "SC_CarController" ثم الصق الكود أدناه داخله:

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;
        }
    }
}
  • قم بإرفاق البرنامج النصي SC_CarController بالكائن "CarRoot"
  • قم بتوصيل مكون الجسم الصلب بالجسم "CarRoot" وقم بتغيير كتلته إلى 1000
  • قم بتعيين متغيرات العجلة في SC_CarController (مصادم العجلات للمتغيرات الأربعة الأولى وWheelTransform لبقية المتغيرات الأربعة)

  • بالنسبة لمتغير Center of Mass، قم بإنشاء كائن GameObject جديد، وأطلق عليه اسم "CenterOfMass" وحركه داخل الكائن "CarRoot"
  • ضع الكائن "CenterOfMass" في المنتصف وأسفل قليلاً، مثل هذا:

  • أخيرًا ولأغراض الاختبار، قم بتحريك الكاميرا الرئيسية داخل الكائن "CarRoot" وقم بتوجيهها نحو السيارة:

  • أنشئ سكريبت جديد وسميه "PUN2_CarSync" ثم الصق الكود أدناه بداخله:

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);
                }
            }
        }
    }
}
  • قم بإرفاق البرنامج النصي PUN2_CarSync بالكائن "CarRoot"
  • قم بإرفاق مكون PhotonView بالكائن "CarRoot"
  • في PUN2_CarSync قم بتعيين البرنامج النصي SC_CarController إلى مجموعة البرامج النصية المحلية
  • في PUN2_CarSync قم بتعيين الكاميرا لمجموعة الكائنات المحلية
  • قم بتعيين كائنات WheelTransform إلى مجموعة Wheels
  • أخيرًا، قم بتعيين البرنامج النصي PUN2_CarSync إلى مجموعة المكونات الملحوظة في عرض Photon
  • احفظ الكائن "CarRoot" في Prefab وضعه في مجلد يسمى Resources (هذا ضروري لتتمكن من إنتاج الكائنات عبر الشبكة)

3. إنشاء مستوى اللعبة

مستوى اللعبة هو مشهد يتم تحميله بعد الانضمام إلى الغرفة، حيث تحدث كل الأحداث.

  • أنشئ مشهدًا جديدًا وأطلق عليه اسم "Playground" (أو إذا كنت تريد الاحتفاظ باسم مختلف، فتأكد من تغيير الاسم في هذا السطر PhotonNetwork.LoadLevel("Playground"); على PUN2_GameLobby.cs).

في حالتي، سأستخدم مشهدًا بسيطًا لطائرة وبعض المكعبات:

  • قم بإنشاء برنامج نصي جديد وأطلق عليه اسم PUN2_RoomController (سيتعامل هذا البرنامج النصي مع المنطق داخل الغرفة، مثل إنتاج اللاعبين، وإظهار قائمة اللاعبين، وما إلى ذلك) ثم الصق الكود أدناه بداخله:

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");
    }
}
  • قم بإنشاء GameObject جديد في المشهد "Playground" وقم بتسميته "_RoomController"
  • قم بإرفاق البرنامج النصي PUN2_RoomController بكائن _RoomController
  • قم بتعيين مبنى جاهز للسيارة ونقاط SpawnPoints ثم قم بحفظ المشهد

  • أضف كلاً من GameLobby وPlayground Scenes إلى إعدادات الإصدار:

4. إجراء اختبار البناء

حان الوقت الآن لإنشاء البنية واختبارها:

Sharp Coder مشغل فديوهات

كل شيء يعمل كما هو متوقع!