Unity/멀티서버

Photon 포톤을 사용한 멀티서버(총게임기준)

sungmin08 2024. 10. 28. 17:29

포톤 개요

포톤네크워크 게임 엔진은 이미 성능이 검증되었고 많은 레퍼런스 보유

- PUN(Photon Unity Networking) : 멀티플레이어 게임용 유니티 패키지

■100명의 동시접속자까지 무료 서비스 

 

●포톤 리얼타임

  • 다양한 개발 플랫폼에 맞춰진 포톤의 핵심 코어 엔진
  • 낮은 지연을 보장하고 빠른 데이터 전송을 보장. 클라이언트 들끼리의 데이터 동기화를 실시간으로 처리
  • 매칭 시스템 : 로비와 룸을 관리

 

 

●포톤 클라우드

  • 이 엔진을 바탕으로 클라우드 서비스를 제공하는 것으로 서버의 보안, 로드 밸런싱, 백업 및 네트워크 트래픽 관리등을 서비스 함
  • 사용자에게 가까운 서버로 연결

●포톤 서버

  • 클라우드 기반의 Photon Cloud 와 달리, Photon 서버는 직접 호스팅 할 수 있어서 서버 환경에 대해 전반적인 제어와 커스터 마이징이 가능
  • 회사 자체에서 물리적인 서버(로컬 서버)를 직접 운영하는 것을 말함

 

Unity와 포톤 연결법

 

1. 관리 화면으로 이동 -> 새 어플리케이션 만들기

 

2. Photon Pun으로 설정, 이름, 설명 기

3. 생성 완료,  App ID : Pun 설정 시 사용할 어플리케이션 ID 

 

4. 에셋스토어에서 Pun 2 - Free 다운로드 후 임포트

 

5. 임포트 후 App ID 입력

 

 

포톤 서버

포턴 서버는 룸과 로비의 개념이 존재. 즉 룸 단위 네크워킹 기능을 제공하여 포톤 서버에 접속하면 룸을 생성할 수 있음.

 

●룸

  • 플레이러들이 게임을 실행할 수 있는 세션을 의미
  • 네크워크 게임을 실행할 수 있는 논리적인 공간으로 룸에 입장해야만 해당 룸에 접속한 다른 유저와 통신이 가능하다.

●로비

  • 플레이어들이 게임에 참여하기 전 대기하거나 룸을 찾기 위한 공간
  • 로비에 입장(접속)한 유저는 어떤 룸이 생성되었는지에 대한 정보를 수신 받을 수 있음. 룸의 이름, 현재 플레이어의 수, 최대 플레이어의 수 등의 정보를 수신
  • 룸의 목록을 받아와서 특정 룸을 선택하여 입력하는 방식

 

●포톤 스크립트

 

 

 

○ 포톤 서버접속

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;


public class PhotonManager : MonoBehaviourPunCallbacks
{
    //게임의 버전
    private readonly string version = "1.0";
    //유저의 닉네임
    private string userId = "SungMo";

    private void Awake()
    {
        //마스터 클라이언트의 씬 자동 동기화 옵션
        PhotonNetwork.AutomaticallySyncScene = true;
        //게임 버전 설정
        PhotonNetwork.GameVersion = version;
        //접속 유저의 닉네임 설정
        PhotonNetwork.NickName = userId;

        //포톤 서버와의 데이터의 초당 전송 횟수
        Debug.Log(PhotonNetwork.SendRate);

        //포톤 서버접속
        PhotonNetwork.ConnectUsingSettings();
    }

    //포톤 서버에 접속 후 호출되는 콜백 함수
    public override void OnConnectedToMaster()
    {
        Debug.Log("Connected to Master!");
        Debug.Log($"PhotonNetwork.InLobby={PhotonNetwork.InLobby}");
        PhotonNetwork.JoinLobby();
    }

