[Unity]12.포톤2를 활용한 좀비 서바이버 #1 - 로비 구현과 캐릭터 설정

안녕하세요 유랑입니다.



실력향상을 위해서 오늘도 책을 따라하면서 공부하겠습니다.

궁금하신점 있으시면 댓글로 남겨주세요^^




1. 좀비 서바이버 멀티



이번 강의는 레트로님께서 만든 예제이며,

교재를 구입하시면 자세한 내용을 배우실 수 있습니다.

저는 책을 구입하였고, 스킬업을 위해서 복습겸 글을 올리겠습니다.



깃허브 사이트 => 깃허브

유튜브 사이트 => 유튜브







1-1) 포톤 준비하기 - ㉠프로젝트 생성



프로젝트를 생성해 주세요.

책에 있는 에셋을 사용해 새로 시작하겠습니다.







1-2) 포톤 준비하기 - ㉡PUN 2



포톤은 네트워크 구현을 쉽게 할 수 있도록 도와주는 종합 솔루션입니다.

포톤 PUN2는 유니티용으로 제작된 포톤 네트워크 엔진입니다.

PUN2를 사용해 포톤의 여러 기능을 구현해 보겠습니다.






포톤 홈페이지에 접속하신 후 ID를 생성해 주세요.

이 ID를 사용해 프로젝트에 연동시킬 겁니다.





만든 ID는 포톤 설치 중 혹은





PhotonServerSettings에 적용하시면 됩니다^^






1-3) 로비 만들기 - ㉠로비 씬



Lobby 씬을 열고 네트워크 로비를 제작해 보겠습니다.

Lobby 씬은 다음과 같은 게임 오브젝트로 구성되어 있습니다.


* Main Camera : 카메라

* Lobby Manager : 네트워크 로비 관리자

* Canvas : UI 캔버스

* Panel : 단순 배경 패널

* Title Text : 단순 제목 텍스트

* Connection Info Text : 네트워크 접속 정보 표시 텍스트

* Join Button : 룸 접속 시작 버튼

* EventSystem : UI 이벤트 관리자







1-4) 로비 만들기 - ㉡Lobby Manager 



<LobbyManager>


LobbyManager는 네트워크 로비로 동작하도록 구현하는 스크립트입니다.

방을 찾거나 접속할 수 있는 기능들을 담고있어요ㅎㅎ




using Photon.Pun; // 유니티용 포톤 컴포넌트들
using Photon.Realtime; // 포톤 서비스 관련 라이브러리
using UnityEngine;
using UnityEngine.UI;

// 마스터(매치 메이킹) 서버와 룸 접속을 담당
public class LobbyManager : MonoBehaviourPunCallbacks
{
    private string gameVersion = "1"; // 게임 버전

    public Text connectionInfoText; // 네트워크 정보를 표시할 텍스트
    public Button joinButton; // 룸 접속 버튼

    // 게임 실행과 동시에 마스터 서버 접속 시도
    private void Start()
    {
        // 접속에 필요한 정보(게임 버전) 설정
        PhotonNetwork.GameVersion = gameVersion;
        // 설정한 정보를 가지고 마스터 서버 접속 시도
        PhotonNetwork.ConnectUsingSettings();

        // 룸 접속 버튼을 잠시 비활성화
        joinButton.interactable = false;
        // 접속을 시도 중임을 텍스트로 표시
        connectionInfoText.text = "마스터 서버에 접속중...";
    }

    // 마스터 서버 접속 성공시 자동 실행
    public override void OnConnectedToMaster()
    {
        // 룸 접속 버튼을 활성화
        joinButton.interactable = true;
        // 접속 정보 표시
        connectionInfoText.text = "온라인 : 마스터 서버와 연결됨";
    }

    // 마스터 서버 접속 실패시 자동 실행
    public override void OnDisconnected(DisconnectCause cause)
    {
        // 룸 접속 버튼을 비활성화
        joinButton.interactable = false;
        // 접속 정보 표시
        connectionInfoText.text = "오프라인 : 마스터 서버와 연결되지 않음\n접속 재시도 중...";

        // 마스터 서버로의 재접속 시도
        PhotonNetwork.ConnectUsingSettings();
    }

