[게임만들기] 플랫포머의 꽃, 대망의 보스 만들기

기획기사 | 윤서호 기자 | 댓글: 13개 |



게임을 플레이할 때 가장 분석이 필요한 시점이라면, 아마 보스를 공략할 때일 겁니다. 모든 보스가 다 그렇진 않겠지만, 대체로 그간 상대해왔던 잡몹보다 다채로운 패턴을 선보이는데다가 잘 죽지도 않으니까요. 플레이어보다 대체로 몇 배 더 많은 체력과 더욱 강력한 공격력으로 덮쳐오는 건 덤이고요.

게임만들기를 하면서 항상 느끼는 것이지만 기계가 '알아서' 해주는 건 없습니다. 다 일일이 명확히 지시하지 않으면 그냥 가만히 있거나 오류를 내는 게 일상이거든요. 물론 이건 게임만 그런 건 아니고 컴퓨터를 통해서 하는 모든 작업이 대체로 그렇긴 합니다. 다만 게임을 플레이할 때는 종종 어려운 보스에게 농락당해보거나 좌절해본 경험이 있다보니, 게임은 마치 그렇지 않은 것처럼 감정이입을 할 때가 있는 것이죠.

아무튼 그렇게 '여러 가지 패턴을 갖고 플레이어를 괴롭히는 보스'를 반대로 생각해보면, 그 항목을 일일이 개발자가 다 설계하고 만들어야 한다는 뜻입니다. 기획의 중요성을 여기서 다시 한 번 느끼긴 합니다만, 이미 되돌리기엔 늦었죠. 이불킥각이긴 합니다만 이번 6월로 신청 마감인 다양한 인디 게임쇼에 신청서를 쓸 작정이기 때문입니다.

그런 만큼, 여태까지 만들어왔던 코드들을 최대한 응용하고 새롭게 재설계하는 방식으로 보스를 구현할까합니다. 이미 보스는 기획 단계에서부터 정해져있었고, 남은 건 그걸 어떻게 최대한 만들어내느냐에 달려있으니까요. 하지만 문제는 다른 곳에 있었습니다.

게임만들기 기사 모아보기
[게임만들기 ①] 1인 게임 개발에 한 번 도전해보았습니다 - 예고 및 기획 단계편
[게임만들기 ②] 1인 게임 개발에 한 번 도전해보았습니다 - 캐릭터 디자인편
[게임만들기 ③] 1인 게임 개발에 한 번 도전해보았습니다 - 중간 점검 및 기획 수정편
[게임만들기 ④] 1인 게임 개발에 한 번 도전해보았습니다 - 배경 및 스테이지 설계편
[게임만들기 ⑤] 1인 게임 개발에 한 번 도전해보았습니다 - 적 캐릭터 제작편
[게임만들기 ⑥] 1인 게임 개발에 한 번 도전해보았습니다 - 적 만들기 심화편
[게임만들기] 1인 게임 개발 도전, 지금도 하고 있나요?
[게임만들기] 1인 게임 개발의 또다른 난관, 엔진 업데이트에 대처하는 자세
[게임만들기] 1인 게임 개발 중간 점검, 데모 빌드 만들었습니다



■ 상어는 생각보다 만들기 어려웠다




사실 보스몹으로 상어를 고른 이유는 딱히 없었습니다. 개인적으로 상어를 굉장히 좋아하고, 해양 다큐멘터리도 많이 보다보니 "이런 거 만들어볼까?" 싶어서 만든 것이죠. 무엇보다도 심플하면서도 그럴듯하게 그리기 쉽기도 했죠.

그렇지만 애니메이션을 넣을 때는 문제가 달라집니다. 특히 헤엄치는 모션을 애니메이션으로 나타내려면 2D로는 좀 귀찮아집니다. 꼬리를 좌우로 흔들면서 헤엄치니까요. 사실 상어 말고도 대다수의 물고기들이 똑같은 문제이긴 합니다. 대부분 물고기의 꼬리지느러미나 지느러미가 세로로 나있으니까요. 만약 3D였다면 그냥 애니메이션을 넣으면 되지만, 그림을 다 일일이 그리지 못해서 2D 본 애니메이션으로 어지간한 걸 다 처리할 생각이었던 만큼 "어떻게 해야 그럴듯하게 만들 수 있을까?" 고민할 수밖에 없었죠.



▲ 상어는 꼬리를 좌우로 흔드는데



▲ 그 좌우로 움직이는 느낌을 본 애니메이션으로 표현하려면 어떻게 해야 할까요?

기본적인 동작뿐만 아니라 이펙트도 문제가 발생했습니다. 상어하면 떠오르는 이미지는 거대한 이빨과 입뿐만 아니라 파도 사이로 드러나는 등지느러미, 먹이를 물고 솟구칠 때 일어나는 물보라 등도 있죠. 그걸 패턴에 도입시켜보려고 생각해보는데, 물을 대체 어떻게 표현해야 그럴듯할지 고민이 들었습니다.