    //로비에 접속 후 호출되는 콜백 함수
    public override void OnJoinedLobby()
    {
        Debug.Log($"PhotonNetwork.InLobby={PhotonNetwork.InLobby}");
        PhotonNetwork.JoinRandomRoom();
    }
}

 

■ AutoMaticallySyncScene 방장(마스터 권한이 있는 유저)이 새로운 씬을 로딩 했을 때 해당 룸에 입장한 다른 접속 유저들에게도 자동으로 씬을 동기화 해주는 기능
GameVersion  같은 버전의 유저끼리 접속을 허용하는 기능
SendRate  포톤서버와의 통신 횟수 : 기본 초당 30회로 설정
ConnectUsingSettings  포톤 서버에 접속

 

포톤에 접속하자마자 제일 먼저 OnConnectedToMaster() 콜백 함수가 호출

자동으로 로비에 들어오지 않기 때문에 False 출력 -> JoinLobby()함수를 호출하여 로비에 입장 -> OnJoinedLobby() 콜백 함수가 호출됨

 

 

○ 로비 입장

//랜덤한 룸 입장이 실패했을 경우 호출되는 콜백 함수
public override void OnJoinRandomFailed(short returnCode, string message)
{
    Debug.Log($"JoinRandom Failed {returnCode}:{message}");

    //룸 속성 정의
    RoomOptions ro = new RoomOptions();
    ro.MaxPlayers = 20;  //룸에 접속할 수 있는 최대 접속자 수
    ro.IsOpen = true;    //룸의 오픈 여부(더 이상 접속을 허용하지 말아야할 경우 룸을 닫아야함)
    ro.IsVisible = true; //로비에 있는 사용자들에게 룸을 공개할지에 대한 여부

    //룸 생성
    PhotonNetwork.CreateRoom("My Room", ro);
}

//룸 생성이 완료된 후 호출되는 콜백 함수
public override void OnCreatedRoom()
{
    Debug.Log("Created Room");
    Debug.Log($"Room Name={PhotonNetwork.CurrentRoom.Name}");
}

//룸에 입장한 후 호출되는 콜백 함수
public override void OnJoinedRoom()
{
    Debug.Log($"Photon.Network.InRoom={PhotonNetwork.InRoom}");
    Debug.Log($"Player Count={PhotonNetwork.CurrentRoom.PlayerCount}");
}
■JoinRandomRoom() 포톤 서버에 접속하거나 로비에 입장 한 후 이미 생성된 룸 중에서 무작위로 선택해 입장할 수 있는 함수
( 포톤 서버에선 랜덤 매칭 메이킹 기능 제공)
■OnJoinRandomFailed() 아무런 룸이 생성되지 않았다면 룸에 입장하지 못하고 이 콜백 함수가 호출

 

 

룸에 접속한 사용자 정보 파악하기

//룸에 입장한 후 호출되는 콜백 함수
public override void OnJoinedRoom()
{
    Debug.Log($"Photon.Network.InRoom={PhotonNetwork.InRoom}");
    Debug.Log($"Player Count={PhotonNetwork.CurrentRoom.PlayerCount}");
    foreach(var player in PhotonNetwork.CurrentRoom.Players)
    {
        Debug.Log($"{player.Value.NickName}, {player.Value.ActorNumber}");
    }
}

 

룸에 입장한 접속 사용자의 정보는 CurrentRoom.Player로 확인 가능

★NickName은 고유의 값이 아니기 때문에 동일한 닉네임이 있을 수 있음. 접속자 고유의 값이 필요한 경우 ACtorNumber 사용해야 함

 

코드 Debug

 

 

주인공 캐릭터의 네크워크 기능 구현

●PhotonView

 

    ○ PhotonView 컴포넌트는 네크워크상에 접속한 플레이어간의 데이터를 송수신하는 통신모듈임

    ○ 동일한 룸에 입장한 다른 플레이어에게 위치와 회전 정보를 동기화 시키고 특정 데이터를 송수신 하려면 반드시                  PhotonView 컴포넌트 필요

    ○ Synchronization은 동기화 방식을 의미

    ○ Observed Components : PhotonView 컴포넌트가 관찰해 데이터를 송수신할 대상을 등록하는 속성

