학원을 다니고, 인강을 들으며 배운 내용들을 응용하여 게임을 만들었습니다.
꾸준하게 업데이트 될 예정입니다.
(23.04.15 기준으로 전체적인 게임이 완성된 상태입니다.)
아래 링크는 게임 시연 영상이 업로드된 유투브 주소입니다.

연습삼아 만들고 있는 게임이 무거운 게임은 아니기에, 로딩화면의 필요성이 비교적 떨어지기는 하지만, 애당초 공부의 목적으로 만들고 있는 것이기에 구현해 보았습니다.
무관한 내용이지만...가운데의 캐릭터는 제가 개인적으로 좋아하는 게임 속 캐릭터입니다.
위 화면은 에디터 Scene 상에서의 로딩창을 2D환경으로 본 모습입니다.
로딩바 구현의 에셋과 코드에 있어서는 '베르의 게임개발 유투브'를 참고하였습니다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SceneLoader : MonoBehaviour
{
static SceneLoader _unique;
public Text loadingText;
[SerializeField] Image loadingBar;
string nextScene;
//캔버스 우선순위 조절
public Canvas canvas;
public enum eLoaddingState
{
None = 0,
Start,
ing,
end
}
//AsyncOperation loadProc;
eLoaddingState _nowLoadState = eLoaddingState.None;
static public SceneLoader _instance
{
get { return _unique; }
}
// Start is called before the first frame update
private void Awake()
{
_unique= this;
DontDestroyOnLoad(this.gameObject);
_LoadScene("UI_Scene");
}
private void Update()
{
switch(_nowLoadState)
{
case eLoaddingState.None:
canvas.sortingOrder = 0;
break;
case eLoaddingState.Start:
canvas.sortingOrder = 10;
canvas.enabled= true;
ShowLoadingText();
break;
case eLoaddingState.ing:
canvas.enabled = true;
canvas.sortingOrder = 10;
ShowLoadingText();
break;
case eLoaddingState.end:
canvas.sortingOrder = 0;
canvas.enabled = false;
ClearLoadingText();
break;
}
}
//문자열을 입력받아 씬을 넘겨주는 함수
public void _LoadScene(string SceneName)
{
_nowLoadState = eLoaddingState.Start;
nextScene = SceneName;
StartCoroutine(LoadSceneProcess());
}
private void ShowLoadingText()
{
//로딩상태 표시
loadingText.gameObject.SetActive(true);
}
private void ClearLoadingText()
{
//로딩상태 제거
loadingText.gameObject.SetActive(false);
}
IEnumerator LoadSceneProcess()
{
_nowLoadState = eLoaddingState.ing;
AsyncOperation obj = SceneManager.LoadSceneAsync(nextScene);
//로딩이 99에서 멈춤
obj.allowSceneActivation= false;
float timer = 0f;
while(!obj.isDone)
{
yield return null;
if (obj.progress <0.9f)
{ //실제 로딩
//바 표기
loadingBar.fillAmount = obj.progress;
//텍스트표기
int percent = Mathf.RoundToInt(obj.progress * 100);
loadingText.text = $"{percent}%";
}
else
{ //페이크 로딩
timer += Time.unscaledDeltaTime;
//텍스트표기
float percent = Mathf.RoundToInt(Mathf.Lerp(0.9f, 1f, timer)*100);
loadingText.text = $"{percent}%";
loadingBar.fillAmount = Mathf.Lerp(0.9f,1f,timer);
if(loadingBar.fillAmount >=1f)
{
obj.allowSceneActivation= true;
}
}
}
_nowLoadState = eLoaddingState.end;
}
}

SceneController의 기능은 인게임과 메뉴 화면의 사이를 오고가는 중간 다리 역할을 하며, 씬의 전환을 전체적으로 관리하는 데에 있습니다.
'LoadSceneAsync' 코드를 이용하여, 비동기적으로 씬을 로드하여 게임 플레이 중에 새로운 씬을 불러올 수 있게 하였습니다.
텍스트와 바 이미지를 직렬화로 가져와, 코드 상에서 로딩 진행 상황을 볼 수 있도록 짜 보았습니다.
씬 전환이 한순간에 끝나버리기에, 이를 막기 위해 로딩의 99% 상태에서 페이크 로딩을 넣었습니다.
실질적인 씬 전환과 관련된 함수는
_LoadScene(string SceneName)과 LoadSceneProcess입니다. 스트링 형태로 씬 이름을 받아와 변수에 저장하고 해당 변수를 LoadSceneProcess에서 씬 전환과 함께 로딩 진행방향을 표시하도록 하였습니다.
컨트롤러의 전체적인 단계를 나타내는 enum eLoaddingState를 이용해, UI를 Off하고 On할 수 있게 하였습니다.
씬 매니저는 씬 전환과 관련된 핵심 매니저이기 때문에, 파괴되지 않도록 DontDestroyOnLoad 메서드를 이용하였습니다. 컨트롤러 뿐만 아니라, UI 역시도 아래와 같은 클래스를 붙여서 파괴되지 않도록 하였습니다.
public class DontRemover : MonoBehaviour
{
private void Awake()
{
DontDestroyOnLoad(this.gameObject);
}
}

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UIBtnEvent : MonoBehaviour
{
//클릭 이벤트 -> SceneLoader의 _LoadScene함수를 이용하여, 다음 씬을 호출한다
public void OnClick()
{
//SceneLoader sceneLoader = FindObjectOfType<SceneLoader>();
//sceneLoader._LoadScene("IngameScene");
SceneLoader._instance._LoadScene("IngameScene");
}
}
씬 호출은 위의 씬로더의 맴버 함수인 _LoadScene에 인게임씬의 이름을 문자열로 넘기는 방식으로 구현하였습니다.
흰 바탕에 버튼만 있으면 메뉴화면이 심심할 것 같아, 좋아하는 캐릭터들을 넣어보았습니다.
하단의 2D 스프라이트 애니메이션은 스프라이트 애니메이션 공부를 위하여 넣어보았습니다.
이미지마다 애니메이터 컴포넌트를 추가하여 프레임 단위로 움직이게 만들었습니다. 에셋은 구글링으로 구하고 Slice로 잘라내어, 사용했습니다.
인게임 부분은 실질적인 게임 부분을 구현하는 부분이기에 다른 씬에 비하여 작업 기간이 굉장히 오래 걸렸습니다.
현재도 꾸준히 업데이트 중이며, 패배 트리거 부분은 결정되었으나 승리 조건을 아직 구현하지 않았기에 미완인 상태입니다.

