Chapter 1. Network

개발하는 운동인·2024년 12월 30일

Server와 Owner 그리고 Client 구분 짓기

  • Client는 한 덩어리,Server는 사각형 안에 있는 모든 동그라미, Owner는 빨간색 동그라미. ex. 만약 3개의 클라이언트라면 3개의 컴퓨터와 9명의 유저가 있음.

📄 네트워크 튜토리얼

    1. 두 패키지를 다운로드 한다.

    1. 다음과 같이 세팅 - Defalut PlayerPrefab 과 Network Transport 프로퍼티 참고 (이 프리팹을 네트워크에서 소환하도록 )
  • DefaultNetworkPrefabs에 네트워크에 존재하는 애들을 미리정의해야함

    1. 플레이어는 아래 처럼 세팅한다
    1. 실행 전 세팅 (구체적임)
  • 5 - 1. Multi Play Mode에서 Player2 를 활성화 한다. -> 싱크 맞추는 용도(매우 중요함)

  • 5 - 2. 디버그 중에 NetworkManager를 StartHost 버튼을 누른다.

  • 5 - 3. Player2 에 StartClient 버튼을 누른다.

  • 5 - 4. 그 결과 Player2에도 플레이어 프리펩이 생성되었다.

1. 플레이어1과 플레이어2를 각각 다르게 움직여보자. (나쁜 예)

2. 버튼 이벤트를 통한 host와 client 나누기

    1. PlayerNetwork.cs을 플레이어에게 할당
    1. 간단한 움직이는 코드 작성. 잘못 된 예임.
using UnityEngine;

public class PlayeNetwork : MonoBehaviour
{
   
    void Update()
    {
        Vector3 movedir = Vector3.zero;

        movedir.x = Input.GetAxis("Horizontal");
        movedir.z = Input.GetAxis("Vertical");

        float movespeed = 3;

        transform.Translate(movedir * movespeed * Time.deltaTime);
    }
}
    1. 다음 계층구조를 만든다.

    1. 캔버스에 스크립트 할당
    1. 코드 작성
using Unity.Netcode;
using UnityEngine;

public class NetworkManagerUI : MonoBehaviour
{

    //네트워크 매니저한테 호스트로 시작할거야? 클라이언트로 시작할거야? 에 대한 정보를 알려주기 위해 간단한 버튼 

    public void HostPreesed()
    {
        NetworkManager.Singleton.StartHost();
    }

    public void ClientPressed()
    {
        NetworkManager.Singleton.StartClient();
    }
}
    1. 버튼 이벤트 연결

  • 실행 화면

  • 이전에, 수동으로 NetworkManager에서 StartHost, StartClient을 직접 클릭하였는데, 그것이 아닌 버튼 이벤트를 통해 host와 Client를 나눴다.

  • 하지만, 모두 똑같이 움직인다. 왜냐하면 각 플레이어에 PlayerNetwork.cs가 할당 되어있기에 똑같이 반응한다.

⭐ 중요 개념 및 포인트

  • 아래 사진은 앞으로 네트워크 및 멀티플레이 게임을 만들 때 중요한 포인트이다. Host가 오브젝트를 만든 다음 만약, 2명의 클라이언트가 오브젝트를 만들면 아래 사진 처럼 오브젝트 총 3개가 생긴다.
  • 이렇게 3개가 생기면 각 클라이언트들도 동일하게 오브젝트 3개가 생긴다.
  • 실제로 오브젝트 개수는 9개가 된다. 즉, 호스트가 클라이언트(인스턴스)마다 3개의 오브젝트를 갖고 있는 것이다.

⭐ 각 플레이어를 다르게 움직이기. (좋은 예)

    1. 코드를 수정한다. NetworkBehaviour 추가 및 IsOwner 추가
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;

public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    void Update()
    {
        if (IsOwner == false) //이 스크립트의 주인이 내가 아니라면
        {
            return; //아무것도 하지마.
        }

        Vector3 movedir = Vector3.zero;

        movedir.x = Input.GetAxis("Horizontal");
        movedir.z = Input.GetAxis("Vertical");

        float movespeed = 3;

        transform.Translate(movedir * movespeed * Time.deltaTime);
    }
}
  • 실행 화면
  • Owner는 각각 다르다. 즉, Host에서는 처음 플레이어가 Owner이고 마지막 플레이어는 Owner가 아니다.
  • Client에서는 처음 플레이어가 Owner가 아니고, 마지막 플레이가 Owner이다.

원리: Owner만 움직일 수 있다. 즉, Host와 Client에서 독립된 객체인 Owner 여야만 움직일 수 있다.

  • 각각 Owner가 다르지만, 3개의 오브젝트는 모두 존재한다.

하지만 움직였을 때 싱크가 제대로 되지 않는다.

⭐ 싱크를 제대로 맞추기 위해 억지로 싱크 맞추기

    1. 플레이어 프리펩에 Network Transform 컴포넌트 추가, 회전과 크기는 안하니까 체크 해제하고, y좌표는 건들지 않고 있기 때문에 x,z 좌표만 체크한다. Scale Threshold는 어느정도 오차까지 허용한다의 의미이다.(이 값이 지나면 싱크를 맞춰야 한다)
  • Authority Mode : 책임자이다. 아래 사진에 책임자는 Server이다. 자기 좌표를 클라이언트가 마음대로 바꿀 수 없고, 위치 바꿧으면 서버한테 물어봐야 한다. 그럼 서버가 위치 바꼇구나? 허락할게 라는 의미이다. -> 아래에서 할 것임.
  • 오브젝트가 3개가 있다. 각 오브젝트의 위치를 항상 같은 위치로 만들 때 이 컴포넌트를 사용한다.

하지만 퍼포먼스가 너무 낮다.

  • 실행 결과
  • 미묘하게 위치가 다르긴 하지만, 위치에 대한 싱크는 되고 있다. 미묘한 것을 없애려면 Tick Rate를 올리면 된다.

책임자 바꾸기(계속)

    1. 책임자를 Owner로 바꾼다. 위치를 바꾸면 서버한테 물어보는 것이 아닌 자기 자신한테 물어보는 것이다. 즉, 자기 위치에 책임자가 Owner가 된다.
  • 실행 결과
  • 위치를 Server가 아닌 Owner(자기 자신)한테 물어보니 위치가 동일하게 잡혀지고 자연스러워졌다.

🌟 클라이언트와 서버가 통신하는 구조 만들기 - NetworkVariable 사용하기

    1. 코드 추가 한다
public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    NetworkVariable<int> randomNumber = new NetworkVariable<int>(1);
    
    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        if (Input.GetKeyDown(KeyCode.T))
        {
            randomNumber.Value = Random.Range(0, 100);
        }

        Debug.Log("random number : " + randomNumber.Value);

        Vector3 moveDir = Vector3.zero;

        moveDir.x = Input.GetAxis("Horizontal");
        moveDir.z = Input.GetAxis("Vertical");

        float moveSpeed = 3;

        transform.Translate(moveDir * moveSpeed * Time.deltaTime);
    }
 }
  • NetworkVariable : 이 변수는 네트워크 전체가 변수 하나이다. 모든 네트워크가 randomNumber 변수의 값이 같게 세팅이 된다.

  • 실행 결과

  • T를 눌러서 randomNumber가 53으로 바뀌었지만, 클라이언트 에서는 코드에서 초기화 했던 1로 바뀌고 있다. 즉, 오류이다. 이유는 Host에서 NetworkVariable 을 변형시켜서 숫자를 바뀐거지, Client에서는 NetworkVariable을 변형시키지 않았다. -> Owner가 아닌 녀석의 NetworkVariable을 변형시킨 것이 아니다.

    1. 다시 수정
public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    NetworkVariable<int> randomNumber = new NetworkVariable<int>(1);
    
    void Update()
    {
    	
        Debug.Log("OwnerClientid" + OwnerClientid + "random number : " + randomNumber.Value);
        
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        if (Input.GetKeyDown(KeyCode.T))
        {
            randomNumber.Value = Random.Range(0, 100);
        }

     

        Vector3 moveDir = Vector3.zero;

        moveDir.x = Input.GetAxis("Horizontal");
        moveDir.z = Input.GetAxis("Vertical");

        float moveSpeed = 3;

        transform.Translate(moveDir * moveSpeed * Time.deltaTime);
    }
 }
  • 두 개의 플레이어 중에서 Owner만 NetworkVariable을 변형시킨 것이다. 그러는 것이 아닌 위에 디버그 로그를 찍는다.

  • 실행 결과

  • 위 사진은 T를 누르지 않은 상태이다. 즉, 초기 사진이다. 각각의 다른 OwnerId가 출력되었고, randomNumber는 1로 동일하다. Host -> OwnerId는 0 , Client -> OwnerId는 1이다.

  • 위 사진은 Host에서 T를 누른 상태인데, Host의 randomNumber가 99로 바뀌었다. Client의 randomNumber는 그대로 1이다. 왜냐하면 Host에서 T를 눌렀기 때문

  • 위 사진은 전체 사진인데, Client에서 Host의 randomNumber가 99로 바뀐것을 알고 있다.

  • 위 사진은 Client에서 T를 눌렀을 때 NetworkVariable가 변형되지 않고 Error가 난다. 왜냐하면 아래 코드 때문이다.

  NetworkVariable<int> randomNumber = new NetworkVariable<int>(1);
  • 1로 초기화 하는 것이 아닌 NetworkVariable의 쓸 수 있고, 바꿀 수 있는 사람을 정해줘야 한다.
  • 위 코드가 아닌 아래 사진 처럼 해야 한다.

🌟 즉, 클라이언트에서 값을 변경하려 했을 때 서버도 아닌데 클라이언트가 감히? 즉, 클라이언트한테 많은 권한을 주면 안된다. (문제가 생길 수 있음)

🌟 마찬가지로 위에서 책임자에 대해서 말했듯이 NetworkVariable을 변형시킬 때 책임자를 변경할 수 있다. Server가 아닌 Owner로 변경한다.

  • NetworkVariable을 읽을 수 있는 사람은 Everyone이고, NetworkVariable을 쓸 수 있는 사람은 Owner이다.
  • 실행 결과
  • Client에서 T를 누르면 randomNumber 값이 17로 변경되었고, Host 또한 그것을 싱크하게 되었다.
  • 반대로 Host에서 T를 누르면 randomNumber 값이 13으로 변경되었고 Client 또한 그것을 싱크하게 되었다.

서버를 맡게 된 Host는 Server이기도 하고, Owner이기도 하다.

🌟 NetworkVariable의 타입은 제한되어 있다.

  • 윗 줄은 c#의 타입이고 아랫줄은 구조체이다. 이것들은 사용할 수 있다. 구조체는 우리가 직접 만들어서 사용할 수 있다. 구조체는 값 타입이다.
    1. 아래 코드를 추가한다.
  • 실행 결과
  • 에러가 난다. 이유는 Struct을 어떻게 변형해야 되는지에 대한 방법을 알려줘야 한다.

에러 해결

    1. Network으로 Struct에 담긴 정보를 보내야 한다
  • 인터페이스 구현 및 다음과 같이 작성한다.
  • 실행 결과
  • 정상적으로 Host와 Client가 싱크에 성공했다.

🌟 숫자 말고 문자열도 해보기 전에 Network에 진입했을 때의 메서드와 빠져나갔을 때의 메서드를 구현한다.(OnEnable() , OnDisable() 같은 개념)

    1. 코드를 작성한다.
public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    struct myStruct : INetworkSerializable
    {
        public bool _bool;
        public int _int;
        public FixedString64Bytes _string;

        public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
        {
            serializer.SerializeValue(ref _bool);
            serializer.SerializeValue(ref _int);
            serializer.SerializeValue(ref _string);
        }
    }

    NetworkVariable<myStruct> randomNumber = new NetworkVariable<myStruct>(
        new myStruct
        {
            _bool = true,
            _int = 1
        }, // 수
        NetworkVariableReadPermission.Everyone, // 모든 대상자가 읽을 수 있는 권한
        NetworkVariableWritePermission.Owner // 소지자만 작성할 수 있는 권한
        );

    // OnEnable의 NetworkBehaviour 버전
    public override void OnNetworkSpawn() //⭐
    {
        randomNumber.OnValueChanged += HandleNumber;
    }

    // OnDisable의 NetworkBehaviour 버전
    public override void OnNetworkDespawn() //⭐
    {
        randomNumber.OnValueChanged -= HandleNumber;
    }

    void HandleNumber(myStruct oldValue, myStruct newValue) //⭐이전 값과 새로운 값 매개변수 목록
    {
        Debug.Log("Owner id : " + OwnerClientId + ", random number : "
         + randomNumber.Value._int + ", "
         + randomNumber.Value._string); //⭐
    }

    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X
        
        if (Input.GetKeyDown(KeyCode.T))
        {
            randomNumber.Value = new myStruct { _int = Random.Range(0, 100), _string = "일이삼다오륙칠팔구" };
        }

        Vector3 moveDir = Vector3.zero;

        moveDir.x = Input.GetAxis("Horizontal");
        moveDir.z = Input.GetAxis("Vertical");

        float moveSpeed = 3;

        transform.Translate(moveDir * moveSpeed * Time.deltaTime);
    }
 }
  • OnValueChanged 는 델리게이트이며, 매개변수 목록은 이전 값과 새로운 값이 필요하다.

  • 실행 결과

