使用 PUN 2 制作多人汽车游戏
在 Unity 中制作多人游戏是一项复杂的任务,但幸运的是,有几个解决方案简化了开发过程。
其中一种解决方案是 Photon Network。具体来说,他们最新发布的 API(称为 PUN 2)负责服务器托管,让您可以自由地按照自己想要的方式制作多人游戏。
在本教程中,我将展示如何使用 PUN 2 创建一个具有物理同步功能的简单汽车游戏。
Unity 本教程使用的版本:Unity 2018.3.0f2(64位)
第 1 部分:设置 PUN 2
第一步是从 Asset Store 下载 PUN 2 包。它包含多人集成所需的所有脚本和文件。
- 打开 Unity 项目,然后转到 Asset Store:(窗口 -> 常规 -> AssetStore)或按 Ctrl+9
- 搜索 "PUN 2- Free" 然后单击第一个结果或 单击此处
- 下载完成后导入PUN 2包


- 导入包后,您需要创建一个 Photon App ID,这是在他们的网站上完成的:https://www.photonengine.com/
- 创建一个新帐户(或登录您现有的帐户)
- 单击配置文件图标,然后单击 "Your Applications" 或点击此链接,转到“应用程序”页面:https://dashboard.photonengine.com/en-US/PublicCloud
- 在应用程序页面上单击 "Create new app"

- 在创建页面上,对于光子类型,选择 "Photon Realtime",对于名称,键入任意名称,然后单击 "Create"

如您所见,应用程序默认为免费计划。您可以在此处阅读有关定价计划 的更多信息
- 创建应用程序后,复制应用程序名称下的应用程序 ID

- 返回到您的 Unity 项目,然后转到 Window -> Photon Unity Networking -> PUN Wizard
- 在 PUN 向导中单击 "Setup Project",粘贴您的应用程序 ID,然后单击 "Setup Project"

PUN 2 现已准备就绪!
第 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 的缩写)
- 将 车轮碰撞器组件添加到 "wfl" 对象并设置下图中的值:


- 创建一个新的 GameObject,将其重命名为 "WheelTransform",然后将其移动到 "wfl" 对象内
- 创建一个新的 Cylinder,将其移动到 "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" 对象
- 将 Rigidbody 组件附加到 "CarRoot" 对象并将其质量更改为 1000
- 在 SC_CarController 中分配车轮变量(前 4 个变量为车轮碰撞器,其余 4 个变量为 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 视图中的 Observed Components 数组
- 将 "CarRoot" 对象保存到 Prefab 并将其放置在名为 Resources 的文件夹中(这是能够通过网络生成对象所必需的)
3. 创建游戏关卡
游戏关卡是加入房间后加载的场景,所有动作都在其中发生。
- 创建一个新场景并将其命名为 "Playground" (或者,如果您想保留不同的名称,请确保更改 PUN2_GameLobby.cs 中 PhotonNetwork.LoadLevel("Playground"); 行中的名称)。
就我而言,我将使用一个带有平面和一些立方体的简单场景:

- 创建一个新脚本并将其命名为 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");
    }
}- 在 "Playground" 场景中创建一个新的 GameObject 并调用它 "_RoomController"
- 将 PUN2_RoomController 脚本附加到 _RoomController 对象
- 分配一个汽车预制件和一个 SpawnPoints,然后保存场景

- 将 GameLobby 和 Playground 场景添加到构建设置中:

4. 进行测试构建
现在是时候进行构建并测试它了:

一切都按预期进行!