[Unity][Study] Photon Cloud Server (2)

suhan0304·2024년 6월 21일

유니티 - Photon Cloud

목록 보기
2/2
post-thumbnail

같은 룸에 입장한 네트워크 유저 간의 RPC를 이용해 총알 발사 로직을 오나성했다. 피격 및 리스폰도 구현해보자.

피격 및 리스폰 (RPC)

Damage.cs

using System.Collections;
using UnityEngine;

public class Damage : MonoBehaviour {
    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");

    void Awake() {
        renderers = GetComponentsInChildren<Renderer>();
        anim = GetComponent<Animator>();
        cc = GetComponent<CharacterController>();

        currHp = initHp;
    }

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

    IEnumerator PlayerDie() {
        cc.enabled = false;
        anim.SetBool(hashRespawn, false);
        anim.SetTrigger(hashDie);

        yield return new WaitForSeconds(3.0f);

        anim.SetBool(hashRespawn, true);

        SetPlayerVisible(false);

        yield return new WaitForSeconds(1.5f);

        Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        int idx = Random.Range(1, points.Length);
        transform.position = points[idx].position;

        currHp = 100;
        SetPlayerVisible(true);
        cc.enabled = true;
    }

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

}

핵심은 총알을 맞아서 hp가 0이 이하일 떄 die 애니이션을 실행한 후 보이지 않게 Renderer 컴포넌트를 비활성화하는 것이다. 리스폰 시간이 지난 후 불규칙한 위치로 이동시키고 다시 Renderer 컴포넌트를 활성화해 화면에 표시한다.

로비 제작

로비는 네트워크에 접속한 플레이어가 대기하는 장소 + 방을 생성 및 입장하는 기능을 제공해야 한다. 포톤 클라우드에서 로비에 접속해야만 현재 생성된 룸의 정보를 서버로부터 받아올 수 있다. 예제에서는 이미 로비에 입장했기 때문에 룸 정보를 받을 준비가 끝났따.

로비 씬 제작

현재 씬을 BattleField로 저장한 후에 Lobby로 하나 복제해준다.

Player Prefab을 하나 추가해주고 원하는 뷰로 Main Camera를 조정해준다.

씬 뷰에서 원하는 구도로 이동 또는 회전 시킨 후에 Main Camera를 선택한 다음 메뉴에서 Game Object > Align with view를 선택하면 Main Camera가 씬 뷰와 동일한 각도로 자동으로 변경된다.


피사계 심도 + 색상 보정

  • 피사계 심도 : 뒷배경을 흐릿하게 처리해서 사물을 부각시킨다. (아웃 포커싱이라고도 한다.)
  • 색상 보정 : 명암 대비와 씬의 전체적인 색상을 설정할 수 있다.

기존 Profile을 clone해서 새로 만들어준 후에 Depth Of Field + Color Adjustments를 추가해준다.

로그인 UI 제작

이제 PhotonManager 스크립트를 아래와 같이 수정한다.

PhotonManager.cs

using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using TMPro;

public class PhotonManager : MonoBehaviourPunCallbacks // PUN의 다양한 콜백 함수를 오버라이드해서 작성
{
    private readonly string version = "1.0";

    private string userId = "Zack";

    // 유저명을 입력할 TMP Input Field
    public TMP_InputField userIF;
    // 룸 이름으 ㄹㄹ입력할 TMP Input Field
    public TMP_InputField roomNameIF;


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

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

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

    void Start() {
        userId = PlayerPrefs.GetString("USER_ID", $"USER_{Random.Range(1,21):00}");
        userIF.text = userId;
        PhotonNetwork.NickName = userId;
    }

    // 유저명을 설정하는 로직
    public void SetUserID() {
        if (string.IsNullOrEmpty(userIF.text)) {
            userId = $"USER_{Random.Range(1,21):00}";
        }
        else {
            userId = userIF.text;
        }

        // 유저명 저장
        PlayerPrefs.SetString("USER_ID", userId);
        // 접속 유저의 닉네임 등록
        PhotonNetwork.NickName = userId;
    }

    // 포톤 서버에 접속 후 호출되는 콜백 함수
    string SetRoomName() {
        if (string.IsNullOrEmpty(roomNameIF.text)) {
            roomNameIF.text = $"ROOM_{Random.Range(1,101):000}";
        }
        return roomNameIF.text;
    }

    // 포톤 서버에 접속 후 호출되는 콜백 함수
    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(); // 수동 접속
    }

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

        /*
        // 룸의 속성 정의
        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($"PhotonNetwork.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);
        */

        // 마스터 클라이언트인 경우에 룸에 입장한 후 전투 씬을 로딩한다.
        if (PhotonNetwork.IsMasterClient) {
            PhotonNetwork.LoadLevel("BattleField");
        }
    }