에디터 상의 모습은 위와 같습니다.
게임 룰은 아래와 같습니다.
- 단계별로 몬스터 웨이브가 시작된다. 이때, 몬스터들은 블럭으로 되어 있는 Plane 위를 뱅글뱅글 돈다.
- 일정 숫자 이상의 몬스터가 쌓이면 패배한다.
- 목표 단계를 넘어서면 게임을 승리한다. (가정)
- 몬스터를 잡을 경우, 재화를 수급할 수 있다.
- 재화를 이용하여 캐릭터를 강화하거나, 오브젝트를 설치할 수 있다(예정)
위와 같이 전체적인 게임의 규칙은 어느정도 구현된 상태입니다.
다만, 비교적 구현이 용이한 승리의 경우에는, 각 단계별 몬스터의 숫자와 강함 등 밸런스 부분이 아직 정해지지 않았기에 승리 조건은 미구현인 상태입니다.
다음은 플레이어 조작과 관련된 설계입니다.
- WASD를 이용하여 플레이어를 움직일 수 있다.
- 마우스를 이용하여 플레이어의 Rotation을 회전할 수 있다.
- 쉬프트 키를 누르면 플레이어가 달린다.
- 마우스 왼쪽 버튼을 누르면 캐논을 Set할 수 있다.
- 마우스 오른쪽 버튼을 누르면 캐릭터가 공격한다.
- Q를 누를 경우 춤을 춘다.

Flaot 타입의 Vertical, Horizontal 파라미터를 입력받아 위와 같이 블랜드 트리에 넘겨서 그 값에 따라 행동이 제어되게 하였습니다.
블랜드 트리의 인스펙터는 아래와 같습니다.
X,Y축의 값에 따라 제어되는 애니메이션이 달라집니다.

뛰기 애니메이션은 Bool형 파라미터.
그외 나머지 애니메이션은 int형 파라미터인 animeGirlControl을 사용하여 숫자의 equal값을 통해 제어했습니다.
춤추기 모션을 제외한 모든 이벤트는 Has Exit Time을 채크 해제하여 곧바로 동작이 바뀔 수 있게하였습니다.
if(Input.GetKey(KeyCode.LeftShift)) //뛰기 속도
{
isRun = true;
moveSpeed = 20f;
}
else
{
aniControl.SetBool("isRun", false);
isRun = false;
moveSpeed = 2f;
//animatonState = 0;
//aniControl.SetInteger("animeGirlControl", animatonState);
}
if(isRun && Input.GetKey(KeyCode.W)) // 뛰기
{
//걷기 이벤트 호출, 인트 스테이트 -1
aniControl.SetBool("isRun", true);
animatonState = -1;
aniControl.SetInteger("animeGirlControl", animatonState);
isAct = true;
float inputVertical = Input.GetAxis("Vertical");
Vector3 forward = transform.TransformDirection(Vector3.forward);
Vector3 moveV = forward * inputVertical;
Vector3 moveVelocity = moveV.normalized * moveSpeed;
myRigid.MovePosition(transform.position + moveVelocity * Time.deltaTime);
}
else if ((Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.S)) && !isRun) //걷기
{
isAct = true;
animatonState = -1;
aniControl.SetInteger("animeGirlControl", animatonState);
float inputHorizontal = Input.GetAxis("Horizontal");
float inputVertical = Input.GetAxis("Vertical");
// 캐릭터의 로컬 좌표계에서의 forward 벡터를 월드 좌표계로 변환.
Vector3 forward = transform.TransformDirection(Vector3.forward);
Vector3 right = transform.TransformDirection(Vector3.right);
Vector3 moveH = right * inputHorizontal;
Vector3 moveV = forward * inputVertical;
Vector3 moveVelocity = (moveH + moveV).normalized * moveSpeed;
myRigid.MovePosition(transform.position + moveVelocity * Time.deltaTime);
//워킹 이벤트 처리
aniControl.SetFloat("vertical", inputVertical);
aniControl.SetFloat("horizontal", inputHorizontal);
}
else
{
isAct = false;
aniControl.SetFloat("vertical", 0);
aniControl.SetFloat("horizontal", 0);
//animatonState = 0;
//aniControl.SetInteger("animeGirlControl", animatonState);
}
Move의 경우에는 신경쓸 부분이 많았습니다.
첫 번째, 키를 입력받아 캐릭터를 이동할 때 애니메이션의 동작과의 연동을 신경써야 했습니다.
기본적으로 캐릭터의 경우에는 idle 상태를 유지합니다.
하지만, 캐릭터가 이동할 때에는 walk 애니메이션이 실행되도록 만들어야 했습니다.
두 번째, 쉬프트 키를 누르면 걷기가 아닌 뛰기로 전환해야 했습니다. 속도의 증가는 단순한 변수 조작일 뿐이기에 어렵지 않았으나, 이 역시도 애니메이션 연동을 신경써야 했습니다.
세 번째, 기본적인 1인칭 FPS형 게임에서는 캐릭터가 뒤로 달리거나 옆으로 달리는 경우가 없습니다. 그렇기에 앞으로 걸어갈 때만 달릴 수 있도록 해야 했습니다.
그리고 이러한 고려사항을 염두에 두고 코드를 구현할 때, 에러를 해결하느라 제법 시간이 소요되었었습니다.
walk 애니메이션과 idle 애니메이션이 번갈아가면서 전환되는 문제. wasd키를 눌러 움직이는 도중에는 idle로 전환되면 안됐습니다. 부자연스러운 애니메이션 동작전환으로 인하여 캐릭터의 생동감이 떨어지는 문제가 있기 때문이었습니다.
하지만, Move와 관련된 함수와 전체적인 애니메이션 제어를 담당하는 EventInput()함수는 모두 Update가 실행하는 함수로서 프레임 단위로 반복합니다.
이때문에 Move함수가 실행되고 다시 EventInput 함수가 실행되어 애니메이션 전환이 수시로 바뀌었습니다.
이를 해결하기 위하여, EventInput에 bool형 변수로 행동 제어 플래그를 기준으로 조건을 판별하여 idle과 dance 이벤트가 작동할 수 있돌고 하였습니다.
뿐만 아니라, Move시에는 animatonState 변수를 -1로 바꾸어, 어떤 애니메이션도 작동하지 않게 만들어서 에러를 잡을 수 있었습니다.
Run 부분은 의외로 어렵지 않았습니다. bool변수인 isRun을 이용하여 뛰기 상태를 판별하고 이를 기준으로 걷기와 뛰기. 각각의 함수를 구현했습니다. 또한 뛰기 함수는 오직 W키만을 이용하여 움직이게 구현하여, 뒤로 뛰기나 옆으로 뛰는 문제를 해결했습니다.

