이 장에서 다루는 내용
- 싱글플레이어 게임을 멀티플레이어로 포팅하는 전반적인 방향
- 로컬과 리모트를 코드로 구별하는 방법
- 호스트에서만 특정 처리를 실행하고, 결과를 클라이언트에 전파하는 방법
- PUN의 다양한 컴포넌트
- Photon View 컴포넌트를 사용해 로컬과 리모트 사이의 동기화
- [PunRPC] 속성
- 직렬화와 역직렬화
@ Main 씬을 열고 네트워크 플레이어 캐릭터 준비
- Player Character 게임 오브젝트에 새로 추가된 컴포넌트
- Camera Setup
- Photon View
- Photon Transform View
- Photon Animator View
@ 네트워크를 통해 동기화될 모든 게임 오브젝트는 Photon View 컴포넌트를 가져야 함.
- Photon View 컴포넌트가 관측할 컴포넌트는 Observed Components 리스트에 할당하면 됨.
단, 모든 컴포넌트가 관측 가능한 것은 아니며, IPunObservable 인터페이스를 상속한 컴포넌트만 관측 가능.
@ Photon Transform View 컴포넌트
- 자신의 게임 오브젝트에 추가된 트랜스폼 컴포넌트 값의 변화를 측정하고, Photon View 컴포넌트를 사용해 동기화 함.
즉, 네트워크를 넘어 로컬과 리모트 오브젝트 사이에서 트랜스폼 컴포넌트의 위치, 회전, 스케일을 동기화 할 수 있음.
< Photon Transform View 컴포넌트는 Photon View 컴포넌트 없이는 동작할 수 없음 >
@ Photon Animator View 컴포넌트
- 네트워크를 넘어 로컬과 리모트 오브젝트 사이에서 애니메이터 컴포넌트의 파라미터를 동기화하여 서로 같은 애니메이션을 재생하도록 함.
@ CameraSetup 스크립트
- 씬의 시네머신 가상 카메라가 로컬 플레이어인지 검사해서 맞으면 추적하도록 설정.
using Cinemachine; // 시네머신 관련 코드
using Photon.Pun; // PUN 관련 코드
using UnityEngine;
// 시네머신 카메라가 로컬 플레이어를 추적하도록 설정
public class CameraSetup : MonoBehaviourPun {
void Start() {
// 만약 자신이 로컬 플레이어라면
if ( photonView.IsMine )
{
// 씬에 있는 시네머신 가상 카메라를 찾고
CinemachineVirtualCamera followCam = FindObjectOfType<CinemachineVirtualCamera>();
// 가상 카메라의 추적 대상을 자신의 트랜스폼으로 변경
followCam.Follow = transform;
followCam.LookAt = transform;
}
}
}
현재 게임 오브젝트가 로컬이면 photonView.IsMine은 true이다.
@ PlayerInput 스크립트
- 로컬 플레이어인 경우에만 사용자 입력을 감지하고 움직이도록 변경.
using Photon.Pun;
using UnityEngine;
// 플레이어 캐릭터를 조작하기 위한 사용자 입력을 감지
// 감지된 입력값을 다른 컴포넌트들이 사용할 수 있도록 제공
public class PlayerInput : MonoBehaviourPun {
public string moveAxisName = "Vertical"; // 앞뒤 움직임을 위한 입력축 이름
public string rotateAxisName = "Horizontal"; // 좌우 회전을 위한 입력축 이름
public string fireButtonName = "Fire1"; // 발사를 위한 입력 버튼 이름
public string reloadButtonName = "Reload"; // 재장전을 위한 입력 버튼 이름
// 값 할당은 내부에서만 가능
public float move { get; private set; } // 감지된 움직임 입력값
public float rotate { get; private set; } // 감지된 회전 입력값
public bool fire { get; private set; } // 감지된 발사 입력값
public bool reload { get; private set; } // 감지된 재장전 입력값
// 매프레임 사용자 입력을 감지
private void Update() {
// 로컬 플레이어가 아닌 경우 입력을 받지 않음
if (!photonView.IsMine)
{
return;
}
// 게임오버 상태에서는 사용자 입력을 감지하지 않는다
if (GameManager.instance != null
&& GameManager.instance.isGameover)
{
move = 0;
rotate = 0;
fire = false;
reload = false;
return;
}
// move에 관한 입력 감지
move = Input.GetAxis(moveAxisName);
// rotate에 관한 입력 감지
rotate = Input.GetAxis(rotateAxisName);
// fire에 관한 입력 감지
fire = Input.GetButton(fireButtonName);
// reload에 관한 입력 감지
reload = Input.GetButtonDown(reloadButtonName);
}
}
- MonoBehaviourPun 상속과 if문을 통해 로컬플레이어를 확인하고 맞으면 입력 감지하도록 구성.
// 로컬 플레이어가 아닌 경우 입력을 받지 않음
if (!photonView.IsMine)
{
return;
}
@ PlayerMovement 스크립트
using Photon.Pun;
using UnityEngine;
// 플레이어 캐릭터를 사용자 입력에 따라 움직이는 스크립트
public class PlayerMovement : MonoBehaviourPun {
public float moveSpeed = 5f; // 앞뒤 움직임의 속도
public float rotateSpeed = 180f; // 좌우 회전 속도
private Animator playerAnimator; // 플레이어 캐릭터의 애니메이터
private PlayerInput playerInput; // 플레이어 입력을 알려주는 컴포넌트
private Rigidbody playerRigidbody; // 플레이어 캐릭터의 리지드바디
private void Start() {
// 사용할 컴포넌트들의 참조를 가져오기
playerInput = GetComponent<PlayerInput>();
playerRigidbody = GetComponent<Rigidbody>();
playerAnimator = GetComponent<Animator>();
}
// FixedUpdate는 물리 갱신 주기에 맞춰 실행됨
private void FixedUpdate() {
// 로컬 플레이어만 직접 위치와 회전을 변경 가능
if (!photonView.IsMine)
{
return;
}
// 회전 실행
Rotate();
// 움직임 실행
Move();
// 입력값에 따라 애니메이터의 Move 파라미터 값을 변경
playerAnimator.SetFloat("Move", playerInput.move);
}
// 입력값에 따라 캐릭터를 앞뒤로 움직임
private void Move() {
// 상대적으로 이동할 거리 계산
Vector3 moveDistance =
playerInput.move * transform.forward * moveSpeed * Time.deltaTime;
// 리지드바디를 통해 게임 오브젝트 위치 변경
playerRigidbody.MovePosition(playerRigidbody.position + moveDistance);
}
// 입력값에 따라 캐릭터를 좌우로 회전
private void Rotate() {
// 상대적으로 회전할 수치 계산
float turn = playerInput.rotate * rotateSpeed * Time.deltaTime;
// 리지드바디를 통해 게임 오브젝트 회전 변경
playerRigidbody.rotation =
playerRigidbody.rotation * Quaternion.Euler(0, turn, 0f);
}
}
- MonoBehaviourPun 상속과 if문을 통해 로컬플레이어를 확인하고 맞으면 이동, 회전, 애니메이터 파라미터 지정하도록 구성.
@ PlayerShooter 스크립트
using Photon.Pun;
using UnityEngine;
// 주어진 Gun 오브젝트를 쏘거나 재장전
// 알맞은 애니메이션을 재생하고 IK를 사용해 캐릭터 양손이 총에 위치하도록 조정
public class PlayerShooter : MonoBehaviourPun {
public Gun gun; // 사용할 총
public Transform gunPivot; // 총 배치의 기준점
public Transform leftHandMount; // 총의 왼쪽 손잡이, 왼손이 위치할 지점
public Transform rightHandMount; // 총의 오른쪽 손잡이, 오른손이 위치할 지점
private PlayerInput playerInput; // 플레이어의 입력
private Animator playerAnimator; // 애니메이터 컴포넌트
private void Start() {
// 사용할 컴포넌트들을 가져오기
playerInput = GetComponent<PlayerInput>();
playerAnimator = GetComponent<Animator>();
}
private void OnEnable() {
// 슈터가 활성화될 때 총도 함께 활성화
gun.gameObject.SetActive(true);
}
private void OnDisable() {
// 슈터가 비활성화될 때 총도 함께 비활성화
gun.gameObject.SetActive(false);
}
private void Update() {
// 로컬 플레이어만 총을 직접 사격, 탄약 UI 갱신 가능
if (!photonView.IsMine)
{
return;
}
// 입력을 감지하고 총 발사하거나 재장전
if (playerInput.fire)
{
// 발사 입력 감지시 총 발사
gun.Fire();
}
else if (playerInput.reload)
{
// 재장전 입력 감지시 재장전
if (gun.Reload())
{
// 재장전 성공시에만 재장전 애니메이션 재생
playerAnimator.SetTrigger("Reload");
}
}
// 남은 탄약 UI를 갱신
UpdateUI();
}
// 탄약 UI 갱신
private void UpdateUI() {
if (gun != null && UIManager.instance != null)
{
// UI 매니저의 탄약 텍스트에 탄창의 탄약과 남은 전체 탄약을 표시
UIManager.instance.UpdateAmmoText(gun.magAmmo, gun.ammoRemain);
}
}
// 애니메이터의 IK 갱신
private void OnAnimatorIK(int layerIndex) {
// 총의 기준점 gunPivot을 3D 모델의 오른쪽 팔꿈치 위치로 이동
gunPivot.position = playerAnimator.GetIKHintPosition(AvatarIKHint.RightElbow);
// IK를 사용하여 왼손의 위치와 회전을 총의 오른쪽 손잡이에 맞춘다
playerAnimator.SetIKPositionWeight(AvatarIKGoal.LeftHand, 1.0f);
playerAnimator.SetIKRotationWeight(AvatarIKGoal.LeftHand, 1.0f);
playerAnimator.SetIKPosition(AvatarIKGoal.LeftHand, leftHandMount.position);
playerAnimator.SetIKRotation(AvatarIKGoal.LeftHand, leftHandMount.rotation);
// IK를 사용하여 오른손의 위치와 회전을 총의 오른쪽 손잡이에 맞춘다
playerAnimator.SetIKPositionWeight(AvatarIKGoal.RightHand, 1.0f);
playerAnimator.SetIKRotationWeight(AvatarIKGoal.RightHand, 1.0f);
playerAnimator.SetIKPosition(AvatarIKGoal.RightHand, rightHandMount.position);
playerAnimator.SetIKRotation(AvatarIKGoal.RightHand, rightHandMount.rotation);
}
}
- MonoBehaviourPun 상속과 if문을 통해 로컬플레이어를 확인하고 맞으면 사용자 입력에 의한 탄알 발사와 UI 갱신을 하도록 구성.
@ LivingEntity 스크립트
- 호스트에서만 체력관리와 데미지 처리 실행.
using System;
using Photon.Pun;
using UnityEngine;
// 생명체로서 동작할 게임 오브젝트들을 위한 뼈대를 제공
// 체력, 데미지 받아들이기, 사망 기능, 사망 이벤트를 제공
public class LivingEntity : MonoBehaviourPun, IDamageable {
public float startingHealth = 100f; // 시작 체력
public float health { get; protected set; } // 현재 체력
public bool dead { get; protected set; } // 사망 상태
public event Action onDeath; // 사망시 발동할 이벤트
// 호스트->모든 클라이언트 방향으로 체력과 사망 상태를 동기화 하는 메서드
[PunRPC]
public void ApplyUpdatedHealth(float newHealth, bool newDead) {
health = newHealth;
dead = newDead;
}
// 생명체가 활성화될때 상태를 리셋
protected virtual void OnEnable() {
// 사망하지 않은 상태로 시작
dead = false;
// 체력을 시작 체력으로 초기화
health = startingHealth;
}
// 데미지 처리
// 호스트에서 먼저 단독 실행되고, 호스트를 통해 다른 클라이언트들에서 일괄 실행됨
[PunRPC]
public virtual void OnDamage(float damage, Vector3 hitPoint, Vector3 hitNormal) {
if (PhotonNetwork.IsMasterClient)
{
// 데미지만큼 체력 감소
health -= damage;
// 호스트에서 클라이언트로 동기화
photonView.RPC("ApplyUpdatedHealth", RpcTarget.Others, health, dead);
// 다른 클라이언트들도 OnDamage를 실행하도록 함
photonView.RPC("OnDamage", RpcTarget.Others, damage, hitPoint, hitNormal);
}
// 체력이 0 이하 && 아직 죽지 않았다면 사망 처리 실행
if (health <= 0 && !dead)
{
Die();
}
}
// 체력을 회복하는 기능
[PunRPC]
public virtual void RestoreHealth(float newHealth) {
if (dead)
{
// 이미 사망한 경우 체력을 회복할 수 없음
return;
}
// 호스트만 체력을 직접 갱신 가능
if (PhotonNetwork.IsMasterClient)
{
// 체력 추가
health += newHealth;
// 서버에서 클라이언트로 동기화
photonView.RPC("ApplyUpdatedHealth", RpcTarget.Others, health, dead);
// 다른 클라이언트들도 RestoreHealth를 실행하도록 함
photonView.RPC("RestoreHealth", RpcTarget.Others, newHealth);
}
}
public virtual void Die() {
// onDeath 이벤트에 등록된 메서드가 있다면 실행
if (onDeath != null)
{
onDeath();
}
// 사망 상태를 참으로 변경
dead = true;
}
}
< 스크립트의 주요 변경 사항 >
- MonoBehaviourPun 사용
- 체력, 사망 상태 동기화를 위한 ApplyUpdatedHealth() 메서드 추가
- Ondamage (), RestoreHealth()에 [PunRPC] 선언
- Ondamage ()에서 데미지 처리는 호스트에서만 실행
- RestoreHealth()에서 체력 추가 처리는 호스트에서만 실행
- [PunRPC]
[PunRPC] 로 선언된 메서드는 다른 클라이언트에서 원격 실행할 수 있음.
RPC를 통해 어떤 메서드를 다른 클라이언트에서 원격 실행할 때는 Photon View 컴포넌트의 RPC() 메서드를 사용함.
- 원격 실행할 메서드 이름( string 타입)
- 원격 실행할 대상 클라이언트 ( RpcTarget 타입 )
- 원격 실행할 메서드에 전달할 값( 필요한 경우 )
ex. photonView.RPC("DoSomething", RpcTarget.All);
- ApplyUpdateHealth()
: 새로운 체력값과 새로운 사망 상탯값을 받아 기존 변숫값을 갱신하는 메서드
호스트 측 LivingEntity의 체력, 사망 상탯값을 다른 클라이언트의 LivingEntity에 전달하기 위해 사용.
ex. photonView.RPC("ApplyUpdatedHealth", RpcTarget.Others, health, dead);
- Ondamage()
: 호스트인 경우에만 데미지 수치를 적용하고, 그것을 호스트에서 다른 클라이언트로 전파하는 처리를 수행.
if (PhotonNetwork.IsMasterClient)
{
// 데미지만큼 체력 감소
health -= damage;
// 호스트에서 클라이언트로 동기화
photonView.RPC("ApplyUpdatedHealth", RpcTarget.Others, health, dead);
// 다른 클라이언트들도 OnDamage를 실행하도록 함
photonView.RPC("OnDamage", RpcTarget.Others, damage, hitPoint, hitNormal);
}
PhotonNetwork.IsMasterClient : 현재 코드를 실행하는 클라이언트가 마스터 클라이언트(호스트)인지 반환하는 프로퍼티.
호스트면 true, 아니면 false 반환.
- RestoreHealth() 메서드
: 어떤 클라이언트가 다른 클라이언트에서 원격 실행할 수 있음.
Ondamage()와 같은 원리로 동작함.
@ PlayerHealth 스크립트
- 기존 기능 : 플레이어 캐릭터의 체력 관리, 체력 UI, 갱신
- 변경 기능 : 리스폰 기능 추가, 아이템을 호스트에서만 사용.
using Photon.Pun;
using UnityEngine;
using UnityEngine.UI; // UI 관련 코드
// 플레이어 캐릭터의 생명체로서의 동작을 담당
public class PlayerHealth : LivingEntity {
public Slider healthSlider; // 체력을 표시할 UI 슬라이더
public AudioClip deathClip; // 사망 소리
public AudioClip hitClip; // 피격 소리
public AudioClip itemPickupClip; // 아이템 습득 소리
private AudioSource playerAudioPlayer; // 플레이어 소리 재생기
private Animator playerAnimator; // 플레이어의 애니메이터
private PlayerMovement playerMovement; // 플레이어 움직임 컴포넌트
private PlayerShooter playerShooter; // 플레이어 슈터 컴포넌트
private void Awake() {
// 사용할 컴포넌트를 가져오기
playerAnimator = GetComponent<Animator>();
playerAudioPlayer = GetComponent<AudioSource>();
playerMovement = GetComponent<PlayerMovement>();
playerShooter = GetComponent<PlayerShooter>();
}
protected override void OnEnable() {
// LivingEntity의 OnEnable() 실행 (상태 초기화)
base.OnEnable();
// 체력 슬라이더 활성화
healthSlider.gameObject.SetActive(true);
// 체력 슬라이더의 최대값을 기본 체력값으로 변경
healthSlider.maxValue = startingHealth;
// 체력 슬라이더의 값을 현재 체력값으로 변경
healthSlider.value = health;
// 플레이어 조작을 받는 컴포넌트들 활성화
playerMovement.enabled = true;
playerShooter.enabled = true;
}
// 체력 회복
[PunRPC]
public override void RestoreHealth(float newHealth) {
// LivingEntity의 RestoreHealth() 실행 (체력 증가)
base.RestoreHealth(newHealth);
// 체력 갱신
healthSlider.value = health;
}
// 데미지 처리
[PunRPC]
public override void OnDamage(float damage, Vector3 hitPoint,
Vector3 hitDirection) {
if (!dead)
{
// 사망하지 않은 경우에만 효과음을 재생
playerAudioPlayer.PlayOneShot(hitClip);
}
// LivingEntity의 OnDamage() 실행(데미지 적용)
base.OnDamage(damage, hitPoint, hitDirection);
// 갱신된 체력을 체력 슬라이더에 반영
healthSlider.value = health;
}
public override void Die() {
// LivingEntity의 Die() 실행(사망 적용)
base.Die();
// 체력 슬라이더 비활성화
healthSlider.gameObject.SetActive(false);
// 사망음 재생
playerAudioPlayer.PlayOneShot(deathClip);
// 애니메이터의 Die 트리거를 발동시켜 사망 애니메이션 재생
playerAnimator.SetTrigger("Die");
// 플레이어 조작을 받는 컴포넌트들 비활성화
playerMovement.enabled = false;
playerShooter.enabled = false;
// 5초 뒤에 리스폰
Invoke("Respawn", 5f);
}
private void OnTriggerEnter(Collider other) {
// 아이템과 충돌한 경우 해당 아이템을 사용하는 처리
// 사망하지 않은 경우에만 아이템 사용가능
if (!dead)
{
// 충돌한 상대방으로 부터 Item 컴포넌트를 가져오기 시도
IItem item = other.GetComponent<IItem>();
// 충돌한 상대방으로부터 Item 컴포넌트가 가져오는데 성공했다면
if (item != null)
{
// 호스트만 아이템 직접 사용 가능
// 호스트에서는 아이템을 사용 후, 사용된 아이템의 효과를 모든 클라이언트들에게 동기화시킴
if (PhotonNetwork.IsMasterClient)
{
// Use 메서드를 실행하여 아이템 사용
item.Use(gameObject);
}
// 아이템 습득 소리 재생
playerAudioPlayer.PlayOneShot(itemPickupClip);
}
}
}
// 부활 처리
public void Respawn() {
// 로컬 플레이어만 직접 위치를 변경 가능
if (photonView.IsMine)
{
// 원점에서 반경 5유닛 내부의 랜덤한 위치 지정
Vector3 randomSpawnPos = Random.insideUnitSphere * 5f;
// 랜덤 위치의 y값을 0으로 변경
randomSpawnPos.y = 0f;
// 지정된 랜덤 위치로 이동
transform.position = randomSpawnPos;
}
// 컴포넌트들을 리셋하기 위해 게임 오브젝트를 잠시 껐다가 다시 켜기
// 컴포넌트들의 OnDisable(), OnEnable() 메서드가 실행됨
gameObject.SetActive(false);
gameObject.SetActive(true);
}
}
주요 변경사항
- RestorHealth(), OnDamage()에 [PunRPC] 선언
- Respawn() 메서드 추가
- Die() 메서드 하단에서 Respawn() 실행
- OnTriggerEnter()의 item.Use()를 if 문으로 감싸기
@ [PunRPC] 선언
- LivingEntity에서 RestoreHealth()와 OnDamage() 메서드는 [PunRPC] 속성이 선언되어있음.
오버라이드하는 측에서도 원본 메서드와 동일하게 [PunRPC] 속성을 선언해야 정상적으로 RPC를 통해 원격 실행 가능.
@ Die() 메서드
기존 Die() 메서드에 Invoke("Respawn", 5f);를 추가.
- Invoke() 메서드는 특정 메서드를 지연실행하는 메서드. Invoke ( 메서드이름, 지연시간 );
@ Respawn() 메서드
- 사망한 플레이어 캐릭터를 부활시켜 재배치(리스폰)하는 메서드
- 부활처리는 게임오브젝트를 끄고 켜는 간단한 방식으로 구현
// 컴포넌트들을 리셋하기 위해 게임 오브젝트를 잠시 껐다가 다시 켜기
// 컴포넌트들의 OnDisable(), OnEnable() 메서드가 실행됨
gameObject.SetActive(false);
gameObject.SetActive(true);
이게 가능한 이유는 6부에서 캐릭터 관련 스크립트를 작성할 때 수치 초기화를 Awake() 또는 Start() 대신에 OnEnable() 메서드에 몰아뒀기 때문임.
- 게임 오브젝트를 끄고 다시 켜면 PlayerHealth, LivingEntity 등의 스크립트의 OnEnable() 메서드가 자동 실행되고, 체력 등 각종 수치가 기본값으로 다시 리셋 됨.
public void Respawn() {
// 로컬 플레이어만 직접 위치를 변경 가능
if (photonView.IsMine)
{
// 원점에서 반경 5유닛 내부의 랜덤한 위치 지정
Vector3 randomSpawnPos = Random.insideUnitSphere * 5f;
// 랜덤 위치의 y값을 0으로 변경
randomSpawnPos.y = 0f;
// 지정된 랜덤 위치로 이동
transform.position = randomSpawnPos;
}
}
@ OnTriggerEnter() 메서드
기존엔 충돌한 아이템을 감지하고 사용하는 처리를 구현.
변경은 기존 아이템 사용처리 item.use(gameObject);를 if문으로 감싸서 호스트에서만 실행.
- 호스트가 아이템 사용 결과를 다른 클라이언트로 전파할 수 있도록 아이템 스크립트를 수정해야함.
@ 네트워크 Gun
- 부모 게임 오브젝트에 Photon View 컴포넌트가 이미 추가되어 있다고 해도 자식게임 오브젝트에서 독자적으로 실행할 네트워크 처리가 있다면 자식 게임 오브젝트에서도 Photon View 컴포넌트를 추가해서 View ID를 부여해야 함.
기존기능 : 사격실행, 사격이펙트 재생, 재장전 실행, 탄알 관리
변경기능 : 실제 사격 처리 부분을 호스트에서만 실행, 상태 동기화
@ 변경사항
- MonoBehaviourPun 사용
- IPunObservable 인터페이스 상속, OnPhotonSerializeView() 메서드 구현
- 새로운 RPC 메서드 AddAmmo() 추가
- Shot()의 사격 처리 부분을 새로운 RPC 메서드 ShotProcessOnServer()로 옮김
- ShotEffect()를 새로운 RPC 메서드 ShotEffectProcessOnClients()로 감쌈
- OnPhotonSerializeView 메서드의 입력으로 들어오는 stream은 현재 클라이언트에서 다른 클라이언트로 보낼 값을 쓰거나, 다른 클라이언트가 보내온 값을 읽을 때 사용할 스트림 형태의 데이터 컨테이너.
- stram.IsWriting은 현재 스트림이 쓰기모드인지 반환함.
// 로컬 게임오브젝트 = 쓰기 모드 = true, 리모트 게임 오브젝트 = 읽기 모드 = false
@ ReceiveNext() 메서드를 통해 값이 들어올 때는 범용적인 object 타입으로 들어오기 때문에 읽는 측에서 원본 타입으로 형변환 함.
- 또한 스트림에 삽입한 순서대로 값이 도착하기 때문에 SendNext()와 ReceiveNext() 메서드간 주고받는 변수들의 나열 순서가 일치해야 함.
캐릭터 오브젝트 주변에 원 퍼져 나가게 하기(스캔) (0) | 2024.05.27 |
---|---|
애니메이터 아바타 변경하기(Animator.Avatar, 캐릭터 교체) (1) | 2024.05.01 |
18장. 좀비 서바이벌(멀티플레이) (0) | 2024.04.29 |
17장. 좀비 서바이벌(아이템 생성, 포스트 프로세싱) (0) | 2024.04.22 |
17장. 좀비 서바이벌(UI, 게임 재시작, 게임 매니저, 4방향 몬스터 소환, 웨이브 갱신) (0) | 2024.04.18 |