None 동기화 처리 X. RPC 호출만을 위한 PhotonView에 적합
Reliable Delta Compress 마지막 데이터가 변경되지 않았을 때 데이터를 송신하지 않는다
Unreliable 송신한 패킷의 수신 여부를 확인하지 않는다
Unreliable On Change Unreliable과 동일하고 변경사항이 발생했을 때만 송신

 

●캐릭터 이동(동기화)

1. 첫번째 방법 -> 컴포넌트 사용하는 방식
: Photon Transform View, Photon Animator View
-쉽게 네크워크 동기화 할 수 있지만 세밀한 조정 불가
-네크워크 레이턴시가 발생 했을 때 위치 및 회전 값을 수동으로 보간할 수 없다.
2. 두번째 방법 -> 콜백함수 사용하는 방식
: OnPhotonSerializeView
-네크워크 레이턴시에 대응할 수 있는 코드를 작성해 좀 더 유연하게 로직을 구현 가능

 

 

1. 첫번째 방법

▶ 2개의 컴포넌트 추가

Photon Transform View

Photon Transform View : Transform 컴포넌트의 position, rotation, scale 값을 동기화하기 위한 것. Use Local 속성은 동기화하는 데이터가 로컬 기준인지를 결정.

 

Photon Animator View

  Photon Animator View : Animator 컴포넌트의 속성을 동기화하는 컴포넌트. 

 추가 시 Animator 컴포넌트의 정보를 읽어 Layer와 Parameter 값을 자동으로 설정함. 다만 동기화  할 Layer와 파라미터 값의 동기화 속도를 설정해야함. 네크워크 대역폭과 동기화의 정확성을 고려해 Discrete와 Continues 중 하나를 선택한다. 모두 Discrete로 설정

 

Discrete로 설정하는 이유 :

애니메이터 파라미터의 변화가 연속적이지 않고 특정 시점에서만 발생할 경우, 이를 효율적으로 동기화 하기 위함. Discrete 설정은 주로 특정 애니메이션 상태가 바뀌거나 트리거가 발생하는 시점에서만 파라미터를 업데이트하도록 하여 네트워크 트래픽을 줄여준다. 

 

▶네크워크 환경에서 플레이어 생성 

  • 1. 프로젝트 뷰에서 Resources 폴더를 생성한 후 하이어라키 뷰의 Player를 Resources 폴더로 드래그해 프리팹으로 생성
  • 2. 프리팹으로 전환한 후 하이어라키 뷰에 있는 Player는 삭제
  • 3. 포톤에서 네트워크로 동기화할 대상은 PhotonNetwork.Instantiate 함수를 사용하며 모두 Resources 폴더에 위치
  • 야 한다.
    //룸에 입장한 후 호출되는 콜백 함수
    public override void OnJoinedRoom()
    {
        Debug.Log($"Photon.Network.InRoom={PhotonNetwork.InRoom}");
        Debug.Log($"Player Count={PhotonNetwork.CurrentRoom.PlayerCount}");
        
        //입장한 플레이어 유저 정보
        foreach(var player in PhotonNetwork.CurrentRoom.Players)
        {
            Debug.Log($"{player.Value.NickName}, {player.Value.ActorNumber}");
        }

        //플레이어 출현 위치 정보를 배열에 저장
        Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        int idx = Random.Range(1, points.Length);

        //네크워크 상에 캐릭터 생성
        PhotonNetwork.Instantiate("Player", points[idx].position, points[idx].rotation, 0);
    }

 

▶캐릭터 이동

Chinemachine Install 후 컴포넌트 할당

플레이어 Cinemachine 지정

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using Cinemachine;

public class Movement : MonoBehaviour
{
    //컴포넌트 캐시 처리를 위한 변수
    private CharacterController controller;
    private new Transform transform;
    private Animator animator;
    private new Camera camera;