공격과 캐논 설치.
캐릭터의 공격과 포탑 설치의 위치는 위 사진과 같이 빈게임오브젝트에 부착된 Quad를 통해 플레이어가 확인할 수 있게 하였습니다.
installPosition에는 몬스터와의 충돌을 감지하기 위한 Collider 컴포넌트가 부착되어 있습니다.
기본적으로 해당 콜라이더는 isTrigger가 체크되어, 충돌 시에도 몬스터가 통과할 수 있도록 하였습니다.
캐논과 캐릭터의 일렉트로 공격에 의해 피해를 입고 데미지를 계산하는 부분은 플레이어 스크립트가 아닌 생성된 일렉트로 오브젝트와 포탄 오브젝트 자체에 부착된 스크립트에서 담당하도록 하였습니다.
private void SetCannon()
{
if (Input.GetButtonDown("Fire1") && !isRun)
{
RaycastHit hit;
// 설치 위치에서 아래로 광선을 쏴서 맞은 오브젝트가 Ground 태그인지 확인합니다.
if (Physics.Raycast(installPos.position, Vector3.down, out hit, Mathf.Infinity) && hit.transform.CompareTag("Ground"))
{
// 설치 위치에 다른 오브젝트가 있는지 확인합니다.
Collider[] colliders = Physics.OverlapSphere(installPos.position, 0.5f);
if (colliders.Length == 1) // 설치 위치에 다른 오브젝트가 없는 경우
{
// 캐논을 설치하는 코드를 작성합니다.
cannonObj.transform.position = installPos.position;
cannonObj.transform.rotation = installPos.rotation;
}
}
}
}
private void ElectroPos()
{
if(Input.GetButtonDown("Fire2") && !isRun && animatonState != 3)
{
isAct = true;
StartCoroutine(FireAct2());
}
else
isAct= false;
}
IEnumerator FireAct2()
{
if(!coroutineFlag)
{
coroutineFlag= true;
animatonState = 2;
aniControl.SetInteger("animeGirlControl", animatonState);
GameObject obj = Instantiate(Electro, installPos.position, installPos.rotation);
Destroy(obj, 1.1f);
yield return new WaitForSecondsRealtime(0.5f);
ElectroCrushCheek electroAttack = installPos.gameObject.GetComponent<ElectroCrushCheek>();
electroAttack.setCheek(true, electroPower);
coroutineFlag = false;
}
}
전체적인 코드는 위와 같습니다.
캐논 설치 함수는 ray를 이용하여, 설치할 위치가 Ground인지 확인하고 참과 거짓을 판별하도록 하였습니다. 또한, 설치할 위치에 Collider를 지닌 다른 오브젝트가 있는지도 검사하여 모든 조건을 만족할 때만 설치하게 만들었습니다.
이때 주의해야하는 부분은, colliders.Length == 1입니다.
installPos에는 전기 공격의 충돌 판별 및 Position 좌표 값을 위한 빈게임오브젝트가 항상 있습니다.
그렇기에 범위를 1로 지정한 것입니다. 만약 0으로 한다면, InstallPosition 오브젝트로 인하여 무조건 조건을 만족하지 못할 것입니다.
캐릭터 회전과 카메라 회전 부
private void RotateX()
{
float inputMouseX = Input.GetAxis("Mouse X");
float rotationY = inputMouseX * rotateSensative;
// 캐릭터를 회전시킵니다.
transform.Rotate(0f, rotationY, 0f);
}
private void RotateY()
{
float inputMouseY = Input.GetAxis("Mouse Y");
float cameraRotation = inputMouseY * rotateSensative;
currentRotate -= cameraRotation;
currentRotate = Mathf.Clamp(currentRotate, -roateLimit, roateLimit);
// 카메라를 회전시킵니다.
Quaternion cameraRotationX = Quaternion.Euler(currentRotate, 0f, 0f);
camera.transform.rotation = transform.rotation * cameraRotationX;
}
캐릭터 회전은 마우스의 X축 이동을 Axis로 좌표 값을 받아 설정된 Sensative값 만큼 단순히 캐릭터를 회전하는 함수입니다.
RotatY 부분은 카메라의 Y축 회전과 관련된 부분입니다.
카메라 회전 역시도 Axis를 이용하여 마우스의 Y축 죄표를 입력 받고, 해당 값에 Sensative 값 만큼 회전값을 변수로 만듭니다.
해당 변수값을 현재 회전값에 감산 대입연산자로 값을 변경하여 바꿀 회전값만큼 실수로 정합니다.
이때, 실수 값이 클램프 함수를 이용하여 리미트값 이상과 이하를 초과하지 않도록 합니다.
기본적으로 유니티의 카메라 회전은 Quaternion(4원수)를 이용하기 때문에, 오일러 값을 4원수로 바꾸는 과정이 필요합니다.
변경된 쿼터니언 변수를 카메라의 transform.rotation 에 곱하여 Y축 카메라 회전을 완성하였습니다.
현재 제작 중인 게임은 기본적으로 디펜스의 형태를 지니고 있습니다.
그렇기에, 몬스터의 존재는 필수적이죠.
저는 디자인 보다는 몬스터의 구현에만 초점을 맞추었고 그렇기에 큐브를 파괴해야 할 몬스터로 만들었습니다.
기본적인 몬스터의 구현 설계는 다음과 같습니다.
몬스터는 EnemyField를 무한히 회전하는 이동을 한다.
몬스터는 스테이지 레벨에 따라 지니고 있는 체력과 골드의 양이 다르다.
플레이어의 공격으로 몬스터의 hp가 0이하로 줄어들면, 객체는 파괴된다.