사실 게임 내에서 유체를 표현하는 작업은 파고들면 파고들수록 난감한 게 한두 가지가 아닙니다. 최근 화자되고 있는 실시간 레이트레이싱도 연관이 되어있죠. 흐르는 물에 실시간으로 반사, 굴절되는 빛을 연산해서 표현하는 것도 포함이 되어있으니까요. 그 정도가 아니고 단순히 흐르는 물이라는 것만 표현하고 싶다면 물 텍스쳐를 입혀서 애니메이션을 주는 식으로 처리가 가능합니다. 그렇지만 물방울이 튀거나 파도가 치는 그런 것을 묘사하는 것은 초보 단계에선 빨리 처리하기가 어려웠죠.



▲ 그냥 점프하는 모션까지는 어떻게 구현할 수 있어도, 실시간으로 튀는 저 물방울까지는 도저히...

만약에 어설프더라도 3D였다거나, 아트가 완전히 캐주얼하다거나 혹은 좀 더 정교했다면 얘기는 달랐을 겁니다. 시중에 파는 애셋과 얼추 맞춰볼 수 있거든요. 그런데 예전부터 언급했다시피 제 되도 않은 아트가 기반이다보니 시중에 파는 애셋과 거진 따로 노는 감이 들었습니다. 그래서 시중에 나온 아트 관련 애셋은 거의 안 쓰고 제가 어찌저찌 만들어보는 식으로 구현하고 있는 중입니다. 그 와중에 복병을 만난 셈이죠.

일단 임시방편으로 물살 스프라이트를 그려내고 스케일에 변화를 주는 식으로 애니메이션을 만들었지만, 아마 이건 나중에 스프라이트 시트를 새로 그려서 애니메이션을 새로 만들게 될 것 같습니다. 아무래도 2D 관련해서 여러 기능이 생기긴 했지만, 가장 자연스럽게 애니메이션을 주려면 결국 직접 그려내야 한다는 점을 여실히 느끼고 있으니까요.



▲ 일단은 임시방편으로 에셋을 만들고



▲ ...폼은 안 나지만 일단은 그냥 허우적거리는 것보단 나을...까요?



■ 보스의 일반적인 패턴 유형 3가지: 페이즈형, 랜덤형, 조건형



▲ 저 엉성해보이는 상어로 어찌저찌 다양한 패턴들을 설계했습니다.

만들고 있는 게임이 복잡하면 복잡할수록 필요한 것도 많고 거쳐야 할 단계도 많습니다. 그렇지만 기본적으로 애셋을 만들고, 그 애셋을 엔진에 넣고, 애셋을 가동할 스크립트와 애니메이터 그리고 다른 부수적인 것들을 조합하게 되죠. 그렇게 해서 만들어진 것이 잘 돌아가나 빌드 추출 전 엔진 내에서 테스트하고, 빌드로 추출해서 테스트해보는 식입니다. 자동차를 예로 든다면 엔진과 내장재, 외장재 등을 따로 만든 뒤에 결합하고 내부에서 테스트해본 뒤에 외부 주행도 테스트하는 식이랄까요?

썩 만족스럽지는 않지만 어쨌든 보스에 필요한 애셋은 다 만들었으니, 남은 건 보스의 패턴을 구현하는 일입니다. 그러려면 일반적으로 말하는 보스 캐릭터들의 패턴 조건이 무엇인가 생각해볼 필요가 있겠죠.

가장 먼저 체력에 따라 패턴이 달라지는, 이른바 페이즈형이 있겠죠. 대다수의 보스 공략을 살펴보면 체력에 따라 1페이즈, 2페이즈, 3페이즈로 나뉘고 패턴도 그에 맞춰서 달라지니까요. 그 한 페이즈 안에서도 여러 패턴이 있는데, 살펴보면 조건이 랜덤하게 실행되거나 혹은 특정 조건에 따라서 구현되는 패턴이 있습니다. 요는 1) 체력에 따라서 달라지는 패턴 구조 2) (체력 조건을 따르긴 하지만) 랜덤하게 나오는 패턴 3) 특정 조건에 따라 구현되는 패턴 최소 이 세 가지를 구현해내야 한다는 점이죠.









▲ 체력 상황 혹은 특정 조건에 따라, 아니면 랜덤하게 패턴이 나오는 게 국룰이죠