    //가상의 Plane에 레이캐스팅하기 위한 변수
    private Plane plane;
    private Ray ray;
    private Vector3 hitPoint;

    //이동속도
    public float moveSpeed = 10.0f;

    //PhotonView 컴포넌트 캐시 처리를 위한 변수
    private PhotonView pv;
    //시네머신 가상 카메라를 저장할 변수
    private CinemachineVirtualCamera virtualCamera;
    private void Start()
    {
        controller=GetComponent<CharacterController>();
        transform = GetComponent<Transform>();
        animator=GetComponent<Animator>();
        camera = Camera.main;

        //가상의 바닥을 주인공의 위치를 기준으로 생성
        plane = new Plane(transform.up, transform.position);

        pv=GetComponent<PhotonView>();
        virtualCamera = GameObject.FindObjectOfType<CinemachineVirtualCamera>();

        //PhotonView가 자신의 것일 경우 시네머신 가상카메라를 연결
        if(pv.IsMine)
        {
            virtualCamera.Follow = transform;
            virtualCamera.LookAt = transform;
        }
    }

    private void Update()
    {
        if (pv.IsMine)
        {
            Move();
            Turn();
        }
    }

    float h => Input.GetAxis("Horizontal");
    float v => Input.GetAxis("Vertical");

    private void Move()
    {
        Vector3 cameraForward = camera.transform.forward;
        Vector3 cameraRight = camera.transform.right;
        cameraForward.y = 0.0f;
        cameraRight.y = 0.0f;

        //이동할 방향 벡터 계산
        Vector3 moveDir = (cameraForward * v) + (cameraRight * h);
        moveDir.Set(moveDir.x, 0.0f, moveDir.z);

        //주인공 캐릭터 이동 처리
        controller.SimpleMove(moveDir * moveSpeed);

        //주인공 애니메이션 처리
        float forward = Vector3.Dot(moveDir, transform.forward);
        float strafe = Vector3.Dot(moveDir, transform.right);

        animator.SetFloat("Forward", forward);
        animator.SetFloat("Strafe", strafe);
    }

    void Turn()
    {
        //마우스의 2차원 좌푯값을 이용해 3차원 광선(레이)를 생성
        ray=camera.ScreenPointToRay(Input.mousePosition);

        float enter = 0.0f;

        //가상의 바닥에 레이를 발사해 충돌한 지점의 거리를 enter 변수로 반환
        plane.Raycast(ray, out enter);
        //가상의 바닥에 레이가 충돌한 좌푯값 추출
        hitPoint = ray.GetPoint(enter);

        //회전해야 할 방향의 벡터를 계산
        Vector3 lookDir = hitPoint - transform.position;
        lookDir.y = 0;
        //주인공 캐릭터의 회전값 지정
        transform.localRotation=Quaternion.LookRotation(lookDir);
    }
}

캐릭터 애니메이터

 

 

○ 동시 접속을 위해 테스트 환경 수정

빨간색 체크

 

2. 두번째 방법

●OnPhotonSerializeView 콜백 함수 사용하는 방식

Player 컴포넌트

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Cinemachine;

public class Movement : MonoBehaviour, IPunObservable
{
    private CharacterController controller;
    private new Transform transform;
    private Animator animator;
    private new Camera camera;

    private Plane plane;
    private Ray ray;
    private Vector3 hitPoint;

    public float moveSpeed = 10.0f;


    [SerializeField]
    //PhotonView 컴포넌트 캐시 처리를 위한 변수
    private PhotonView pv;
    //시네머신 가상 카메라를 저장할 변수
    private CinemachineVirtualCamera virtualCamera;

    [SerializeField]
    //수신된 위치와 회전값을 저장할 변수
    private Vector3 receivePos;
    private Quaternion receiveRot;
    //수신된 좌표로 이동 및 회전 속도의 민감도
    public float damping = 10.0f;