실질적인 게임플레이를 하는 공간인 Ground와 EnemyField.
이와 반대로 몬스터와 오브젝트의 저장을 담당하는 필드를 따로 구분 지어,
몬스터의 원형(default)가 되는 오브젝트를 분리하여 두었습니다.
몬스터 오브젝트는 몬스터의 이동을 담당하는 스크립트(MonsterMover_2).
몬스터의 체력, 골드. 그리고 파괴 등을 담당하는 스크립트(MonsterStates).
두 개의 스크립트를 지니고 있습니다.

몬스터 이동 스크립트.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class MonsterMover_2 : MonoBehaviour
{
public Transform[] destinations;
//몬스터 속도
[SerializeField] float moveSpeed;
private int currentIndex = 0;
// Update is called once per frame
void Update()
{
RaycastHit hit;
Ray ray = new Ray(this.transform.position, Vector3.down);
if (Physics.Raycast(ray.origin, ray.direction, out hit))
{
if (hit.collider.tag == "EnemyField")
{
gameObject.transform.Translate((destinations[currentIndex].position - transform.position).normalized * moveSpeed * Time.deltaTime);
if (Vector3.Distance(transform.position, destinations[currentIndex].position) < 0.1f)
{
currentIndex = (currentIndex + 1) % destinations.Length;
}
}
}
}
}
몬스터의 이동과 관련된 스크립트입니다.
public 접근지정자로 선언된 Transform 변수 배열에 몬스터가 이동해야 할 포인트 거점을 저장합니다.
몬스터는 EnemyField에서만 이동하도록 만들었으며, 이를 ray를 이용하여 태그를 비교, 판별합니다.
조건을 만족하면 배열 내에 저장된 목표 포인트와 자신의 거리를 계산하여 일정 0.1f 미만이라면 다음 포인트로 이동합니다.
'currentIndex = (currentIndex + 1) % destinations.Length;'문은 도착 후에 다음 포인트를 설정하는 함수입니다.
몬스터 상태 스크립트.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MonsterStates : MonoBehaviour
{
[SerializeField] private int monsterHP;
[SerializeField] GameObject DestroyEffect;
bool isDead = false;
public int gold;
GameManager gameMng;
void Start()
{
gameMng = GameManager.Instance;
}
// Update is called once per frame
void Update()
{
if (monsterHP <= 0)
{
isDead = true;
}
if (isDead)
{
GameObject effectObj = Instantiate(DestroyEffect, transform.position, transform.rotation);
Destroy(effectObj, 1.1f);
gameMng.setGold(gold);
gameMng.setMonsterCount(1);
Destroy(gameObject);
}
if(EndingCheek())
{
GameObject effectObj = Instantiate(DestroyEffect, transform.position, transform.rotation);
Destroy(effectObj, 1.1f);
Destroy(gameObject);
}
}
public int getMonsterHP()
{
return monsterHP;
}
public void setMonsterHP(int hp)
{
this.monsterHP = hp;
}
private bool EndingCheek()
{
return gameMng.isEnding;
}
}
몬스터 상태 스크립트는, 몬스터의 hp를 계산하여 일정 이하에 이를 경우 이펙트를 생성하고 스스로를 파괴합니다.
또한 게임이 ending 단계에 도달하면, 모든 몬스터 객체를 파괴하도록 하였습니다.
이는, 게임의 전체적인 프로세스를 총괄하는 GameManager 클래스 내에 존재하는 isEnding 부울 변수를 검사하여 확인하게 만들었습니다.
몬스터는 스테이지 레벨에 따라 체력과 골드가 달라야 하므로, 직렬화와 접근지정자를 이용하여 Inspector창 내에서 수정할 수 있게 하였습니다.
이렇게 완성된 몬스터들은 최종적으로 GameManager를 통하여 생성됩니다.
자세한 내용은 게임매니저 파트 때 설명하도록 하겠습니다.
캐논은 비교적 단순합니다.
캐논 함수의 전체적인 제어는 플레이어 부에서 담당하고, 캐논 객체는 사격과 탄환 생성 등을 담당하고 있기 때문입니다.
전체적인 설계는 다음과 같습니다.
스페이스 바를 누르면 캐논이 포탄을 발사한다.
캐논이 발사하는 탄환은 스크립트가 부착된 프리팹으로서 발사와 동시에 운동성을 지녀야 한다.
기본적으로 캐논 객체는 위의 몬스터 오브젝트와 마찬가지로 SaveObjectField라는 오브젝트 저장용 공간 내에 위치하고 있습니다.
그리고 플레이어가 마우스 왼쪽 버튼을 클릭하면 해당 위치로 포탑을 이동하는 방식으로 구현되고 있습니다.
이는, 무의미한 Instantiate 함수 사용으로 퍼포먼스 상의 부하를 줄이기 위한 최적화 기법입니다.