⭐ RPC 사용

  • 플레이어들 간의 함수를 호출. 즉, 멀리서 함수 호출.

🌟 ServerRPC

    1. 코드를 작성한다.
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;

public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    struct myStruct : INetworkSerializable
    {
        public bool _bool;
        public int _int;
        public FixedString64Bytes _string;

        public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
        {
            serializer.SerializeValue(ref _bool);
            serializer.SerializeValue(ref _int);
            serializer.SerializeValue(ref _string);
        }
    }

    NetworkVariable<myStruct> randomNumber = new NetworkVariable<myStruct>(
        new myStruct
        {
            _bool = true,
            _int = 1
        }, // 수
        NetworkVariableReadPermission.Everyone, // 모든 대상자가 읽을 수 있는 권한
        NetworkVariableWritePermission.Owner // 소지자만 작성할 수 있는 권한
        );

    // OnEnable의 NetworkBehaviour 버전
    public override void OnNetworkSpawn()
    {
        randomNumber.OnValueChanged += HandleNumber;
    }

    // OnDisable의 NetworkBehaviour 버전
    public override void OnNetworkDespawn()
    {
        randomNumber.OnValueChanged -= HandleNumber;
    }

    void HandleNumber(myStruct oldValue, myStruct newValue)
    {
        Debug.Log("Owner id : " + OwnerClientId + ", random number : "
         + randomNumber.Value._int + ", "
         + randomNumber.Value._string);
    }

    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        if (Input.GetKeyDown(KeyCode.Z)) //⭐ 추가
        {
            TestServerRpc(); //클라이언트가 아닌 서버의 함수를 호출. //⭐ 추가
        }

        if (Input.GetKeyDown(KeyCode.T))
        {
            randomNumber.Value = new myStruct { _int = Random.Range(0, 100), _string = "이것은 스트링이다." };
        }

        Vector3 moveDir = Vector3.zero;

        moveDir.x = Input.GetAxis("Horizontal");
        moveDir.z = Input.GetAxis("Vertical");

        float moveSpeed = 3;

        transform.Translate(moveDir * moveSpeed * Time.deltaTime);
    }

    ////RPC : 플레이어들 간의 함수를 호출. 즉, 멀리서 함수 호출. 

    [ServerRpc] //서버에서 실행. 함수 이름 =  함수 이름 + ServerRpc 로 끝나야 함.
    void TestServerRpc() //⭐ 추가 -> 함수 이름 주의
    {
        Debug.Log("RPC 메서드 호출");
    }

}
  • 함수 이름은 무조건 ServerRpc가 뒤에 있어야 한다.

  • 실행 결과

  • Host에서 z를 누르면 로그가 찍힌다. 하지만 Client에서 z를 누르면 Host에서 로그가 찍힌다. 왜냐하면 ServerRpc이므로 무조건 Server 함수를 호출하므로 Host에서만 로그가 찍힌다.

  • ClientRPC도 있다. Host에서 Client에 함수를 호출할 때 사용한다. 동시에 함수 호출은 불가능하다.

🌟 ClientRpc

    1. ClientRpc를 구현하기 위해 코드를 추가한다.
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;

public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

	//..생략

    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        Debug.Log("is server : " + IsServer + "is client : " + IsClient);
        if (Input.GetKeyDown(KeyCode.Z))
        {
            //TestServerRpc(); //클라이언트가 아닌 서버의 함수를 호출.
            TestClientRpc(); //⭐ 
        }

       //..생략
    }

    ////RPC : 플레이어들 간의 함수를 호출. 즉, 멀리서 함수 호출. 

    [ServerRpc] //서버에서 실행. 함수 이름 =  함수 이름 + ServerRpc 로 끝나야 함.
    void TestServerRpc()
    {
        Debug.Log("ServerRpc 메서드 진입");
    }

    [ClientRpc] //⭐ 
    void TestClientRpc() //⭐ 
    {
        Debug.Log("ClientRpc 메서드 진입"); //⭐ 
    }
}
  • 실행 결과
  • Host에서 z를 누르면 메서드가 Host에서도 호출이 되고, Client에서도 호출이 된다.
  • 당연히 Host에서 Client 메서드를 호출하는 방식인 ClientRpc이므로 Client에서는 z를 눌러도 반응이 없다.
  • 그리고, 중요한 점은 Host는 Server이기도 하고, Client이기도 하므로 자기 자신도 호출한다.

🌟 Host가 Server와 Client 역할을 병행할 수 있는 이유

Unity의 네트워크 아키텍처에서 Host는 "서버"와 "클라이언트"를 동일한 프로세스 내에서 실행할 수 있도록 설계되어 있습니다. 이는 다음과 같은 이유에서 가능합니다:

하나의 네트워크 인스턴스에서 두 역할 구현:

Unity는 네트워크를 처리할 때, 같은 프로세스에서 Server와 Client를 동시에 동작시킬 수 있도록 설계되었습니다.
이를 통해 네트워크 통신이 내부적으로 처리되며, 추가적인 연결이나 네트워크 비용이 필요하지 않습니다.

로컬 클라이언트 최적화:

Host는 자신의 데이터를 로컬에서 바로 처리하므로, 네트워크 지연(latency)이 없습니다.
실제 클라이언트-서버 구조처럼 동작하지만, Host와 자신의 클라이언트 간의 통신은 내부적으로 최적화됩니다.

권위(authority) 시스템:

Host는 서버와 클라이언트 모두를 제어할 권위를 가지며, 자신의 데이터를 즉각적으로 처리할 수 있습니다.

🌟 RPC를 호출한 클라이언트나 서버 판별하기 - ServerRpcParams

    1. 코드를 추가한다.
    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        Debug.Log("is server : " + IsServer + "is client : " + IsClient);
        if (Input.GetKeyDown(KeyCode.Z))
        {
            TestServerRpc(new ServerRpcParams()); //⭐ 
        }

       	//..생략
    }

    ////RPC : 플레이어들 간의 함수를 호출. 즉, 멀리서 함수 호출. 

    [ServerRpc] //서버에서 실행. 함수 이름 =  함수 이름 + ServerRpc 로 끝나야 함.
    void TestServerRpc(ServerRpcParams rpcParams) //⭐ ServerRpcParams : RPC를 호출한 클라이언트를 식별 할 때 사용하는 파라미터.
    {
       // Debug.Log("ServerRpc 메서드 진입" + number);
        Debug.Log("ServerRpc 메서드 진입" + rpcParams.Receive.SenderClientId); //⭐ 
    }

    [ClientRpc]
    void TestClientRpc()
    {
        Debug.Log("ClientRpc 메서드 진입");
    }
  • 실행 결과 (Host에서 z버튼 , Client에서 z버튼)
  • Host에서 z버튼 누르면 rpcParams.Receive.SenderClientId 가 0으로, Client에서 z버튼 누르면 rpcParams.Receive.SenderClientId 가 1으로 출력된다.

rpcParams.Receive.SenderClientId

  • 이 값은 RPC를 호출한 클라이언트를 식별하는 데 사용됩니다. 이를 통해 서버나 다른 클라이언트는 해당 요청이 어떤 클라이언트에서 온 것인지 알 수 있습니다.

ServerRpcParams 정의 타고 들어가보자.

    1. ServerRpcParams 에 들어가면 Send와 Receive가 있고,
    1. Receive에 타입인 ServerRpcReceiveParams 에 들어가면 SenderClientId가 들어있다.

🌟 RPC를 호출한 클라이언트나 서버 판별하기 - ClientRpcParams

    1. 코드를 추가한다.
    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        Debug.Log("is server : " + IsServer + "is client : " + IsClient);
        if (Input.GetKeyDown(KeyCode.Z))
        {
            TestClientRpc(new ClientRpcParams //⭐ 
            {
                Send = new ClientRpcSendParams
                {
                    TargetClientIds = new List<ulong> { 1 },
                }
            });

        }

      	//..생략
    }

    ////RPC : 플레이어들 간의 함수를 호출. 즉, 멀리서 함수 호출. 

    [ClientRpc]
    void TestClientRpc(ClientRpcParams rpcParams) //⭐ 
    {
        Debug.Log("ClientRpc 메서드 진입");
    }
  • 실행 결과
  • 아래 코드를 보면 1을 TargetClientIds로 저장하고 있다. 1은 클라이언트를 의미한다. 위글 참고. 따라서 Hosts에서 z를 누르면 Host 자신의 함수를 호출하는 것이 아닌 클라이언트를 호출한다
TargetClientIds = new List<ulong> { 1 }

ClientRpcParams 정의 타고 들어거자

⭐ 네트워크에 무언가를 Spawn 하기

    1. 일단 코드 먼저 작성하자
public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    [SerializeField]
    GameObject spawnedObjectPrefab; // ⭐

	//..생략

    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        if (Input.GetKeyDown(KeyCode.X))  // ⭐
        {
            Instantiate(spawnedObjectPrefab);
        }

		//..생략
       
     }
}
    1. 할당
  • 실행 결과

  • Host에서 x키를 눌렀으나 Host에서만 생성되고, Client에서는 생성되지 않았다.

  • 반대로, Client에서 x키를 눌렀으나 Client에서만 생성되고, Host에서 생성되지 않았다.

🌟 해결 방법

    1. 코드를 수정한다.
public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    [SerializeField]
    GameObject spawnedObjectPrefab; 

	//..생략

    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

        if (Input.GetKeyDown(KeyCode.X))  
        {
           GameObject go  = Instantiate(spawnedObjectPrefab);  // ⭐
            go.GetComponent<NetworkObject>().Spawn(true); // ⭐Spawn의 권한은 서버에 있다. 
        }

		//..생략
       
     }
}
  • 실행 결과
  • Host에서 x키를 눌렀더니 Host,Client 모두 프리펩이 생성된 것을 볼 수 있다.
  • 그와 반대로 Client에서 x키를 누르는 순간 에러가 난다.

그 이유는 Spawn 프로퍼티에 권한은 Server. 즉, Host에 있으므로 Client가 이를 접근할 수 없으니 에러가 난다. 당연한 것이다.

⭐ 네트워크에 무언가를 Despawn 하기.

    1. 코드를 추가한다
public class PlayeNetwork : NetworkBehaviour //네트워크 기능들을 좀 더 사용할 수 있는 모노비해비어의 한정판 
{
    //네트워크 비해비어에만 존재하는 것들
    // 1. IsOwner
    // 2. OwnerClientId
    // 3. [ServerRpc]

    [SerializeField]
    GameObject spawnedObjectPrefab; //⭐

	//..생략

    void Update()
    {
        if (!IsOwner) { return; } // 해당 오브젝트의 Network Owner가 아니면 작동X

  		if (Input.GetKeyDown(KeyCode.X))
  		{
       		spawnedObject =  Instantiate(spawnedObjectPrefab); 
       		spawnedObject.GetComponent<NetworkObject>().Spawn(true);
   		}
   		if (Input.GetKeyDown(KeyCode.C)) //⭐
   		{
       		//DeSpawn하는 방법은 2가지. 모두 똑같지만 2번째 방법이 좋다.
       		//Destroy(spawnedObject); //⭐ 네트워크에서 스폰된 것들은 자동으로 Despawn한다. 
       		spawnedObject.GetComponent<NetworkObject>().Despawn(true); //⭐ 네트워크에서는 Despawn이 된다.
   		}
		
        
		//..생략
       
     }
}

실행 결과

  • 생성
  • Host에서 C키를 눌렀더니 모두 사라진다. 즉, 자동으로 Host,Client 모두 Despawn된다.
  • 하지만, 마찬가지로 Client에서 Despawn하려 하면 에러가 난다. Despawn의 권한은 Server인데 Client가 접근하려 했기 때문에 에러가 난다. -> 당연한 것.

📄 네트워크 튜토리얼 마지막

    1. 이 에셋을 이용할 것이다. 패키지를 다운로드 한다

    1. PlayerAramture 프리펩을 사용할 것이므로 기존 Prefabs 폴더로 이동
    1. NetworkObject 컴포넌트를 PlayerAramture 프리펩에 추가
    1. 해당 프리펩을 NetworkManager에 Defalut Player Prefab에 할당
  • 실행 화면

  • 생성도 되고, 기본 이동도 물론 된다.

    1. Network Transform을 추가하고 아래와 같이 세팅한다. x,y,z 위치로 모두 움직이므로 체크 하고, y축 을 기준으로만 회전하므로 y축 rotation만 체크, 크기조정은 안할 것이므로 모두 해제.
  • 일단 이 상태로 실행하면

  • Host에서는 움직여지만, Client에서 움직여지지 않다.

  • 왜냐하면 Client에서 PlayerInput을 먹어버리고 있다. 즉, PlayerInput 컴포넌트가 입력을 처리한 뒤, 다른 시스템에 전달되지 않는 상태이다.