이미 기획 단계에서 보스의 패턴은 대충 생각해두긴 했지만, 이 세 가지 틀에 맞춰서 다시 정립해야했습니다. 그러지 않으면 어느 한 패턴이 나와야 할 때 다른 것이 동시에 나오는 불상사가 발생할 수 있기 때문이죠. 기계라는 녀석은 정말 눈치가 없어서, "대충 알겠지?"라고 말하면 절대로 못 알아듣습니다. 조건을 명확히 제시하지 않으면 "Null Reference Exception" 같은 여러 오류만 일으키죠. 머신 러닝이나 AI 심화 기능까지는 써보지 않아서 어떤지 모르겠습니다만, 적어도 기초 단계에선 "기계 = 시키는 것만 한다, 무엇 하나라도 빠지면 오류를 낸다. 그런데 그걸 말 그대로 일일이 다 말해줘야 한다"라고 생각하는 게 편한 것 같습니다.



■ 패턴의 뼈대를 만들기 위해 쓴 공식 1 - 데이터형 변환과 파라미터, Coroutine



▲ 결론은 결국 코딩인데, 어떤 방법으로 접근하냐가 문제였습니다

어쨌든 앞서 언급한 세 가지의 뼈대를 구축하기 위해선 무엇을 해야 할까? 이게 가장 큰 문제일 겁니다. "체력에 따라서 구현되는 패턴도 달라져야 하는데, 그게 랜덤하게 나오거나 혹은 특정 조건을 지켜야만 나온다"라는 걸 초보 단계에선 도저히 코딩해내기 어려워보이거든요.

앞에서 업계에서도 쓰지 않은 임시방편적인 용어를 써서 세 가지 조건을 나눈 이유는 간단합니다. 분할해두고 하나하나 처리해나가는 게 쉽기 때문이죠. 그렇게 분할하면 '체력에 따라서'라는 조건과 '랜덤하게'라는 조건, 그리고 '특정 조건을 지켜야만 한다'라는 조건을 따로따로 만들어낼 수는 있습니다.

가장 먼저 '체력에 따라'라는 특정 패턴을 연출하는 건 의외로 간단합니다. 이미 지금 단계에 왔다면, 캐릭터들의 체력과 데미지를 구현해낸 시점이죠. 그 스크립트에 체력과 관련한 조건을 주면 스크립트 내에서는 처리가 될 겁니다.



▲ 지난 기사에 나온 EnemyStats 코드를 참조해서 새로 만들긴 했지만, 기본은 동일합니다
(클릭하면 확대됩니다)

그렇지만 특정 동작을 하게 만들려면 어떻게 해야할까? 라는 의문이 들 수도 있죠. 특히나 다수의 예제를 보면 체력이나 데미지는 int값으로 설정하곤 합니다. 그래서 갓 입문한 단계에선, 특히 C#를 예전에 접하지 않고 게임 만들기 수업 때 벼락치기로 들었다면 애니메이션 파라미터를 못 쓴다고 생각하기 쉽습니다. SetInt가 있긴 하지만 SetInt를 활용하는 예제는 많이 없어서 쓰기가 애매하죠. 그래서 SetFloat을 쓰고 싶은데 int값은 인식을 못하죠.

int, float를 모르는 분들을 위해서 간단히 설명하자면, 데이터의 종류를 식별하는 분류라고 보면 되겠습니다. int와 float의 차이를 일단 설명하자면 int는 소수점 아래의 숫자는 표현 못합니다. float은 소수점 이하 6자리까지 표현 가능하죠. 그리고 컴퓨터는 이 분류가 다른 것들을 혼합해서 연산하거나 처리할 수 없습니다. 어느 하나에 맞춰서 통일해주지 않는 한 말이죠.

데이터형을 변환할 때는 주로 데이터형의 크기가 작은 것을 크기가 큰 분류로 변환시켜줍니다. 큰 걸 작은 걸로 압축 변환할 때는 일부 손실이 있을 수 있기 때문이죠. 일단 int는 float보다 작은 데이터형이니 이를 float으로 바꿔도 큰 문제는 없습니다. 다만 체력 자체를 float형으로 바꾸는 게 아니라, int값인 체력 수치를 float형으로 바꾼 값을 나타내줄 변수를 또 하나 만들어야 합니다. 목표는 체력 수치를 float 값으로 표현하는 게 아니라, 그 값을 float형으로 바꾼 수치를 애니메이터에 따로 적용시키는 것이니까요. 그 파라미터를 새로 만들기 위해서 float 변수를 하나 더 만들어서 공식에 도입한다고 생각하면 되겠습니다.



▲ 그림으로 표현하자면 대략 이렇습니다



▲ 체력에 따른 패턴 변화를 구현할 조건 하나는 일단 완성