캐논 오브젝트는 유니티 기본 오브젝트인 큐브와 스페어, 그리고 실린더를 응용하여 만들었습니다.
포탑역할을 하는 실린더의 부분에는 포탄의 발사 장소로 사용될 빈게임 오브젝트가 자식 오브젝트로 부착되어 있습니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Cannon : MonoBehaviour
{
public GameObject ballCreate;
[SerializeField] GameObject cannonBall;
GameManager gameMng;
bool isEnd;
void Start()
{
gameMng = GameManager.Instance;
}
void Update()
{
isEnd = gameMng.isEnding;
if(isEnd)
{
Destroy(gameObject);
}
BallCreate();
}
void BallCreate()
{
if(Input.GetKeyDown(KeyCode.Space))
{
Instantiate(cannonBall, ballCreate.transform.position, ballCreate.transform.rotation);
}
}
}
캐논 제어와 관련된 스크립트입니다.
몬스터 오브젝트와 마찬가지로, 게임이 Ending Process에 돌입할 시, 객체는 파괴됩니다.
직렬화를 이용하여 포탄이 생성될 위치, 그리고 포탄으로써 사용될 프리팹을 저장합니다.
GetKeyDown()을 이용하여 플레이어가 스페이스 바를 누르면 지정한 위치로 포탄을 생성하게 됩니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Ball : MonoBehaviour
{
Rigidbody rigd3D;
private float addForce = 500;
private int ballPower = 1;
private void Awake()
{
rigd3D = GetComponent<Rigidbody>();
rigd3D.AddForce(transform.up * addForce);
}
void Start()
{
Destroy(gameObject, 1.5f);
}
// Update is called once per frame
void Update()
{
RaycastHit hit;
float rayLength = 0.2f;
Ray ray = new Ray(transform.position, Vector3.down);
if (Physics.Raycast(ray.origin, ray.direction, out hit, rayLength))
{
if (hit.collider.tag == "Ground" || hit.collider.tag == "EnemyField")
{
Destroy(gameObject, 0.7f);
}
}
}
private void OnTriggerEnter(Collider other)
{
//몬스터 레이어 검사.
if(other.gameObject.layer == LayerMask.NameToLayer("Monster"))
{
MonsterStates monsterStates = other.gameObject.GetComponent<MonsterStates>();
int currentHP = monsterStates.getMonsterHP();
monsterStates.setMonsterHP(currentHP - ballPower);
Destroy(gameObject);
Debug.Log("Monster Hit");
}
}
public void ballPowerSetter(int ballPower)
{
this.ballPower = ballPower;
}
}
포탄의 발사는 탄환으로서 사용되는 Bullet 프리팹에 부착되어 있습니다.
포탄은 크게
물리적 운동 제어부.
객체 파괴부.
데미지 계산부로 나뉘어 있습니다.
물리적 운동부는 Start() 부분입니다.
AddForce를 이용하여 탄환에 힘을 실어 날려보냅니다.
캐논 - 실린더 - FirePos의 구조 때문에, Y축이 정면으로 향하게 되었기 때문에 UP방향으로 발사하게 되었습니다.
오브젝트 파괴는 아래 방향으로 0.2f만큼 ray를 쏘아 보내, 그라운드라면 일정 시간(0.7f) 후에 스스로 파괴하도록 만들었습니다.
마지막으로 데미지 계산은 OnTriggerEnter 함수를 이용해 충돌을 검출하고, 충돌한 오브젝트의 레이어를 확인.
그것이 Monster라면 해당 객체의 HP관련 Setter를 호출하여 체력을 감산하게 하였습니다.
데미지 계산 후, 중복 호출이 일어나지 않도록 자기자신을 파괴하여 에러가 일어나지 않도록 하였습니다.
게임매니저는 게임 진행에 있어 가장 핵심적인 기능을 담당하고 있습니다.
프로세스의 진행 단계를 유한상태기계를 이용하여, 구현하였고,
열거형 자료형을 이용하여, 스테이지를 관리하였습니다.

게임매니저는 전체적인 진행을 총괄하는 만큼, 직렬화를 이용하여 많은 오브젝트를 관리합니다.
UI의 전체적인 SetActive를 관리하여, 직관적인 게임의 화면을 구현했습니다.
enum eSTATE
{
READY = 0,
START,
PLAY,
END,
RESULT
}
enum eSTAGE
{
NONE = 0,
LEVEL1,
LEVEL2,
LEVEL3, LEVEL4, LEVEL5,
EndLevel
}
[SerializeField] eSTATE _eState;
[SerializeField] eSTAGE eStage;
private bool isCoroutineFlag = false;
public bool isPlaying = false;
//몬스터 생산 제어 플래그
private bool MonstercontrolFleg = false;
private static GameManager _instance = null;
public bool isEnding = false;
//스테이지 텍스트 관리 플래그
private bool StageBgrFlag = false;
[SerializeField] private Image set_Text_Bgr;
[SerializeField] private Text set_StateText;
[SerializeField] private GameObject player;
//몬스터 생성 필드
[SerializeField] Transform monster_CreateField;
//골드 표시
[SerializeField] private Image img_Gold_Bag;
[SerializeField] private Text txt_Gold_Text;
//몬스터 숫자
[SerializeField] private Image img_MonsterNumber;
[SerializeField] private Text txt_MonsterNumber;
//엔드 메시지
[SerializeField] private Image img_EndBag;
[SerializeField] private Text txt_EndBag;
//아이템샵버튼
[SerializeField] private Button Shopbutton;
//몬스터 카운트용
private int nMonsterTotalCount = 0;
private int nMonsterCount = 0;
//골드 저장 변수
private int nGold=0;
//타이머
float timer = 0;
//승리 체크
[SerializeField]bool isVictory =false;
[SerializeField]bool isFaild = false;
//커서체크
public bool isCursor = true;
Awake에서는 State와 Stage를 각각 READY와 NONE값으로 초기화하여, 게임 플레이를 준비했으며, 싱글턴 패턴을 사용하기 위한, 정적화를 완료하였습니다.