#region UI_BUTTON_EVENT

    public void OnLoginClick() {
        // 유저명 저장
        SetUserID();

        // 무작위로 추출한 룸으로 입장
        PhotonNetwork.JoinRandomRoom();
    }

    public void OnMakeRoomClick() {
        // 유저명 저장
        SetUserID();

        // 룸의 속성 정의
        RoomOptions ro = new RoomOptions();
        ro.MaxPlayers = 20;     // 최대 접속자 수
        ro.IsOpen = true;       // 룸의 오픈 여부
        ro.IsVisible = true;    // 로비에서 룸을 노출시킬지 여부

        // 룸 생성
        PhotonNetwork.CreateRoom(SetRoomName(), ro);
    }
#endregion

}
  • 한번이라도 USER_ID 키로 저장한 유저명이 있다면 해당 값을 표시한다. 없으면 랜덤하게 무작위로 유저명을 지정 ( "USER_01" ~ "USER_20" )

  • SetUserId : Login 또는 Make Room 버튼을 클릭했을 때 유저명의 변경 사항을 최종 확인하고 PlayerPrefs를 사용해 유저명을 저장한 후 PhotonNetwork.NickName을 설정한다.

  • OnJoinedRandomFailed : OnMakeRoomClick 함수를 호출

Scene Load
씬을 로딩하는 함수는 유니티에서 제공하는 SceneManagerMent.SceneManager.LoadScene 함수 대신에 PhotonNetwork.LoadLevel 함수를 사용한다.
PhotonNetwork.LoadLevel : 다른 씬을 로딩하기 전에 데이터 송수신을 잠시 멈추고 다른 씬의 로딩이 완료된 후 다시 데이터 송수신을 재개하는 로직이 포함돼 있다. 수동으로 처리하려면 PhotonNetwork.IsMessageQueueRunning 속성을 false로 지정한 후 씬을 로딩하고 로딩된 씬에서 다시 true로 변경해야 한다.

씬의 로딩은 마스터 클라이언트만 호출해야 한다. PhotoNetwork.AutomaticallySyncScene을 true로 해놨기 때문에 마스터 클라이언트가 다른 씬을 로딩하면 자동으로 씬이 로딩된다.

이제 PhotonManager에 Input Field를 연결해주고 Button 이벤트를 연결해준다.

이제 이름을 입력하고 Login 버튼을 누르면 룸 접속 과정이 진행되며, 룸에 입장하면 콘솔 뷰에 유저명과 룸에 접속한 접속자 수가 표시되면서 BattleField로 변경된 것을 확인할 수 있다.

주인공 캐릭터 생성

GameManager.cs

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

public class GameManager : MonoBehaviourPunCallbacks
{
    void Awake() {
        CreatePlayer();
    }

    void CreatePlayer() {
        Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        int idx = Random.Range(1, points.Length);

        PhotonNetwork.Instantiate("Player", points[idx].position, points[idx].rotation, 0);
    }
}

Build Settings 에 Lobby와 BattleField 씬을 추가해주고 Lobby를 맨 위에 위치시킨다.

잘 작동한다.


룸 목록 UI

Scroll Rect 컴포넌트를 사용해서 룸 목록 UI를 구현한다.

Panel - Room List는 가로와 세로 모두 Stretch로 설정해서 상하좌우 여백을 적절히 설정한다.

동일하게 앵커 프리셋을 가로 세로 Stretch로 설정해 Room List 영역에 맞게 설정해준다.

이제 UI를 프리팹으로 미리 만들어두고 로비에 접속했을 때 수신된 룸 개수만큼 반복문을 반복하면서 동적으로 룸 정보를 표시하는 프리팹을 Scroll View > Viewport > Content 하위에 생성하는 방식으로 구현한다.

UI 프리팹 :

Content에 Grid Layout Group 컴포넌트를 추가한 다음 속성을 설정해주면 아래와 같이 출력할 수 있다.

스크롤 + 버튼 애니메이션 모두 잘 작동한다.

룸 목록 받아오기

로비에 접속해서 포톤 클라우드 서버는 현재 생성된 모든 룸 목록을 전달해 준다. 해당 정보를 수신해서 RoomItem을 생성하는 로직을 구현한다.

PhotonManager.cs