그 변수를 만든 뒤에는 SetFloat()에 도입하고, 애니메이터 파라미터에 Float을 만들고 SetFloat() 안에 ""에 적은 그대로 이름붙이면 애니메이션 상태를 바꾸는 조건으로 작동하게 됩니다. Float형 파라미터를 쓰면 어느 특정 수 초과(greater), 미만(less)로 조건을 특정할 수 있는데, 예를 들어서 체력 600 이하일 때 적이 특정 패턴을 사용하게 하고 싶다면 less 601이라고 설정하는 식이죠. 여기에 less 600이라고 하게 되면 미만, 즉 600은 조건에 포함이 안 되기 때문에 겉돌 수 있다는 점을 유의해야 합니다.

코루틴은 그간 일부러 쓰지 않고 있다가, 한계를 느끼고 이제서야 좀 어떻게든 쓰려고 하고 있습니다. 프로그래머라면 "아니 코루틴 안 쓰고 이걸 만들 생각이었어?"라고 의아해하실지 모르지만, 사실 코루틴 개념은 제가 프로그래밍 배울 때 가장 애먹었던 부분 중 하나였습니다. 지금도 온전히 이해하고 쓰는 게 아니다보니, "그냥 어쨌든 쓴다"에 가깝습니다.

제가 이해하고 있는 코루틴은 1) 매 프레임마다 불러오는 Update()와는 달리 처리 중간에 대기할 수 있다 2) Time.deltaTime을 쓰지 않고도 지연시키는 게 가능하다 3) 구조는 StartCoroutine("a")으로 호출한 뒤에 ""안에 들어가는 함수는 IEnumerator a(){} 이와 같은 식으로 표현하고, 함수 말미에 yield가 필요하다 4) 협력형 멀티태스킹이 가능하다 정도입니다. 그 중 세 번째와 네 번째는 대충 그렇다고 아는 정도고 어떻게 그런 게 가능한지, 왜 그런 것인지는 정확히 모르는 상황입니다. 그래서 주로 쓰는 용도는 1과 2에 가깝습니다. Random.Range를 무한정 불러오지 않고 지정된 시간 동안에만 호출하도록 쓴 것이죠.



▲ 코루틴은 바로 밑에 설명할 Random.Range를 매번 부르지 않기 위해 쓰고 있는 정도입니다



■ 공식 2 - Random.Range, if문, 애니메이터에서 이벤트 처리하기




Random.Range는 말 그대로 랜덤한 무언가를 만들어내기 위해 쓰는 코드입니다. 예제 양식은 대체로 float a = Random.Range(0, 10)이고, 해석하자면 float a는 0에서부터 9까지 중 아무 숫자의 값이 나온다는 의미죠. 마치 주사위 같다고 할까요.

주사위 같다고 한 의미는, 앞의 예제를 보다시피 Random.Range의 괄호 안에는 float형만 들어갈 수 있습니다. 숫자 외에 다른 건 못 들어간다는 뜻이죠. 그냥 주사위를 굴려서 어떤 눈이 나왔다는 것만 표현한 셈입니다. 그 다음에는 마치 D&D나, 주사위 게임처럼 주사위에 어떤 눈이 나왔는지에 따라, 혹은 그래서 몇 칸 갔을 때 무슨 일이 발생하는지를 개발자가 설명해줘야 하는 것이죠. 그렇지 않으면 기계는 "인벤이 정확하게 보증합니다. 주사위가 98 나왔습니다!!"라고만 말하니까요. 그 주사위값이 게임에서 무슨 의미를 갖는지는, 그 게임을 만드는 사람이 정하게 되는 셈이랄까요.

앞서 코루틴을 쓴 이유가 Random.Range를 무한정 부르지 않기 위해서라고 했는데, 만일 Update()에 별 조건 없이 쓰면 매 프레임마다 주사위를 굴리게 됩니다. 매번 입력되는 수치가 계속 바뀌게 되면 그걸 컨트롤하는 것도 까다롭고 부하가 걸리기 때문에 그걸 막기 위해서 일단은 코루틴을 쓴 셈이죠.

어쨌든 주사위를 만들었으니 그 주사위에 나온 눈에 따라서 무슨 동작을 실행할지를 설계해야겠죠. 이를 설계하는 가장 기본이 if문입니다. 그 말 그대로 "만약 ~라면"이라는 조건을 달아주는 문장이죠. 다만, 이 안에 들어가는 데이터형은 Boolean형 혹은 비교연산자(<, >, <=, >=, ==, != 등)로 표현된 것들만 가능합니다.

만약 주사위 눈이 1이 나왔을 때 어떤 특정 동작(이 함수를 일단 Rise라고 임시로 붙여두겠습니다. 영어인 이유는, 프로그래밍 언어는 영어밖에 인식 못합니다)이 일어나야 한다, 라고 지정한다면 이런 식으로 표현할 수 있겠죠.

void Start()
{
float a = Random.Range(0, 6);
Debug.Log(a);
if (a ==1)
{
Rise();
}
}