Update에서는 프레임 단위로 엔딩 조건을 만족하였는지 검사하며,
알트키를 이용하여 커서를 잠그거나 해제하도록 합니다.
void Update()
{
isEnding = isGameOver();
if(isCursor && isPlaying)
{
Cursor.lockState = CursorLockMode.Locked;
}
else
{
Cursor.lockState = CursorLockMode.None;
}
if(Input.GetKeyDown(KeyCode.LeftAlt))
{
isCursor= false;
}
switch (_eState)
{
case eSTATE.READY:
GameReady();
break;
case eSTATE.START:
GameStart();
break;
case eSTATE.PLAY:
GamePlay();
break;
case eSTATE.END:
if(!isVictory) GameEnd();
else GameVictory();
break;
case eSTATE.RESULT:
EndScreenInput();
break;
}
ExitInput();
}
또한, 전체적인 게임의 순환을 제어합니다.
switch를 이용하여, 유한기계상태를 체크하고 각 단계별로 알맞는 클래스를 호출합니다.
아래는 이러한 State에서 사용하는 함수들입니다.
// State와 관련된 함수.
void GameReady()
{
_eState = eSTATE.START;
img_Gold_Bag.gameObject.SetActive(false);
txt_Gold_Text.gameObject.SetActive(false);
img_MonsterNumber.gameObject.SetActive(false);
txt_MonsterNumber.gameObject.SetActive(false);
img_EndBag.gameObject.SetActive(false);
txt_EndBag.gameObject.SetActive(false);
}
void GameStart()
{
StartCoroutine(CountDown());
}
void GamePlay()
{
if(!StageBgrFlag)
{
set_StateText.gameObject.SetActive(false);
set_Text_Bgr.gameObject.SetActive(false);
}
img_Gold_Bag.gameObject.SetActive(true);
txt_Gold_Text.gameObject.SetActive(true);
img_MonsterNumber.gameObject.SetActive(true);
txt_MonsterNumber.gameObject.SetActive(true);
//몬스터 숫자 카운트
MonsterNumberPrint();
txt_Gold_Text.text = "Gold : " + nGold;
isPlaying = true;
if (isEnding) //엔딩 확인
{
_eState = eSTATE.END;
}
switch (eStage)
{
case eSTAGE.NONE:
eStage = eSTAGE.LEVEL1;
break;
case eSTAGE.LEVEL1:
Stage_1();
break;
case eSTAGE.LEVEL2:
Stage_2();
break;
case eSTAGE.LEVEL3:
Stage_3();
break;
case eSTAGE.LEVEL4:
Stage_4();
break;
case eSTAGE.LEVEL5:
Stage_5();
break;
case eSTAGE.EndLevel:
End_Level();
break;
}
}
void GameEnd()
{
set_Text_Bgr.gameObject.SetActive(true);
set_StateText.gameObject.SetActive(true);
set_StateText.fontSize = 130;
set_StateText.color = Color.red;
set_StateText.text = "G A M E O V E R!";
txt_Gold_Text.gameObject.SetActive(false) ;
img_Gold_Bag.gameObject .SetActive(false);
txt_MonsterNumber.gameObject.SetActive(false);
img_MonsterNumber.gameObject.SetActive(false);
Shopbutton.gameObject.SetActive(false);
isPlaying = false;
Invoke("GameResult", 3);
}
void GameVictory()
{
set_Text_Bgr.gameObject.SetActive(true);
set_StateText.gameObject.SetActive(true);
set_StateText.fontSize= 130;
set_StateText.color = new Color(0.529f, 1f, 0.706f);
set_StateText.text = "G A M E C L E A R!";
txt_Gold_Text.gameObject.SetActive(false);
img_Gold_Bag.gameObject.SetActive(false);
txt_MonsterNumber.gameObject.SetActive(false);
img_MonsterNumber.gameObject.SetActive(false);
Shopbutton.gameObject.SetActive(false);
isPlaying = false;
Invoke("GameResult", 3);
}
void GameResult()
{
_eState = eSTATE.RESULT;
int score = 0;
score = nMonsterTotalCount - nMonsterCount;
set_StateText.fontSize = 130;
set_StateText.color = Color.blue;
set_StateText.text = "파괴한 몬스터 수 : " + score;
img_EndBag.gameObject.SetActive(true);
txt_EndBag.gameObject.SetActive(true);
}
각 클래스들은 필요성에 따라, UI를 끄고 켭니다.
GamePlay()함수는 게임 플레이를 관리합니다.
마찬가지로 Ending조건을 검사하고, Stage의 단계 별로 필요한 함수를 호출합니다.
게임은 승리와 패배로 나뉘기 때문에, bool함수를 통해서, 엔딩 조건과 함께 승리와 패배를 구분지어 함수를 호출하도록 하였습니다.
Result()는 정산창입니다.
총 생성된 몬스터 숫자에 필드에 남은 몬스터 수를 계산하여 플레이어가 파괴한 큐브 숫자를 보여줍니다.


GamePlay에서 사용되는 스테이지 내의 클래스들은 위와 같습니다.
몬스터 태그와 다음 스테이지, 그리고 개체수와 생성 주기, 보스 여부를 파라미터로 입력받는 코루틴 함수를 호출합니다.
IEnumerator MonsterCreate(string enemyTag, eSTAGE nextLevel, int MonsterNumber, float CreateTimer, bool isBoss)
{
if(!MonstercontrolFleg)
{
//몬스터 생성
for(int i =0; i < MonsterNumber; i++)
{
MonstercontrolFleg = true;
GameObject enemy = Instantiate(GameObject.FindGameObjectWithTag(enemyTag));
enemy.transform.position = monster_CreateField.position;
nMonsterCount++;
nMonsterTotalCount++;
yield return new WaitForSecondsRealtime(CreateTimer);
MonstercontrolFleg = false;
}
//일정 시간 이후, 다음 스테이지 부르기.
if (!MonstercontrolFleg)
{
MonstercontrolFleg = true;
if(!isBoss)
{
StageBgrFlag = true;
set_Text_Bgr.gameObject.SetActive(true);
set_StateText.gameObject.SetActive(true);
set_StateText.fontSize = 130;
set_StateText.color = new Color(0.529f, 1.5f, 0.706f);
set_StateText.text = "1.5초 후 다음 스테이지";
Invoke("StageBgrSetFlag", 1.5f);
yield return new WaitForSecondsRealtime(5);
}
else
{
StageBgrFlag = true;
set_Text_Bgr.gameObject.SetActive(true);
set_StateText.gameObject.SetActive(true);
set_StateText.fontSize = 45;
set_StateText.color = Color.red;
set_StateText.text = "모든 몬스터 파괴 시, 5초 후 보스 스테이지.\n";
set_StateText.text += "보스몹은 한 바퀴 내에 잡아야 합니다.";
Invoke("StageBgrSetFlag", 2.5f);
}
eStage = nextLevel;
MonstercontrolFleg = false;
yield break;
}
}
}
호출한 클래스는 다음과 같은 로직으로 구성되어 있습니다.
보스몬스터 여부는 UI관리를 위하여 필요로 하고 있습니다.
Instantiate함수를 이용하여 매개변수로 입력받은 태그를 가진 오브젝트를 하이어라키에서 찾아 생성합니다.

Invoke함수를 이용하여 호출하는 함수입니다.
보스몬스터는 필드 위의 모든 몬스터를 파괴한 후에 5초 후에 생성되도록 하여야 하기 때문에, 다음과 같은 함수로 구현하였습니다.
기본적으로 State와 Stage 프로세스는 Update에서 진행되기 때문에, 함수가 반복되어 호출됩니다.
그렇기 때문에, MonstercontrolFleg를 True로 두어서, 보스 몬스터가 반복되어 생성되지 않도록 하였습니다.