    // 룸 접속 시도
    public void Connect()
    {
        // 중복 접속 시도를 막기 위해, 접속 버튼 잠시 비활성화
        joinButton.interactable = false;

        // 마스터 서버에 접속중이라면
        if (PhotonNetwork.IsConnected)
        {
            // 룸 접속 실행
            connectionInfoText.text = "룸에 접속...";
            PhotonNetwork.JoinRandomRoom();
        }
        else
        {
            // 마스터 서버에 접속중이 아니라면, 마스터 서버에 접속 시도
            connectionInfoText.text = "오프라인 : 마스터 서버와 연결되지 않음\n접속 재시도 중...";
            // 마스터 서버로의 재접속 시도
            PhotonNetwork.ConnectUsingSettings();
        }
    }

    // (빈 방이 없어)랜덤 룸 참가에 실패한 경우 자동 실행
    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        // 접속 상태 표시
        connectionInfoText.text = "빈 방이 없음, 새로운 방 생성...";
        // 최대 4명을 수용 가능한 빈방을 생성
        PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 4 });
    }

    // 룸에 참가 완료된 경우 자동 실행
    public override void OnJoinedRoom()
    {
        // 접속 상태 표시
        connectionInfoText.text = "방 참가 성공";
        // 모든 룸 참가자들이 Main 씬을 로드하게 함
        PhotonNetwork.LoadLevel("Main");
    }
}



컴포넌트에는 텍스트와 버튼이 할당되어 있습니다.





그리고 Join Button의 Interactable이 언체크되어 있는 이유는

서버에 정상적으로 접속된 상태에서만 해당 버튼을 클릭하도록 만들었기 때문입니다.





게임을 실행하면 서버에 연결 그리고 방을 찾고 참가하는 걸 확인할 수 있습니다






1-5) 게임 월드 - ㉠Main 씬



이번에는 Main 씬을 다뤄줄텐데요.

오잉 플레이어 캐릭터가 없네요.

캐릭터는 프리팹으로 만들어져 있습니다.

이유는 다음 시간에 알려드릴게요.







1-6) 게임 월드 - ㉡플레이어 캐릭터



플레이어 캐릭터를 하이라키 뷰에 추가하여 어떤 기능을 가지고 있는지 확인해 볼게요^^

Camera Setup ~ Photon Animator View 스크립트가 추가되어 있군요!!!






<CameraSetup>


CameraSetup 스크립트를 알아볼게요.

시네머신 가상 카메라는 다른 플레이어가 아닌 로컬 플레이어만 추적을 하도록 설정되어 있습니다.

그러기 위해 게임 오브젝트가 로컬 게임 오브젝트인지 검사를 합니다.


using Cinemachine; // 시네머신 관련 코드
using Photon.Pun; // PUN 관련 코드
using UnityEngine;

// 시네머신 카메라가 로컬 플레이어를 추적하도록 설정
public class CameraSetup : MonoBehaviourPun {
    void Start() {
        // 만약 자신이 로컬 플레이어라면
        if (photonView.IsMine)
        {
            // 씬에 있는 시네머신 가상 카메라를 찾고
            CinemachineVirtualCamera followCam =
                FindObjectOfType();
            // 가상 카메라의 추적 대상을 자신의 트랜스폼으로 변경
            followCam.Follow = transform;
            followCam.LookAt = transform;
        }
    }
} 


1-7) 게임 월드 - ㉢Gun



<Gun>


Gun 스크립트는 호스트에서만 실행되고, 상태를 동기화하는 부분이 변경되었습니다.

여기서 RPC는 어떤 메서드를 다른 클라이언트에서 원격으로 실행하도록 만들어 줍니다.




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

// 총을 구현한다
public class Gun : MonoBehaviourPun, IPunObservable {
    // 총의 상태를 표현하는데 사용할 타입을 선언한다
    public enum State {
        Ready, // 발사 준비됨
        Empty, // 탄창이 빔
        Reloading // 재장전 중
    }

    public State state { get; private set; } // 현재 총의 상태

    public Transform fireTransform; // 총알이 발사될 위치

    public ParticleSystem muzzleFlashEffect; // 총구 화염 효과
    public ParticleSystem shellEjectEffect; // 탄피 배출 효과

    private LineRenderer bulletLineRenderer; // 총알 궤적을 그리기 위한 렌더러