해결 방법 : 내가 Owner가 아니라면 PlayerInput을 비활성화 해서 입력을 처리하지 않게 한다. 즉, Host와 Client는 모두 Owner가 존재하는데, Owner가 아닐 때는 입력을 처리하지 않게 한다.

    1. 기존 ThirdConroller.cs을 NetworkBehaviour을 상속받게 한다.
    1. 코드를 작성한다.
using Unity.Netcode;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM 
using UnityEngine.InputSystem;
#endif

/* Note: animations are called via the controller for both the character and capsule using animator null checks
 */

namespace StarterAssets
{
    [RequireComponent(typeof(CharacterController))]
#if ENABLE_INPUT_SYSTEM 
    [RequireComponent(typeof(PlayerInput))]
#endif
    public class ThirdPersonController : NetworkBehaviour
    {
        //..생략

        public override void OnNetworkSpawn() //⭐
        {
            if (IsOwner == false) //내가 주인이 아니면
            {
                GetComponent<PlayerInput>().enabled = false; //⭐ 인풋 시스템을 끈다. 
            }

        }
		//..생략

        private void Update()
        {
            if (IsOwner == false) //⭐ 주인이 아니라면
            {
                return;
            }
        	//..생략
        }

 	//..생략
}
  • 실행 결과

  • Host에서 플레이어가 움직이면 Client에서도 움직인다. 하지만 Client에서 움직이는 애니메이션이 좀 이상하다.

  • 반대로, Client에서 플레이어가 움직이면 Host에서도 움직인다. 하지만 Host에서 움직이는 애니메이션이 좀 이상하다.

  • Host에서 Owner가 아닌 플레이어를 보면 PlayerInput이 비활성화 됨.

  • 마찬가지로 Client에서 Owner가 아닌 플레이어를 보면 PlayerInput이 비활성화 됨.

⭐ 애니메이션 고치기

    1. Network Animator 컴포넌트를 추가하고 Animator을 할당한다.
  • 실행 화면
  • Host에서 Client에 있는 플레이어를 봤을 때 애니메이션 문제는 없지만, Client에서는 여전히 문제가 있다.

⭐ 애니메이션 최종 해결

    1. ClientNetworkAnimator.cs 스크립트 생성 후 코드 작성한다
using Unity.Netcode.Components;
using UnityEngine;

public class ClientNetworkAnimator : NetworkAnimator
{
    protected override bool OnIsServerAuthoritative()
    {
        return false;
    }
}
    1. 스크립트 할당 후 애니메이터 할당
  • 기존 Network Animator 컴포넌트를 없애고 스크립트를 할당하여야 한다.

  • 실행 화면

  • 정상적으로 고쳐졌다. 문제가 뭐였을까? 클라이언트는 애니메이터를 건들일 수 없다.

  • 아까 아래 사진처럼 Network Transform 컴포넌트에 Authority Mode가 Owenr가 되어있었다. 즉, 위치를 클라이언트도 변경할 수 있었다. 옛날에는 Owner가 없었고 오로지 Server만 가능했다. 즉, Client도 Animator을 접근할 수 있게 해야 한다.

  • 이 얘기를 왜 하냐면은, Network Animator에 정의를 따라가면 아래 사진처럼 나오게 되는데 그 중에서 IsServerAuthoritative() 메서드를 볼 수 있다.

  • 그래서 아래 코드 처럼 NetworkAnimator을 상속 받아서 OnIsServerAuthoritative()을 false처리 하는 것이다. 즉, 서버만 애니메이션을 바꿀 수 있는 것을 false하는 것이다.

  • 네트워크 애니메이터에 정보를 전달하는 역할을 Client도 가능하게 한 것.

  • 이렇게 하지 않으면, 애니메이션이 없이 클라이언트는 서버한테 저 무슨 동작(애니메이션)을 해야 되요? 라고 요청해야 됨.

⭐ 네트워크 성능 수치 모니터링 방법

    1. 다음 패키지를 다운로드 한다.
    1. StatsMonitor 빈 객체를 만들고 Runtime Network Stats Monotior 컴포넌트를 추가한다.
  • 실행 화면

  • 그래프가 생겼다.

  • 이 컴포넌트는 네트워크 성능 데이터를 시각적으로 보여주는 컴포넌트이다.

  • 이 컴포넌트를 추가하면 게임 화면의 좌측 상단에 실시간 네트워크 성능을 나타내는 그래프가 표시됩니다.

  • 그래프와 데이터는 네트워크 성능을 이해하고 최적화하는 데 중요한 지표를 제공합니다.

  • 나중에 여기서 Address Port 옵션 설정해서 뚫어주면 된다. 같은망으로 연결해 주는건 유니티 백앤드 서비스를 어떻게 써야하는 듯

📄 새로운 프로젝트 시작 전 환경 설정

    1. 에셋 다운로드
  • 가브리엘 어쩌고 에셋

  • 위자드 에셋

  • 텍스처 에셋

    1. 가브리엘 어쩌고 에셋은 URP로 풀어준다.
    1. 텍스처 에셋의 텍스처는 모두 깨져있으므로 렌더링 파이프라인으로 변환 해준다.
    1. Wizard 에셋 폴더에서 Animations와 Mesh폴더를 제외하고 Delete한다.
  • 아래 폴더를 열고 압축을 푼다.

  • 결과.

  • 이 씬에서 에셋이 제대로 나오면 성공

📄 새로운 프로젝트 시작

    1. 씬 생성 후 Plane 배치

    1. 프리펩 정하자. 난 이걸로 할랭

    1. 아무 텍스처를 고르고, 땅에 텍스쳐를 바꾼다.

⭐ 간단한 컨트롤 + 우클릭 시 이동 + 좌클릭 시 공격

    1. PlayerController.cs 스크립트 생성
  • 할당까지 해보자
    1. 코드 작성
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField]
    float rotationSpeed = 10;

    [SerializeField]
    float moveSpeed = 5;

    Vector3 targetPos; //목표 위치

    private void Start()
    {
        targetPos = transform.position;
    }
    void Update()
    {
        //1. 마우스 우 클릭하면 이동해야 하므로 , 우클릭 했을 때 마우스 위치를 targetPos에 저장해야 함
        
        if(Input.GetMouseButtonDown(1))
        {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;

            if(Physics.Raycast(ray,out hit))
            {
                targetPos = hit.point;
            }
        }

        //2. 내 위치에서 targetPos 위치로 회전 및 이동 해야 함.
        Vector3 dir = targetPos - transform.position;
        if(dir.magnitude > moveSpeed * Time.deltaTime) //한 프레임에 이동하는 크기가 moveSpeed * Time.deltaTime보다 크다면 이동
        {
            Quaternion targerRot = Quaternion.LookRotation(dir); //가야할 방향으로 쳐다 봄
            transform.rotation = 
                Quaternion.Lerp(transform.rotation, targerRot, rotationSpeed * Time.deltaTime); //현재 회전 위치에서 쳐다 봐야할 회전 값까지 서서히 회전

            transform.position += dir.normalized * moveSpeed * Time.deltaTime;
        }
    }
}

  • 실행 화면

  • 정상적으로 이동 및 회전이 구현 되었다. 하지만, 아직 카메라와 애니메이션은 구현되지 않았다.

  • 애니메이션은 데모용이므로 나중에 조정할 것 이다.

    1. 레이어 설정. Player 레이어와 Ground 레이어를 추가


    1. 코드 수정. 레이 범위를 1000m로 하고 Ground 레이어에 마스크를 씌어서 Ground레이어에만 레이 캐스팅 가능하게
   //..생략
   void Update()
   {
       //1. 마우스 우 클릭하면 이동해야 하므로 , 우클릭 했을 때 마우스 위치를 targetPos에 저장해야 함
       
       if(Input.GetMouseButtonDown(1))
       {
           Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
           RaycastHit hit;

           if(Physics.Raycast(ray,out hit,1000, LayerMask.GetMask("Ground"))) //⭐ 추가.
           {
               targetPos = hit.point;
           }
       }

    	//..생략
   }

⭐ 프로젝트에 네트워크 추가

    1. 플레이어 프리펩에 Network Object 컴포넌트와 Network Transform 컴포넌트를 추가한다.
  • 점프를 하지 않을 것이므로 pos y축 해제, rot은 y축 기준으로 회전하므로 y축만 체크 , 크기 조정 안할 것이므로 scale은 모두 해제 . 그리고 Authority Mode는 Owner로 하여 Host와 Client가 독립된 Owner를 각각 존재할 수 있도록 한다.

    1. NetworkManager 빈 객체 생성 후 NetworkManager 컴포넌트 추가
  • 그리고 UnityTransport을 해준다.

    1. Host버튼과 Client 버튼 추가

    1. Canvas에 위에서 만들었던 NetworkManagerUI 스크립트를 할당한다.
using Unity.Netcode;
using UnityEngine;

public class NetworkManagerUI : MonoBehaviour
{

    //네트워크 매니저한테 호스트로 시작할거야? 클라이언트로 시작할거야? 에 대한 정보를 알려주기 위해 간단한 버튼 

    public void HostPreesed()
    {
        NetworkManager.Singleton.StartHost();
    }

    public void ClientPressed()
    {
        NetworkManager.Singleton.StartClient();
    }
}
    1. NetworkManagerUI 을 통해 버튼 이벤트 할당 바로 한다

    1. 코드 수정한다. Host에서 Owner여야지 프로그램이 돌아간다. 또한, Client에서도 Owner여야지 프로그램이 돌아간다.
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour //⭐
{
    //..생략

    public override void OnNetworkSpawn()
    {
        if(IsOwner == false) //내가 Owner가 아니라면 
        {
            return;
        }

        targetPos = transform.position; //Owner일 때만 실행
    }
    private void Start()
    {
       
    }
    void Update()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;
        }

    	//..생략
}
    1. 플레이어를 프리펩으로 이동 후 하이라키 창에서 삭제
    1. NetworkManager에 플레이어 프리펩 할당
  • 실행 결과

  • 정상적으로 Host의 Owner만 움직이고 회전 하고, Client의 Owner만 움직이고 회전하게 되었다. 즉, 싱크가 잘 되었다.

카메라만 가볍게 구현하자.

    1. 카메라 따라가게 코드 수정
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour 
{
    //..생략

    public override void OnNetworkSpawn()
    {
        if(IsOwner == false) //내가 Owner가 아니라면 
        {
            return;
        }

        targetPos = transform.position; //Owner일 때만 실행
    }
    private void Start()
    {
       
    }
    void Update()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;
        }

    	//..생략
       Camera.main.transform.position = transform.position + new Vector3(0, 9 , -9); //⭐플레이어에 후방 위치를 카메라 위치로 저장
}
  • 실행 결과
  • 정상적으로 작동한다.

PlayerController.cs 현재 코드

using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    [SerializeField]
    float rotationSpeed = 10;

    [SerializeField]
    float moveSpeed = 5;

    Vector3 targetPos; //목표 위치

    public override void OnNetworkSpawn()
    {
        if(IsOwner == false) //내가 Owner가 아니라면 
        {
            return;
        }

        targetPos = transform.position; //Owner일 때만 실행
    }
    private void Start()
    {
       
    }
    void Update()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;
        }

        //1. 마우스 우 클릭하면 이동해야 하므로 , 우클릭 했을 때 마우스 위치를 targetPos에 저장해야 함

        if (Input.GetMouseButtonDown(1))
        {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;

            if(Physics.Raycast(ray,out hit,1000, LayerMask.GetMask("Ground")))
            {
                targetPos = hit.point;
            }
        }

        //2. 내 위치에서 targetPos 위치로 회전 및 이동 해야 함.
        Vector3 dir = targetPos - transform.position;
        if(dir.magnitude > moveSpeed * Time.deltaTime) //한 프레임에 이동하는 크기가 moveSpeed * Time.deltaTime보다 크다면 이동
        {
            Quaternion targerRot = Quaternion.LookRotation(dir); //가야할 방향으로 쳐다 봄
            transform.rotation = 
                Quaternion.Lerp(transform.rotation, targerRot, rotationSpeed * Time.deltaTime); //현재 회전 위치에서 쳐다 봐야할 회전 값까지 서서히 회전

            transform.position += dir.normalized * moveSpeed * Time.deltaTime;
        }

        Camera.main.transform.position = transform.position + new Vector3(0, 9, -9); //플레이어에 후방 위치를 카메라 위치로 저장
    }
}