위 코드는 Ready 단계에서 사용되는 CountDown 클래스와
IngameScene 전체 단계에서 쓸 수 있는 Exit 클래스입니다.
for문의 조건을 수정하여, n초부터 카운트 다운을 셀 수 있습니다.
//골드 setter
public void IncomeGold(int gold)
{
nGold+=gold;
}
public void setGold(int gold)
{
nGold=gold;
}
public int getGold()
{
return nGold;
}
void MonsterNumberPrint()
{
txt_MonsterNumber.text = "Monster : " + nMonsterCount.ToString();
}
public void setMonsterCount(int nMonsterCount)
{
this.nMonsterCount -= nMonsterCount;
}
bool isGameOver()
{
if(isVictory)
{
return true;
}
if(isFaild)
{
return true;
}
if (nMonsterCount >= 10)
{
return true;
}
else
return false;
}
void EndScreenInput()
{
if(Input.GetKeyDown(KeyCode.Space))
{
Application.Quit();
}
else if(Input.GetKeyDown(KeyCode.Return))
{
SceneLoader._instance._LoadScene("UI_Scene");
}
}
골드 Setter와 Income 함수는 각자 상점에서 강화시, 소모된 골드를 Set하거나 큐브를 파괴하고 얻는 수입을 반영할 수 있도록 합니다.
그리고 GameOver함수를 이용해, 승리 여부, 그리고 실패 여부와 일정 개체수 이상이 필드에 존재했을 때, Ending State로 넘어갈 수 있도록 bool을 반환합니다.
최종적으로 EndScreenInput 함수를 이용해, Result 단계에서 플레이어가 게임을 재시작하거나, 종료할 수 있도록 키를 입력받아, 진행합니다.
스테이지가 넘어가면 더 강한 큐브가 나타납니다.
이에 따른 플레이어블 캐릭터의 공격력 강화 역시 필요했습니다.
상점의 기본적인 설계는 다음과 같았습니다.
- UI버튼을 누르면 상점창 Active.
- 요구하는 골드량 이상을 보유했을 경우, 강화 가능.
- 강화 할 때마다, 요구되는 골드량 증가.
- 강화 성공시, 캐릭터의 공격력과 캐논의 공격력에 반영.
- 상점 UI창이 출력되면, 캐릭터의 캐논 설치(마우스 왼쪽클릭)가 반응하면 안된다.
- 캐릭터가 움직이거나, 공격하면 상점 창이 자동으로 닫힌다.
- 상점을 이용하기 위해서, 마우스 커서를 적절히 On/OFF할 수 있어야 한다.
설계를 완료한 후에 필요한 부분을 직접 수정보완하며, 코드를 고쳤습니다.
상점 구현은 비교적 기능이 명료했기 때문에, 어렵지 않게 완성할 수 있었습니다.
가장 먼저 반영해야할 부분은 마우스 커서였습니다.
void Update()
{
isEnding = isGameOver();
if(isCursor && isPlaying)
{
Cursor.lockState = CursorLockMode.Locked;
}
else
{
Cursor.lockState = CursorLockMode.None;
}
위 코드와 같이 알트 키를 누르면 마우스 커서가 나타나서 UI와 상호작용을 할 수 있게 하였고


GirlControl 클래스에서는 플레이어가 움직였을 때, 부울 변수를 다시 true를 반환하여 커서가 사라지도록 만들었습니다.
플레이어의 공격(마우스 오른쪽 버튼)도 마찬가지였습니다.
void Update()
{
if(gameMng.isCursor == true)
{
MenuOut();
}
}
public void MenuSet()
{
isMenuOpen= true;
gameObject.SetActive(true);
}
public void MenuOut()
{
isMenuOpen= false;
gameMng.isCursor = true;
gameObject.SetActive(false);
}
위 코드가 상점 UI의 On/Off와 관련된 부분입니다.
마우스 커서가 사라지면 상점 창도 닫힙니다.
그리고, 상점 On버튼을 누르면 Menu가 set되면서 gameObject(UI캔버스)가 true가 되어 게임 창에 나타나게 됩니다.

그렇게해서 나온 상점창은 위와 같습니다.
하얀색 강화 버튼을 누르면, 플레이어 공격력이나 캐논 공격력이 강화됩니다.
오른쪽에는 현재 플레이어 캐릭터와 캐논의 공격력을 알려줍니다.
public void BuyPlayerPowerUp()
{
if(gameMng.getGold() >= nPlayerPowerForCoin)
{
GameObject player = GameObject.FindGameObjectWithTag("Player");
player.GetComponent<GirlControl>().electroPower += 6;
int gold = gameMng.getGold();
gold -= nPlayerPowerForCoin;
gameMng.setGold(gold);
nPlayerPowerInNum++;
nPlayerPowerForCoin += 10;
txtPlayerPowUp.text = nPlayerPowerInNum.ToString() + "강 - " + nPlayerPowerForCoin.ToString() + "골드";
PlayerAttack.text = "플레이어 공격력\n" + player.GetComponent<GirlControl>().electroPower.ToString();
}
}
상점 구매 코드입니다.
Player 태그를 지닌 오브젝트를 가져와 GirlControl 컴포넌트의 electroPower의 공격력을 수정합니다.
이후, GameManager를 싱글턴 패턴으로 변수화한 gameMng에서 getGold를 이용하여, 골드를 감산합니다.
다음 공격력과 요구 코인량을 수정하고, 최종적으로 UI의 텍스트에 반영합니다.
캐논 공격력 역시, 위와 유사한 방법으로 구현하였으나,
초기 캐논의 Bullet 데미지 계산은, Bullet이 자체적으로 지니고 있는 공격력 값으로 계산하였기 때문에, 수정할 필요가 있었습니다.

Cannon클래스에서 자체적으로 공격력을 보유하고, 해당 공격력을 Ball에게 넘기는 방식으로 수정했습니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Ball : MonoBehaviour
{
Rigidbody rigd3D;
private float addForce = 1000;
public int ballPower;
private void Awake()
{
rigd3D = GetComponent<Rigidbody>();
rigd3D.AddForce(transform.up * addForce);
}
void Start()
{
GameObject obj= GameObject.FindGameObjectWithTag("Cannon");
ballPower = obj.GetComponent<Cannon>().BallPowerGetter();
Destroy(gameObject, 1.5f);
}
// Update is called once per frame
void Update()
{
RaycastHit hit;
float rayLength = 0.2f;
Ray ray = new Ray(transform.position, Vector3.down);
if (Physics.Raycast(ray.origin, ray.direction, out hit, rayLength))
{
if (hit.collider.tag == "Ground" || hit.collider.tag == "EnemyField")
{
Destroy(gameObject, 0.7f);
}
}
}
private void OnTriggerEnter(Collider other)
{
//몬스터 레이어 검사.
if(other.gameObject.layer == LayerMask.NameToLayer("Monster"))
{
MonsterStates monsterStates = other.gameObject.GetComponent<MonsterStates>();
int currentHP = monsterStates.getMonsterHP();
monsterStates.setMonsterHP(currentHP - ballPower);
Destroy(gameObject);
Debug.Log("Monster Hit");
}
}
}
위 코드가 Bullet에 부착되는 Ball 스크립트입니다.
AddForce로 포탄을 날리고, 일정시간(1.5초)이 지나면 객체가 스스로 파괴됩니다.
이후, 아래 방향으로 레이를 쏘아서 Ground나 EnemyField태그면 0.7f초 이후에 파괴합니다.
BallPower와 관련된 부분은
ballPower = obj.GetComponent<Cannon>().BallPowerGetter();
입니다.
Cannon오브젝트에서 BallPowerGetter를 호출하여, 공격력을 변경합니다.
게임을 처음 시작하여 즐기는데, 게임의 룰을 숙지하는 건 중요합니다.
조작키를 비롯한 규칙을 모르고 게임을 시작하면, 그 재미가 반감되기 때문입니다.
그렇기 때문에,저는 UI씬에서 HowToGame 버튼을 눌러, 메뉴얼 씬으로 들어갈 수 있게 만들어 보았습니다.
설계
- 게임 화면을 출력하여 보여줍니다.
- 버튼을 누르면, 다음 이미지와 함께, 언더라인으로 강조선을 나타내 주요 기능을 가리킵니다.
- 스크린샷 하단부에 TEXT를 넣어, 메시지를 출력합니다.

HowToPlay 버튼을 누르면 메뉴얼 씬으로 들어갈 수 있습니다.

오른쪽의 하이어라키를 보시면, IngameImage1과 2를 확인할 수 있습니다.
인게임 화면을 촬영한 스프라이트 이미지입니다.
ExplainText는 이미지 하단부의 문자입니다.
UnderLine은 좌측 상단부에 위치한 붉은색 강조선입니다.
Pos1~5는 UnderLine이 이동할 위치를 선점해둔 빈게임 오브젝트입니다.
using UnityEngine;
using UnityEngine.UI;
public class ExplainManager : MonoBehaviour
{
[SerializeField] Image screenShot1;
[SerializeField] Image screenShot2;
[SerializeField] Text ExplainText;
[SerializeField] Button switchButton;
[SerializeField] Image underLine;
[SerializeField] Transform[] ImagePos;
int switchCount = 0;
int switchMaxCount = 5;
float newWidthSize = 1900f;
void Start()
{
switchButton.onClick.AddListener(SwitchText);
underLine.enabled= false;
}
// Update is called once per frame
void SwitchText()
{
switch(switchCount)
{
case 0:
underLine.enabled= true;
ExplainText.text = "붉은 선으로 표시된 곳은 미니맵입니다.\n플레이어와 오브젝트, 큐브의 위치를 확인할 수 있습니다.";
break;
case 1:
underLine.transform.position = ImagePos[switchCount - 1].position;
ExplainText.text = "다음은 필드 내에 존재하는 큐브의 숫자입니다.\n20마리 이상이 필드에 있다면 게임은 패배하게 됩니다.";
break;
case 2:
underLine.transform.position = ImagePos[switchCount - 1].position;
ExplainText.text = "큐브를 파괴하면 골드를 얻을 수 있습니다.";
break;
case 3:
underLine.transform.position = ImagePos[switchCount - 1].position;
ExplainText.text = "위 버튼을 클릭하면, 상점 창이 오픈됩니다.\n골드를 소모하여, 캐릭터나 캐논을 강화할 수 있습니다.";
break;
case 4:
screenShot1.enabled= false;
screenShot2.gameObject.SetActive(true);
underLine.enabled= false;
ExplainText.text = "상점 창은 다음과 같이 되어있습니다. \n상점은 알트키를 눌러, 커서를 꺼낸 후에 들어올 수 있습니다.";
break;
case 5:
underLine.transform.position = ImagePos[switchCount - 2].position;
Vector2 size = underLine.rectTransform.sizeDelta;
size.x = newWidthSize;
underLine.rectTransform.sizeDelta = size;
underLine.enabled = true;
ExplainText.text = "플레이어 공격력 강화 부분입니다.\n강화할 때마다, 플레이어의 공격력이 상승되며, 요구 골드량도 증가합니다.";
break;
case 6:
underLine.transform.position = ImagePos[switchCount - 2].position;
ExplainText.text = "캐논 공격력 강화 부분입니다.\n강화할 때마다, 캐논의 공격력이 상승되며, 요구 골드량도 증가합니다.";
break;
case 7:
underLine.enabled = false;
ExplainText.text = "기본적인 게임 조작법은 WASD키와 마우스를 이용합니다.\n쉬프트를 이용하여 달리기도 가능합니다.";
break;
case 8:
ExplainText.text = "마우스 오른쪽 버튼은 플레이어 공격입니다.\n마우스 왼쪽 버튼은 캐논 설치입니다.";
break;
case 9:
ExplainText.text = "승리 조건은 모든 스테이지를 클리어하고 보스 큐브를 잡는 것입니다.\n보스큐브는 한 싸이클 내에 잡아야 하며, 실패하면 패배합니다.";
break;
case 10:
SceneLoader._instance._LoadScene("UI_Scene");
break;
}
switchCount++;
}
}
메뉴얼 씬을 관리하는 ExplainManager입니다.
직렬화를 이용하여 필요한 컴포넌트들을 받습니다.
ImagePos는 배열을 이용하여, 위치들을 받습니다.
스위치 문을 이용하여, 버튼의 클릭 횟수를 카운트하여 메뉴얼의 진행흐름을 제어했습니다.
UnderLein을 SetActive로 조정하여, 강조선의 유무를 표현했습니다.
newWidthSize 변수를 이용해, 언더라인의 길이를 바꾸는 코드도 구현했습니다.
하나의 게임을 만들면서, 많은 것을 배웠다고 생각합니다.
에러를 잡을 때는 어떤 방식으로 문제를 해결해야하는지 고심했었고, 개발자 커뮤니티를 찾아보기도 했습니다.
또한, 기능을 어떤식으로 구현해야할지도 많은 고민을 했었습니다.
최종적으로 완성한 게임을 플레이하면서, 뿌듯함과 함께 자신감도 느꼈습니다.
앞으로도, 더 많은 게임을 만들면서 더 높이 성장하는 개발자가 될 것입니다.
완성한 프로젝트는 깃허브의 master 브랜치에 올려두었습니다.