게임 프로그래밍에 있어서 기초 기능을 배우고, 가장 단골적으로 만들어 보는 게임이 닷지 게임이 아닐까 싶다.
곰플레이어에 있었던 닷지이다. 이 게임은 사방에서 날아오는 총알을 피하면서 얼마나 오래 버틸 수 있는지 최고기록을 도전하는 게임이다. 간단하면서도 플레이어의 움직임, 총알의 움직임 및 랜덤 발사 요소나 오브젝트 풀, 시간 요소 등 구현할 수 있는 내용이 많아 보인다.
우리가 배우고 있는 것은 유니티3D이므로, 유니티3D 느낌으로의 닷지를 구현해보고자 한다.
닷지 게임을 위해 구현해야 할 내용이 무엇이 있을지 생각해보자.
이번 닷지 게임의 경우 에셋 없이 만들 예정이며, 맵과 캐릭터, 타워, 총알 모두 직접 만들 예정이다.
맵을 만드는 과정에 대해서는 특이사항이 없으니 간단하게만 정리하고 넘어간다.
플레이어에 있어 제일 먼저 구현해야 할 사안은, 플레이어의 이동을 구현하는 것이다.
플레이어의 이동을 구현하는 방법은 여러가지 방법이 있으며, 아래와 같이 정리할 수 있다고 할 수 있다.
- Transform을 직접 이동시키기
- AddForce로 이동시키기
- GetAxis + Rigidbody로 구현하기
이 외에도 구현 방법이 여러가지 있겠으나, 여기서 Transform으로 이동시키는 경우, 벽을 Collision으로 해서 플레이어의 이탈을 막을 수도 있겠지만 직접 좌표를 이동시키는 경우의 리스크가 있다. 벽에 붙어서 드드드득 하는 움직임을 보일 수 있으므로, 이번 과제에서는 3번을 이용해 구현해 보기로 했다.
여기서 생각해보아야 할 것이 있다.
이에 따라, 입력받는 함수와 이동하는 함수를 분리하여 구현하고, 각각 Update와 FixedUpdate로 넣기로 했다.
[SerializeField] private Rigidbody rigid;
[SerializeField] private float playerSpeed;
private Vector3 inputVec;
void Start()
{
// 프로그래머의 실수로 Rigidbody가 들어가지 않았을 경우를 방지
if (rigid == null)
rigid = GetComponent<Rigidbody>();
}
void Update()
{
PlayerInput();
}
private void FixedUpdate()
{
Move();
}
private void PlayerInput()
{
float x = Input.GetAxisRaw("Horizontal");
float z = Input.GetAxisRaw("Vertical");
inputVec = new Vector3(x, 0, z).normalized;
}
private void Move()
{
rigid.velocity = inputVec * playerSpeed;
}
이와 같이 작성하고 플레이어가 움직이는 도중 넘어지거나 굴러가지 않도록, Rigidbody Constraints를 Rotation 전부와 Position.y 를 고정시킨다.
타워를 만들 때 Muzzle의 위치에 주의하도록 하자.
이와 같이 Muzzle이 X축 혹은 Z축을 향해 있어야 하며, 그 외의 각도로 꽃아놓으면 나중에 플레이어로 총구를 향할 때 이상하게 출력되는 현상을 볼 수 있다.
Tower는 아래와 같은 특성을 가진 것으로 구현하기로 했다.
우선은 타워가 돌아가는 것부터 구현해보자.
타워가 돌아가는 것 자체는 간단하다.
[SerializeField] private float rotSpeed;
...
private void TowerRotate()
{
transform.Rotate(Vector3.up, rotSpeed * Time.deltaTime);
}
그 다음 플레이어의 탐지 범위를 설정해야 하는데, 이걸 위해서 사용할 함수에 대해 알아보고자 한다.
- Physics.OverlapSphere()
중점과 반지름으로 가상의 원을 만들어 추출하려는 반경 이내에 들어와 있는 Collider들을 반환하는 함수
여기에서 layerMask로 Player가 있는지를 판별하고, 해당 bool값이 있는지를 확인하여 플레이어 탐지 여부를 결정할 수 있다.
[SerializeField] private float detectRadius;
[SerializeField] private LayerMask playerLayer;
[SerializeField] private Transform targetPos;
...
private void DetectPlayer()
{
// 해당 함수의 결과값은 배열로 나오게 되며, 배열에 플레이어가 있을 경우(0보다 클 경우) 플레이어 탐지
if(Physics.OverlapSphere(transform.position, detectRadius, playerLayer).Length > 0)
{
transform.LookAt(targetPos);
}
}
// 가시성을 위해 기즈모 설정
private void OnDrawGizmos()
{
Gizmos.color = Color.black;
Gizmos.DrawWireSphere(transform.position, detectRadius);
}
다만 이와 같이 코드를 작성했을 때 타워가 기울어지는 것을 확인할 수 있다.
이런 부분을 방지하기 위해서, targetPos를 바로 향하는 것이 아닌, 변수를 추가로 만들어준다.
Vector3 lookPos = new Vector3(targetPos.position.x, transform.position.y, targetPos.position.z);
transform.LookAt(lookPos);
이와 같이 작성하면 기즈모도 잘 보이고, 총구도 알맞게 돌아가는 것을 확인할 수 있다.
이후에 오브젝트 풀을 만들면 해당 내용을 수정해야 하지만, 당장 구동이 되는지부터 확인하기 위해 Bullet 스크립트를 간단하게 구현했다.
[SerializeField] private Rigidbody rigid;
[SerializeField] private float firePower;
private void Start()
{
rigid.AddForce(transform.forward * firePower, ForceMode.Impulse);
}
이와 같이 작성하고, Tower에 Fire 함수를 만들자.
[SerializeField] private GameObject bulletPrefeb;
[SerializeField] private Transform muzzlePos;
[SerializeField] private float bulletTime;
...
private void Fire()
{
GameObject bullet = Instantiate(BulletPrefeb, muzzlePos.position, Quaternion.identity);
Destroy(bullet, bulletTime);
}
다만 이와 같이 작성하였을 경우 두 가지 문제점이 있다.
이와 같은 문제를 해결하기 위해 Coroutine으로 총알의 발사 시간에 지연을 줄 필요가 있다.
...
[SerializeField] private GameObject bulletPrefeb;
[SerializeField] private Transform muzzlePos;
[SerializeField] private float bulletTime;
// 총알의 발사 주기를 설정
[SerializeField] private float fireRate;
// 코루틴을 담아 할당과 할당해제
private Coroutine fireCoroutine;
// 코루틴 내에서의 총알 발사 지연시간을 하나만 생성(최적화용)
private YieldInstruction fireDelay;
void Start()
{
fireDelay = new WaitForSeconds(fireRate);
}
void Update()
{
DetectPlayer();
}
private void DetectPlayer()
{
if(Physics.OverlapSphere(transform.position, detectRadius, playerLayer).Length > 0)
{
Vector3 lookPos = new Vector3(targetPos.position.x, transform.position.y, targetPos.position.z);
transform.LookAt(lookPos);
if (fireCoroutine == null)
{
fireCoroutine = StartCoroutine(Fire());
}
}
else
{
TowerRotate();
if (fireCoroutine != null)
{
StopCoroutine(fireCoroutine);
fireCoroutine = null;
}
}
}
private IEnumerator Fire()
{
while (true)
{
GameObject bullet = Instantiate(BulletPrefeb, muzzlePos.position, Quaternion.identity);
Destroy(bullet, bulletTime);
yield return fireDelay;
}
}
오브젝트 풀 패턴을 구현하는 방법은 여러가지가 있지만, 저번의 List 구현 방법과 달리 이번에는 Stack을 통해 구현해보려고 한다.
Stack을 통해 오브젝트 풀 패턴을 구현한다고 하면, Push와 Pop을 반복하는 방식으로 구현해야 할 것이다.
우선 Bullet에 ReturnPool을 구현하자.
...
public Stack<GameObject> returnPool;
private void OnEnable()
{
rigid.AddForce(transform.forward * firePower, ForceMode.Impulse);
StartCoroutine(ReturnPool(3f));
}
private IEnumerator ReturnPool(float delay = 0f)
{
yield return new WaitForSeconds(delay);
gameObject.SetActive(false);
rigid.velocity = Vector3.zero;
returnPool.Push(gameObject);
}
다음으로 TowerController 코드에 Stack을 추가하자.
...
private Stack<GameObject> bulletPool;
[SerializeField] private int poolSize;
void Start()
{
fireDelay = new WaitForSeconds(fireRate);
bulletPool = new Stack<GameObject>();
for(int i = 0; i < poolSize; i++)
{
GameObject obj = Instantiate(bulletPrefeb);
obj.GetComponent<Bullet>().returnPool = bulletPool;
// 총알을 생성할 당시 비활성화한다.
obj.SetActive(false);
bulletPool.Push(obj);
}
}
...
private IEnumerator Fire()
{
while (true)
{
GameObject bullet = bulletPool.Pop();
bullet.transform.position = muzzlePos.position;
bullet.transform.forward = transform.forward;
bullet.SetActive(true);
yield return fireDelay;
}
}
간단하게 싱글톤 패턴으로 만들 수 있는 게임매니저를 구현해보자.
public static GameManager Instance;
public bool IsFinished;
public GameObject playerObject;
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
private void GameStart()
{
IsFinished = false;
}
public void GameOver()
{
playerObject.SetActive(false);
IsFinished = true;
}
게임오버를 판정할 bool 변수를 추가하고, 플레이어에 체력과 게임오버를 구현하자.
private int playerHealth = 5;
...
private void OnTriggerEnter(Collider other)
{
if(other.CompareTag("Bullet"))
{
TakeHit();
}
}
private void TakeHit()
{
playerHealth--;
if(playerHealth <= 0)
{
GameManager.Instance.GameOver();
}
}
이와 같이 만들면 이제 게임 느낌이 나는 닷지가 완성되기는 하나, 게임의 시작과 종료만 하는 밋밋한 게임이 되고 마니, 게임 시작 버튼과 재시작 버튼을 구현해보고자 한다.
게임 시작과 종료를 위한 패널, 그리고 버튼을 구현해준다.
시작 패널
종료 패널
이제 게임매니저를 통해 게임의 시작과 진행, 종료 중에 해당 패널이 꺼지거나 켜지도록 코드를 추가한다.
public static GameManager Instance;
public bool IsFinished;
// public event Action OnPlayerDied;
public UnityEvent OnPlayerDied;
public GameObject playerObject;
[SerializeField] private Transform spawnPos;
[Header("UI")]
[SerializeField] private GameObject gameStartPanel;
[SerializeField] private Button startBtn;
[SerializeField] private GameObject gameOverPanel;
[SerializeField] private Button retryBtn;
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
private void Init()
{
IsFinished = true;
playerObject.SetActive(false);
gameOverPanel.SetActive(false);
ingameTimeSpan.SetActive(false);
hpBar.SetActive(false);
}
private void GameStart()
{
gameStartPanel.SetActive(false);
gameOverPanel.SetActive(false);
IsFinished = false;
playerObject.transform.position = spawnPos.position;
playerObject.SetActive(true);
ingameTimeSpan.SetActive(true);
hpBar.SetActive(true);
playedTime = 0;
}
// Listener를 통해 버튼을 누를 시 해당 함수가 실행됨
private void OnEnable()
{
Init();
OnPlayerDied.AddListener(GameOver);
startBtn.onClick.AddListener(GameStart);
retryBtn.onClick.AddListener(GameStart);
}
public void GameOver()
{
playerObject.SetActive(false);
IsFinished = true;
gameOverPanel.SetActive(true);
}
private void OnDisable()
{
OnPlayerDied.RemoveListener(GameOver);
startBtn.onClick.RemoveListener(GameStart);
retryBtn.onClick.RemoveListener(GameStart);
}
또한 이벤트를 이용하여 게임오버를 선언하고, 각각의 오브젝트들이 정지할 수 있도록 설정한다.
플레이어가 죽었을 시 게임 종료
private void TakeHit()
{
ParticleSystem particle = Instantiate(damageParticle);
particle.transform.position = gameObject.transform.position;
playerHealth--;
if(playerHealth <= 0)
{
// 게임 오버 시에 플레이어가 비활성화됨
GameManager.Instance.OnPlayerDied.Invoke();
}
}
void Update()
{
// 게임오버가 아닐 시에만 작동
if(!GameManager.Instance.IsFinished)
{
DetectPlayer();
}
}
이제 여기서 추가로 구현한 내용을 적어보고자 한다.
먼저 게임매니저에 생존 시간 기록을 위한 변수와 시간 흐름을 추가하였다.
...
// 생존 시간 기록
private static float playedTime;
public float PlayedTime { get { return playedTime; } }
...
private void GameStart()
{
...
// 게임 재시작 시 초기화
playedTime = 0;
}
private void Update()
{
// 게임이 진행 중일 때만 시간을 측정
if(!IsFinished) playedTime += Time.deltaTime;
}
다음으로 게임 진행 중 시간을 표기하기 위한 UI용 스크립트를 추가한다.
public class RunTimeUI : MonoBehaviour
{
[SerializeField] TMP_Text textUI;
private void FixedUpdate()
{
if (!GameManager.Instance.IsFinished)
{
TimeSpanUI(GameManager.Instance.PlayedTime);
}
}
private void TimeSpanUI(float score)
{
// ToString에서 텍스트의 표시형식을 정할 수 있음
textUI.text = $"Score : {score.ToString("0.00")}";
}
}
마지막 게임 종료 후 점수를 표기하기 위한 UI용 스크립트도 추가한다. 해당 UI는 게임 종료 시에 활성화되기 때문에 따로 조건 없이 출력하면 된다.
public class FinalScoreText : MonoBehaviour
{
[SerializeField] TMP_Text textUI;
private void OnEnable()
{
SetScore(GameManager.Instance.PlayedTime);
}
private void SetScore(float score)
{
textUI.text =$"Score : {score.ToString("0.00")}";
}
}
마지막으로 해당 UI들을 만들고, UI들의 활성화 여부를 다시 게임 매니저에서 처리한다. (중복되는 내용이 많아 게임매니저 최종 스크립트는 후에 첨부)
인게임 시간 측정
최종스코어
캐릭터의 체력이 표기가 되고 있지 않아 불편하다고 생각했다. 해당 내용을 구현하기 위해 텍스트로 표기했다.
플레이어의 체력을 실시간으로 받을 수 있는 함수를 추가하고, 체력바용 UI 스크립트를 추가한다.
public class HPBarUI : MonoBehaviour
{
[SerializeField] TMP_Text hpBar;
private void Update()
{
HPBar();
}
private void HPBar()
{
// 플레이어 체력을 가져오기 위해 PlayerHealthInfo 함수를 플레이어에 추가함.
int health = GameObject.FindWithTag("Player").GetComponent<PlayerController>().PlayerHealtInfo();
string curHealthbar = "";
switch (health)
{
case 1: curHealthbar = "■"; break;
case 2: curHealthbar = "■■"; break;
case 3: curHealthbar = "■■■"; break;
case 4: curHealthbar = "■■■■"; break;
case 5: curHealthbar = "■■■■■"; break;
}
hpBar.text = $"Life : {curHealthbar}";
}
인게임의 좌측 상단에서 이렇게 표기가 되는 것을 확인할 수 있다.
다만 플레이어의 체력을 표기하는 방법에서 좀 더 String을 덜 쓰는 방법에 대해 고민이 필요할 것 같다.
파티클을 아직 배우기 전 단계이지만, 예습하는 느낌으로 미리 만들어보기로 했다.
특히나 플레이어가 총알과 부딪혔는지 잘 확인이 안 될 때가 많아서, 총알에 부딪혔을 때 효과를 넣어주는 게 좋아보였다.
파티클은 Effects > Particle System으로 만들 수 있다.
세부적으로 만질 수 있는 설정이 많지만, 이번에는 간단하게만 하고 넘어가려고 한다.
색과 지속시간, 파티클 개수와 터지는 모양 정도만 설정을 했고, 이를 플레이어가 피격 시에 터지게 하면 된다.
이번에 작업하면서 배운 것으로, 파티클은 그냥 바로 Instantiate해서는 터지지 않는다는 것을 확인했다.
우선 파티클 시스템은 파티클 시스템이란 프리팹을 따로 만들어서 넣어야 하며, 파티클 시스템 변수를 따로 만들어서 해당 변수에 Instantiate을 넣고, 해당 파티클 시스템의 변수를 다시 설정해야지 의도대로 터뜨릴 수 있다는 걸 확인했다.
[SerializeField] private ParticleSystem damageParticle;
...
public int PlayerHealtInfo()
{
return playerHealth;
}
private void TakeHit()
{
ParticleSystem particle = Instantiate(damageParticle);
particle.transform.position = gameObject.transform.position;
playerHealth--;
if(playerHealth <= 0)
{
GameManager.Instance.OnPlayerDied.Invoke();
}
}