void Rise()
{
Debug.Log("Rise");
}

이렇게 해두면 유니티 엔진 하단의 콘솔창에 a값이 나올 것이고, 만약 a가 1일 때는 Rise라는 말도 뜰 겁니다. 그것까지 확인이 되면 Debug.Log("Rise") 대신에 어떤 행동에 필요한 코드들을 적어두면 그것을 실제로 이행하게 됩니다.

주사위로 비유하긴 했지만, Random.Range()안에 들어갈 숫자는 float값의 최소~최대값까지 다 포함이 됩니다. 이론상으로는 3.4*10의 -38승~3.4*10의 38승까지 표현이 가능합니다. 수로 대강 환산해서 표현하자면 10의 38승인 100,000,000,000,000,000,000,000,000,000,000,000,000 이상의 숫자 표현도 충분히 가능하다는 뜻이죠. 물론 지금은 최소값과 최대값에 다 대응하게 짤 필요는 없으니, 그냥 저 범위 안에서 다양하게 확률 표현이 가능하다는 점만 알아두면 될 것 같습니다.

앞서 SetFloat을 활용한 예제를 응용하면, Random.Range로도 애니메이션 전환을 만들어낼 수 있습니다. Float형 파라미터는 초과, 미만으로만 표현되는데 그걸 응용하면 Random.Range로 특정 값이 나올 때만 패턴을 만들 수 있거든요.

숫자랑 영어로 표현하자면 조금 이질적이니까 우선 우리말로 표현을 해보죠. 예를 들어서 1이 나왔을 때 캐릭터의 걷기 애니메이션을 불러오게 하고 싶다고 칩니다. 1이 나오는 것까지는 이미 앞서 말한 방식으로 구현이 가능하니, 이젠 애니메이터에 1이 나왔다고 명령을 내리는 게 필요합니다. 이때 트랜지션에는 Greater 0, Less 2를 입력하면 됩니다. 해석하면 0 초과 2 미만, 즉 정수 범위 내에서는 1을 나타내는 표현인 셈이죠.



▲ 주사위는 계속 굴리고 있고



▲ 그에 따라서 애니메이션이 전환되도록 파라미터 조건을 만들었습니다

이런 방식으로 페이즈형, 랜덤형까지 만들었다면 나머지는 조건형 패턴을 만드는 일인데, 그건 여태까지 만든 적 패턴과 크게 다르지 않았습니다. 특정 사물이 어디에 닿았을 때나, 어디에서 나왔을 때 등의 조건은 OnTriggerEnter2D나 OnTriggerExit2D로 표현했고, 조건은 If 등으로 처리했으니까요. 그리고 특정 이벤트, 예를 들면 잡몹을 뱉어내는 패턴은 입을 벌린 애니메이션 안에 이벤트 키를 주고 거기에 함수를 대입하는 식으로 표현했습니다. 그 특정 동작이 일어날 때마다 이벤트가 발생한다는 조건을 코드로 짤 만큼 능숙하지 않다보니, 그게 좀 더 편하더군요.



▲ 적을 뱉어내는 함수는 저 동작 때 불러오도록 처리했습니다



■ 보스의 약점에만 피격 판정을 넣고 싶다면 - GetComponentInParent 응용




사실 일반몹들 중에도 약점을 공략해야 하는 유형이 있습니다. 처음 이 기획을 시작할 단계에는 그걸 구현할 방법을 미처 못 찾아서 안 쓰고 넘어갔었죠. 그렇지만 보스몹까지 그렇게 설계하려니까 꼬이는 게 한두 가지가 아니었습니다. 재미있냐 없냐 문제보다는 피격 판정 범위가 눈에 보이는 것과 일치하지 않은 문제가 더 컸죠.

사실 이건 통짜 스프라이트를 넣고서 그것만 프레임별로 교체하는 스프라이트 애니메이션 방식이 아니고 이미지에 심어둔 본을 쓰는 본 애니메이션이라서 발생한 일입니다. 본을 한 번 심어두면 스프라이트의 위치나 회전 정보는 전부 다 본에 묶여버리게 되거든요. 즉 본에다가 콜라이더를 안 넣어두면 콜라이더가 이미지의 움직임에 뒤따라가지 않고 따로 논다는 의미입니다. 움직임의 주체는 이미지나 오브젝트가 아니라 본이 되어버리니까요.



▲ 부모 오브젝트에 넣은 콜라이더는



▲ 따로 무얼 하지 않는 이상 본 애니메이션, 스프라이트 애니메이션에 영향을 안 받습니다