다시 계속

    1. 플레이어에 Rigidbody 추가
  • 아래와 같이 세팅.

    1. 코드를 작성한다.기존에 transform.position += 이런식으로 이동했었는데 Rigidbody의 MovePosition을 이용한다. 또한, FixedUpdate을 사용해야 하므로 fixeDeltaTime을 사용한다.
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    [SerializeField]
    float rotationSpeed = 10;

    [SerializeField]
    float moveSpeed = 5;

    Vector3 targetPos; //목표 위치

    public override void OnNetworkSpawn()
    {
        if(IsOwner == false) //내가 Owner가 아니라면 
        {
            return;
        }

        targetPos = transform.position; //Owner일 때만 실행
    }
    private void Start()
    {
       
    }
    void Update()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;
        }

        //1. 마우스 우 클릭하면 이동해야 하므로 , 우클릭 했을 때 마우스 위치를 targetPos에 저장해야 함

        if (Input.GetMouseButtonDown(1))
        {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;

            if(Physics.Raycast(ray,out hit,1000, LayerMask.GetMask("Ground")))
            {
                targetPos = hit.point;
            }
        }

 
    }

    private void FixedUpdate()
    {
     	if (IsOwner == false) //⭐내가 Owner가 아니라면  반환
 		{
     		return;
 		}
        //2. 내 위치에서 targetPos 위치로 회전 및 이동 해야 함.
        Vector3 dir = targetPos - transform.position;
        if (dir.magnitude > moveSpeed * Time.fixedDeltaTime) //한 프레임에 이동하는 크기가 moveSpeed * Time.deltaTime보다 크다면 이동
        {
            Quaternion targerRot = Quaternion.LookRotation(dir); //가야할 방향으로 쳐다 봄
            transform.rotation =
                Quaternion.Lerp(transform.rotation, targerRot, rotationSpeed * Time.fixedDeltaTime); //현재 회전 위치에서 쳐다 봐야할 회전 값까지 서서히 회전

            Vector3 movementVector = dir.normalized * moveSpeed * Time.fixedDeltaTime; //⭐
            GetComponent<Rigidbody>().MovePosition(transform.position + movementVector);//⭐

           
        }

        Camera.main.transform.position = transform.position + new Vector3(0, 9, -9); //플레이어에 후방 위치를 카메라 위치로 저장
    }
}
    1. 사용할 애니메이션 클립과, 컨트롤러 생성
  • 파라미터 세팅도 해보자.



    1. 저번에 만든 Client Network Animator 연결 후 할당.
    1. 간이 State Machine 코드를 작성. 상태에 따른 애니메이션 관리 메서드 생성
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    enum State //⭐
    {
        Idle,
        Move,
        Attack,
        Dying
    }
    [SerializeField]
    float rotationSpeed = 10;

    [SerializeField]
    float moveSpeed = 5;

    Vector3 targetPos; //목표 위치
    State state; //⭐ 
    Animator animator; //⭐ 여러번 쓸 거면 캐싱한다.

    //OnNetworkSpawn() 와 Start() 중 누가 먼저 실행될까? 경우에 따라 다르다.
    //미리 Spawn 해 놓을 수도 있고 나중에 Spawn 해 놓을 수 있으므로 순서가 다르다.
    // Awake()을 사용하는 것이 좋음

    private void Start() 
    {
        state = State.Idle; //⭐Idle이긴 하지만 명시적으로 사용

        animator = GetComponent<Animator>(); //⭐ 캐싱 초기화.
    }
    public override void OnNetworkSpawn()
    {
       //..생략
    }

    void Update()
    {
  		  //..생략

 
    }

    private void FixedUpdate()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;

        }
        //2. 내 위치에서 targetPos 위치로 회전 및 이동 해야 함.
        Vector3 dir = targetPos - transform.position;
        if (dir.magnitude > moveSpeed * Time.fixedDeltaTime) //한 프레임에 이동하는 크기가 moveSpeed * Time.deltaTime보다 크다면 이동
        {
            SetState(State.Move); //⭐ Move 상태로 전환 

          	//..생략
        }
        else
        {
            SetState(State.Idle); //⭐ Idle 상태로 전환 
        }
        Camera.main.transform.position = transform.position + new Vector3(0, 9, -9); //플레이어에 후방 위치를 카메라 위치로 저장
    }

    void SetState(State newstate) //⭐ 상태에 따른 애니메이터 관리 메서드.
    {
        // 원래 State머신은 각 state을 스크립트로 만들어 버리는데 용량이 작기 때문에 그냥 이렇게 ㄱ
        switch(state)
        {
            case State.Idle:
                if(newstate == State.Attack)
                {
                    animator.SetTrigger("Attack");
                }
                else if (newstate == State.Move)
                {
                    animator.SetTrigger("Move");
                }            
                break;
            case State.Move:
                if (newstate == State.Attack)
                {
                    animator.SetTrigger("Attack");
                }
                else if (newstate == State.Idle)
                {
                    animator.SetTrigger("Idle");
                }
                break;
            case State.Attack:

                animator.SetTrigger("Idle");

                break;
        }

        state = newstate;
    }
}
    1. 공격 구현 코드 작성
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    //..생략
    
    Vector3 attackTargetPos; //⭐ 공격 대상 위치.
    bool isWaitingAttackInput; //⭐ 공격 키 입력 여부

	  //..생략

    void Update()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;
        }

        //1. 마우스 우 클릭하면 이동해야 하므로 , 우클릭 했을 때 마우스 위치를 targetPos에 저장해야 함

        if (Input.GetMouseButtonDown(1) && (state == State.Idle || state == State.Move)) //⭐
        {
            isWaitingAttackInput = false; //⭐
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); 
            RaycastHit hit; 

            if(Physics.Raycast(ray,out hit,1000, LayerMask.GetMask("Ground")))
            {
                targetPos = hit.point;
            }
        }

        // 2. 공격
        if(Input.GetKeyDown(KeyCode.A)) //⭐
        {
            isWaitingAttackInput = true; //⭐
        }
        if (Input.GetMouseButtonDown(0) && isWaitingAttackInput && (state == State.Idle || state == State.Move)) //⭐
        {
            isWaitingAttackInput = false; //⭐

            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); //⭐
            RaycastHit hit; //⭐

            if (Physics.Raycast(ray, out hit, 1000, LayerMask.GetMask("Ground"))) //⭐
            {
                attackTargetPos = hit.point; //해당 마우스 지점을 attakcTargetPos에 저장. //⭐
                SetState(State.Attack); //⭐ 상태 전환 
                targetPos = transform.position; //⭐멈춘다.

                //실제 발사
            }
        }
    }

    public void AttackEnd() //⭐ Attack 애니메이션 끝자락에 이벤트로 호출. Attack 애니메이션 끝나면 Idle로
    {
        SetState(State.Idle);
    }
    private void FixedUpdate()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;

        }

        if (state != State.Attack) //⭐ Attack 상태가 아닐 때만 플레이어 이동 및 회전 가능. 없으면 중복상태 오류
        {

            //2. 내 위치에서 targetPos 위치로 회전 및 이동 해야 함.
            Vector3 dir = targetPos - transform.position;
            if (dir.magnitude > moveSpeed * Time.fixedDeltaTime) //한 프레임에 이동하는 크기가 moveSpeed * Time.deltaTime보다 크다면 이동
            {
                SetState(State.Move);

                Quaternion targerRot = Quaternion.LookRotation(dir); //가야할 방향으로 쳐다 봄
                transform.rotation =
                    Quaternion.Lerp(transform.rotation, targerRot, rotationSpeed * Time.fixedDeltaTime); //현재 회전 위치에서 쳐다 봐야할 회전 값까지 서서히 회전

                Vector3 movementVector = dir.normalized * moveSpeed * Time.fixedDeltaTime;
                GetComponent<Rigidbody>().MovePosition(transform.position + movementVector);


            }
            else //⭐ 이동이 끝났다면 Idle로 
            {
                SetState(State.Idle);
            }
        }
        else //⭐
        {
            Vector3 direction = attackTargetPos - transform.position;
            Quaternion targetRotation = Quaternion.LookRotation(direction);
            transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.fixedDeltaTime);
        }

        Camera.main.transform.position = transform.position + new Vector3(0, 9, -9); //플레이어에 후방 위치를 카메라 위치로 저장
    }

    void SetState(State newstate)
    {
        //..생략
    }
}
    1. 위 코드에서 AttackEnd 호출 방식은 아래와 같다.
    1. Attack LoopTime 해제
  • 실행 화면

  • 이동 중 마우스 커서 방향으로 공격 방향이 설정되고, 공격 도중에는 움직일 수 없다. 그리고 공격 상태가 끝나면 Idle상태가 된다.

네트워크 연결 후 실행 화면

  • 이동 명령을 내리지 않았는데 이동하려 하고 있고, 이동했을 때 원래 위치로 되돌아가려고 하기 때문에 명령이 겹친 오류이다.

해결 방법: 다른 곳에 스폰하면 그만임

투사체 발사 3가지 방법 (계속)

1. 클라이언트가 발사하고 싶다고 서버에 알림. 서버가 다 컨트롤하고 다른 클라이언트에 알림.

  • 클라이언트가 발사하려고 해도 클라이언트는 모른다.
  • 바로 서버에게 피드백을 받기 어려움

2. 클라이언트가 일단 발사하고 서버에 알림.

  • 서버는 다른 클라이언트에 알리고, 충돌과 데미지 계산은 서버에서 함.

3. 클라이언트가 발사하고 서버에 알리는데 충돌과 데미지 계산도 클라이언트가 함

  • 결과만 서버에 알림

우린 2번을 하자. 이유는 2번이 귀찮고 복잡하기 때문임ㅋㅋ 1번은 위에서 했었음

투사체 발사 구현. 2번방법으로 ㄱㄱ

    1. 다음과 같이 세팅
  • 회전은 안할거므로 x,y,z Freeze 시킨다.

  • 중력 끄고, Angular Drag을 0으로.

    1. 레이어도 추가
    1. 일정 시간이 지나면 터지는 스크립트 작성
using UnityEngine;

public class LifeTime : MonoBehaviour
{

    [SerializeField]
    float lifeTime = 2;

    private void Start()
    {
        Destroy(gameObject, lifeTime);
    }
}
    1. 무언가에 부딪히면 터지는 스크립트 작성.
using UnityEngine;

public class DestroyOnContact : MonoBehaviour
{

    private void OnTriggerEnter(Collider other)
    {
        if(other != null)
        {
            Destroy(gameObject);
        }
    }
}
    1. 스크립트 할당.
    1. 충돌 레이어 설정
    1. 프리펩의 부모인 PrefabVariant 설정

  • 총 2개. ClientProjectile과 ProjectileBase을 만듦.

    1. 이제 발사체를 생성하고 관리하는 스크립트를 작성한다.
using Unity.Netcode;
using UnityEngine;

public class ProjectileLancuher : NetworkBehaviour
{
    //클라이언트나 서버에서 이 스크립트를 사용할 수 있으므로 구분해야함

    [SerializeField]
    GameObject serverProjectilePrefab; //서버에서 사용하는 발사체 프리팹

    [SerializeField]
    GameObject clientProjectilePrefab; //클라이언트에서 사용하는 발사체 프리팹입니다

    [SerializeField]
    float projectileSpeed = 3; //발사체 속도

    [SerializeField]
    float coolTime = 1; //쿨타임

    [SerializeField]
    float initialDistance = 1; //발사체가 생성되는 위치를 발사 원점으로부터 얼마나 떨어뜨릴지 설정

    float coolTimer = 0; //현재 쿨타임 -> 시간으로 증가 시킬 것임

    private void Update()
    {
        if(IsOwner == false)
        {
            return;
        }

        coolTimer += Time.deltaTime;
    }

    public void Attack(Vector3 targetPos)
    {

        if(coolTimer < coolTime) //쿨타임이 아직 지나지 않았으면 발사를 중단합니다.
        {
            return;
        }
        coolTimer = 0; //발사가 성공적으로 이루어진 후 쿨타이머를 초기화

        //발사


    }
}
    1. 추가 코드 작성
using System.Collections;
using Unity.Netcode;
using UnityEngine;

public class ProjectileLancuher : NetworkBehaviour
{
    //클라이언트나 서버에서 이 스크립트를 사용할 수 있으므로 구분해야함

    [SerializeField]
    GameObject serverProjectilePrefab; 

    [SerializeField]
    GameObject clientProjectilePrefab;

    [SerializeField]
    float projectileSpeed = 3;

    [SerializeField]
    float coolTime = 1;

    [SerializeField]
    float initialDistance = 1;

    float coolTimer = 0;

    private void Update()
    {
        if(IsOwner == false)
        {
            return;
        }

        coolTimer += Time.deltaTime;
    }

    public void Attack(Vector3 targetPos)
    {

        if(coolTimer < coolTime)
        {
            return;
        }
        coolTimer = 0;

        //⭐ 발사 구현 시작

        Vector3 dir = (targetPos - transform.position).normalized; //방향 벡터. 
        Vector3 spawnPoint = dir * initialDistance + transform.position; //발사체 스폰 위치. 

        spawnPoint.y = spawnPoint.y + 1;

        StartCoroutine(FireAfterDelay(spawnPoint, dir));
    }

    IEnumerator FireAfterDelay(Vector3 spawnPoint, Vector3 dir)  //⭐ 딜레이 주는 메서드
    {
        yield return new WaitForSeconds(0.5f);

        SpawnDummyProjectile(spawnPoint, dir);
    }