    void Start()
    {
        controller = GetComponent<CharacterController>();
        transform = GetComponent<Transform>();
        animator = GetComponent<Animator>();
        camera = Camera.main;

        plane = new Plane(transform.up, transform.position); //평면방정식=(x+y+z)+d 법선벡터transform.up과 세점인 transform.position으로 평면을 만듦

        pv = GetComponent<PhotonView>();
        virtualCamera = GameObject.FindObjectOfType<CinemachineVirtualCamera>();
        
        //PhotonView가 자신의 것일 경우 시네머신 가상카메라 연결
        if(pv.IsMine)
        {
            virtualCamera.Follow = transform;
            virtualCamera.LookAt = transform;
        }
    }

    void Update()
    {
        if (pv.IsMine)
        {
            Move();
            Turn();
        }
        else
        {
            //수신된 좌표로 보간한 이동처리
            transform.position = Vector3.Lerp(transform.position, receivePos, Time.deltaTime * damping);

            //수신된 회전값으로 보간할 회전처리
            transform.rotation = Quaternion.Slerp(transform.rotation, receiveRot, Time.deltaTime * damping);
        }
    }

    float h => Input.GetAxis("Horizontal");
    float v => Input.GetAxis("Vertical");

    void Move()
    {
        Vector3 cameraForward = camera.transform.forward;
        Vector3 cameraRight = camera.transform.right;
        cameraForward.y = 0.0f;
        cameraRight.y = 0.0f;

        //이동할 방향 벡터 계산(내적)
        Vector3 moveDir = (cameraForward * v) + (cameraRight * h);
        moveDir.Set(moveDir.x, 0.0f, moveDir.z);

        //주인공 캐릭터 이동처리
        controller.SimpleMove(moveDir * moveSpeed);

        //주인공 캐릭터 애니메이션
        float forward = Vector3.Dot(moveDir, transform.forward);
        float strafe = Vector3.Dot(moveDir, transform.right); 

        animator.SetFloat("Forward", forward);
        animator.SetFloat("Strafe", strafe);
    }

    void Turn()
    {
        //마우스의 2차원 자표값을 이용해 3차원 광선을 성생
        ray=camera.ScreenPointToRay(Input.mousePosition);

        float enter = 0.0f;

        //가상의 바닥에 레이를 발사해 충돌한 지점의 거리를 enter 변수로 반환
        plane.Raycast(ray, out enter);
        //가상의 바닥에 레이가 충돌된 좌표값 추출
        hitPoint = ray.GetPoint(enter);

        //회전해야 할 방향의 벡터 계싼
        Vector3 lookDir = hitPoint - transform.position;
        lookDir.y = 0;

        //주인공 캐릭터의 회전값 지정
        transform.localRotation = Quaternion.LookRotation(lookDir);
    }

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        //자신의 로컬 캐릭터인 경우 자신의 데이터를 다른 네크워크 유저에게 송신
        if(stream.IsWriting)
        {
            stream.SendNext(transform.position);
            stream.SendNext(transform.rotation);
        }
        else
        {
            receivePos=(Vector3)stream.ReceiveNext();
            receiveRot = (Quaternion)stream.ReceiveNext();
        }
    }
}

 

■ stream.SendNext() : 

Pun에서 네크워크를 통해 데이터를 전송할 때 사용하는 함수

 

 

총알 발사 로직

Bullet 옵젝

○ 총알 코드

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bullet : MonoBehaviour
{
    public GameObject effect;

    private void Start()
    {
        GetComponent<Rigidbody>().AddRelativeForce(Vector3.forward * 1000.0f);
        Destroy(this.gameObject, 3.0f);
    }

    private void OnCollisionEnter(Collision coll)
    {
        //충돌 지점 추출
        var contact = coll.GetContact(0);
        //충돌 지점에 스파크 이펙트 생성
        var obj = Instantiate(effect, contact.point, Quaternion.LookRotation(-contact.normal));
        Destroy(obj, 2.0f);
        Destroy(this.gameObject);
    }
}

 