    private AudioSource gunAudioPlayer; // 총 소리 재생기
    public AudioClip shotClip; // 발사 소리
    public AudioClip reloadClip; // 재장전 소리

    public float damage = 25; // 공격력
    private float fireDistance = 50f; // 사정거리

    public int ammoRemain = 100; // 남은 전체 탄약
    public int magCapacity = 25; // 탄창 용량
    public int magAmmo; // 현재 탄창에 남아있는 탄약

    public float timeBetFire = 0.12f; // 총알 발사 간격
    public float reloadTime = 1.8f; // 재장전 소요 시간
    private float lastFireTime; // 총을 마지막으로 발사한 시점

    // 주기적으로 자동 실행되는, 동기화 메서드
    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
        // 로컬 오브젝트라면 쓰기 부분이 실행됨
        if (stream.IsWriting)
        {
            // 남은 탄약수를 네트워크를 통해 보내기
            stream.SendNext(ammoRemain);
            // 탄창의 탄약수를 네트워크를 통해 보내기
            stream.SendNext(magAmmo);
            // 현재 총의 상태를 네트워크를 통해 보내기
            stream.SendNext(state);
        }
        else
        {
            // 리모트 오브젝트라면 읽기 부분이 실행됨
            // 남은 탄약수를 네트워크를 통해 받기
            ammoRemain = (int) stream.ReceiveNext();
            // 탄창의 탄약수를 네트워크를 통해 받기
            magAmmo = (int) stream.ReceiveNext();
            // 현재 총의 상태를 네트워크를 통해 받기
            state = (State) stream.ReceiveNext();
        }
    }

    // 남은 탄약을 추가하는 메서드
    [PunRPC]
    public void AddAmmo(int ammo) {
        ammoRemain += ammo;
    }

    private void Awake() {
        // 사용할 컴포넌트들의 참조를 가져오기
        gunAudioPlayer = GetComponent();
        bulletLineRenderer = GetComponent();

        // 사용할 점을 두개로 변경
        bulletLineRenderer.positionCount = 2;
        // 라인 렌더러를 비활성화
        bulletLineRenderer.enabled = false;
    }


    private void OnEnable() {
        // 현재 탄창을 가득채우기
        magAmmo = magCapacity;
        // 총의 현재 상태를 총을 쏠 준비가 된 상태로 변경
        state = State.Ready;
        // 마지막으로 총을 쏜 시점을 초기화
        lastFireTime = 0;
    }


    // 발사 시도
    public void Fire() {
        // 현재 상태가 발사 가능한 상태
        // && 마지막 총 발사 시점에서 timeBetFire 이상의 시간이 지남
        if (state == State.Ready
            && Time.time >= lastFireTime + timeBetFire)
        {
            // 마지막 총 발사 시점을 갱신
            lastFireTime = Time.time;
            // 실제 발사 처리 실행
            Shot();
        }
    }

    private void Shot() {
        // 실제 발사 처리는 호스트에게 대리
        photonView.RPC("ShotProcessOnServer", RpcTarget.MasterClient);

        // 남은 탄환의 수를 -1
        magAmmo--;
        if (magAmmo <= 0)
        {
            // 탄창에 남은 탄약이 없다면, 총의 현재 상태를 Empty으로 갱신
            state = State.Empty;
        }
    }

    // 호스트에서 실행되는, 실제 발사 처리
    [PunRPC]
    private void ShotProcessOnServer() {
        // 레이캐스트에 의한 충돌 정보를 저장하는 컨테이너
        RaycastHit hit;
        // 총알이 맞은 곳을 저장할 변수
        Vector3 hitPosition = Vector3.zero;

        // 레이캐스트(시작지점, 방향, 충돌 정보 컨테이너, 사정거리)
        if (Physics.Raycast(fireTransform.position,
            fireTransform.forward, out hit, fireDistance))
        {
            // 레이가 어떤 물체와 충돌한 경우

            // 충돌한 상대방으로부터 IDamageable 오브젝트를 가져오기 시도
            IDamageable target =
                hit.collider.GetComponent();

            // 상대방으로 부터 IDamageable 오브젝트를 가져오는데 성공했다면
            if (target != null)
            {
                // 상대방의 OnDamage 함수를 실행시켜서 상대방에게 데미지 주기
                target.OnDamage(damage, hit.point, hit.normal);
            }

            // 레이가 충돌한 위치 저장
            hitPosition = hit.point;
        }
        else
        {
            // 레이가 다른 물체와 충돌하지 않았다면
            // 총알이 최대 사정거리까지 날아갔을때의 위치를 충돌 위치로 사용
            hitPosition = fireTransform.position +
                          fireTransform.forward * fireDistance;
        }

        // 발사 이펙트 재생, 이펙트 재생은 모든 클라이언트들에서 실행
        photonView.RPC("ShotEffectProcessOnClients", RpcTarget.All, hitPosition);
    }

    // 이펙트 재생 코루틴을 랩핑하는 메서드
    [PunRPC]
    private void ShotEffectProcessOnClients(Vector3 hitPosition) {
        StartCoroutine(ShotEffect(hitPosition));
    }

    // 발사 이펙트와 소리를 재생하고 총알 궤적을 그린다
    private IEnumerator ShotEffect(Vector3 hitPosition) {
        // 총구 화염 효과 재생
        muzzleFlashEffect.Play();
        // 탄피 배출 효과 재생
        shellEjectEffect.Play();

        // 총격 소리 재생
        gunAudioPlayer.PlayOneShot(shotClip);

        // 선의 시작점은 총구의 위치
        bulletLineRenderer.SetPosition(0, fireTransform.position);
        // 선의 끝점은 입력으로 들어온 충돌 위치
        bulletLineRenderer.SetPosition(1, hitPosition);
        // 라인 렌더러를 활성화하여 총알 궤적을 그린다
        bulletLineRenderer.enabled = true;

        // 0.03초 동안 잠시 처리를 대기
        yield return new WaitForSeconds(0.03f);

        // 라인 렌더러를 비활성화하여 총알 궤적을 지운다
        bulletLineRenderer.enabled = false;
    }

    // 재장전 시도
    public bool Reload() {
        if (state == State.Reloading ||
            ammoRemain <= 0 || magAmmo >= magCapacity)
        {
            // 이미 재장전 중이거나, 남은 총알이 없거나
            // 탄창에 총알이 이미 가득한 경우 재장전 할수 없다
            return false;
        }

        // 재장전 처리 실행
        StartCoroutine(ReloadRoutine());
        return true;
    }

    // 실제 재장전 처리를 진행
    private IEnumerator ReloadRoutine() {
        // 현재 상태를 재장전 중 상태로 전환
        state = State.Reloading;
        // 재장전 소리 재생
        gunAudioPlayer.PlayOneShot(reloadClip);

        // 재장전 소요 시간 만큼 처리를 쉬기
        yield return new WaitForSeconds(reloadTime);

        // 탄창에 채울 탄약을 계산한다
        int ammoToFill = magCapacity - magAmmo;

        // 탄창에 채워야할 탄약이 남은 탄약보다 많다면,
        // 채워야할 탄약 수를 남은 탄약 수에 맞춰 줄인다
        if (ammoRemain < ammoToFill)
        {
            ammoToFill = ammoRemain;
        }

        // 탄창을 채운다
        magAmmo += ammoToFill;
        // 남은 탄약에서, 탄창에 채운만큼 탄약을 뺸다
        ammoRemain -= ammoToFill;

        // 총의 현재 상태를 발사 준비된 상태로 변경
        state = State.Ready;
    }
} 


부모가 Photon View 컴포넌트가 이미 추가되어 있더라도

자식 게임 오브젝트가 독자적으로 실행할 네트워크 처리가 있다면

꼭 추가하셔야 합니다.

이 때 View ID도 당연히 부여됩니다.






2. 마무리



오늘 강의는 여기까지입니다.

좀비 서바이버 멀티를 따라하면서 로비 구현과 캐릭터 설정을 만들어 보았습니다.

스크립트가 많아 내용이 생략된 부분이 많습니다.

자세한 내용은 책이나 제가 만든 다른 포톤 포스팅에서 찾아보시길 바랍니다.

감사합니다.




수업자료: 포톤2를 활용한 좀비 서바이버 #1 - 로비 구현과 캐릭터 설정




댓글

Designed by JB FACTORY