// 룸 목록에 대한 데이터를 저장하기 위한 딕셔너리 자료형
private Dictionary<string, GameObject> rooms = new Dictionary<string, GameObject>();
// 룸 목록을 표시할 프리팹
private GameObject roomItemPrefab;
// RoomItem 프리팹이 추가될 ScrollContent
public Transform scrollContent;

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

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

    // RoomItem 프리팹 로드
    roomItemPrefab = Resources.Load<GameObject>("RoomItem");

    // 포톤 서버 접속
    if (PhotonNetwork.IsConnected == false) {
        PhotonNetwork.ConnectUsingSettings();
    }
}

// 룸 목록을 수신하는 콜백 함수
public override void OnRoomListUpdate(List<RoomInfo> roomList)
{
    // 삭제된 RoomItem 프리팹을 저장할 임시변수
    GameObject tempRoom = null;

    foreach (var roomInfo in roomList) {
        // 룸이 삭제된 경우
        if (roomInfo.RemovedFromList == true) {
            // 딕셔너리에서 룸 이름으로 검색해 저장된 RoomItem 프리팹을 추출
            rooms.TryGetValue(roomInfo.Name, out tempRoom);

            // RoomItem 프리팹 삭제
            Destroy(tempRoom);

            // 딕셔너리에서 해당 룸 이름의 데이터를 삭제
            rooms.Remove(roomInfo.Name);
        }
        else { //룸 정보가 변경된 경우
            // 룸 이름이 딕셔너리에 없는 경우 새로 추가
            if (rooms.ContainsKey(roomInfo.Name) == false) {
                // RoomInfo 프리팹을 scrollContent 하위에 생성
                GameObject roomPrefab = Instantiate(roomItemPrefab, scrollContent);
                // 룸 정보를 표시하기 위해 RoomInfo 정보 전달
                roomPrefab.GetComponent<RoomData>().RoomInfo = roomInfo;
            }
            else { // 룸 이름이 딕셔너리에 없는 경우에 룸 정보를 갱신 
                rooms.TryGetValue(roomInfo.Name, out tempRoom);
                tempRoom.GetComponent<RoomData>().RoomInfo = roomInfo;
            }
        }
        Debug.Log($"Room={roomInfo.Name} ({roomInfo.PlayerCount}/{roomInfo.MaxPlayers})");
    }
}

의사 코드(pseudo-code)로 표현하면 아래와 같다.

OnRoomListUpdate 함수는 RoomInfo 타입의 데이터가 List 자료형으로 넘어오고 RemovedFromList 불린 변수로 룸의 삭제 여부를 알 수 있다. 그 다음 Content를 연결해준다.

RoomData.cs (버튼 이벤트 동적 연결)

using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using TMPro;

public class RoomData : MonoBehaviour
{
    private RoomInfo _roomInfo;
    // 하위에 있는 TMP_text를 저장할 변수
    private TMP_Text roomInfoText;
    // PhotonManager 접근 변수
    private PhotonManager photonManager;

    // 프로퍼티 정의
    public RoomInfo RoomInfo {
        get {
            return _roomInfo;
        }
        set {
            _roomInfo = value;
            // 룸 정보 표시
            roomInfoText.text = $"{_roomInfo.Name} ({_roomInfo.PlayerCount}/{_roomInfo.MaxPlayers})";
            // 버튼 클릭 이벤트에 함수 연결
            GetComponent<UnityEngine.UI.Button>().onClick.AddListener(() => OnEnterRoom(_roomInfo.Name));
        }
    }

    void Awake() {
        roomInfoText = GetComponentInChildren<TMP_Text>();
        photonManager = GameObject.Find("PhotonManager").GetComponent<PhotonManager>();
    }

    void OnEnterRoom(string roomName) {
        // 유저명 설정
        photonManager.SetUserID();

        // 룸의 속성 정의
        RoomOptions ro = new RoomOptions();
        ro.MaxPlayers = 20;     // 최대 접속자 수
        ro.IsOpen = true;       // 룸의 오픈 여부
        ro.IsVisible = true;    // 로비에서 룸을 노출시킬지 여부

        // 룸 접속
        PhotonNetwork.JoinOrCreateRoom(roomName, ro, TypedLobby.Default);
    }
}

RoomData 스크립트는 OnRoomListUpdate에서 룸 정보가 갱신될 때마다 접근해 RoomInfo 데이터를 넘겨받아서 내부적으로 저장하고 하위에 있는 텍스트 UI에 룸 이름과 접속자 정보를 표시한다. 또한, 버튼을 클릭했을 때 룸에 접속하는 함수를 람다식으로 연결해뒀다.

이제 RoomData를 RoomItem에 추가해준다.

이제 빌드해서 테스트해보자.