그간 만들어둔 간단한 적 정도는 이런 문제가 없었지만 보스는 원체 크고 구조도 좀 더 복잡하다보니 이런 문제가 발생했습니다. 임시 방편으로 보스몹 본체(하이어라키창에서 가장 상위에 있는 것, 여기에 애니메이터 및 주요 스크립트가 다 있습니다)에다가 콜라이더를 줬는데, 앞서 언급한 것처럼 콜라이더가 상어의 움직임을 제대로 못 따라오는 문제가 발생했습니다.

그런 문제도 있기도 하고, 필드 밖으로 보스몹이 나갔을 때 다시 돌아오도록 코드를 만들었는데 그 코드와도 충돌해버려서 다른 방법을 찾아야만 했습니다. 기존에 있던 스탯 관련 코드를 다른 파트에 붙이자니 그 파트에는 애니메이터가 없어서 오류가 났고, 그렇다고 코드 없이 본에 콜라이더를 넣고 태그만 바꿔두면 "Null Reference Exception"이 떠버립니다. 기존의 코드는 주인공이 공격할 때 적과 보스에게 데미지를 준다, 라고 선언하는 코드입니다. 그런데 이번에는 그 발동 조건이 맞아도, 그 데미지 수치를 적용해서 연산해야 하는 코드가 그 자리에 없어서 오류가 뜬 거죠.

이럴 때 유용한 코드가 GetComponentInParent<>()였습니다. 해석을 보면 '그 오브젝트의 부모 오브젝트를 검색해서, 가장 처음에 나타난 부모 오브젝트를 반환한다'고 하는데 요는 부모 오브젝트에 있는 것들을 가져올 수 있다는 뜻입니다. 보통 유니티 엔진에서 코드는 별도의 언급이 없으면 그 오브젝트에 있는 것들을 참고해서 처리하게 됩니다. GetComponentInParent는 그 별도로 언급하는 방법 중 하나인 셈이죠. 그렇게 해서 만든 예제는 다음과 같습니다.

여기서 OnTriggerEnter2D, 즉 isTrigger가 달린 콜라이더가 들어올 때 불러오는 함수를 구축했습니다. 원래는 주인공이 그 데미지에 대한 정보를 갖고 있는데, 그걸 바로 상어의 본체에 있는 체력 함수로 전달할 수가 없었거든요. 다른 방법이 있을지 모르겠지만, 어쨌든 제가 선택한 방법으로는 여의치가 않아서 주인공의 스크립트에서 데미지 정보를 받아온 뒤에 한 단계 더 처리해서 본체에 있는 체력 함수쪽으로 보내는 식으로 설계했습니다.

주인공의 공격 패턴은 크게 세 가지인데, 그 중 하나만 일단에 대응하도록 맞춰둔 상황입니다. 그 외에 다른 공격도 이것을 편집하면 문제없이 구축이 가능합니다. 태그 부분을 교체하고, 해당 파트에 넣어둔 스크립트의 이름만 편집해서 넣으면 가능하니까요.



▲ 원래는 플레이어의 공격 스크립트에 넣었던 것을 없애고



▲ 보스 약점에 넣을 새로운 스크립트를 만들어서 그쪽에서 처리하게 했습니다



■ 추가된 것들 - 코인과 타이머, 아이템 드랍, 체력이 회복되거나 감소하는 필드

▲ 가장 최근까지 만든 것 테스트, 빌드 추출이 안 되서 빌드 테스트는 못해봤습니다

비정기 연재라서 일일이 말씀드리지 못했지만, 그간 여러 가지 변화가 있었습니다. UI도 달았고, 캐릭터의 특수기 그리고 3단 점프까지 구현이 됐습니다. 또한 원래 컨셉에 맞춰서 일정 간격으로 캐릭터의 체력이 줄어드는 필드, 혹은 캐릭터의 체력이 회복되는 필드까지 만들어냈고, 씬의 전환과 대화 씬까지도 만들어냈습니다. "이제야 좀 게임 같네"라고 할 수 있는 단계가 된 셈이죠.

예전에는 유니티에서 인터뷰 및 근황을 들을 겸, 신규 기능에 대한 소개도 들을 겸 겸사겸사 미팅 간 김에 게임을 점검받았습니다. 그렇지만 이번에는 유니티에서 정식으로 인디클리닉을 진행했죠. 제가 배웠을 때와는 씬의 전환 기능이 좀 바뀐 터라 그 부분을 중점적으로 개선했고, 캐릭터 조작 간에 부자연스럽게 떨리는 카메라 워크 등에 대해서도 상담을 받았습니다. 그 외에는 인디클리닉을 받기 전에 구현하거나, 간단하게 조언을 받고 제가 직접 만들어낸 것들입니다.

Scene의 전환은 어떻게 했나, 그리고 캐릭터는 어떻게 씬이 전환되어서도 계속 유지가 되나 이런 것은 저도 클리닉을 받고 나서야 "아 이런 게 있구나"라는 걸 아는 정도기 때문에, 소스코드만 공개하도록 하겠습니다. 타이머는 Time.deltaTime와 Time.timeScale을 응용해서 설정했습니다.