    void SpawnDummyProjectile(Vector3 spawnPoint,Vector3 dir)  //⭐ 모양은 있으나, 실제로 데미지 계산은 하지 않는.
    {
        GameObject projectile = Instantiate(clientProjectilePrefab, spawnPoint, Quaternion.identity);

        Physics.IgnoreCollision(GetComponent<Collider>(),projectile.GetComponent<Collider>()); // 내 콜라이더와 발사체 콜라이더를 무시.

        if(projectile.TryGetComponent<Rigidbody>(out Rigidbody rb)) //TryGetComponent : 컴포넌트 찾아내면 True -> rb에 저장
        {
            rb.linearVelocity = dir * projectileSpeed;
        }
    }
}
  • ProjectileLancuher 스크립트에 있는 Attack 메서드 호출 부분
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
      //..생략

        // 2. 공격
        if(Input.GetKeyDown(KeyCode.A))
        {
            isWaitingAttackInput = true;
        }
        if (Input.GetMouseButtonDown(0) && isWaitingAttackInput && (state == State.Idle || state == State.Move))
        {
            isWaitingAttackInput = false;

            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;

            if (Physics.Raycast(ray, out hit, 1000, LayerMask.GetMask("Ground")))
            {
                attackTargetPos = hit.point; //해당 마우스 지점을 attakcTargetPos에 저장.
                SetState(State.Attack); //상태 전환
                targetPos = transform.position; //멈춘다.

                //실제 발사

                GetComponent<ProjectileLancuher>().Attack(attackTargetPos); // ⭐ Attack 메서드 호출
            }
        }
    }

  	
    //..생략
}
    1. ClientProjecttile에 이펙트 프리펩을 산하에 넣는다. 크기는 4,4,4 로
    1. 플레이어에 프리펩을 할당한다.
  • 실행 결과

❗아직 클라이언트에서만 투사체 발사에 대한 정보를 알고 있기 때문에 서버에게도 알려야 한다.

  • 없다. 클라이언트에서 발사체에 대한 정보도 없고 나타나질 않는다.

🌟투사체 발사에 대한 정보를 서버에게 알리기

    1. 그 전에 에셋 다운로드

    1. 세팅


    1. 충돌 레이어 세팅

🌟투사체 발사에 대한 정보를 서버에게 알리기(계속)

    1. 코드를 추가한다.
using System.Collections;
using Unity.Netcode;
using UnityEngine;

public class ProjectileLancuher : NetworkBehaviour
{
    //클라이언트나 서버에서 이 스크립트를 사용할 수 있으므로 구분해야함

    [SerializeField]
    GameObject serverProjectilePrefab; 

    [SerializeField]
    GameObject clientProjectilePrefab;

    [SerializeField]
    float projectileSpeed = 3;

    [SerializeField]
    float coolTime = 1;

    [SerializeField]
    float initialDistance = 1;

    float coolTimer = 0;

    private void Update()
    {
        if(IsOwner == false)
        {
            return;
        }

        coolTimer += Time.deltaTime;
    }

    public void Attack(Vector3 targetPos)
    {

        if(coolTimer < coolTime)
        {
            return;
        }
        coolTimer = 0;

        //발사

        Vector3 dir = (targetPos - transform.position).normalized; //방향 벡터. 
        Vector3 spawnPoint = dir * initialDistance + transform.position; //발사체 스폰 위치. 

        spawnPoint.y = spawnPoint.y + 1;

        StartCoroutine(FireAfterDelay(spawnPoint, dir));
    }

    IEnumerator FireAfterDelay(Vector3 spawnPoint, Vector3 dir) //딜레이 주는 메서드
    {
        yield return new WaitForSeconds(0.5f);

        SpawnDummyProjectile(spawnPoint, dir); //⭐여기서 서버한테도 알려야 함.
        FireServerRpc(spawnPoint, dir); //⭐
    }

    [ServerRpc] //⭐
    void FireServerRpc(Vector3 spawnPoint, Vector3 dir) //⭐서버 -> 클라이언트
    {
        GameObject projectile = Instantiate(serverProjectilePrefab, spawnPoint, Quaternion.identity); //serverProjectilePrefab 생성

        Physics.IgnoreCollision(GetComponent<Collider>(), projectile.GetComponent<Collider>()); // 내 콜라이더와 발사체 콜라이더를 무시.

        if (projectile.TryGetComponent<Rigidbody>(out Rigidbody rb)) //찾아내면 True -> rb에 저장
        {
            rb.linearVelocity = dir * projectileSpeed;
        }

        FireClientRpc(spawnPoint, dir); 
    }


    [ClientRpc] //⭐
    void FireClientRpc(Vector3 spawnPoint, Vector3 dir) //⭐
    {
        if(IsOwner)
        {
            return;
        }
        SpawnDummyProjectile(spawnPoint, dir);
    }

    void SpawnDummyProjectile(Vector3 spawnPoint,Vector3 dir) //모양은 있으나, 실제로 데미지 계산은 하지 않는.
    {
        GameObject projectile = Instantiate(clientProjectilePrefab, spawnPoint, Quaternion.identity);

        Physics.IgnoreCollision(GetComponent<Collider>(),projectile.GetComponent<Collider>()); // 내 콜라이더와 발사체 콜라이더를 무시.

        if(projectile.TryGetComponent<Rigidbody>(out Rigidbody rb)) //찾아내면 True -> rb에 저장
        {
            rb.linearVelocity = dir * projectileSpeed;
        }
    }
}
  • 실행 결과

개념 정리

FireServerRpc 메서드 설명

[ServerRpc]
  • 클라이언트가 서버로 데이터를 보낼 때 사용됩니다.
  • 호출은 클라이언트에서 하지만, 실행은 서버에서 이루어집니다.
  • 주로 발사, 이동, 공격 등의 이벤트를 서버가 처리하도록 전달할 때 유용합니다.

FireClientRpc 메서드 설명

[ClientRpc]
  • 서버가 모든 클라이언트(또는 특정 클라이언트)에 데이터를 보낼 때 사용됩니다.
  • 호출은 서버에서 하지만, 실행은 각 클라이언트에서 이루어집니다.
  • 주로 시각적 효과나 동기화를 위해 사용됩니다.

예시를 통한 흐름 설명

    1. Server & Client 와 ClientB, Client C가 있다고 가정하자.
    1. Server & Client가 투사체 발사 시도
    1. Server & Client가 투사체 발사 했다고 Server & Client에게 알림 > [ServerRpc]
    1. Server & Client는 생성된 발사체의 정보를 Server & Client,Client B, Client C에게 알림 -> [ClientRpc]
    1. Client B, Client C 는 발사체를 자신의 화면에 표시하기 위해 SpawnDummyProjectile을 호출 (아래 사진 참고)

또 다른 예시

Q & A

    1. FireClientRpc 메서드에서 if (IsOwner) { return; } 을 한 이유는?
  • 발사체를 소유한 클라이언트가 중복 생성하지 않도록 하기 위해 작성된 코드

    1. 클라이언트가 상호작용 하고 싶다면 서버에게 알려야 한다. 하지만, 돌은 서버와 클라이언트 모두 갖고 있기 때문에 상호작용 할 필요도 없고, 네트워크 오브젝트로 할 필요가 없다.

발사체가 충돌하거나 이펙트 재생 시간이 지나면 이펙트 생성

    1. SpawnOnDestry 스크립트 생성
    1. 코드 작성
using UnityEngine;

public class SpawnOnDestroy : MonoBehaviour
{
    //발사체가 사라지는 순간에 이펙트 생성 슈우웃~
    [SerializeField]
    GameObject dustPrefab;

    private void OnDestroy()
    {
        Instantiate(dustPrefab).transform.position = transform.position; //발사체가 사라질 때 사라진 위치에 이펙트 생성.
    }
}
    1. 발사체 프리펩에 스크립트 할당 후, 사라지는 이펙트 할당
    1. 1회성 파티클일 때 , 플레이 타임이 끝나면 알아서 없어지게 Destroy 해야 함.

체력과 마나 시스템 구현

    1. 플레이어 산하에 캔버스 생성하고, 머리 위에 위치하도록한다. 그리고 Canvas 컴포넌트만 남게 한다. (캔버스 위치 중요. )
    1. 2d 패키지 다운로드
    1. 이미지 생성후 아래와 같이 세팅
  • 최종 세팅

    1. LookAtCamera 스크립트 생성 후 코드 작성. 위에 만들었던 Canvas가 플레이어를 바라보게(평행하게) 해야 하므로.
using UnityEngine;

public class LookAtCamera : MonoBehaviour
{
   
    void Update()
    {
        transform.LookAt(transform.position + Camera.main.transform.forward);
    }
}
    1. StatCanvas에 LookAtCamera 스크립트 할당
  • 실행 결과

  • 사라지는 이펙트, 체력 바 , 마나 바 모두 잘 작동한다.

    1. Health 스크립트 생성 후 코드 작성
using Unity.Netcode;
using UnityEngine;

public class Health : NetworkBehaviour
{

    public int maxHealth = 100;
    public NetworkVariable<int> currentHealth = new NetworkVariable<int>();

    bool isDead;

    public override void OnNetworkSpawn()
    {
        if(IsServer == false) //클라이언트의 체력은 서버가 책임져야 한다.
        {
            return;
        }
        currentHealth.Value = maxHealth;
    }

    public void TakeDamage(int damage) //체력 감소
    {
        ModifyHealth(-damage);
    }

    public void RestoreHealth(int health) //체력 증가
    {
        ModifyHealth(health);
    }

    void ModifyHealth(int value)
    {
        if(isDead)
        {
            return;
        }

        int newHealth = currentHealth.Value + value; //현재 체력 + 변화된 체력 = 새로운 체력
        currentHealth.Value = Mathf.Clamp(newHealth, 0, maxHealth); //새로운 체력은 maxHealth 값 까지만 도달할 수 있ㅇ므. 

        if(currentHealth.Value == 0)
        {
            isDead = true;
        }
    }
}
    1. 플레이어에게 할당

중요한 점은 플레이어의 체력은 Server가 관리한다.

    1. 체력을 깎으려면 Server용 발사체 프리펩에다 DealDamageOnContact 스크립트를 생성 후 할당한다.
    1. 코드를 작성한다.
using Unity.Netcode;
using UnityEngine;

public class DealDamageContact : MonoBehaviour
{

    [SerializeField]
    int damage = 10;

    ulong ownerClientid;

    public void SetOwner(ulong owerClientid)
    {
        this.ownerClientid = owerClientid;
    }

    private void OnTriggerEnter(Collider other)
    {
        if(other.attachedRigidbody == null) //충돌한 콜라이더의 부모 오브젝트의 리지드바디
        {
            return;
        }

        if(other.TryGetComponent<NetworkObject>(out NetworkObject obj))
        {
            if(ownerClientid == obj.OwnerClientId) //자해를 막고
            {
                return;
            }
        }

        if(other.TryGetComponent<Health>(out Health health))
        {
            health.TakeDamage(damage);
        }
    }
}
    1. SetOwner메서드 호출
using System.Collections;
using Unity.Netcode;
using UnityEngine;

public class ProjectileLancuher : NetworkBehaviour
{
    //클라이언트나 서버에서 이 스크립트를 사용할 수 있으므로 구분해야함

    //..생략

    [ServerRpc]
    void FireServerRpc(Vector3 spawnPoint, Vector3 dir) //2. 클라이언트 -> 서버
    {
        GameObject projectile = Instantiate(serverProjectilePrefab, spawnPoint, Quaternion.identity); //serverProjectilePrefab 생성

        projectile.GetComponent<DealDamageContact>().SetOwner(OwnerClientId); //⭐ 호출

        Physics.IgnoreCollision(GetComponent<Collider>(), projectile.GetComponent<Collider>()); // 내 콜라이더와 발사체 콜라이더를 무시.

        if (projectile.TryGetComponent<Rigidbody>(out Rigidbody rb)) //찾아내면 True -> rb에 저장
        {
            rb.linearVelocity = dir * projectileSpeed;
        }

        FireClientRpc(spawnPoint, dir); 
    }


   	//..생략
}
  • 실행 결과
  • Damage(10)씩 감소하는 것을 볼 수 있다.

서버용 발사체는 서버에만 있는 거고, 딜 계산은 서버만 할 것이다. 그래서 서버가 딜 계산을 하기 위해서 서버용 발사체가 따로 있는거다. 호스트는 서버와 클라이언트 모두 있지만, 다른 클라이언트에게 발사체를 보여주기 위해서 클라이언트용 발사체가 있는 것이다.

투사체를 체력 UI 깎이게 하기

    1. StatDisplayer 스크립트를 생성 후 Player 산하에 Canvas에다가 할당
    1. 스크립트 작성
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UI;

public class StatDisPlayer : NetworkBehaviour
{

    [SerializeField]
    Health health;

    [SerializeField]
    Image healthBarImage;

    [SerializeField]
    Image mpBarImage;

    private void Start()
    {
        if(IsClient == false)
        {
            return;
        }

        if(health != null)
        {
            health.currentHealth.OnValueChanged += HandleHealthChange;
        }
    }

    void HandleHealthChange(int oldHelath,int newHealth)
    {
        healthBarImage.fillAmount = newHealth / (float)health.maxHealth;
    }
}
    1. 인스펙터 할당
  • 실행 결과

투사체 사용 시 마나 감소

구현 할 것

  • 몬스터를 잡으면 마나 아이템을 파밍할 수 있다.

  • 마나가 다 떨어지면, 마나가 점차 증가하는 것을 구현

    1. MagicPoint 스크립트 생성 후 플레이어에게 할당
    1. 코드 작성
using Unity.Netcode;
using UnityEngine;