Room 목록 갱신이 잘 이뤄진다. 해당 Room을 클릭하면 잘 BattleField Scene이 로드되면서 잘 통신이 이루어진다.


접속 정보 및 룸 나가기 기능 구현

Pnael - Msg에 Mask 컴포넌트를 추가해서 Over되는 텍스트를 보여주지 않도록 한다.

GameManager.cs

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

public class GameManager : MonoBehaviourPunCallbacks
{
    public TMP_Text roomName;
    public TMP_Text connectInfo;
    public TMP_Text msgList;
    public Button exitBtn;

    void Awake() {
        CreatePlayer();
        // 접속 정보 추출 및 표시
        SetRoomInfo();
        // Exit 버튼 이벤트 연결
        exitBtn.onClick.AddListener(() => OnExitClick());
    }

    void CreatePlayer() {
        Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        int idx = Random.Range(1, points.Length);

        PhotonNetwork.Instantiate("Player", points[idx].position, points[idx].rotation, 0);
    }

    void SetRoomInfo() {
        Room room = PhotonNetwork.CurrentRoom;
        roomName.text = room.Name;
        connectInfo.text = $"({room.PlayerCount}/{room.MaxPlayers})";
    }

    private void OnExitClick() {
        PhotonNetwork.LeaveRoom();
    }

    // 룸으로 새로운 네트워크 유저가 접속했을때 콜백 함수
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        SetRoomInfo();
        string msg = $"\n<color=#00ff00>{newPlayer.NickName}</color> is joined room";
        msgList.text += msg;
    }

    // 룸에서 네트워크 유저가 퇴장했을때 호출되는 콜백함수
    public override void OnPlayerLeftRoom(Player otherPlayer){
        SetRoomInfo();
        string msg = $"\n<color=#ff0000>{otherPlayer.NickName}</color> is left room";
        msgList.text += msg;
    }
}

ActorNumber 활용

RPC로 호출된 FireBullet 함수에서 생성한 Bullet에 룸에 접속한 네트워크 유저의 고유 번호인 ActorNumber를 저장해 확인해서 누가 누구를 쐈는지를 구별해보자.

Bullet.cs

public int actorNumber;

Fire.cs

void Update() {
    // 로컬 유저 여부와 마우스 왼쪽 버튼을 클릭했을 때 총알 발사
    if (pv.IsMine && isMouseClick) {
        FireBullet(pv.Owner.ActorNumber);
        pv.RPC("FireBullet", RpcTarget.Others, pv.Owner.ActorNumber);
    }
}

[PunRPC]
void FireBullet(int acntorNo)  {
    if (!muzzleFlash.isPlaying) muzzleFlash.Play(true);
    GameObject bullet = Instantiate(bulletPrefab, firePos.position, firePos.rotation);
    bullet.GetComponent<Bullet>().actorNumber = acntorNo;
}

Damage.cs

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

public class Damage : MonoBehaviourPunCallbacks {
    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 GameManager gameManager;

    void Awake() {
        renderers = GetComponentsInChildren<Renderer>();
        anim = GetComponent<Animator>();
        cc = GetComponent<CharacterController>();

        currHp = initHp;

        gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
    }

    void OnCollisionEnter(Collision coll) {
        if (currHp > 0 && coll.collider.CompareTag("BULLET")) {
            currHp -= 20;
            if (currHp <= 0) {
                if (photonView.IsMine) {
                    var actorNo = coll.collider.GetComponent<Bullet>().actorNumber;
                    Player lastShootPlayer = PhotonNetwork.CurrentRoom.GetPlayer(actorNo);

                    string msg = string.Format("\n<color=#00ff00>{0}</color> is killed by <color=#ff0000>{1}</color>",
                    photonView.Owner.NickName, lastShootPlayer.NickName);

                    photonView.RPC("KillMessage", RpcTarget.AllBufferedViaServer, msg);
                }
                StartCoroutine(PlayerDie());
            }
        }
    }

    [PunRPC]
    void KillMessage(string msg) {
        gameManager.msgList.text += msg;
    }

    IEnumerator PlayerDie() {
        cc.enabled = false;
        anim.SetBool(hashRespawn, false);
        anim.SetTrigger(hashDie);

        yield return new WaitForSeconds(3.0f);

        anim.SetBool(hashRespawn, true);

        SetPlayerVisible(false);

        yield return new WaitForSeconds(1.5f);

        Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        int idx = Random.Range(1, points.Length);
        transform.position = points[idx].position;

        currHp = 100;
        SetPlayerVisible(true);
        cc.enabled = true;
    }

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

}

profile
Be Honest, Be Harder, Be Stronger

0개의 댓글