아이템 드랍은 앞서 말씀드린 Random.Range()를 응용했습니다. 적이 죽었을 때 Random.Range()로 임의의 숫자가 나오게 하고, 그 숫자에 따라서 아이템이 나오게 설정한 것이죠. 그 아이템은 스크립트 맨 처음에 public GameObject ~~로 지정하고, 해당 아이템을 나오게 하는 코드인 Instantiate()를 활용했습니다.


GameSceneManagement 소스코드

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class GameSceneManager : MonoBehaviour
{
static int SceneID = -1;
public Text scoreTimer;
private float time =0;

void Start()
{
if (SceneID < 0)
{
Scene scene = SceneManager.GetActiveScene();
SceneID = scene.buildIndex;
}
}

void Update()
{
time += Time.deltaTime;

if(SceneID == 7)
{
Time.timeScale = 0;
}

if (SceneID ==8 )
{
Time.timeScale = 0;
scoreTimer.text = "클리어 타임: " + time;
}

if(SceneID == 9)
{
Time.timeScale = 0;
Destroy(gameObject);
}
}


public void GoToNextStage()
{
SceneID++;
SceneManager.LoadScene(SceneID, LoadSceneMode.Single);
}

public static void RestartStage()
{
Time.timeScale = 0f;
SceneManager.LoadScene(SceneID, LoadSceneMode.Single);
}


public void GoBackToBeginning()
{
SceneID = 2;
SceneManager.LoadScene(SceneID, LoadSceneMode.Single);

time = 0;
}

public void UItoMain()
{
SceneID = 0;
SceneManager.LoadScene(SceneID, LoadSceneMode.Single);
}

}




GameManager 소스코드

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class GameManager : MonoBehaviour
{
public GameObject player;
Vector3 StartingPos;
Quaternion StartingRotate;

void Awake()
{
Time.timeScale = 0f;
}

void Start()
{
var go = GameObject.FindGameObjectWithTag("PlayerSet");
go.SetActive(true);

StartingPos = GameObject.FindGameObjectWithTag("Start").transform.position;
StartingRotate = GameObject.FindGameObjectWithTag("Start").transform.rotation;

StartGame();

}

public void StartGame()
{
Time.timeScale = 1f;

int a = 0;
a++;

StartingPos = new Vector3(StartingPos.x, StartingPos.y + 2f, StartingPos.z);

GameObject player = GameObject.FindGameObjectWithTag("Player");

if (player == null)
Instantiate(player, StartingPos, StartingRotate);
player.transform.position = StartingPos;

Debug.Log(a);
}
}




EnemyStats 소스코드(아이템 드랍과 관련된 부분 포함)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyStats : MonoBehaviour
{

public int startingHealth = 100;
public int currentHealth;
public float flashSpeed = 5f;
public int damage;
Animator anim;
//AudioSource EnemyAudio;
public bool isDead;
public bool isImmune = false;
public float immunityDuration = 3f;
public float immunityTime = 0f;
public GameObject energyDrink;
public GameObject coin;
public GameObject fakeDrink;
public int luckyNumber;

void Awake()
{
anim = GetComponent();
currentHealth = startingHealth;
}

void Update()
{
if (this.isImmune == true)
{
immunityTime = immunityTime + Time.deltaTime;
if (immunityTime >= immunityDuration)
{
this.isImmune = false;
}
}
}

public void TakeDamage(int damage, bool playHitReaction)
{

if (this.isImmune == false)
{
currentHealth -= damage;

if (currentHealth <= 0)
{
Death();
}

else if (playHitReaction == true)
{
PlayHitReaction();
}
}

}

void PlayHitReaction()
{
this.isImmune = true;
this.immunityTime = 0f;
this.gameObject.GetComponent().SetTrigger("Damage");
}

public void Death()
{
anim.SetTrigger("Die");
luckyNumber = Random.Range(1, 11);
Debug.Log("럭키넘버는?" + luckyNumber);


if (1 <= luckyNumber && luckyNumber <= 5)
{
Destroy(gameObject);

}
else if (luckyNumber <= 7)
{
GameObject energyDrinkClone;
energyDrinkClone = Instantiate(energyDrink, gameObject.transform.position, gameObject.transform.rotation) as GameObject;
Destroy(gameObject);
}
else if (luckyNumber <= 9)
{
GameObject coinClone;
coinClone = Instantiate(coin, gameObject.transform.position, gameObject.transform.rotation) as GameObject;
Destroy(gameObject);

}
else if (luckyNumber == 10)
{
GameObject fakeDrinkClone;
fakeDrinkClone = Instantiate(fakeDrink, gameObject.transform.position, gameObject.transform.rotation) as GameObject;
Destroy(gameObject);
}
}
}