public class MagicPoint : NetworkBehaviour
{
    public float maxMagicPoint = 100;

    public NetworkVariable<float> currentMagic = new NetworkVariable<float>(0,NetworkVariableReadPermission.Everyone,NetworkVariableWritePermission.Owner);
    //클라이언트에서 관리할 때 즉시즉시 반영 -> 이제 클라이언트가 마나를 관리

   // public NetworkVariable<float> currentMagic = new NetworkVariable<float>();

    [SerializeField]
    float regerateRate = 6.0f;

    public override void OnNetworkSpawn() //스폰되지마자 실행
    {
        if(IsOwner == false) //서버가 관리해야 하므로 서버가 아니라면 실행 x
        {
            return;
        }

        currentMagic.Value = maxMagicPoint;
    }



    private void Update()
    {
        if (IsOwner == false) //서버가 관리해야 하므로 서버가 아니라면 실행 x
        {
            return;
        }

        currentMagic.Value = Mathf.Clamp(currentMagic.Value + regerateRate * Time.deltaTime, 0, maxMagicPoint);  //마나 업데이트 코드
    }

    public bool UseMagic(float magic) //마법을 사용할 때 마나 깎기
    {
        //마나가 쓸 수 있는 상황과 쓸 수 없는 상황을 true,false로 판별. 

        if(currentMagic.Value < magic) // 현재 마나 30 < 사용해야 될 마나 50 => 30마나로 50마나를 사용할 수 없음 => false
        {
            return false;
        }

        ModifyMagicPoint(-magic);
        return true;
        
    }

    public void RestoreMagic(float magic)
    {
        ModifyMagicPoint(magic);

    }

    private void ModifyMagicPoint(float value)
    {
        currentMagic.Value = Mathf.Clamp(currentMagic.Value + value, 0, maxMagicPoint);
    }
}
    1. UseMagic 메서드 호출. 마나 20을 사용할 수 있다면? 공격 가능. 없다면? 공격 불 가능
using TMPro;
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
	 //..생략
     
    void Update()
    {
        if (IsOwner == false) //내가 Owner가 아니라면  반환
        {
            return;
        }

        //1. 마우스 우 클릭하면 이동해야 하므로 , 우클릭 했을 때 마우스 위치를 targetPos에 저장해야 함

        if (Input.GetMouseButtonDown(1) && (state == State.Idle || state == State.Move))
        {
            isWaitingAttackInput = false;
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;

            if(Physics.Raycast(ray,out hit,1000, LayerMask.GetMask("Ground")))
            {
                targetPos = hit.point;
            }
        }

        // 2. 공격
        if(Input.GetKeyDown(KeyCode.A))
        {
            isWaitingAttackInput = true;
        }
        if (Input.GetMouseButtonDown(0) && isWaitingAttackInput && (state == State.Idle || state == State.Move))
        {
            isWaitingAttackInput = false;

            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;

            if (Physics.Raycast(ray, out hit, 1000, LayerMask.GetMask("Ground")))
            {
                if(GetComponent<MagicPoint>().UseMagic(20)) //⭐ 마나 20을 사용할 수 있다면?
                {
                    attackTargetPos = hit.point; //해당 마우스 지점을 attakcTargetPos에 저장.
                    SetState(State.Attack); //상태 전환
                    targetPos = transform.position; //멈춘다.

                    //실제 발사

                    GetComponent<ProjectileLancuher>().Attack(attackTargetPos);
                }

               
            }
        }
    }

    public void AttackEnd() //Attack 애니메이션 끝자락에 이벤트로 호출
    {
        SetState(State.Idle);
    }
  
  
  //..생략
}
  • 실행 결과
  • 투사체를 발사할 때 마나 20씩 감소되고, 마나는 점차 증가한다. 증가를 빠르게 하고 싶다면, 인스펙터 창에서 regerateRate값을 올리면 된다. 만약 현재 마나 20보다 작다면, 투사체를 발사할 수 없게 된다.

마나 ui 감소 및 증가 시키기.

    1. 기존 StatDisplayer.cs에 코드 추가
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UI;

public class StatDisPlayer : NetworkBehaviour
{

    [SerializeField]
    Health health;

    [SerializeField]//⭐
    MagicPoint magicPoint;//⭐

    [SerializeField]
    Image healthBarImage;

    [SerializeField]
    Image mpBarImage;

    public override void OnNetworkSpawn() //⭐
    {
        if (IsClient == false)
        {
            return;
        }

        if (health != null)
        {
            health.currentHealth.OnValueChanged += HandleHealthChange;
        }

        if (magicPoint != null) //⭐
        {
            magicPoint.currentMagic.OnValueChanged += HandleMagicPointChange;
        }
    }

    public override void OnNetworkDespawn() //⭐
    {
        if (IsClient == false)
        {
            return;
        }

        if (health != null)
        {
            health.currentHealth.OnValueChanged -= HandleHealthChange;
        }
        if (magicPoint != null) //⭐
        {
            magicPoint.currentMagic.OnValueChanged -= HandleMagicPointChange;
        }
    }
    void HandleHealthChange(int oldHelath,int newHealth)
    {
        healthBarImage.fillAmount = newHealth / (float)health.maxHealth;
    }
    void HandleMagicPointChange(float oldMagic, float newMagic) //⭐
    {
        mpBarImage.fillAmount = newMagic / magicPoint.maxMagicPoint;
    }
}
    1. 할당
  • 실행 결과

  • 마나 감소 또한 마나 ui가 감소되고 증가가 잘 되는 것을 볼 수 있다.

Q & A

    1. currentMagic을 다음과 같이 정의 했을 때, IsServer를 사용해야 하는 이유는 무엇인가?
public NetworkVariable<float> currentMagic = new NetworkVariable<float>();
  • 그래서 아래 와 같이 조건문을 작성하는 것이다.
    public NetworkVariable<float> currentMagic = new NetworkVariable<float>();

    [SerializeField]
    float regerateRate = 6.0f;
    
    public override void OnNetworkSpawn() //스폰되지마자 실행
    {
        if(IsServer == false) //⭐ 서버가 관리해야 하므로 서버가 아니라면 실행 x
        {
            return;
        }

        currentMagic.Value = maxMagicPoint;
    }



    private void Update()
    {
        if (IsServer == false) //⭐ 서버가 관리해야 하므로 서버가 아니라면 실행 x
        {
            return;
        }

        currentMagic.Value = Mathf.Clamp(currentMagic.Value + regerateRate * Time.deltaTime, 0, maxMagicPoint);  //마나 업데이트 코드
    }


    1. currentMagic을 다음과 같이 정의 했을 때 IsOwner를 사용해야 하는 이유는 무엇인가?
public NetworkVariable<float> currentMagic = new NetworkVariable<float>();
  • 그래서 아래 와 같이 조건문을 작성하는 것이다.

마나 아이템 파밍.

    1. 에셋 다운로드.

    1. 렌더링 파이프 라인 ㄱㄱ (텍스처 깨졌음)
  • 결과. 이 아이템으로 마나를 증가 시킬 것이다.

    1. 프리펩으로 만든다.
    1. 몬스터 스폰은 , 서버와 클라이언트 모두 스폰되어야 하므로 네트워크 오브젝트여야 한다. 추가로, MonsterSpawner 스크립트를 생성한다. 그리고 할당 까지.
    1. 아래 처럼 세팅한다.
  • Monster 레이어를 만든다.

  • Monster 레이어 설정.

  • 레이어 충돌체 상호작용 설정.

  • 인스펙터에서 값 설정한다.

    1. MonsterSpawner.cs 코드 작성한다
using Unity.Netcode;
using UnityEngine;

public class MonsterSpawner : NetworkBehaviour
{
    [SerializeField]
    GameObject[] monsterPrefabs; //몬스터가 여러개이므로 배열로.

    [SerializeField]
    int maxMonsters = 20; //최대 몬스터 생성 갯수

    [SerializeField]
    Vector2 minSpawnPos;

    [SerializeField]
    Vector2 maxSpawnPos;

    //몬스터가 겹치지 않기 위해서. 즉, 피아할 레이어
    [SerializeField]
    LayerMask layerMask;

    float monsterRadius; //몬스터 생성 범위. 

    Collider[] monsterBuffer = new Collider[1]; //1개 짜리 버퍼를 만들어도 ㄱㅊ음. 

    public override void OnNetworkSpawn()
    {
        //뿌려준다. 서버만 관리해야 함. 클라이언트는 접근 불가.
        if(IsServer == false)
        {
            return;
        }

        monsterRadius = monsterPrefabs[0].GetComponent<SphereCollider>().radius;

        for(int i = 0; i< maxMonsters; i++)
        {
            SpawnMonster();
        }
    }
    void SpawnMonster()
    {
        GameObject go = Instantiate(monsterPrefabs[Random.Range(0, monsterPrefabs.Length)], GetSpawnPos(), Quaternion.identity);

        go.GetComponent<NetworkObject>().Spawn();//서버에서 스폰을 하긴 하지만 네트워크 오브젝트를 통해서 전송
    }

    Vector3 GetSpawnPos()//몬스터가 생성될 때 겹치지 않아야 하므로 관련 메서드 생성
    {
        float x = 0;
        float z = 0;

        while(true)
        {
            x = Random.Range(minSpawnPos.x, maxSpawnPos.x);
            z = Random.Range(minSpawnPos.y, maxSpawnPos.y);

            Vector3 spawnPos = new Vector3(x, 0, z); //y는 0으로 해야함. 3d에서는 

            int numColliders = Physics.OverlapSphereNonAlloc(spawnPos, monsterRadius, monsterBuffer,layerMask);
            //return 값이 int. int 겹친 갯수를 numColliders에 저장. 충돌체 정보는 monsterBuffer에 담겨져있음.

            if(numColliders == 0)
            {
                return spawnPos;
            }
        }
    }
    
}
    1. Animation Controller 를 만들어주고, 몬스터에게 할당한다.
  • 애니메이션은 기본 Idle 애니메이션만 넣어준다.
    1. 몬스터에게 NetworkObject, SpherCollider, Rigidbody,NetworkTransform,NetworkRigidbody 컴포넌트를 할당한다.
  • Rigidbody에서 x,z축 회전을 막고, y축으로 위치 이동하는 것을(미끄러짐) 막는다.

  • Network Transform에서 x,z축으로 만 위치 싱크를 맞출 수 있게 하고, y축 회전(3d기준)을 할 수 있게 싱크한다. scale은 조정 안할 것이므로 모두 해제.

  • NetworkRigidbody는 일반적인 리지드 바디를 갖고 있는 것을 서버가 갖고와서 서버가 관리한다.

    1. 몬스터 프리펩 할당하고, 레이어를 다시 설정.
  • 실행 결과

    1. 몬스터에게 기존 Health 스크립트를 할당한다.
    1. 몬스터 산하에 Canvas를 만들고, 체력 바 이미지를 만든다.
  • Cavas에 StatDisplayer 스크립트와 LookAtCamera 스크립트를 추가한다. 아래와 같이 세팅

    1. 몬스터 프리펩에 Monster 스크립트 생성 후 추가한다.
    1. DealDamageOnContact.cs 코드 수정
using Unity.Netcode;
using UnityEngine;

public class DealDamageContact : MonoBehaviour
{

    [SerializeField]
    int damage = 10;

    ulong ownerClientid;

    public void SetOwner(ulong owerClientid)
    {
        this.ownerClientid = owerClientid;
    }

    private void OnTriggerEnter(Collider other)
    {
        if(other.attachedRigidbody == null) //충돌한 콜라이더의 부모 오브젝트의 리지드바디
        {
            return;
        }

        if(other.TryGetComponent<NetworkObject>(out NetworkObject obj))
        {
            if(ownerClientid == obj.OwnerClientId && other.GetComponent<Monster>() == null) //⭐ 자해를 막고
            {
                return;
            }
        }

        if(other.TryGetComponent<Health>(out Health health))
        {
            health.TakeDamage(damage);
        }
    }
}
  • 결과

몬스터 사망

    1. Health 스크립트 수정
using System;
using Unity.Netcode;
using UnityEngine;

public class Health : NetworkBehaviour
{

    public int maxHealth = 100;
    public NetworkVariable<int> currentHealth = new NetworkVariable<int>();

    bool isDead;

    public Action OnDie; //⭐

    public override void OnNetworkSpawn()
    {
        if(IsServer == false) //클라이언트의 체력은 서버가 책임져야 한다.
        {
            return;
        }
        currentHealth.Value = maxHealth;
    }

    public void TakeDamage(int damage) //체력 감소
    {
        ModifyHealth(-damage);
    }

    public void RestoreHealth(int health) //체력 증가
    {
        ModifyHealth(health);
    }

    void ModifyHealth(int value)
    {
        if(isDead)
        {
            return;
        }

        int newHealth = currentHealth.Value + value; //현재 체력 + 변화된 체력 = 새로운 체력
        currentHealth.Value = Mathf.Clamp(newHealth, 0, maxHealth); //새로운 체력은 maxHealth 값 까지만 도달할 수 있ㅇ므. 

        if(currentHealth.Value == 0)
        {
            isDead = true;
            OnDie?.Invoke(); //⭐ OnDie가 null 아니면, 즉, OnDie라면 이벤트 뿌리기.
        }
    }
}
    1. 죽는 이벤트를 뿌리는 것은 Monster.cs 에서 진행한다. 코드를 추가한다.
