PUN 2로 멀티플레이어 자동차 게임 만들기

Unity에서 멀티플레이어 게임을 만드는 것은 복잡한 작업이지만 다행히도 여러 솔루션이 개발 프로세스를 단순화합니다.

그러한 솔루션 중 하나가 Photon Network입니다. 특히 PUN 2라는 API의 최신 릴리스는 서버 호스팅을 관리하고 원하는 방식으로 멀티플레이어 게임을 자유롭게 만들 수 있도록 해줍니다.

이 튜토리얼에서는 PUN 2를 사용하여 물리 동기화를 통해 간단한 자동차 게임을 만드는 방법을 보여 드리겠습니다.

Unity 이 튜토리얼에서 사용된 버전: Unity 2018.3.0f2(64비트)

1부: PUN 2 설정

첫 번째 단계는 Asset Store에서 PUN 2 패키지를 다운로드하는 것입니다. 여기에는 멀티플레이어 통합에 필요한 모든 스크립트와 파일이 포함되어 있습니다.

  • Unity 프로젝트를 연 다음 Asset Store로 이동합니다: (Window -> General -> AssetStore) 또는 Ctrl+9를 누릅니다.
  • "PUN 2- Free"을 검색한 다음 첫 번째 결과를 클릭하거나 여기를 클릭하세요.
  • 다운로드가 완료된 후 PUN 2 패키지를 가져옵니다.

  • 패키지를 가져온 후 Photon 앱 ID를 생성해야 하며 해당 웹사이트에서 수행됩니다: https://www.photonengine.com/
  • 새 계정 만들기(또는 기존 계정에 로그인)
  • 프로필 아이콘을 클릭한 후 "Your Applications"을 클릭하여 애플리케이션 페이지로 이동하거나 다음 링크를 따르세요: https://dashboard.photonengine.com/en-US/PublicCloud
  • 애플리케이션 페이지에서 클릭하세요. "Create new app"

  • 생성 페이지에서 Photon Type으로 "Photon Realtime"를 선택하고 Name으로 이름을 입력한 후 클릭합니다. "Create"

보시다시피, 애플리케이션은 기본적으로 무료 플랜으로 설정되어 있습니다. 가격 플랜 에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

  • 애플리케이션이 생성되면 앱 이름 아래에 있는 앱 ID를 복사하세요.

  • Unity 프로젝트로 돌아가서 Window -> Photon Unity Networking -> PUN Wizard로 이동합니다.
  • PUN 마법사에서 "Setup Project"를 클릭하고 앱 ID를 붙여넣은 다음 클릭하세요. "Setup Project"

이제 PUN 2가 준비되었습니다!

2부: 멀티플레이어 자동차 게임 만들기

1. 로비 설정

로비 로직(기존 룸 탐색, 새 룸 생성 등)을 포함하는 로비 장면을 생성하는 것부터 시작해 보겠습니다.

  • 새로운 Scene을 생성하고 호출하세요. "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를 생성하고 이름을 "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;
        }
    }
}
  • "CarRoot" 개체에 SC_CarController 스크립트를 연결합니다.
  • Rigidbody 구성 요소를 "CarRoot" 개체에 연결하고 질량을 1000으로 변경합니다.
  • SC_CarController에 바퀴 변수를 할당합니다(처음 4개 변수는 Wheel Collider, 나머지 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);
                }
            }
        }
    }
}
  • "CarRoot" 개체에 PUN2_CarSync 스크립트를 연결합니다.
  • PhotonView 구성요소를 "CarRoot" 객체에 연결
  • PUN2_CarSync에서 SC_CarController 스크립트를 Local Scripts 배열에 할당합니다.
  • PUN2_CarSync에서 카메라를 로컬 개체 배열에 할당합니다.
  • WheelTransform 객체를 Wheels 배열에 할당
  • 마지막으로 PUN2_CarSync 스크립트를 Photon View의 Observed Components 배열에 할당합니다.
  • "CarRoot" 개체를 Prefab에 저장하고 Resources라는 폴더에 넣습니다(네트워크를 통해 개체를 생성하려면 필요함).

3. 게임 레벨 생성

게임 레벨은 Room에 참여한 후 로드되는 장면으로, 모든 작업이 발생합니다.

  • 새 장면을 만들고 이름을 "Playground"로 지정합니다(또는 다른 이름을 유지하려면 PUN2_GameLobby.cs의 PhotonNetwork.LoadLevel("Playground") 줄에서 이름을 변경해야 합니다).

내 경우에는 평면과 일부 큐브가 포함된 간단한 장면을 사용하겠습니다.

  • 새 스크립트를 생성하고 이름을 PUN2_RoomController로 지정합니다(이 스크립트는 플레이어 생성, 플레이어 목록 표시 등과 같은 Room 내부 로직을 처리합니다). 그 안에 아래 코드를 붙여넣습니다.

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"
  • _RoomController 개체에 PUN2_RoomController 스크립트를 연결합니다.
  • 자동차 프리팹과 SpawnPoint를 할당한 다음 장면을 저장합니다.

  • GameLobby 및 Playground 장면을 모두 빌드 설정에 추가합니다.

4. 테스트 빌드 만들기

이제 빌드하고 테스트할 차례입니다.

Sharp Coder 비디오 플레이어

모든 것이 예상대로 작동합니다!

추천 기사
PUN 2를 사용하여 Unity에서 멀티플레이어 게임 만들기
Unity에서 멀티플레이어 네트워크 게임 구축
Unity, PUN 2 룸에 멀티플레이어 채팅 추가
PUN 2를 사용하여 네트워크를 통해 강체 동기화
Photon 네트워크(클래식) 초보자 가이드
멀티플레이어 데이터 압축 및 비트 조작
PUN 2 지연 보상