여기까지만 놓고 보면 게임은 거의 90% 완성됐지만, 아직 갈 길이 남았습니다. 이펙트나 사운드는 하나도 안 넣은 상태고, 무엇보다도 지금 빌드가 게임을 클리어한 뒤에 다시 플레이할 때 멈춰버리곤 하거든요. 스테이지 1에서 캐릭터가 죽었다가 다시 생성될 때에도 비슷한 오류가 있는 걸로 봐선 이쪽에 뭔가 엉키는 게 있는 것 같긴 한데, 자세한 건 아직 확인이 안 되고 있죠. 거기다가 보스가 플레이어를 뒤쫓게끔 target으로 지정하고 transform으로 대입했는데, 다른 잡몹에 적용했을 때는 잘만 추적되던 코드가 타겟 위치를 (0, 0, 0) 즉 원점으로 자꾸 잡고 있어서 실제 테스트를 못해보고 있는 상황입니다.



▲ 애먼데서 자꾸 허우적거리는 이유는



▲ target으로 지정한 주인공 캐릭터의 좌표를 제대로 못 읽고 있어서 그런 건데...이유가 뭘까요?


원래 계획대로라면 6월 9일까지 마감인 방구석 인디게임쇼나 인디크래프트에도 신청서를 넣으려고 했지만, 이 오류를 과연 극복할 수 있을지가 관건일 것 같습니다. 사실 그런 행사에 제가 감히 신청을 한다는 것 자체가 언어도단이라는 생각이 들고, 괜히 이불킥각 흑역사만 늘리는 게 아닐까 하는 생각이 듭니다. 인디클리닉 때도 그 이야기를 했는데, 되돌아보면 그냥 쥐구멍에 숨고 싶은 생각만 들거든요.



▲ 당면 목표는 온라인으로 진행하는 인디 게임쇼에 출전신청서 제출(=빌드 추출까지 완성)입니다

그렇지만 이 기획을 시작하게 된 계기가 "이거만 알면 뭔가 모자라보이긴 해도 그럴듯한 게임을 만들 수 있다", "게임개발에 도전해보려는 사람들이 무엇이 필요할지 잘 모를 텐데, 이걸 보면 참고가 되지 않을까?"라는 생각에서였습니다. 이걸 보고 실력 있는 지망생들은 "이런 것도 신청하는데 내 게 설마 안 되겠어?"라고 할지 모르고, 기초만 아는 사람들은 "예제에 나오는 기초만 응용했는데 어떻게 게임의 모양새가 나오네?"라고 할지 모르죠.

그보다는 이 엉성한 모양새를 보고 전혀 무관심하게 지나치거나, 아예 신청서를 냈는데 검토 단계에서 떨어질 가능성이 훨씬 높아보이지만요. 그렇지만 이렇게라도 제 자신에게 채찍질을 안 하고 극단적으로 밀어붙이지 않으면 게을러지고 쉽게 좌절하기 때문에 배수진을 쳤다고 봐주시면 될 것 같습니다.

각종 슬럼프 및 일정을 핑계로 길게 끌어오긴 했지만, 여하튼 게임만들기 기획은 이제 어느 덧 끝을 향해 달려가고 있습니다. 아마 다음 회가 이 기획의 마지막이 될 것 같습니다. 그 마지막 기획이 올라오는 시기는 아마 제가 게임 빌드의 마무리를 짓고, 어디 인디 게임 이벤트에 신청서를 낸 다음이 되지 않을까합니다. 다루게 될 내용은 아마 신청서를 쓴 후기, 그리고 포스트모템일 테고요.

전에도 한 차례 소스코드를 공개하긴 했지만, 완결편에서는 1차 완성된 소스 코드를 전체 공개할 예정입니다. 캐릭터나 스토리, 설정은 원래 제가 소설에 쓰려던 것들을 추려내서 썼기 때문에 이걸 어떻게 처리해야할까 아직 고민하는 단계지만, 이번 작품은 1차 완성을 하고 난 뒤엔 되도록이면 모든 걸 무료로 공개하고자 합니다.

그간 제가 게임만들기를 도전하면서 각종 예제를 봤는데, 그걸 보면서 도움도 많이 받았지만 아쉬웠던 부분도 많았거든요. '제 13차원'의 모든 것은, 제가 이걸 어찌저찌 땜빵하면서 채워나간 기록입니다. 전문가가 아니다보니 일부 설명이 미흡한 점도 많고, 제대로 작동하지 않은 부분도 많지만 더 나은 무언가를 만들 때 도움이 되었으면 하는 바람입니다.

댓글

새로고침
새로고침

기사 목록

1 2 3 4 5