using Unity.Netcode;
using UnityEngine;

public class Monster : NetworkBehaviour //⭐
{
    public override void OnNetworkSpawn() //⭐
    {
        //체력이 떨어져서 죽을 때 이벤트 받기
        GetComponent<Health>().OnDie += OnDie;
    }

    public override void OnNetworkDespawn() //⭐
    {
        GetComponent<Health>().OnDie -= OnDie;
    }

    void OnDie() //⭐
    {
        //죽을 때 뭔가 애니메이션 또는 특수효과를 넣고 싶다면, 여기에다 ㄱㄱ
        Destroy(gameObject);
    }
}
    1. MagicPointItem 빈 객체를 생성하고, 객체 산하에 죽었을 때 보여줄 이펙트를 추가한다.
    1. 프리펩화 한다.
  • 5 . 죽었을 때 아이템을 떨구기 위해 이전에 작성했던 SpawnOnDestroy 스크립트를 추가하고, 프리펩화 한 것을 할당한다.

using UnityEngine;

public class SpawnOnDestroy : MonoBehaviour
{
    //발사체가 사라지는 순간에 이펙트 생성 슈우웃~
    [SerializeField]
    GameObject dustPrefab;

    private void OnDestroy()
    {
        Instantiate(dustPrefab).transform.position = transform.position; //발사체가 사라질 때 사라진 위치에 이펙트 생성.
    }
}
    1. MagicPointItem 스크립트 생성
    1. MagicPointItem 할당, NetworkObject 컴포넌트 할당한다.
    1. 코드를 작성한다.
using Unity.Netcode;
using UnityEngine;

public class MagicPotionItem : NetworkBehaviour
{

    [SerializeField]
    float magicPoint = 50; //먹으면 마나 50증가


    public float Collect()
    {
        if(IsServer == false) //서버가 아니라면 실행 x.
        {
            return 0;
        }

        Destroy(gameObject);

        return magicPoint;
    }
}

하지만, Monster가 Destory 될 때 MonoBehaviour용 OnDestroy을 사용하고 있다. Monster는 NetworkObject이다. NetworkBehaviour용 OnDestroy을 사용해야 함.

    1. 기존 몬스터에 부착된 SpawnOnDestroy 스크립트를 없앤다. 이 스크립트는 MonoBehaviour용 Destroy였다.
      그리고, NetworkSpawnDestroy 스크립트를 생성하고 위 사진처럼 부착한다.
    1. NetworkSpawnDestroy 스크립트 작성한다.
using Unity.Netcode;
using UnityEngine;

public class NetworkSpawnDestroy : NetworkBehaviour
{
    [SerializeField]
    GameObject prefab;

    public override void OnDestroy() //NetworkBehaviour 용 OnDestroy()임. 
    {
        base.OnDestroy();

        if(IsServer == false)
        {
            return;
        }

        GameObject go = Instantiate(prefab); //prefab생성
        go.transform.position = transform.position; //프리펩 위치를 이 스크립트 주인의 위치로
        go.GetComponent<NetworkObject>().Spawn();  //프리펩 스폰.
    }
}
    1. 프리펩 할당까지 슈웃
  • 실행 결과

  • 몬스터가 죽었을 때 프리펩(마나 아이템)이 정상적으로 생성된다.

플레이어가 마나 아이템 획득

    1. ItemCollecter 스크립트 생성하고 플레이어에게 추가.

마나 관리는 클라이언트. 아이템을 먹는건 서버. 서버가 아이템을 먹었다면 클라이언트에게 알려줘야 함

    1. 다음 코드 추가한다. MagicPoint.cs에 추가해야 함. ClientRPC 사용.
    1. ItemCollecter.cs 스크립트 작성
using Unity.Netcode;
using UnityEngine;

public class ItemCollecter : NetworkBehaviour
{

    private void OnTriggerEnter(Collider other)
    {
        if(!other.TryGetComponent<MagicPotionItem>(out MagicPotionItem item))
        {
            return;
        }

        float magic = item.Collect();

        if (IsServer == false)
        {
            return;
        }

        GetComponent<MagicPoint>().RestoreMagicClientRpc(magic,
            new ClientRpcParams
            {
                Send = new ClientRpcSendParams
                {
                    TargetClientIds = new[] { OwnerClientId }
                }
            });
    }
}
    1. 마나 아이템에 콜라이더 추가, 그리고 Item 레이어 생성 후 설정. Trriger 활성화.

  • 아이템 레이어 충돌 상호작용 설정.

    1. 기존 NetworkSpawnOnDestroy.cs 코드를 수정한다.
using Unity.Netcode;
using UnityEngine;

public class NetworkSpawnDestroy : NetworkBehaviour
{
    [SerializeField]
    GameObject prefab;

    public override void OnDestroy() //NetworkBehaviour 용 OnDestroy()임. 
    {
        base.OnDestroy();

        if(NetworkManager.Singleton.IsListening && !NetworkManager.Singleton.ShutdownInProgress) //⭐ 추가.
        {
            if (IsServer == false)
            {
                return;
            }

            GameObject go = Instantiate(prefab); //prefab생성
            go.transform.position = transform.position; //프리펩 위치를 이 스크립트 주인의 위치로
            go.GetComponent<NetworkObject>().Spawn();  //프리펩 스폰.
        }

       
    }
}
  • 실행 결과
  • 몬스터를 죽이고 나서 마나 아이템을 먹으면 마나가 증가하는 것을 볼 수 있다.

🌟 슬라임이 죽고 나서 다시 스폰하게 만들기

    1. 누가 죽었는지 정보를 알아야 한다(Health.cs가 알고있음). Health.cs에 코드를 수정한다.
using System;
using Unity.Netcode;
using UnityEngine;

public class Health : NetworkBehaviour
{

    public int maxHealth = 100;
    public NetworkVariable<int> currentHealth = new NetworkVariable<int>();

    bool isDead;

    public Action<Health> OnDie; //⭐ this는 곧 이 스크립트인 Health이므로 Health 타입.

    public override void OnNetworkSpawn()
    {
        if(IsServer == false) //클라이언트의 체력은 서버가 책임져야 한다.
        {
            return;
        }
        currentHealth.Value = maxHealth;
    }

    public void TakeDamage(int damage) //체력 감소
    {
        ModifyHealth(-damage);
    }

    public void RestoreHealth(int health) //체력 증가
    {
        ModifyHealth(health);
    }

    void ModifyHealth(int value)
    {
        if(isDead)
        {
            return;
        }

        int newHealth = currentHealth.Value + value; //현재 체력 + 변화된 체력 = 새로운 체력
        currentHealth.Value = Mathf.Clamp(newHealth, 0, maxHealth); //새로운 체력은 maxHealth 값 까지만 도달할 수 있ㅇ므. 

        if(currentHealth.Value == 0)
        {
            isDead = true;
           // OnDie?.Invoke(); //OnDie가 null 아니면, 즉, OnDie라면 이벤트 뿌리기.
            OnDie?.Invoke(this); //⭐ this 키워드를 붙이면 누가 죽었는지 알 수 있다.
        }
    }
}
    1. Monster.cs 에도 코드를 수정한다.
using Unity.Netcode;
using UnityEngine;

public class Monster : NetworkBehaviour
{
    public override void OnNetworkSpawn()
    {
        //체력이 떨어져서 죽을 때 이벤트 받기
        GetComponent<Health>().OnDie += OnDie;
    }

    public override void OnNetworkDespawn()
    {
        GetComponent<Health>().OnDie -= OnDie;
    }

    void OnDie(Health sender) //⭐Health sender 추가.
    {
        //죽을 때 뭔가 애니메이션 또는 특수효과를 넣고 싶다면, 여기에다 ㄱㄱ
        Destroy(gameObject);    
    }
}
  • 누가 죽었는지 정보를 알기 때문에 정보를 통해 다시 스폰하기
using Unity.Netcode;
using UnityEngine;

public class MonsterSpawner : NetworkBehaviour
{
    [SerializeField]
    GameObject[] monsterPrefabs; //몬스터가 여러개이므로 배열로.

    [SerializeField]
    int maxMonsters = 20; //최대 몬스터 생성 갯수

    [SerializeField]
    Vector2 minSpawnPos;

    [SerializeField]
    Vector2 maxSpawnPos;

    //몬스터가 겹치지 않기 위해서. 즉, 피아할 레이어
    [SerializeField]
    LayerMask layerMask;

    float monsterRadius; //몬스터 생성 범위. 

    Collider[] monsterBuffer = new Collider[1]; //1개 짜리 버퍼를 만들어도 ㄱㅊ음. 

    public override void OnNetworkSpawn()
    {
        //뿌려준다. 서버만 관리해야 함. 클라이언트는 접근 불가.
        if(IsServer == false)
        {
            return;
        }

        monsterRadius = monsterPrefabs[0].GetComponent<SphereCollider>().radius; //0.5

        for(int i = 0; i< maxMonsters; i++)
        {
            SpawnMonster();
        }
    }
    void SpawnMonster()
    {
        GameObject go = Instantiate(monsterPrefabs[Random.Range(0, monsterPrefabs.Length)], GetSpawnPos(), Quaternion.identity);

        go.GetComponent<NetworkObject>().Spawn();//서버에서 스폰을 하긴 하지만 네트워크 오브젝트를 통해서 전송

        go.GetComponent<Health>().OnDie += OnDie; //⭐
    }

    private void OnDie(Health sender) //⭐
    {
        sender.OnDie -= OnDie; //⭐
        SpawnMonster(); //⭐
    }

    Vector3 GetSpawnPos()//몬스터가 생성될 때 겹치지 않아야 하므로 관련 메서드 생성
    {
        float x = 0;
        float z = 0;

        while(true)
        {
            x = Random.Range(minSpawnPos.x, maxSpawnPos.x);
            z = Random.Range(minSpawnPos.y, maxSpawnPos.y);

            Vector3 spawnPos = new Vector3(x, 0, z); //y는 0으로 해야함. 3d에서는 

            int numColliders = Physics.OverlapSphereNonAlloc(spawnPos, monsterRadius, monsterBuffer,layerMask);
            //return 값이 int. int 겹친 갯수를 numColliders에 저장. 충돌체 정보는 monsterBuffer에 담겨져있음.

            if(numColliders == 0)
            {
                return spawnPos;
            }
        }
    }
    
}

👌 실행 결과 - 성공

유저 죽이기

    1. PlayerRespawnManager.cs 스크립트 생성
    1. PlayerRespawnManager 빈 객체에다 PlayerRespawnManager.cs 추가
  • 유저는 네트워크가 필요한 객체이므로, NetworkObject 컴포넌트도 추가.

    1. PlayerRespawnManager.cs 코드 작성
using System;
using System.Collections;
using Unity.Netcode;
using Unity.Services.Lobbies.Models;
using UnityEngine;

public class PlayerRespawnManager : NetworkBehaviour
{
    [SerializeField]
    NetworkObject playerPrefab;

    public override void OnNetworkSpawn()
    {
       //죽은 이벤트를 갖다가 리스폰하겠다.
        if(IsServer == false) //플레이어 리스폰은 무조건 서버가 할 것이므로 서버가 아니면 return;
        {
            return;
        }

- 4. 

        //Manager가 생성되기 전에 생성된 Player 친구도 이벤트를 뿌려야 함. -> 뭔말?
        PlayerController[] players = FindObjectsByType<PlayerController>(FindObjectsSortMode.None); //모든 PlayerController 컴포넌트를 모두 찾음

        foreach(PlayerController player in players)
        {
            HandlePlayerSpawn(player);
        }


        PlayerController.OnPlayerSpawn += HandlePlayerSpawn;
        PlayerController.OnPlayerDespawn += HandlePlayerDeSpawn;
    }
    public override void OnNetworkDespawn()
    {
        PlayerController.OnPlayerSpawn -= HandlePlayerSpawn;
        PlayerController.OnPlayerDespawn -= HandlePlayerDeSpawn;
    }
    private void HandlePlayerSpawn(PlayerController player)
    {
        //너의 죽음 이벤트를 받겠노라
        player.GetComponent<Health>().OnDie += HandlePlayerDie;
    }

    private void HandlePlayerDeSpawn(PlayerController player)
    {
        player.GetComponent<Health>().OnDie -= HandlePlayerDie;
    }
    private void HandlePlayerDie(Health sender)
    {
        PlayerController player = sender.GetComponent<PlayerController>();
        StartCoroutine(RespawnPlayerRoutine(player.OwnerClientId)); //주인은 바뀌면 안되므로 주인을 인자로 넘김.
        Destroy(player.gameObject);
    }

    IEnumerator RespawnPlayerRoutine(ulong ownerClientid)
    {
        yield return null;

        NetworkObject playerObj = Instantiate(playerPrefab,SpawnPoint.GetRandomSpawnPoint(),Quaternion.identity); //NetworkObject타입의 playerPrefab을 Instantiate하면 NetworkObject을 반환
        playerObj.SpawnAsPlayerObject(ownerClientid);


    }

}
    1. PlayerController.cs 코드 작성