○ 총알 발사 로직 코드

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;

public class Fire : MonoBehaviour
{
    public Transform firePos;
    public GameObject bulletPrefab;
    private ParticleSystem MuzzleFlash;

    private PhotonView pv;

    //왼쪽 마우스 버튼클릭이벤트저장
    private bool isMouseClick => Input.GetMouseButtonDown(0);

    private void Start()
    {
        //포톤뷰 컴포넌트 연결
        pv = GetComponent<PhotonView>();
        //FirePos 하위에 있는 총구화염효과연결
        MuzzleFlash = firePos.Find("MuzzleFlash").GetComponent<ParticleSystem>();
    }

    private void Update()
    {
        //로컬유저여부와 마우스 왼쪽 버튼을 클릭 했을 때 총알을 발사
        if ((pv.IsMine && isMouseClick))
        {
            FireBullet();
            //RPC로 원격지에 있는 함수 호출
            pv.RPC("FireBullet", RpcTarget.Others, null);
        }
    }
    [PunRPC]
    void FireBullet()
    {
        //총구화염 효과가 실행 중이 아닌 경우에 총구 화염효과 실행
        if (!MuzzleFlash.isPlaying)
        {
            MuzzleFlash.Play(true); 
        }
        GameObject bullet = Instantiate(bulletPrefab, firePos.position, firePos.rotation);
    }
}

 

 

○ 적 피격 및 리스폰 코드

using Photon.Pun.Demo.Cockpit;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Damage : MonoBehaviour
{
    //사망 후 투명 처리를 위한 MeshRenderer 컴포넌트의 배열
    private Renderer[] renderers;

    //캐릭터의 초기 생명치
    private int initHp = 100;
    //캐릭터의 현재 생명치
    private int currHp=100;

    private Animator anim;
    private CharacterController cc;

    //애니메이터 뷰에 생성한 파라미터 해시값 추출
    private readonly int hashDie = Animator.StringToHash("Die");
    private readonly int hashRespawn = Animator.StringToHash("Respawn");

    private void Start()
    {
        //캐릭터 모델의 모든 Renderer 컴포넌트를 추출한 후 배열에 할당
        renderers = GetComponentsInChildren<Renderer>();
        anim = GetComponent<Animator>();
        cc= GetComponent<CharacterController>();

        //현재 생명치를 초기 생명치로 초기값 설정

        currHp = initHp;
    }

    private void OnCollisionEnter(Collision coll)
    {
        if(currHp>0&&coll.collider.CompareTag("BULLET"))
        {
            currHp -= 20;
            if(currHp<=0)
            {
                StartCoroutine(PlayerDie());
            }
        }
    }

    IEnumerator PlayerDie()
    {
        //CharacterController 컴포넌트 비활성화
        cc.enabled = false;
        //리스폰 비활
        anim.SetBool(hashRespawn, false);
        //캐릭터 사망 애니메이션
        anim.SetTrigger(hashDie);

        yield return new WaitForSeconds(3.0f);

        //리스폰 활성화
        anim.SetBool(hashRespawn, true);
        //캐릭터 투명처리
        SetPlayerVisible(false);

        //생성 위치 재조정
        Transform[] points=GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        int idx = Random.Range(1, points.Length);
        transform.position = points[idx].position;

        yield return new WaitForSeconds(1.2f);

        //리스폰 시 생명 초깃값 설정
        currHp = 100;
        //캐릭터 다시 보이게 처리
        SetPlayerVisible(true);
        //CharacterController 컴포넌트 활성화
        cc.enabled = true;
    }

    void SetPlayerVisible(bool isVisible)
    {
        for(int i=0; i<renderers.Length; i++)
        {
            renderers[i].enabled = isVisible;
        }
    }
}

 

 

UnityPackage 자료

https://drive.google.com/drive/folders/1SST7hNSz0LUyiVjHS0zlakjDbF1BN_S7?usp=sharing