안녕하세요. 나위입니다.
STUDIO NAWE의 첫 타이틀. TAP FLOAT의 개발 일기를 찾아주셔서 감사합니다.
이전 포스팅에서 이어지는 내용이오니, 못 보신 분들은 아래 링크로 앞 내용을 먼저 보고 오시는 게 좋겠어요!
그럼, 시작합니다!
글을 쓰다보니 문득, 이 개발 일기는 유니티3D를 알려주는 것인가? 하는 생각이 들었습니다.
개발 일기니까 개발 과정을 다루는 것이 맞긴 하겠지만...
이보다는 '왜 이렇게 만든 것인지'를 설명하는 것이 더 맞는건가? 하는 의문이 이어지네요.
...으음.
고민이 들긴 했지만 별 수 없습니다.
누군가 내게 다가와서 '게임을 왜 이렇게 만들었나요?'라고 물어본다면, 할 말이 정해져 있거든요.
"제가.. 지금은 이렇게밖에 못만들어서요.."
"그나마 제가 할 수 있는 한에서는 재미있게 만들어 본 거에요. 헤헤..."
지금까지 조금은 슬픈 이야기였습니다.
각설하고, 지난 포스팅에 이어서 Enemy를 뿌려댈 친구인 Spawner를 만들어 볼게요!
Spawner.
뭘 하는 놈이냐면, 의미 그대로 '소환하는 녀석'이죠.
이 녀석은 플레이어에게 보이지 않도록 메인 카메라의 상단에 위치하게 될 거에요.
그리고 그 자리에서 '아래로 내려오면서 회전하는 Enemy'를 계속 소환하는 역할을 하게 될 겁니다.
우선, 빈 오브젝트를 하나 만들어 줍니다.
Hierarchy에서 마우스 우클릭 → Create Empty를 눌러 빈 오브젝트를 생성해 주세요.
그런 뒤, 오브젝트의 이름을 Spawner로 지정하고 위치를 카메라의 상단으로 적당히 잡아줄게요.
이렇게요!
이제, 저 Spawner에게 스크립트를 붙여 줄 차례입니다.
스크립트의 내용은 이렇게 될 거에요.
"미리 만들어 둔 Enemy 프리팹들을 지정한 몇몇 좌표 중에서 랜덤하게 생성시켜 줘."
자, 어떻게 저 내용이 만들어지는지를 같이 볼까요?
스크립트 전문은 아래와 같습니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
[SerializeField]
private GameObject[] enemies;
private float[] arrPosX = { -2f, -1f, 0f, 1f, 2f };
[SerializeField]
private float spawnInterval = 2f;
public int intervalEnemy = 10;
public float downSpawnInterval = 0.1f;
void Start()
{
StartEnemyRoutine();
}
void StartEnemyRoutine()
{
StartCoroutine("EnemyRoutien");
}
public void StopEnemyRoutine()
{
StopCoroutine("EnemyRoutien");
}
IEnumerator EnemyRoutien()
{
yield return new WaitForSeconds(1f);
int spawnCount = 0;
while (true)
{
int index = Random.Range(0, enemies.Length);
int posRandom = Random.Range(0, arrPosX.Length);
SpawnEnemy(arrPosX[posRandom], index);
spawnCount++;
if(spawnCount % intervalEnemy == 0)
{
spawnInterval -= downSpawnInterval;
}
yield return new WaitForSeconds(spawnInterval);
}
}
void SpawnEnemy(float posX, int index)
{
Vector3 spawnPos = new Vector3(posX, transform.position.y, transform.position.z);
Instantiate(enemies[index], spawnPos, Quaternion.identity);
}
}
이전까지의 스크립트와는 달리 상당히 복잡해 보이는 모습입니다.
하지만! 하나하나씩 해석해나가보면 그다지 복잡하지 않아요!
우선 변수들의 역할부터 알아보시죠!
[SerializeField]
private GameObject[] enemies;
private float[] arrPosX = { -2f, -1f, 0f, 1f, 2f };
[SerializeField]
private float spawnInterval = 2f;
public int intervalEnemy = 10;
public float downSpawnInterval = 0.1f;
접근제한자로 private를 사용하지만, {SerializeField]를 사용하여 엔진상에서 자유롭게 등록할 수 있도록 한 enemies 변수.
여기에는 int라던지 float, string 등 자료형이 들어가야 할 자리에 자료형 대신 'GameObject[]' 라고 적혀 있어요.
[ ] → 이 모양은 '배열'을 사용하겠다는 의미입니다. 또한, GameObject는 유니티에서 사용하는 게임 오브젝트들을 의미하는 것이에요. 지금 저의 상황에서는 미리 만들어둔 Enemy 프리팹들을 의미합니다.
결국 저건, 미리 만들어 둔 Enemy 프리팹을 enemies라는 이름의 배열로 저장하겠다는 이야기에요.
게임 상에서 몇 개의 Enemy를 사용하든지간에, 그만큼 배열의 공간을 늘리고 또 등록함으로서 자유롭게 쓰려는 의도입니다.
enemies 변수 바로 아래에 있는 float 자료형의 arrPosX 변수 또한 배열로 만들어져 있는데, 안에 저장된 값으로 -2, 1, 0, 1, 2까지. 총 5개의 값이 들어가 있죠.
이처럼 배열을 사용하면 하나의 변수 이름에 여러 값을 저장할 수 있습니다.
배열에 저장된 값을 사용하는 법은 간단합니다. arrPosX를 예로 들면, 각 데이터는 아래와 같이 불러낼 수 있습니다.
arrPosX[0] = -2
arrPosX[1] = -1
arrPosX[2] = 0
arrPosX[3] = 1
arrPosX[4] = 2
저렇게 [ ] 안에 데이터가 저장된 순서를 (0부터 시작해요!) 넣는 걸로 간단하게 값을 불러낼 수 있습니다.
...으음.
사실 지금 제가 작성하는 내용들은 너무나 기초적인 것들이라... 진짜(?) 프로그래머 분들의 입장에서는 너무나 가소로운 설명이겠지만, 저는 저 모든 것들을 하나하나 배우고 익혀가며 개발을 했답니다 헤헤.
이런 나 자신 칭찬해...(쓰담쓰담)
어쨌든, 배열이고 자시고 변수들의 역할을 설명하자면 아래와 같습니다.
- enemies = 프리팹으로 저장한 Enemy를 배열로 등록하는 변수.
- arrPosX = Enemy를 스폰시킬 X축 위치 값을 배열로 등록하는 변수.
- spawnInterval = Enemy를 다시 스폰시키기까지 필요한 대기 시간.
- IntervalEnemy = spawnInterval이 downSpawnInterval만큼 줄어들기까지 필요한 Enemy 스폰 횟수.
- downSpawnInterval = Enemy가 IntervalEnemy의 수만큼 스폰된 이후에 spawnInterval에서 줄어드는 값.
IntervalEnemy와 downSpawnInterval이 조금 복잡해 보일 수 있겠다 싶어요.
적절한 이름을 찾질 못해서 대충 저렇게 이름을 적긴 했는데, 저걸 쓴 이유는 간단합니다. 게임을 더 재밌게 하려는 이유였지요.
게임을 처음 시작했을 때는 Enemy가 여유있게 떨어지다가, 점점 Enemy가 떨어지는 간격이 짧아지면서 게임의 난이도를 점점 높이려는 의도였습니다.
다른 게임에서는 스테이지나 레벨 등으로 이름붙여지는 그 요소를 저 두 변수로 해결한 셈이죠.
다음 내용은 간단합니다. Start 함수인데, 이건 게임이 시작하면 같이 실행되는 것이죠.
void Start()
{
StartEnemyRoutine();
}
안의 내용은 아주 간단합니다.
"StartEnemyRoutine 함수를 실행시켜줘."
진짜 간단하죠?
그럼, StartEnemyRoution 함수를 살펴봅시다.
void StartEnemyRoutine()
{
StartCoroutine("EnemyRoutien");
}
StartEnemyRoution안의 내용 또한 아주 간단하죠.
"EnemyRoutine 이라는 코루틴 함수를 실행시켜줘."
진짜 간단하죠?
그런데 코루틴? 코루틴이 뭐지?? 의아할 수 있습니다.
하지만 의문은 일단 제쳐두고... 그 바로 아래에 있는 StopEnemyRoutine 함수를 살펴볼게요.
public void StopEnemyRoutine()
{
StopCoroutine("EnemyRoutien");
}
보시다시피 간단합니다. 내용은 이렇네요.
"EnemyRoutine 이라는 코루틴 함수를 멈춰줘."
함수의 이름이 참으로 직관적입니다.
StartEnemyRoution은 EnemyRoutine이라는 이름의 코루틴을 실행시켜달라는 거고,
StopEnemyRoutine은 EnemyRoutine이라는 이름의 코루틴을 멈춰달라는 거니까요.
이처럼 프로그래밍을 할 때는 최대한 직관적인 이름을 붙이는 게 중요한 것 같습니다.
그래야 나중에 헷갈리는 일이 그나마 없는 것 같아요.
그런데 아까부터 자꾸 등장하는 코루틴이 도대체 뭐길래 실행시켜달랬다가 멈춰달랬다가 하는 걸까요?
오래 끌 것 없이 바로 구경해봅시다.
IEnumerator EnemyRoutien()
{
yield return new WaitForSeconds(1f);
int spawnCount = 0;
while (true)
{
int index = Random.Range(0, enemies.Length);
int posRandom = Random.Range(0, arrPosX.Length);
SpawnEnemy(arrPosX[posRandom], index);
spawnCount++;
if(spawnCount % intervalEnemy == 0)
{
spawnInterval -= downSpawnInterval;
}
yield return new WaitForSeconds(spawnInterval);
}
}
코루틴이란, 제가 간신히 이해한 바에 따르면...
모든 코드는 위에서부터 아래 방향으로 순차적으로 실행되는데, 코루틴을 쓰면 코드를 읽어내려가다가 '잠시 멈추고' 다른 걸 처리하는 거라고 합니다.
요컨데 '직렬로 처리되는 코드를 병렬로 처리하게 만드는 것'이라 보면 되겠는데...
솔직히 잘 모르겠습니다. 헤헤..
그냥, 쿨타임 같은 걸 만들 때 사용하는 거라고 생각하고 있어요.
왜 게임에서 스킬을 사용하면 '재사용 대기 시간'동안 스킬을 못쓰잖아요? 여기서 '재사용 대기 시간'을 돌리는 방식이 바로 코루틴을 써서 만드는 것 정도로 이해하고 있습니다.
그래서 저 코드를 해석하면, 대충 이런 거에요.
yield return new WaitForSeconds(1f);
"일단 1초 있다가 실행해."
int spawnCount = 0;
"spawnCount라는 변수는 0부터 시작하는 정수야. 이 함수 안에서만 쓸 거야."
while (true)
{
int index = Random.Range(0, enemies.Length);
int posRandom = Random.Range(0, arrPosX.Length);
SpawnEnemy(arrPosX[posRandom], index);
spawnCount++;
if(spawnCount % intervalEnemy == 0)
{
spawnInterval -= downSpawnInterval;
}
yield return new WaitForSeconds(spawnInterval);
}
"무한하게 반복할 건데, 뭘 반복할 거냐면..."
"index라는 값을 만들거야. 그건 enemies 배열에 저장된 값들 중 하나를 랜덤으로 뽑을 거야."
"posRandom이라는 값도 만들거야. 그건 arrPosX 배열에 저장된 값들 중 하나를 랜덤으로 뽑을 거야."
"그런 다음에 SpawnEnemy함수를 호출할 거야. 그 함수에서 arrPosX배열의 값들 중 랜덤한 값 하나와, enemies 배열의 값들 중 랜덤한 하나를 사용할 거야."
"그리고 spawnCount 값을 1씩 늘릴 거야."
"만약, 1씩 늘어나던 spawnCount 값이 intervalEnemy의 배수가 되면..."
"spawnInterval 값은 downSpawnInterval 값만큼 빠지게 될 거야."
"그리고 spawnInterval만큼 대기해."
이게 도대체 무슨 소리인가 할 수도 있지만...
찬찬히 읽어보면 이해될 거에요! 이게 지금의 제가 할 수 있는 가장 쉬운 설명이에요 ㅠ
그리고, SpawnEnemy 함수가 뒤이어집니다.
앞서 봤던 EnemyRoutine 안에서 불러내어지는 함수이기도 하죠. 내용은 이러합니다.
void SpawnEnemy(float posX, int index)
{
Vector3 spawnPos = new Vector3(posX, transform.position.y, transform.position.z);
Instantiate(enemies[index], spawnPos, Quaternion.identity);
}
"SpawnEnemy 함수를 쓸려면 posX라는 실수값과 index라는 정수값이 필요해."
"spawnPos라는 Vector3 자료를 만들거야. x / y / z 값이 들어갈 건데, x는 함수를 쓸 때 받는 posX값이, y는 현재 이 오브젝트의 y축 위치값이, z도 현재 이 오브젝트의 z축 위치값이 들어갈거야."
"인스턴트 오브젝트를 생성할 거야. enemies중에서 함수를 쓸 때 받는 index의 오브젝트를 생성해. 생성할 위치는 앞서 만든 spawnPos 위치로 하고, 회전값은 자기 자신의 값을 가지게 될 거야."
나름 열심히 해석 + 설명을 붙이긴 했는데...
간추려 말하자면 이미 등록해 둔 Enemy 중에서 랜덤한 녀석을 랜덤한 위치에서 생성해라. 라는 것이에요.
이렇게...
Spawner 스크립트가 완료되었습니다.
이제 마무리를 위해 엔진으로 돌아와서 몇가지 설정을 해볼게요.
Spawner 스크립트를 보면 아래의 이미지처럼 배열을 만들 수 있게 될 텐데, 여기 내가 등록할 Enemy 프리팹 만큼 수를 입력하시면 됩니다.
그러면 Element 0이라는 이름으로 빈 슬롯이 생성될 텐데, 거기에 만들어 둔 프리팹을 드래그 & 드롭으로 넣어줍니다.
이거면 전부 다 끝났어요!
이제 모든게 끝났다고 생각할 수도 있지만...!
아직 하나가 남았습니다.
지난 포스팅에서 '프리팹 = Ctrl + C와 같다.'라고 제가 이야길 했었죠?
그런데 프리팹을 미리 만들어 둔 뒤, 스크립트를 작성해서 붙여버리는 만행을 저질렀었습니다.
한마디로 복사가 잘 안된 상황인데, 유니티에서는 그런 상황을 타게하기 위한 기능이 있죠. 바로 'Apply'라는 거에요!
만들어 둔 프리팹을 선택한 뒤, Inspector창에서 Overrides를 선택하면 나타나는 Apply All버튼을 누르기만 하면...
지금까지 해당 프리팹에 수정된 내용이 일괄적으로 적용되는 것입니다.
이제 게임을 실행하면 우리가 작업했던 내용이 정상적으로 나오는 것을 확인할 수 있어요.
뿌듯뿌듯합니다.
뭔가 벌써 게임을 다 만든 것 같은 기분이 드네요.
하지만... 여기서 끝낼 순 없죠! 아직 갈 길이 멉니다.
다음에는 저렇게 생성된 Enemy와 플레이어가 서로 충돌하도록, 그래서 플레이어가 죽을 수 있도록 만들어야겠어요.
<다음 편에 계속>
'도전! N잡러! > 도전! 게임 개발!' 카테고리의 다른 글
TAP FLOAT 개발 일기 #10 (98) | 2023.09.25 |
---|---|
TAP FLOAT 개발 일기 #9 (82) | 2023.09.23 |
TAP FLOAT 개발 일기 #7 (76) | 2023.09.20 |
TAP FLOAT 개발 일기 #6 (81) | 2023.09.19 |
TAP FLOAT 개발 일기 #5 (60) | 2023.09.17 |
댓글