using System;
using TMPro;
using Unity.Netcode;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    enum State
    {
        Idle,
        Move,
        Attack,
        Dying
    }
    [SerializeField]
    float rotationSpeed = 10;

    [SerializeField]
    float moveSpeed = 5;

    Vector3 targetPos; //목표 위치
    State state;
    Animator animator; //여러번 쓸 거면 캐싱한다.

    //OnNetworkSpawn() 와 Start() 중 누가 먼저 실행될까? 경우에 따라 다르다.
    //미리 Spawn 해 놓을 수도 있고 나중에 Spawn 해 놓을 수 있으므로 순서가 다르다.
    // Awake()을 사용하는 것이 좋음

    public static Action<PlayerController> OnPlayerSpawn; //⭐ static 이므로 플레이어가 죽어도 OnPlayerSpawn , OnPlayerDespawn 을 호출할 수있다.
    public static Action<PlayerController> OnPlayerDespawn; //⭐static이므로 플레이어가 죽어도 OnPlayerSpawn , OnPlayerDespawn 을 호출할 수있다.

    Vector3 attackTargetPos; //공격 대상 위치.
    bool isWaitingAttackInput; //공격 키 입력 여부

    private void Start()
    {
        state = State.Idle; //Idle이긴 하지만 명시적으로 사용

        animator = GetComponent<Animator>();
    }
    public override void OnNetworkSpawn()
    {
        if(IsServer)  //⭐ 
        {
            OnPlayerSpawn?.Invoke(this); //⭐ this 키워드를 통해 어느 플레이어가 죽었는지 알 수 있음.
        }

        if(IsOwner == false) //내가 Owner가 아니라면 
        {
            return;
        }

        targetPos = transform.position; //Owner일 때만 실행
    }
    public override void OnNetworkDespawn()
    {
        if (IsServer) //⭐
        {
            OnPlayerDespawn?.Invoke(this); //⭐this 키워드를 통해 어느 플레이어가 죽었는지 알 수 있음.
        }
    }
    void Update()
    {
      //..생략
    }

    public void AttackEnd() //Attack 애니메이션 끝자락에 이벤트로 호출
    {
       //..생략
    }
    private void FixedUpdate()
    {
        //..생략
    }

    void SetState(State newstate)
    { //..생략
    }
}
    1. Player 프리펩을 인스펙터 할당

👌 실행 결과 - 성공

네트워크 2

    1. Package Manager에서 Multiplayer 관련 패키지 중 아래와 같은 총 5개가 설치 되어있는지 확인.
  • Window - Multiplayer - Multiplayer Center . 이 탭은 상황에 맞춰서 만들고자 하는 게임의 장르와 설정을 선택하면 필요한 패키지를 설치할 수 있게 해줍니다.

    1. 아래와 같이 Multiplayer Center 탭 설정 후 InStall Packages 클릭
    1. Install이 끝나고 QuickStart 탭 - Hosting - Multiplayer Roles 설정으로 들어간다.
  • Server 탭에서 활성화 시킨 옵션들은 서버에서 UI,Audio,렌더링 컴포넌트 같은게 필요없을 경우 활성화 시킨다. 마지막 Apply 버튼 클릭

    1. 현재 프로젝트 버전에 Add Modules 한다.
  • Linux Dedicated Server Build Support 설치

  • 이것도 설치

    1. Linux Server로 switch 한다
  • 결과

    1. 아래 링크를 들어가서 UGSWrapper 패키지 다운로드 후 바로 임포트 한다.
      https://drive.google.com/file/d/1izZ-U89vKqrZvGCGnbNRopDc2eWfkvuG/view?usp=sharing
  • 임포트를 하고 나면 7개의 에러메시지가 나타난다. 나중에 수정할 예정이니 걱정 ㄴ

    1. UserData.cs 수정한다.
  • userId를 userAuthId로 수정한다. 그래도 에러메시지가 나타나면 패키지에 경고 아이콘이 뜨는 패키지를 Remove한다.

using System;
using UnityEngine;

public enum GameMode //⭐ 추가
{
    Default//⭐ 추가 
}

public enum GameQueue //⭐ 추가
{
    Solo,  //⭐ 추가
    Team //⭐ 추가
}

public enum Map //⭐ 추가
{
    Default //⭐ 추가
}

[Serializable]
public class UserData
{
    public string userName;
    public string userAuthId; 
    public GameInfo userGamePreferences; //⭐ 추가
}
[Serializable] //⭐ 추가
public class GameInfo //⭐ 추가
{
    public Map map; //⭐ 추가
    public GameMode gameMode; //⭐ 추가
    public GameQueue gameQueue; //⭐ 추가

    public string ToMultiplayQueue() //⭐ 추가
    {
        return ""; //⭐ 추가
    }
}
  • 에러메시지 사라짐.
    1. ApplicationManger.cs 수정
using System.Threading.Tasks;
using Unity.Android.Gradle.Manifest;
using Unity.Multiplayer;
using Unity.Services.Authentication;
using Unity.Services.Core;
using UnityEngine;
using UnityEngine.SceneManagement;

public class ApplicationManager : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    async void Start()
    {
        DontDestroyOnLoad(gameObject);

       // await LaunchInMode(SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null); //⭐ 주석
        await LaunchInMode(MultiplayerRolesManager.ActiveMultiplayerRoleMask == MultiplayerRoleFlags.Server); //⭐ 추가
    }
   
    //서버일 경우와 아닐 경우 나눔.
   
    async Task LaunchInMode(bool isDedicatedServer)
    {
        if(isDedicatedServer)
        {

        }
        else
        {
            //임시 코드

            bool authenticated = await ClientSingleTon.Instance.InitAsync();

            HostSingleTon hostSingleTon = HostSingleTon.Instance;
            //await HostSingleTon.Instance.StartHostAsync(); //버튼 눌렀을 대 사용할 코드

            if(authenticated)
            {
                GotoMenu();
            }
        }
    }

    public void GotoMenu()
    {
        SceneManager.LoadScene("MenuScene");
    }
}

데드케이트 서버 구조

  • 데드케이트 서버는 게임 시작 시 ServerInstance가 생성되고, 게임 종료시 인스턴스는 사라진다.

    1. ServerGameManager.cs 스크립트 생성한다. 이 스크립트는 서버관련 데이터를 관리하는 스크립트이다. 스크립트 작성
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Unity.Netcode;
using UnityEngine;

public class ServerGameManager : IDisposable
{
    string serverIp;
    ushort serverPort;  // 게임 데이터를 얻을 때 사용하는 포트
    ushort queryPort;   // 서버 정보를 얻을 때 사용하는 포트
    MultiplayAllocationService multiplayAllocationService;

    public ServerGameManager(string serverIp, ushort serverPort, ushort queryPort, NetworkManager manager)
    {
        this.serverIp = serverIp;
        this.serverPort = serverPort;
        this.queryPort = queryPort;
        multiplayAllocationService = new MultiplayAllocationService();
    }

    public async Task StartGameServerAsync()
    {
        await multiplayAllocationService.BeginServerCheck();

        if (!ServerSingleTon.Instance.OpenConnection(serverIp, serverPort))
        {
            Debug.LogWarning("Network Server not started");
            return;
        }

        // 서버가 열렸으면 BattleScene으로 이동
        NetworkManager.Singleton.SceneManager.LoadScene("BattleScene",
            UnityEngine.SceneManagement.LoadSceneMode.Single);
    }

    public void Dispose()
    {
        multiplayAllocationService?.Dispose();
    }



}
  • 10 . ServerSingleTon.cs 수정. 서버 생성 함수 제작한다.
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Services.Core;
using UnityEditor.EditorTools;
using UnityEngine;

public class ServerSingleTon : MonoBehaviour
{

    static ServerSingleTon instance;

    public ServerGameManager serverManager; //⭐ 추가 

    public static ServerSingleTon Instance
    {
        get
        {
            if (instance == null)
            {
                GameObject singleTon = new GameObject("ServerSingleTon");
                instance = singleTon.AddComponent<ServerSingleTon>();

                DontDestroyOnLoad(singleTon);
            }
            return instance;
        }
    }

    public void Init() //인스턴스의 생성을 위한 메서드
    {

    }

    public Action<string> OnClientLeft;
    private void OnEnable()
    {
        NetworkManager.Singleton.ConnectionApprovalCallback += ApprovalCheck;
       
        NetworkManager.Singleton.OnClientDisconnectCallback += OnClientDisConnect;  //클라이언트가 접속을 종료 했을 때. -> 실행해야할 함수를 지정
    }

    private void OnDisable()
    {
        if(NetworkManager.Singleton != null)
        {
            NetworkManager.Singleton.ConnectionApprovalCallback -= ApprovalCheck;
            NetworkManager.Singleton.OnClientDisconnectCallback -= OnClientDisConnect;

        }
    }

    private void OnClientDisConnect(ulong clientid) //클라이언트가 호스트 서버에서 접속 종료했을 때 메서드
    {
        if(ClientToUserData.ContainsKey(clientid))
        {
            //로비에 호스트가 있고, 클라이언트가 호스트에 접속. 로비에서 클라이언트들이 접속을 종료했다고 호스트에 알려줘야함
            // -> 로비를 만드는 HostSingleTon.cs에서 구현해야 함. 

            string authld = ClientToUserData[clientid].userAuthId;
            ClientToUserData.Remove(clientid);

            if (clientid != 0)       //클라이언트 id가 0이 아닐 경우 -> 즉, 서버가 아닐 경우
            {
                OnClientLeft?.Invoke(authld);
            }
        }
          
    }

    public Dictionary<ulong, UserData> ClientToUserData = new Dictionary<ulong, UserData>();
    private void ApprovalCheck(NetworkManager.ConnectionApprovalRequest request,
        NetworkManager.ConnectionApprovalResponse response)
    {
        // 
        string payload = Encoding.UTF8.GetString(request.Payload);
        UserData userData = JsonConvert.DeserializeObject<UserData>(payload);

        Debug.Log("User Data : " + userData.userName);

        //저장. 들어온 유저의 클라이언트id를 저장한다. 딕셔너리로 관리 
        ClientToUserData[request.ClientNetworkId] = userData;


        response.Approved = true; //승인
        response.CreatePlayerObject = true; //플레이어 오브젝트 만들어줌.

        response.Position = SpawnPoint.GetRandomSpawnPoint(); //이 클라이언트가 들어올 때 스폰포인트로 지정해서 들어올 수 있음.
        response.Rotation = Quaternion.identity;
        
    }

    public async Task CreateServer() //⭐ 추가 
    {
        await UnityServices.InitializeAsync(); //⭐ 추가  

        serverManager = new ServerGameManager(
            ApplicationData.IP(),
            (ushort)ApplicationData.Port(),
            (ushort)ApplicationData.QPort(),
            NetworkManager.Singleton); //⭐ 추가 
    }

    public bool OpenConnection(string ip, ushort port) //⭐ 추가 
    {
        // transport는 서로 데이터를 주고 받는 역할
        UnityTransport transport = NetworkManager.Singleton.GetComponent<UnityTransport>(); //⭐ 추가 
        transport.SetConnectionData(ip, port); //⭐ 추가  

        return NetworkManager.Singleton.StartServer();//⭐ 추가 
    }
  


}
    1. ApplicationManager.cs 수정. 위에서 작업한 코드들을 불러온다.
using System.Threading.Tasks;
using Unity.Android.Gradle.Manifest;
using Unity.Multiplayer;
using Unity.Services.Authentication;
using Unity.Services.Core;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.SceneManagement;

public class ApplicationManager : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    async void Start()
    {
        DontDestroyOnLoad(gameObject);

       // await LaunchInMode(SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null); //널이면 데드케이트 서버임.
        await LaunchInMode(MultiplayerRolesManager.ActiveMultiplayerRoleMask == MultiplayerRoleFlags.Server);
    }
   
    //서버일 경우와 아닐 경우 나눔.
   
    async Task LaunchInMode(bool isDedicatedServer)
    {
        if(isDedicatedServer)
        {
            ServerSingleTon.Instance.Init(); //⭐ 추가
            await ServerSingleTon.Instance.CreateServer(); //⭐ 추가
            await ServerSingleTon.Instance.serverManager.StartGameServerAsync(); //⭐ 추가
     
        }
        else
        {
            //임시 코드

            bool authenticated = await ClientSingleTon.Instance.InitAsync();

            HostSingleTon hostSingleTon = HostSingleTon.Instance;
            //await HostSingleTon.Instance.StartHostAsync(); //버튼 눌렀을 대 사용할 코드

            if(authenticated)
            {
                GotoMenu();
            }
        }
    }

    public void GotoMenu()
    {
        SceneManager.LoadScene("MenuScene");
    }
}

빌드 제대로 되는지 확인

  • ApplicationManager에서 아래 using 지운다.


  • 아래 ServerSingleTon.cs에서 using도 삭제

  • 빌드 성공
  • 파일도 제대로 들어있다.

UnityCloud에서 아래 2가지 설치

  • Multiplay Hosting은 카드를 등록해야 무료로 6개월간 쓸 수 있습니다.

0개의 댓글