no image
워크래프트3 던전 주파형 유즈맵, 용병던전
게임 공략 영상 : https://www.youtube.com/watch?v=8aNHZYGdo1g&t=1738s&pp=ygUM7Jqp67OR642Y7KCE 1. 게임 개요2. 게임 오프닝 영상 & 시놉시스3. 등장 캐릭터4. 던전5. 잘된 점6. 잘못된 점7. 교훈8. 게임 크레딧 영상 1. 게임 개요 워크래프트 3 유즈맵 중 '던전 앤 드래곤'이라는 던전 돌파형 맵이 있었다. 벨트스크롤 액션 형태의 원본 아케이드 게임을 RTS 유즈맵으로 재창작한 게임이었는데, 다수의 동료들과 던전을 헤쳐나가고 보스를 상대하는 형태가 어렸던 나의 도전의식을 자극해 몰입하며 즐겼던 기억이 있다.   이런 구성에 감명받은 적이 있던 나도 재미있는 던전 돌파 -> 보스 격파 형태의 게임을 만들고 싶었고, 총 10명의 유저가 ..
2025.01.01
no image
InWanderLand - 쿼터뷰 전략 액션 게임
깃허브 주소 : https://github.com/GAFinal-Predator/Predator게임 시연 영상 : https://youtu.be/xjCrXEDaerc?si=laKyc-V1Po2zvh5H  목차 1. 프로젝트 개요2. 프로토타입 게임 개발3. 빌드머신 구축, 빌드와 테스트의 자동화4. Yunuty 게임엔진 개선4.1. 길찾기 라이브러리 Recast Navigation 연동4.2. 물리 라이브러리 PhysX 연동4.3. 컴포넌트 코루틴 구현5. Wander 에디터6. RTS 프레임워크 개발7. UI8. 성과8.1. 게임 플레이 영상8.2. 개발 과정 영상8.3. 도쿄 게임쇼9. 느낀 점 1. 프로젝트 개요 게임 인재원 파이널 졸업 프로젝트는 1년의 시간을 두고 진행되는 장기 게임 개발 프로젝..
2024.08.14
no image
가디언 슈터 - 2D 런앤건 게임 개발
깃허브 주소 : https://github.com/yunu95/GuardianShooter유튜브 주소 : https://youtu.be/tS9Ps5plkps?si=ffo03FuEch4AwP40   목차 1. 프로젝트의 시작 2. Yunuty 게임엔진2.1. Collider2D2.2. D2DAnimatedSprite2.3. YunutyCycle2.4. D2DCamera 3. 인게임 로직 코드3.1. 플랫포머 시스템3.2. 디버그 출력 4. 게임 에디터 기능4.1. 에디터 버튼 기능4.2. 플랫폼 배치4.3. 장식물 배치4.4. 카메라 레일4.5. 적군 마커4.6. 적군 웨이브 5. 소감 6. 기타 설계문서 1. 프로젝트의 시작 이번에 맡게 된 게임의 기획은 2D 플랫포머 슈팅 게임을 만들자는 것으로, 한 ..
2023.11.05
no image
스트레시 - 소코반 퍼즐게임
스토브 인디 게시 페이지 : https://store.onstove.com/ko/games/2070깃허브 주소 : https://github.com/yunu95/Stresh유튜브 주소 : https://youtu.be/BMZWLyMs3lc?si=edAJFXUATMV_2R46 게임 플레이 영상 목차1. 배경2. C언어 기반 라이프사이클 개발3. 클라이언트 구현4. 소감  1. 배경 게임 인재원은 총 2년 8학기 커리큘럼으로 운영되었고, 첫 1년의 4학기는 학기말마다 기획반 학생, 아트반 학생, 프로그래밍반 학생들이 연합해 팀을 짜 2주~ 4주의 시간에 걸쳐 게임을 개발하는 미니게임 프로젝트를 진행했다. 이 글이 소개하는 프로젝트는 내가 첫 1학기때 개발한 프로젝트로 개발완료까지 1주일 반의 기간이 주어졌고..
2023.10.11
no image
종갓집 김치 - 클리커 게임 모작 프로젝트
깃허브 주소 : https://github.com/yunu95/EndingFamily-Kimchi유튜브 주소 : https://www.youtube.com/watch?v=mDR3Lyo6fZk&ab_channel=Floater 목차계획 단계사전 작업- Yunuty 게임 엔진의 개발프로젝트의 시작소감 1. 계획단계 2022년 11월 게임인재원 1학기, 약 1주~1주 반 정도의 시간을 들여 간단한 게임을 만들어 보는 시간이 있었다. 오후 5시까지 배정된 수업은 수업대로 진행하고 남는 시간에 게임을 개발하는 과제였다. 할당된 개발 시간이 절대적으로 부족하고, 팀원들도 모두 프로그래밍 초심자였기 때문에 개발역량의 견적이 나오지 않는 상황에서 떠올린 나의 전략은 "최소기능의 구현은 매우 간단하지만 확장이 용이한 게..
2023.10.05

게임 공략 영상 : https://www.youtube.com/watch?v=8aNHZYGdo1g&t=1738s&pp=ygUM7Jqp67OR642Y7KCE

 

1. 게임 개요

2. 게임 오프닝 영상 & 시놉시스

3. 등장 캐릭터

4. 던전

5. 잘된 점

6. 잘못된 점

7. 교훈

8. 게임 크레딧 영상

 

1. 게임 개요

 워크래프트 3 유즈맵 중 '던전 앤 드래곤'이라는 던전 돌파형 맵이 있었다. 벨트스크롤 액션 형태의 원본 아케이드 게임을 RTS 유즈맵으로 재창작한 게임이었는데, 다수의 동료들과 던전을 헤쳐나가고 보스를 상대하는 형태가 어렸던 나의 도전의식을 자극해 몰입하며 즐겼던 기억이 있다.

 

좌 : 벨트 스크롤 아케이드 게임 던전 앤 드래곤, 우 : 왼쪽의 게임을 워크래프트3 유즈맵으로 재창작한 '던전 앤 드래곤' 플레이 화면

 

 이런 구성에 감명받은 적이 있던 나도 재미있는 던전 돌파 -> 보스 격파 형태의 게임을 만들고 싶었고, 총 10명의 유저가 힘을 합쳐 5개의 스테이지와 보스를 클리어하는 방식으로 게임을 구성했다.

 

2. 게임 오프닝 영상 & 시놉시스

 

 기획 의도는 '매우 다양하고 독특한 개성을 갖고 있는 캐릭터들이 조화를 이루며 던전을 클리어하는 게임을 만들어 보자.'는 것이었다. 스토리 또한 이런 의도에 맞게 '세상을 이상적인 형태로 관리하려는 초월적인 존재, '개선자'가 여러 세계를 탐색하며 찾은 뜻있는 이들을 용병으로 고용해 세상의 문제를 해결한다.'는 편리한 이야기다.

팬더같이 생긴 자는 초월적인 존재인 개선자, 이를 바라보고 있는 10명의 영웅들은 개선자가 세계를 돌며 등용한 자들로, 사회악을 청소하는 용병들이다.

 

3. 등장 캐릭터

 플레이 스타일이 뚜렷한 캐릭터들은 특별히 볼드체로 표기했다.

 

3.1. 겁 없는 촌부, 방앗간네 박씨

 부패한 정부관료에게 아내와 딸을 잃고 봉기를 일으키려 한 농부입니다. 폭발적으로 많은 소환수를 소환해 적을 숫자로 압도할 수 있습니다.

더보기
- 사용 기술

 

 

 
 
 
 

  

- 배경 이야기

 

 박씨는 모든 용병들 중 가장 무난한 삶을 살아 본 인물입니다. 그는 살아온 인생 35년 중 27년을 밭을 갈고 씨를 뿌리고 추수를 하는데에 보냈습니다. 27년간 박 씨는 커다란 자극 없이 무난하고 행복한 삶을 살았고, 그 27년간의 행복한 세월은 그의 수동적인 인생관을 단단하게 굳혔습니다.
그 27년간 같은 일을 반복하며 안정적인 생활을 통해 쌓은 무감각함의 관성 때문인지, 그는 자신이 일하는 논과 자신이 사는 마을 밖에서 일어나는 일들이 얼마나 심각한 지도 눈치채지 못했습니다.
박 씨가 살던 나라의 새로운 독재자 김택은은 특유의 정치 감각으로 쿠데타를 저지른 후 그것을 미화시켜 농민들의 지지를 얻었습니다. 하지만 이후 그의 기형적인 권력유지체계가 수많은 숙청과 학살을 야기했고 부패와 광기, 공포로 사회는 병들어갔습니다. 결국 박씨는 자신의 딸과 아내가 부패관리에게 한꺼번에 겁탈당한 후 살해당하자 졸지에 가족을 모두 잃은 처참한 피해자로 전락하는데, 그는 이런 급격한 심적인 충격을 다스리지 못하고 낫과 호미로 고을원님의 목을 따러 갑니다.
당연히 이런 무모한 돌격에는 아무런 성과가 없었고, 되레 자신의 목이 따이려는 찰나 순진하고 만만하고 흥분한 고기방패를 찾던 개선자에게 김택은을 처치할 기회를 얻는 조건으로 용병으로 고용되었습니다.

 

3.2. 잉꼬부부 에밀리와 하환백

 사별한 남편의 영혼을 활에 담아 함께 싸우는 여전사입니다. 광역피해를 입히는 것에 능한 원거리 공격수입니다.

더보기

 

- 사용 기술

 

 


 
 

- 배경 이야기

 

에밀리는 자국 외교대사의 직속 호위대장으로 일하고 있었는데, 그녀의 일솜씨는 굉장히 뛰어났지만 한편으론 난폭하고 강한 기질과 한없이 성급한 성격을 가져 에밀리는 괴팍한 악녀로 악명이 높았습니다. 물론 남성중심적 풍조의 자국 사회분위기에서 여성이 요직을 꿰찬 탓에 이야기가 과장된 것인지, 아니면 그런 남성들의 눈초리에 성미가 독해질 수밖에 없었던 것인지는 알 수 없습니다.
어느 날 인근 강대국에 조공을 바치는 행사에 호위를 맡게 된 그녀는 강대국 사절단의 호위대장 하환백과 업무상의 대화를 나누게 되었는데, 에밀리는 안 좋은 소문이 도는 약소국의 여성무관인 자신에게 한없이 친절하고 예를 지키던 하환백의 태도에 감화되었고 그 후로 오래도록 그와 정분을 나누게 됩니다. 하환백은 그녀의 거친 성격의 이면에 상처가 숨겨져 있음을 처음으로 이해해 줬으며 결국 그 둘은 혼인합니다.
하지만 극과 극의 성미를 가진 사람들의 사랑이야기로 사람들의 입에서 자주 오르내리던 그들의 관계는 오래가지 못했습니다. 그들은 양국 간의 평화를 의미하는 비공식적인 상징이 되었으나 양국의 마찰로 이익을 얻을 수 있다 판단한 부패고위관료들의 음모로 둘은 불시의 기습을 받았고 하환백은 그녀를 지키다 사망합니다. 하환백은 에밀리의 행복을 위해 그녀에게 자신이 죽으면 재혼하라는 유언을 남기지만 에밀리는 정신을 반 날려먹은 듯한 충격과 상실감을 견디지 못하고 산속에 남편을 묻은 후 옆에 오두막을 짓고 살았습니다.
나날이 늘어가는 죄책감과 남편에 대한 그리움 끝에 극단적인 선택을 하려는 찰나 개선자가 나타나 용병으로 일해준다면 에밀리의 활에 남편의 영혼을 담아 남편과 다시 함께할 기회를 주겠다는 제안을 했고, 그녀는 그 제안을 단번에 받아들였습니다.

 

3.3. 정파 무속인 라이 양

 부족장의 아들로서 강한 긍지를 가진 무속인입니다. 아군을 치유하고, 보호하며, 죽은 동료를 부활시킬 수 있습니다.

더보기

 

- 사용 기술

 

 
 
 

 

- 배경 이야기

 

라이 양은 자신이 속한 부족제사장의 장남이자 후계자입니다. 그는 동생 샤오 양과 함께 부족의 전통을 지키며 살았으며, 올곧은 행실과 성품으로 주변 사람들 사이에서 그 명망이 높았습니다.
그러던 어느 해 바다 건너 다른 미지의 대륙에서 온 푸른빛 피부의 이질적인 이민자들이 그의 고향에 정착합니다. 그 후로 얼마 지나지 않아 동생 샤오 양의 심기가 이상해지더니, 점점 그녀는 부족의 제사에 불참하고 외지인들과 놀러 다니며 이민자들의 학문에 관심을 갖는 등, 사파의 길로 새게 됩니다.
라이 양은 오빠로서 동생을 바른 길로 이끌어주기 위해 노력했지만 동생은 결코 말을 듣지 않았고 어느 날 둘이 심하게 다툰 이후로 샤오 양이 실종되자 그제야 뭔가 잘못되어 있었다는 걸 느낀 라이 양은 서둘러 샤오 양을 찾아 나서지만 샤오 양의 시신만 찾게 됩니다. 제사장의 피를 이어받은 사람들 중 강인한 정신력을 가진 사람은 죽은 사람을 부활시킬 수 있는 권능을 가지고 있었(다고 믿어졌)으나 이는 자신의 부족의 숭고한 원칙인 대순환을 역행하는 일이기에 라이 양은 동생의 시신을 수습한 후 그 앞에서 동생의 목숨과 규율 중 뭘 우선시해야 하는 것인지 고민합니다.
그때 만만하고 순진한 의무병을 찾던 개선자가 갑자기 그 앞에 등장하고, 라이 양에게 용병업을 통해 샤오 양을 살릴지 말지 고민할 시간을 주겠다고 제안합니다. 라이 양은 동생을 부활시킬지 말지 동생의 시신이 부패하기 전에 서둘러 결정을 내려야 했지만 개선자의 힘을 빌린다면 용병일을 끝내고 다시 이 시점으로 돌아올 때까지의 시간을 통해 진지하게 고민을 해 볼 수 있었기에 개선자의 제안을 받아들였습니다.

 

3.4. 정신병자 탄초

 끊임없는 불안과 자기혐오에 휩싸인 추방자입니다. 넘치는 정신적 에너지를 승화하여 아군의 마나를 회복시키고 적군을 혼란에 빠트립니다. 무작위로 동료를 부활시킬 수 있습니다.

더보기

 

- 사용 기술

 

 

- 배경 이야기

 

 탄초는 정신건강이 양호한 사람들과 마찬가지로 선천적으로는 정상적인 아기로 태어났습니다. 하지만 일에 바빠 가정을 돌볼 틈이 없었던 아버지와 화를 잘 내는 신경질적인 기질의 어머니 때문에 탄초는 유년기를 불안하고 불행하게 보냈고, 불행하고 불안했던 유년기의 영향으로 탄초는 죽 정서불안에 시달려 사회에서 둔재, 고문관, 또라이 취급을 받으며 살았습니다.
탄초가 30대가 되던 해에, 인근 국가에서 종교개혁이 일어나 그 영향이 사방으로 번지기 시작했는데, 그 종교개혁의 주 내용은 온전히 사회를 지키기 위해서는 마녀, 흑염소, 악마의 숙주를 있는 대로 색출해 화형, 혹은 정화해야 한다는 것이었고, 말과 행동이 어눌했던 탄초는 그중 '악마의 숙주' 혐의를 받아 입에 끓는 납물을 붓는 신성치료를 선고받고 감옥에 갇히게 되었습니다.
축축한 감옥에 갇힌 채 단념한 채로 최대한 안 아프게 자살하는 방법을 고민하던 탄초 앞에 갑자기 개선자가 나타났고, 개선자는 많은 세계를 쏘다니며 얻은 지식을 바탕으로 그를 신경증환자로 진단하며, 그에게 협조의 대가로 정신질환을 진지하게 받아들이는 문명으로의 이민을 약속하며 용병업을 제안합니다.
그저 자신의 성격과 행동양식을 운명적인 저주로 인식하고 자책하던 탄초에게 있어서, 자신을 도움이 필요한 병자로 정의하는 '정신병'이란 단어는 자신의 인생에 개선의 여지가 있으리란 희망을 줬습니다. 개선자의 제안은 탄초에게 완벽히 이상적인 두 번째 기회로 다가왔고 그는 흔쾌히 제안을 수락했습니다.

 

3.5. 퇴역군인 가롯 보울

 불명예스럽게 은퇴한 늙은 퇴역군인이었지만, 개선자의 힘으로 회춘해 찬란한 명예를 되찾고 싶어 합니다. 다른 캐릭터와 연계해 광역 피해를 입힐 수 있는 근접 공격수입니다. 

더보기

 

- 사용 기술

 

 

 

- 배경 설명

 

가롯은 자신의 세계에서 망치 할아범으로 불리는 괴짜 외톨이였습니다. 하지만 젊었을 적의 그는 전쟁영웅이었고 온 국민의 존경을 한 몸에 받는 나라의 아이콘이었습니다.
중년이 된 가롯은 군인의 신분에서 내려와 그동안 모아둔 돈으로 프랜차이즈 맥주사업을 하지만, 갑작스레 나라를 휩쓴 혁명이 가롯이 치른 전쟁을 비롯한 역사를 재조명했고, 순식간에 가롯은 전쟁영웅에서 영아살해자의 오명을 뒤집어쓰게 됩니다.
이런 불명예를 잠시도 견디지 못했던 가롯은 이전 이름을 버리고 현재의 가롯 보울이란 이름으로 개명한 후 외딴 벽지로 내려가 살지만, 모든 부와 명예를 잃어버린 가롯은 영웅으로 추앙받던 과거의 잔영에 집착하게 되고, 온갖 기행을 일삼는 괴짜 할아범으로 추하게 늙습니다.
남은 건 과거에 대한 그리움뿐인 가롯을 지켜본 개선자는 그에게 남은 재능과 갱생의 가능성을 함께 본 후, 가롯에게 용병으로 일 해준다면 젊음을 돌려줄 것이라 제안했고, 가롯은 왕년의 영광을 되찾을 수 있을지도 모른다는 생각이 들자 자세한 이야기를 끝까지 듣지도 않은 채 개선자의 제안을 승낙했습니다.

 

3.6. 미술가 논시팟사

 괴짜 미술가입니다. 전장 위에 물감을 뿌려 버프, 디버프 영역을 지정해 아군을 보조합니다.

더보기

- 사용 기술

 

 
 
 

 

- 배경 이야기

 

논시팟사의 가문은 대대로 유명한 화가, 조각가, 행위예술가들을 배출해 온 예술가가문입니다. 논시팟사는 그중 가장 독특한 화가인데, 도화지에 굵은 선을 몇 개 그리고 색칠한 후 그 그림을 그대로 보존하는, 이 채 5분도 걸리지 않는 작업 끝에 나온 결과물을 예술작품이라 주장했기 때문입니다.
수세식 소변기에 샘이란 이름표를 단 것이 예술작품의 반열에 들어가는 오묘 신비한 현대예술을 당신은 아마 한 번쯤 접해봤기에 논시팟사를 '독특하다, 창의적이다'라고 생각해 볼 수도 있겠죠. 하지만 논시팟사가 사는 세계는 그렇게 열린 세계가 아니었습니다. 도화지에 장난질을 해 놓은 것을 가지고 예술이라 억지를 부린다며 논시팟사의 가문에서는 그를 가문의 수치로 여겼고, 평론가들은 '그는 작품을 만드는 사람이 아닌 배설하는 사람이다.'라며 시도 때도 없이 조롱합니다.
소통하지 못하는 예술가 논시팟사는 자신만의 세계에서 산 셈이죠. 그렇게 집에서 은둔생활을 하던 논시팟사의 눈앞에 만만한 보조용병을 찾던 개선자가 나타나 논시팟사의 예술을 사람들이 이해할 수 있는 것으로 만들어주겠다며 용병업을 제안합니다.
그 제안을 어떻게 거절할 수 있었겠습니까? 문밖에는 자신을 이해 못 하고 싫어하는 사람들밖에 없었는데요.

 

3.7. 의적 히르만

 경박함 속에 스스로를 숨긴 협객입니다. 매우 민첩한 기동력을 갖고 있으며, 적의 뒤로 잠입하는 데에 성공하면 막대한 피해를 입힐 수 있습니다.

더보기

- 사용 기술

 


 

 

- 배경 이야기

 

히르만은 개선자의 마력 없이도 강한 전투력을 가지는 몇 안 되는 용병 중 하나입니다. 그는 젊은 나이에도 불구하고 상당히 복잡 다난하고 기구한 인생을 살아 10대 양아치와 같은 건들거리는 외모 안에 환갑노인의 관록과 같은 지혜를 숨기고 있습니다.
그는 빈민가 출신으로 어릴 적부터 특유의 날렵한 몸짓과 잔머리로 동네꼬마 패거리들을 평정하며 골목대장에서 건달두목으로 성장했습니다. 하지만 그는 사람을 때리고 겁먹이고 윽박지르는 일상과 지조 없고 기회주의에 찌든 근시안적인 부하들에게 오래전부터 환멸을 느끼고 있었습니다. 결국 히르만의 이름을 내세우며 패악질을 일삼던 부하들이 빈민가의 꼬마를 죽인 일을 계기로 히르만은 자기 손으로 자신의 패거리들을 모두 불구자로 만든 후 자신이 진정 원하는 삶, 진정으로 따를 가치가 있는 숭고한 가치를 찾으려 애씁니다.
아쉽게도, 그가 사는 세계는 영웅을 낳을 여건조차 되지 않던 소말리아 뺨치는 피폐한 세계였습니다. 히르만이 찾은 뭔가 숭고한 가치를 추구하던 사람들은 그저 사기꾼으로만 드러났습니다. 뭔가 숭고한 가치를 찾고자 하던 히르만의 순수함은 날이 갈수록 수없이 많은 배신과 기만과 망신을 당하며 빛이 바래갔고, 오히려 살벌한 사회에서 살아남기 위해 히르만이 직접 배신과 기만을 일삼게 되는 지경에까지 이릅니다. 그 후로 죄책감과 자기부정, 허무주의가 차례로 그의 머릿속을 차지하자, 그는 오직 안정된 삶만을 꿈꾸게 되었고 개선자가 그를 찾기 전까지 히르만은 호떡장사를 하며 살았습니다.
어느 날 남창을 모집하던 포주가 우연히 이 번들번들한 호떡장사꾼을 눈여겨보게 되고, 무력으로 히르만을 포획할 계획을 세웁니다. 진작 이상한 낌새를 눈치챈 히르만은 야반도주를 하다 포주의 강력한 졸개들에 의해 궁지에 몰리게 됐는데, 이때 개선자가 히르만의 의식 속에 나타나 강력한 힘을 주겠다며 히르만을 설득합니다. 히르만은 개선자를 그다지 믿지 않았지만, 일단 제안을 받아들이면 당장의 곤경에서는 벗어날 수 있을 것이라 생각했기에 밑져도 본전이란 식으로 용병이 됐습니다.

 

3.8. 사파 무속인 샤오 양

 오랜 기간 몸담은 부족의 전통과 신념에 등 돌린 사파 무속인입니다. 적의 시체로부터 영혼을 수집해 동료를 부활시킬 수 있습니다.

더보기

- 사용 기술

 

 


 

 

- 배경 이야기

 

 샤오 양은 한때는 부족의 전통과 신념에 강한 믿음을 가지고 있었습니다. 또 부족의 의녀로서, 또한 무녀로서, 그녀는 오래전부터 고향에 만연해있던 돌림병을 전통적인 방법대로 병자의 영혼정화와 영물의 축복을 담은 문신을 통해 치료하려 했습니다.
그러던 어느 해 바다 건너 다른 미지의 대륙에서 온 푸른빛 피부의 이질적인 이민자들이 샤오 양의 고향에 정착합니다. 그 후로 얼마 지나지 않아 샤오 양은 인생에 다시없을 충격적인 상황을 보게 되는데, 자신의 부족이 수십 년간 극복하지 못했던 지독하고 악랄했던 돌림병을 신대륙을 밟은 지 수개월도 되지 않았던 이민자들이 백신주사를 자체개발해 면역력을 갖춰 정복해 버린 것입니다. 그 후로 샤오 양은 부족이 가진 전통과 사상의 가치를 의심하기 시작하고 이민자들과 잦은 교류를 가지며 점점 새로운 세계에 눈을 뜨게 됩니다.
물론, 제사장의 딸이자 부족대표무녀, 의녀가 스스로 부족의 근간을 의심하고 외지인과 어울리는 모습을 고깝게 본 사람들의 수는 결코 적지 않았습니다. 오빠인 라이 양은 끊임없이 동생에게 자중하라 충고했지만, 동생은 결코 고집을 꺾지 않았고, 하루는 둘이 심하게 다툰 어느 날 이후로 샤오 양은 부족을 떠나 연락도 끊고 라이 양을 피합니다. 그제야 뭔가 잘못되어 있는 것을 느낀 라이 양은 다시 동생을 챙기기 위해 그 뒤를 쫓지만 어느새 샤오 양은 데소나 독도마뱀에게 물려 싸늘한 시체가 되어 있었고, 라이 양은 있을 수 없는 일이라며 절규하며 샤오 양의 시신을 수습했습니다.
하지만 어찌 된 일인지, 시간이 얼마 흐르자 샤오 양은 자리를 툭툭 털며 어떤 음침한 동굴에서 깨어났습니다. 그리고 그 앞에 나타난 개선자가 샤오 양의 내적인 가치갈등과 오빠와의 외적인 갈등을 동시에 해결해 주겠다는 조건을 걸고 샤오 양에게 용병업을 제안합니다. 샤오 양은 개선자가 자신의 목숨을 구해줬다고 생각했으며, 또 그의 분위기가 자신이 친하게 지내던 이민자들의 가볍고 자유로운 느낌과 흡사하다 느꼈습니다. 개선자에게 좋은 인상을 받은 샤오 양은 그 제안을 수락했습니다.

 

3.9. 능욕자 메스가탄

 악마의 군대를 타고난 재치로 농락하면서 싸워온 대담한 마법사입니다. 적들을 함정에 빠트리고 일거에 소탕하는 것에 능합니다.

더보기

 

- 사용 기술

 

 

 

 

- 배경 이야기

 

메스가탄의 고향세계에서는 악마와 인간사이의 전쟁이 치열하게 벌어지고 있었습니다. 미운 정이라는 말이 이때 쓰는 말일까요? 전쟁이 장기전으로 흘러가자 악마의 힘인 마력을 인간들도 쓸 수 있게 됐는데, 메스가탄은 특히 악마의 힘이 체질에 맞았던 모양입니다. 그는 마력을 적극적으로 활용해 유황지옥불을 유출시켜 악마들을 혼내줬고 차원문주술을 익혀 수많은 악마들을 낙사, 익사시키며 음란마귀의 페로몬분비샘을 활용해 대악마 함정을 만드는 등 악마들을 조롱능욕하는 데에 일가견을 보입니다. 이런 인재를 놓칠 수 없었던 개선자는 어느 날 메스가탄의 눈앞에 나타나 그의 도움을 요청합니다. 메스가탄은 당연히 고향방위를 이유로 거절하려 했지만, 개선자가 더 강력한 힘을 미끼로 꾀어내 결국 용병이 되었습니다.

 

3.10. 빙검술가 옐라르

 무뚝뚝한 인외의 존재입니다. 적을 얼려 무력화시키거나 약화시키는 것에 특화되어 있습니다.

더보기

 

- 사용 기술

 

 


 
 
 

 

- 배경 이야기

 

 옐라르라는 이름은 개선자가 직접 옐라르에게 지어준 이름인데, 그 이름에 별 뜻은 없습니다. 그전까지 옐라르에게는 이름이 없었기에 뭐라고 부를 이름이 필요했고, 개선자가 ㅖ와 ㄹ의 발음이 부드럽다고 생각해 옐라르라는 혀에 착착 감기는 이름을 만들어준 것뿐입니다. 실제로 그의 이름을 발음하면 혀가 입천장을 가볍게 간지럽히는 느낌과 함께 옥구슬이 은쟁반 위를 구르는 소리가 납니다.
옐라르는 다른 용병들이 합류하기 이전부터 비서처럼 개선자의 일을 도와줬습니다. 그 비서가 이번엔 용병일을 하는 거죠.
그의 체질은 보통 사람들과 굉장히 다른데, 그는 체온부터가 영하인 괴물이며 대부분의 물질대사를 자신만의 고유한 방식으로 처리합니다. 이런 걸 봐서는 애초에 그는 고향의 다른 주민들과 같은 종에 속하지도 않을 가능성도 있습니다.
개선자에게 고용되기 전, 고향에서 옐라르는 냉기를 끊임없이 강하게 내뿜는 탓에 푸른 도깨비라는 가명으로 불리며 주민들이 모두 두려워했습니다. 본명이 없을법한 이유죠. 아무도 가까이 갈 생각을 못했으니까요. 그런 옐라르에게 처음으로 말을 건 이가 개선자인데, 개선자는 옐라르의 성품을 살펴본 후 옐라르에게 자신의 체질을 조절할 수 있는 힘을 주겠다는 조건으로 그에게 비서직을 제안합니다. 사실 그 당시 옐라르는 그 제안의 내용에 크게 귀를 기울이지는 않았고, 그저 처음으로 누가 자신에게 말을 건 기념으로 제안을 받아들입니다. 도중에 일을 때려치우지 않고 계속하는 걸 보면 하는 일에 큰 불만은 없는 것 같습니다.
그의 피부가 지금은 붉은 이유는 자신의 냉기가 동료들을 해치지는 않을까 걱정한 옐라르가 자신의 냉기를 적당히 완화시키기 위한 노력의 일환으로 파란 파장의 강한 빛 에너지는 주로 흡수하려 하고 있기 때문입니다.

 

4. 던전

 던전은 튜토리얼 던전 하나와 수평적으로 배치되어 유저가 원하는 순서대로 진행할 수 있는 던전 셋, 최종 던전 하나로 총 5 종류의 던전을 만들었다.

던전을 하나 클리어할때마다 나오는 던전 선택화면, 3 종류의 던전을 호스트 유저가 정하는 순서대로 도전한다. 세 던전을 모두 클리어하고 나면 마지막 던전이 해금된다.

 

4.1. 마약왕 낙스

 

 울창한 숲 속에서 숨어 마약을 제조하는 카르텔을 소탕하는 스테이지다. 현지인들이 제공한 벌목차량을 이용해 나무를 무너뜨리고 길을 뚫어 마약왕의 은신처를 습격해야 한다. 

 

벌목 차량으로 숲을 뚫으며 비밀기지를 찾는 구간

 

 

 

카르텔의 비밀기지 잠입

 

 

 

스테이지 보스 마약왕 낙스

 

 

 

4.2. 독재자 김택은

 

 

 나라를 무자비하게 다스리는 잔혹무도한 독재자 김택은을 징벌하기 위해 정치범 수용소를 해방시키고 수도를 습격하는 스테이지다.

 

정치범 수용소 해방

 

 

수도 시가전

 

스테이지 보스 김택은

 

모티브는 북한이다.

 

4.3. 나찰녀

 

 기후변화를 일으켜 극지방의 원주민들을 핍박하는 나찰녀를 무찌르고 파초선을 빼앗아 환경을 안정화시키는 것이 목표다. 나찰녀의 폭정으로 성정이 포악해진 원주민들도 상대하면서 나아가야 한다.

 

 

포악한 성정을 가진 원주민과의 교전

 

나찰녀와 손잡고 이상기후를 일으켜 원주민을 핍박하는 불의세력과의 전투

 

 

 

스테이지 보스 나찰녀

 

 보스의 모티브는 서유기에서 나오는 우마왕의 아내, 나찰녀에서 따왔다. 이 여자도 화산의 불길을 통제하며 화염산의 불길을 선선하게 해주는 대가로 지역 주민들에게 삥을 뜯는 악한이다.

 

5. 잘된 점

- 개성 있는 플레이어 캐릭터의 스킬셋

 전통적인 탱/딜/힐의 역할배분을 벗어나 적군보다 더 많은 물량의 소환수로 던전을 압도하는 캐릭터, 적의 뒤통수를 노리며 잠입과 교란을 일삼는 캐릭터 등 고유한 플레이 스타일을 만들어내는 것을 시도했다. 이런 특색 있는 캐릭터 구성을 매력적이라 생각하는 유저들이 있었다.

 

- 근본 있는 게임 구조

 "팀원들과 힘을 합쳐 적들이 득실대는 던전을 주파하고, 보스를 잡는다."는 왕도적인 전개 방식에 충실하게 게임을 만들었다. 2014년 당시에 이런 접근을 취한 맵들은 많지 않았기에 이 맵을 오래도록 플레이하는 고정 팬층이 있었던 것 같다.

 

- 디테일

 게임 인트로 컷신, 크레딧 영상, 캐릭터별 조작 안내 튜토리얼 등 게임의 완성도를 높이기 위해 디테일한 요소들을 신경 써서 만들었다.

캐릭터 중 조작법이 난해한 캐릭터 '의적 히르만'의 조작법을 안내하는 튜토리얼 스테이지

 

6. 잘못된 점

- 감당이 힘들 정도로 중구난방이었던 스킬셋 구성

 메커니즘이 독특한 캐릭터들이 너무 많았기 때문에, 캐릭터의 능력치와 스킬 수치를 조정하는 것에 사실상 실패했다.


- 과한 플레이 인원

 예상 플레이 인원은 10명이었는데, 막상 게임은 중요 캐릭터 한 명만 나가도 진행이 위태로워지는 구성이었기 때문에 게임을 재미있게 즐길 수 있는 환경이 안정적으로 유지되기 힘들었다.

 

- 탈진의 흔적

 게임을 기획하다가 탈진한 나머지 성의 없는 아이디어로 빈칸을 메운 부분이 종종 보인다. 괜찮은 기획을 떠올리기 힘들었다면 잠깐 쉬었다가 다시 돌아와도 좋았을 텐데, 빨리 작업을 해치우고 다음으로 넘어가야 한다는 집착에 경솔한 판단을 내려버린 경우가 많다. 플레이어블 캐릭터 '가롯 보울'의 성의 없는 스킬셋, 사파 무속인 샤오 양의 뜬금없는 단일대상 공격 기술 Q가 대표적인 사례다. 다른 유저들과 같이 게임을 할 때 '가롯 보울'을 선택한 유저가 지루함을 느끼고 바로 나가버리거나, 사파 무속인 '샤오 양'을 선택한 유저가 "스킬셋에 왜 이렇게 일관성이 없냐?"는 실망감을 드러냈을 때 내 무성의했던 마음이 들통난 것 같아 창피했고, 미안했다.

 

7. 교훈

 '항상 자기 자신에게 솔직해야 한다.'는 것이 이 프로젝트를 통해 얻은 가장 큰 교훈이다.

 

 이 게임을 만들 때 내가 진심으로 성의를 다해 개발한 부분도 많지만, 대충 구색만 맞추어 만들고 치운 부분도 많았다. 캐릭터 '가롯 보울'을 기획할 때가 딱 그랬다.

 노망 난 퇴역군인이라는 배경설정을 가진 플레이어블 캐릭터, '가롯 보울'은 영화 포레스트 검프의 등장인물인 댄 중위로부터 모티브를 얻은 캐릭터다. 상실감에 시달리며 현실을 놓치고 사는 사람이 구원을 얻기 위해 노력한다는 배경설정을 넣고 싶었고, 그에 부합하는 캐릭터인 댄 중위의 서사를 가롯 보울이라는 캐릭터에 넣었다.

'포레스트 검프'의 등장인물 댄 중위, 어수룩한 주인공에게 따뜻하고 헌신적인 장교였으나, 베트남전에서 다리와 부하, 삶의 목표를 잃고 폐인이 된다.

 

 이어, 퇴역군인이라는 컨셉을 캐릭터의 스킬 구성에 녹이기 위해 상실감을 고함으로 내뱉어 공격하는 범위공격 스킬, 부하들의 환영을 소환하는 소환 스킬로 스킬셋을 구상했다. 하지만 세 번째 스킬을 구상할 때 즈음에는 완전히 탈진이 와버린 나머지, '워크래프트 3의 영웅 스킬 중 다른 스킬을 강화하는 패시브 스킬이 있는데, 대충 그거 넣고 끝내면 안 되나?'라는 마음으로 무성의한 패시브 스킬을 넣어 구색만 맞췄다.

 

성난 노인의 호통같은 Q 스킬, 왕년의 용사들을 소환하는 W스킬, 늙은 퇴역군인이라는 컨셉에는 맞지만, 사실상 이 캐릭터는 이 두 스킬이 전부다.

 

 나는 스스로 '캐릭터 컨셉을 잘 잡았고, 스킬도 컨셉에 맞게 2개 잘 뽑았으니 나는 이 캐릭터에게 충분한 성의를 보였다.'라고 되뇌며 피로와 싫증에 뿌리를 둔 내 동기를 외면했다. 피곤하면 그냥 쉬었다가 작업하면 될 것을, 서둘러 일을 끝내려는 집착에 사로잡힌 나머지 이 퇴역군인 캐릭터를 매우 단순하고 재미없게 만들어버렸다. 이 캐릭터를 기획했을 때 나는 유저가 상실감에 시달리는 소외된 노인의 입장에 서서 게임에 몰입해보길 바랐지만, 실제 유저의 플레이 경험은 같은 말, 같은 행동만 반복하는 노인의 뒤치닥꺼리를 해주는 느낌을 받았을 거다.

 만약 내가 게임을 만드는 순간순간마다 항상 스스로의 마음에 솔직할 수 있었다면, 한 순간 올라온 마음속 권태로움이 작업물에까지 영향을 미치는 일은 없었을 것이다. 유저들은 귀신이다. 내가 소홀하게 개발한 콘텐츠를 경험한 유저들은 바로 '이 캐릭터를 재미있으라고 만든 거냐.', '제작자가 스킬 이름 짓다가 지쳤나 보다.', '진짜 대충 만들었네'와 같은 신랄한 말을 하며 콘텐츠 너머에 있는 내 과거의 모습을 꿰뚫어 보더라. 이 게임에 실망한 유저들이 남긴 이 같은 피드백들은 지금까지도 스스로의 마음상태와 의도에 항상 깨어있는 것이 창작자에게 얼마나 중요한지 경각심을 남기고 있다.

 

 

8. 게임 크레딧 영상

 

Warcraft 3 Custom Models

Download custom models for Warcraft 3.

www.hiveworkshop.com

 이 맵을 제작 하는 데에 쓰인 3d 모델과 텍스처들 중 많은 내용들은 워크래프트 3 맵 제작 커뮤니티 하이브 워크샵(https://www.hiveworkshop.com/repositories/models.530/)에서 다운로드한 것이다.

 사용된 커스텀 아트 리소스와 해당 창작물을 만들어 주신 아티스트 분들의 닉네임을 함께 배치해 구성한 게임 크레딧 영상이다.

 

깃허브 주소 : https://github.com/GAFinal-Predator/Predator

게임 시연 영상 : https://youtu.be/xjCrXEDaerc?si=laKyc-V1Po2zvh5H

 

 목차

 

1. 프로젝트 개요

2. 프로토타입 게임 개발

3. 빌드머신 구축, 빌드와 테스트의 자동화

4. Yunuty 게임엔진 개선

4.1. 길찾기 라이브러리 Recast Navigation 연동

4.2. 물리 라이브러리 PhysX 연동

4.3. 컴포넌트 코루틴 구현

5. Wander 에디터

6. RTS 프레임워크 개발

7. UI

8. 성과

8.1. 게임 플레이 영상

8.2. 개발 과정 영상

8.3. 도쿄 게임쇼

9. 느낀 점


 

1. 프로젝트 개요

 게임 인재원 파이널 졸업 프로젝트는 1년의 시간을 두고 진행되는 장기 게임 개발 프로젝트였다. 모든 팀원이 서로 열심히 노력해서 개발한 결과 게임 인재원 우수 프로젝트로 지정되어 도쿄 게임쇼에 출품하는 영광을 얻을 수 있었다.

 기획된 게임은 여러 캐릭터들을 동시에 조종하는 실시간 쿼터뷰 액션 게임이었다. 게임 플레이 엔진은 내가 여러 프로젝트를 거치며 만들어 온 Yunuty 엔진을 사용하기로 했고, 그래픽스 엔진은 DirectX11 기반으로 팀원이 만들기로 했다.

팀 구성  
프로그래밍 팀장 ( 게임 엔진 )
프로그래밍 팀원 ( 맵 툴 ) A
프로그래밍 팀원 ( 그래픽스 엔진 ) B
프로그래밍 팀원 ( 콘텐츠 프로그래밍 ) C
아트 팀 3인
기획 팀 2인

 

프로젝트 발표에 쓰인 기획 프레젠테이션

 

2. 프로토타입 게임 개발

( 게임 플레이 기획의 영감을 얻었다는 두 게임, '트랜지스터'와 '드래곤 에이지 인퀴지션'. )

 

 프로젝트를 시작하기 이전에 기획자로부터 어떤 게임을 만들 생각인지 대강의 설명을 들었다. 쿼터뷰 시점의 다중 유닛 컨트롤 게임이자, 실시간 전투와 시간정지 플레이가 혼재된 액션 게임이라고 설명을 들었다. 그렇지만 내가 이해한 바가 기획자의 생각과 일치하는지가 여전히 모호했다. 앞으로 장기간 협업하게 될 테니 서로 생각하고 있는 게임의 형태를 처음부터 확실하게 공유하고 싶었다. 마침 게임의 형태가 RTS 게임과 비슷했고, 나는 RTS 게임 워크래프트 3의 맵 에디터 기능을 능숙하게 다룰 수 있었기 때문에 짧은 시간 안에 프로토타입 게임을 먼저 만들었다.

( 재미 검증을 위한 프로토타입 게임, 스킬과 전술모드, 적군 웨이브가 구현되었다. )
( 밸런싱 작업을 위한 프로토타입 게임의 수치 테이블 )

 

 프로토타입 게임의 목적은 단지 팀이 같은 비전을 공유하는 것만이 아니었다. 우리 게임만을 위한 맵 에디터와 그래픽스 엔진, RTS 코드를 완전히 처음부터 만드는 만큼, 실질적으로 구동되는 게임을 기획자가 받아보는 시점은 매우 늦어질 예정이었다. 그렇다고 기획자의 레벨 디자인과 수치 밸런싱 작업을 그때까지 마냥 미룰 수는 없었다. 이런 작업들은 병렬적으로 진행될 필요가 있었다. 기획자는 이 프로토타입을 통해 그러한 수치들을 적용하고 확인할 수 있었다. 자체 맵 에디터를 워크래프트3 에디터와 유사하게 만들 계획이었기 때문에, 기획자가 에디터의 활용에 미리 익숙해지는 효과도 기대할 수 있었다.

( 프로토타입의 지형 배치, 이 배치가 그대로 게임 레벨디자인에 적용되었다. )

 

 

 

 

 

 

3. 빌드머신 구축, 빌드와 테스트의 자동화

 프로토타입을 전달해 기획자의 작업이 병렬적으로 수행될 수 있게 되었으니, 이제 우리는 본격적인 프로그램 개발에만 집중하면 됐다. 프로젝트가 긴 시간 동안 진행될 예정이었기 때문에, 코드 작성에 앞서 개발 프로세스에 대해 신중히 고민하는 시간을 가졌다. 장기 프로젝트에서는 잘못된 커밋이 쌓여 버그가 발생할 경우, 문제의 원인을 찾는 것이 마치 볏짚 속에서 바늘을 찾는 것처럼 어려워질 수 있었다. 이를 방지하기 위해 테스트 주도 개발 방식을 도입했다. 우리는 주요한 기능을 구현할때마다 그 기능의 정상작동을 검증하는 단위 테스트 코드도 짝을 지어 만들기로 했다. 그리고 빌드머신을 세팅해 커밋이 들어올 때마다 테스트들을 진행시켜  모두 통과해야 빌드를 성공으로 표시하게 만들었다. 테스트 자동화 시스템 덕분에 잘못된 커밋이 들어오면 즉각적으로 해당 커밋이 어떤 문제를 일으키는지 특정할 수 있었다.

 

링크 : 게임개발 프로세스에 테스트 자동화 환경을 구축한 이야기

 

 

 

 

 

 

4. Yunuty 게임엔진의 개선

 RTS 게임을 만들기 위해 게임 엔진에서 지원해야 할 기능들이 있었다. 하나는 길찾기 기능, 하나는 3차원 충돌 기능이었다. 자체 개발한 길찾기 기능과 2D 충돌 기능이 준비되어 있었지만, 최적화와 예외처리에서 미심쩍은 부분들이 많았다. 때문에 안정성이 확보된 오픈소스 라이브러리인 RecastNavigation과 PhysX를 게임엔진과 연동시켜 사용했다.

 

 

4.1. 길찾기 라이브러리 Recast Navigation 연동

좌 : Recast Navigation Demo 프로그램의 모습, 우 : Recast Navigation이 게임엔진에 연동되어 동작하는 모습

 

 라이브러리의 데모 프로젝트의 코드를 참고해 게임엔진에서 NavigationField와 NavigationAgent라는 각각의 클래스로 래핑했다. 게임 엔진 사용자가 최대한 간단하게 기능을 사용할 수 있도록 인터페이스를 뚫어주고 싶었다. NavigationField는 버텍스 좌표들의 리스트만 받으면 이동가능한 지형을 생성할 수 있게 만들었고, NavigationAgent는 이동속도 설정, 충돌 크기 설정, 이동 등의 기능만 사용할 수 있게 만들었다.

 

링크 : 자체 게임 엔진에 RecastNavigation 이식하기

길찾기 기능을 래핑하기 위한 인터페이스 설계

 

4.2. 물리 라이브러리 PhysX 연동

 PhysX 라이브러리의 기능을 연동하고 래핑했다. 물체의 충돌범위 판정 속성과 강체로서의 속성은 따로 구분해 Collider와 Rigidbody의 두 클래스로 만들었다.

Collider와 Rigidbody의 설계

PhysX 라이브러리를 Yunuty 엔진에서 래핑한 코드

더보기

- Collider 클래스 헤더

#pragma once
#include <Windows.h>
#include <vector>
#include <unordered_set>
#include "Graphic.h"
#include "Vector2.h"
#include "Vector3.h"
#include "Rect.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

using namespace std;
namespace yunutyEngine
{
    namespace physics
    {
        class RigidBody;
        class YUNUTY_API Collider : virtual public Component
        {
        public:
            class Impl;
            Collider(Impl* impl);
            virtual ~Collider();
            //bool IsTrigger();
            //void SetAsTrigger(bool isTrigger);
            bool IsUsingCCD();
            // Continuous Collision Detection 기능을 활성화하면 한 프레임에서 다음 프레임까지의 충돌을 연속적으로 체크합니다.
            // CCD 기능이 활성화되면 Bullet through paper 현상이 발생하지 않습니다.
            void EnableCCD(bool enable);
            // 콜라이더의 크기가 0이면 터짐, 콜라이더 크기가 매우 작을때 적용되는 최소한의 스케일
        protected:
            virtual void Start()override;
            virtual void Update()override;
            virtual void OnTransformUpdate() override;
            // 피직스 객체의 월드스케일이 달라졌을 때 이를 어떻게 반영할지 결정합니다.
            virtual void ApplyScale(const Vector3d& worldScale) = 0;
            virtual void OnEnable()override;
            virtual void OnDisable()override;
            RigidBody* rigidBody;

            Impl* impl;
        private:
            bool WasPxActorInitialized();
            Vector3d cachedScale;
            friend RigidBody;
#ifdef _DEBUG
            Vector3d firstLocation;
            Quaternion firstRotation;
#endif
        };
    }
}

 

-Rigidbody 클래스 헤더

#pragma once
#include <Windows.h>
#include <vector>
#include <unordered_set>
#include "Graphic.h"
#include "Vector2.h"
#include "Vector3.h"
#include "Rect.h"
#include "YunutyForceType.h"
#include "YunutyPhysicsMaterialInfo.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

// RigidBody는 콜라이더에 강체 속성을 부여하고 싶을때 사용됩니다. 콜라이더 없이는 RigidBody의 코드가 제대로 동작할 수 없습니다.
using namespace std;
namespace yunutyEngine
{
    namespace physics
    {
        class Collider;
        class YUNUTY_API RigidBody : public Component
        {
        public:
            RigidBody();
            virtual ~RigidBody();
            bool GetIsStatic();
            void SetAsStatic(bool isStatic);
            void SetAsKinematic(bool isKinematic);
            bool GetIsKinematic();

            //void SetMaterialInfo();

            void LockTranslation(bool x, bool y, bool z);
            void LockRotation(bool x, bool y, bool z);
            void SetMass(float mass);
            void AddForce(const Vector3f& forceVector, ForceType forceType);
            void AddTorque(const Vector3f& forceVector, ForceType torqueType);
        protected:
            virtual void Start() override;
            virtual void OnEnable()override;
            virtual void OnDisable()override;
        private:
            std::vector<std::function<void()>> onStartCallbacks;
            Collider* attachedCollider;
            bool isStatic{ false };
            friend Collider;
        };
    }
}

 

이식된 물리 기능, 애석하게도 게임에서 물리 시뮬레이션 기능이 쓰이는 일은 없었다.

4.3. 컴포넌트 코루틴 구현

 유니티에는 StartCoroutine과 같은 함수를 통해 컴포넌트에서 코루틴 객체를 붙여 비동기적 동작을 실행하는 기능이 있다. 내 엔진에도 같은 기능이 있으면 좋을것 같아 유니티와 유사하게 만들어 보았다. 유니티는 코루틴 함수에서 YieldInstruction 객체를 반환할 수 있게 만들어 코루틴이 재활성화되는 시점을 손쉽게 조절할 수 있도록 만들었다. 나도 유사한 클래스를 만들어 '이 코루틴은 yield 후 n초 후 다시 동작'과 같은 편의성을 제공했다.

 

관련 코드

더보기

- YunutyCoroutine.h

#pragma once

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

#include <vector>
#include <functional>

namespace yunutyEngine
{
    class Component;
    class YunutyCycle;
    namespace coroutine
    {
        class YieldInstruction;
        struct YUNUTY_API Coroutine
        {
            struct Coroutine_Handler;
            struct promise_type {
                YieldInstruction* yield = nullptr;
                std::vector<std::function<void()>> destroyCallBack;
                Coroutine get_return_object()
                {
                    return Coroutine(std::make_shared<Coroutine_Handler>(std::coroutine_handle<promise_type>::from_promise(*this)));
                }
                std::suspend_always initial_suspend() { return std::suspend_always{}; }
                std::suspend_always final_suspend() noexcept { return std::suspend_always{}; }
                void return_void() {}
                /*void return_value(YieldInstruction& yield) { this->yield = &yield; }
                void return_value(YieldInstruction&& yield) { this->yield = &yield; }*/
                std::suspend_always yield_value(std::suspend_always) { return {}; }
                std::suspend_always yield_value(YieldInstruction& yield) { this->yield = &yield; return {}; }
                std::suspend_always yield_value(YieldInstruction&& yield) { this->yield = &yield; return {}; }
                void unhandled_exception() 
                {
                    std::rethrow_exception(std::current_exception());
                }
            };
            explicit Coroutine(const std::shared_ptr<Coroutine_Handler>& h) : handle(h) {}
            ~Coroutine()
            {
                if (handle)
                {
                    for (auto each : handle->promise().destroyCallBack)
                    {
                        each();
                    }
                }
            }
            Coroutine(Coroutine const&) = delete;
            Coroutine(Coroutine&& other) noexcept
            {
                deathWish = other.deathWish;
                handle = other.handle;
                other.handle.reset();
            };
            void PushDestroyCallBack(const std::function<void()>& callBack)
            {
                handle->promise().destroyCallBack.push_back(callBack);
            }
            Coroutine& operator=(Coroutine const&) = delete;
            void resume() { handle->resume(); }
            YieldInstruction* GetLastYield() { return handle->promise().yield; };
            bool Done() { return handle->done(); }

            class Coroutine_Handler
            {
                friend struct Coroutine;
            public:
                Coroutine_Handler(const std::coroutine_handle<promise_type>& handle)
                    : primitive_handle(handle)
                {

                }

                ~Coroutine_Handler()
                {
                    if (primitive_handle)
                    {
                        primitive_handle.destroy();
                    }
                }

                auto& promise()
                {
                    return primitive_handle.promise();
                }
                
                void resume()
                {
                    primitive_handle.resume();
                }

                bool done()
                {
                    return primitive_handle.done();
                }

            private:
                std::coroutine_handle<promise_type> primitive_handle;
            };

            std::shared_ptr<Coroutine_Handler> handle;
        private:
            bool deathWish = false;
            friend yunutyEngine::Component;
            friend yunutyEngine::YunutyCycle;
        };
    }
}

( 코루틴 클래스의 정의 )

 

- YunutyYieldInstruction.h

#pragma once

namespace yunutyEngine
{
    namespace coroutine
    {
        class YieldInstruction
        {
        public:
            virtual bool ShouldResume() const = 0;
            virtual void Update() = 0;
        };
    }
}

( 코루틴 함수에서 반환할수 있는 객체 YieldInstruction의 정의 )

- YunutyWaitForSeconds.h

#pragma once
#include "YunutyYieldInstruction.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

namespace yunutyEngine
{
    namespace coroutine
    {
        class YUNUTY_API WaitForSeconds :public YieldInstruction
        {
        private:
            float elapsed{ 0 };
            float seconds{ 0 };
            bool isRealTime{ false };
        public:
            WaitForSeconds(float seconds, bool isRealTime = false);
            virtual void Update() override;
            virtual bool ShouldResume() const override;
            float GetExceededTime() const;
        };
    }
}

( 코루틴이 n초동안 쉬었다가 다시 동작을 재개하게 만드는 YieldInstruction의 한 형태인 WaitForSeconds )

 

- YunutyCycle.cpp

...
...
for (unsigned int i = 0; i < updateTargetComponentsSize; i++)
{
    if (!updateTargetComponents[i]->GetActive() || !updateTargetComponents[i]->GetGameObject()->GetActive())
        continue;
    UpdateComponent(updateTargetComponents[i]);
    for (auto coroutine : updateTargetComponents[i]->coroutines)
    {
        if (coroutine->Done() || coroutine->deathWish)
        {
            continue;
        }
        if (auto yield = coroutine->GetLastYield(); yield)
        {
            yield->Update();
            if (yield->ShouldResume())
            {
                coroutine->handle->promise().yield = nullptr;
                coroutine->resume();
            }
        }
        else
        {
            coroutine->resume();
        }
    }
    if (!updateTargetComponents[i]->coroutines.empty())
    {
        std::erase_if(updateTargetComponents[i]->coroutines, [](std::shared_ptr<yunutyEngine::coroutine::Coroutine> coroutine) {return coroutine->Done() || coroutine->deathWish; });

        if (!updateTargetComponents[i]->coroutines.empty())
        {
            updateTargetComponents[i]->GetGameObject()->HandleComponentUpdateState(updateTargetComponents[i]);
        }
    }
}
...
...

( 컴포넌트 객체를 Update할 때 Coroutine도 같이 업데이트하는 부분 )

 

 

 

 

 

 

5. RTS 프레임워크 개발

InWanderLand Contents.drawio
0.01MB
콘텐츠 코드의 전체 설계

 

 

 

 유닛, 유닛 컨트롤러, 투사체, 스킬, 상태이상과 같은 RTS 프레임워크 코드를 설계하고 구현했다. 이 중 가장 핵심이 되는 클래스는 유닛이었다. 이동, 공격, 스킬 시전과 같은 유닛의 상태는 행동 트리로 관리했다.

InWanderLand Unit Behaviour Tree.drawio
0.02MB
유닛의 상태를 관리하기 위한 행동 트리의 설계도

 

행동 트리와 관련된 코드

더보기

BehaviourTree.h

#include <vector>
#include <functional>
#include <unordered_set>
#include <unordered_map>

class BehaviourTree
{
public:
    struct Node
    {
    private:
        int nodeKey;
        std::unordered_map<int, Node> children;
        std::vector<Node*> childrenInOrder;
    public:
        // 재심청구 플래그, 이 플래그가 true이면 노드의 onExit 주기를 실행한 후 다시 조건을 확인한다.
        int GetNodeKey() const { return nodeKey; };
        std::function<void()> onEnter = []() {};
        std::function<void()> onExit = []() {};
        std::function<void()> onUpdate = []() {};
        std::function<bool()> enteringCondtion = []() {return false; };
        Node& operator[](int key);
        friend BehaviourTree;
    };
    const std::vector<const Node*>& GetActiveNodes()const { return activeOnCurrentUpdate; };
    void Update(int reassessCount = 0);
    static constexpr int maxReassessCount = 3;
    Node& operator[](int key);
    bool reAssessFlag = false;
private:
    std::vector<const Node*> activeOnLastUpdate;
    std::vector<const Node*> activeOnCurrentUpdate;
    std::unordered_map<int, Node> rootChildren;
    std::vector<Node*> rootChildrenInOrder;
};
BehaviourTree::Node& BehaviourTree::Node::operator[](int key)
{
    if (!children.contains(key))
    {
        children[key] = Node{};
        children[key].nodeKey = key;
        childrenInOrder.push_back(&children[key]);
    }
    return children[key];
}
void BehaviourTree::Update(int reassessCount)
{
    if (reassessCount > maxReassessCount)
        return;
    reAssessFlag = false;
    activeOnCurrentUpdate.clear();
    const auto* nodeChildren = &rootChildrenInOrder;
    while (nodeChildren && !nodeChildren->empty())
    {
        auto nodeChildrenBefore = nodeChildren;
        for (const auto& node : *nodeChildren)
        {
            if (node->enteringCondtion())
            {
                activeOnCurrentUpdate.push_back(node);
                nodeChildren = &node->childrenInOrder;
                break;
            }
        }
        assert(nodeChildrenBefore != nodeChildren, "behaviour node can't reach the leaf node.");
    }
    // 먼저 Exit를 순차적으로 부르고 다음 Enter들을 부른다.
    for (int i = activeOnLastUpdate.size() - 1; i >= 0 && reassessCount == 0; i--)
    {
        if (activeOnCurrentUpdate.size() - 1 < i || activeOnLastUpdate[i] != activeOnCurrentUpdate[i])
        {
            activeOnLastUpdate[i]->onExit();
        }
    }
    // Exit를 부른 상태에서 재심청구가 들어왔다면, 다시 Update문을 실행한다.
    if (reAssessFlag)
    {
        Update(reassessCount + 1);
        return;
    }
    for (int i = 0; i < activeOnCurrentUpdate.size(); i++)
    {
        if (((int)activeOnLastUpdate.size() - 1 < i) || (activeOnLastUpdate[i] != activeOnCurrentUpdate[i]))
        {
            activeOnCurrentUpdate[i]->onEnter();
        }
        activeOnCurrentUpdate[i]->onUpdate();
    }
    activeOnLastUpdate = activeOnCurrentUpdate;
}
BehaviourTree::Node& BehaviourTree::operator[](int key)
{
    if (!rootChildren.contains(key))
    {
        rootChildren[key] = Node{};
        rootChildren[key].nodeKey = key;
        rootChildrenInOrder.push_back(&rootChildren[key]);
    }
    return rootChildren[key];
}

 

 

Unit.cpp

...
...
unitBehaviourTree[UnitBehaviourTree::Hold][UnitBehaviourTree::Stop].enteringCondtion = [this]()
    {
        return true;
    };
unitBehaviourTree[UnitBehaviourTree::Hold][UnitBehaviourTree::Stop].onEnter = [this]()
    {
        OnStateEngage<UnitBehaviourTree::Stop>();
    };
unitBehaviourTree[UnitBehaviourTree::Hold][UnitBehaviourTree::Stop].onExit = [this]()
    {
        OnStateExit<UnitBehaviourTree::Stop>();
    };
unitBehaviourTree[UnitBehaviourTree::Move].enteringCondtion = [this]()
    {
        return CanProcessOrder<UnitOrderType::Move>();
    };
unitBehaviourTree[UnitBehaviourTree::Move].onEnter = [this]()
    {
        currentOrderType = pendingOrderType;
        pendingOrderType = UnitOrderType::None;
        OnStateEngage<UnitBehaviourTree::Move>();
    };
unitBehaviourTree[UnitBehaviourTree::Move].onExit = [this]()
    {
        OnStateExit<UnitBehaviourTree::Move>();
        if (pendingOrderType == UnitOrderType::None)
        {
            OrderHold();
            unitBehaviourTree.reAssessFlag = true;
        }
    };
unitBehaviourTree[UnitBehaviourTree::Move].onUpdate = [this]()
    {
        OnStateUpdate<UnitBehaviourTree::Move>();
    };
 ...
 ...

( 유닛의 초기화 코드 중 유닛이 가진 행동 트리의 각 노드의 진입조건, 진입시 실행할 함수, 해당 노드가 활성화되어있는 동안 실행할 업데이트 동작 등 노드의 속성을 지정하는 코드 )

( 빨간색 구는 유닛의 공격 사거리 안에 속한 적을 탐지하기 위한 충돌체, 회색 구는 포착 거리 내의 적을 탐지하기 위한 충돌체다. )

 

 유닛의 적 추적 범위, 사거리와 같이 일정 범위 안의 유닛을 탐색해야 하는 기능은 구형의 충돌체를 유닛의 위치에 붙이는 방식으로 구현했다.

 

 유닛을 탐색하는 데에 쓰이는 UnitAcquisitionCollider 클래스의 코드

더보기

UnitAcquistionCollider.h

#pragma once
#include "YunutyEngine.h"
#include "UnitAcquisitionCollider.h"

class Unit;
class UnitAcquisitionCollider : virtual public Component
{
public:
    // nullable
    std::weak_ptr<Unit> owner;
    int teamIndex{ 0 };
    // 콜라이더에 닿은 모든 유닛과 owner 기준 적, 아군 유닛들의 목록
    const std::unordered_set<Unit*>& GetUnits() { return units; }
    const std::unordered_set<Unit*>& GetEnemies() { return enemies; }
    const std::unordered_set<Unit*>& GetFriends() { return friends; }
    bool includeDeadUnits = false;
    bool includeInvulnerableUnits = false;
protected:
    virtual void Update() override;
    int GetTeamIndex();

    // 유닛이면 일단 집어넣는 리스트
    std::unordered_set<Unit*> unitsWhatSoEver;

    std::unordered_set<Unit*> units;
    std::unordered_set<Unit*> enemies;
    std::unordered_set<Unit*> friends;

    GameObject* debugMesh = nullptr;
private:
    bool ShouldContain(Unit* unit);
    virtual void OnTriggerEnter(physics::Collider* other) override;
    virtual void OnTriggerExit(physics::Collider* other) override;
};

 

UnitAcquistionCollider.cpp

#include "UnitAcquisitionCollider.h"
#include "InWanderLand.h"

bool UnitAcquisitionCollider::ShouldContain(Unit* unit)
{
    return (includeDeadUnits || unit->IsAlive()) && (includeInvulnerableUnits || !unit->IsInvulenerable());
}

void UnitAcquisitionCollider::OnTriggerEnter(physics::Collider* other)
{
    if (auto collider = other->GetGameObject()->GetComponent<UnitCollider>(); collider)
    {
        auto unit = collider->owner.lock().get();
        unitsWhatSoEver.insert(unit);
        bool includeDeadUnits = false;
        bool includeInvulnerableUnits = false;
        if (ShouldContain(unit))
        {
            units.insert(unit);
            int teamIndex = GetTeamIndex();
            int otherTeamIndex = unit->GetTeamIndex();
            if (teamIndex != otherTeamIndex)
            {
                enemies.insert(unit);
            }
            else
            {
                friends.insert(unit);
            }
        }
    }
}

void UnitAcquisitionCollider::OnTriggerExit(physics::Collider* other)
{
    if (!other) return;
    if (auto collider = other->GetGameObject()->GetComponent<UnitCollider>(); collider)
    {
        auto unit = collider->owner.lock().get();
        unitsWhatSoEver.erase(unit);
        units.erase(unit);
        enemies.erase(unit);
        friends.erase(unit);
    }
}

void UnitAcquisitionCollider::Update()
{
    units.clear();
    friends.clear();
    enemies.clear();
    std::copy_if(unitsWhatSoEver.begin(), unitsWhatSoEver.end(), std::inserter(units, units.end()), [this](Unit* const unit) { return ShouldContain(unit); });
    std::copy_if(units.begin(), units.end(), std::inserter(friends, friends.end()), [this](Unit* const unit) {return unit->GetTeamIndex() == GetTeamIndex(); });
    std::copy_if(units.begin(), units.end(), std::inserter(enemies, enemies.end()), [this](Unit* const unit) {return unit->GetTeamIndex() != GetTeamIndex(); });
}

int UnitAcquisitionCollider::GetTeamIndex()
{
    if (owner.expired())
        return teamIndex;
    else
        return owner.lock()->GetTeamIndex();
}

 

Unit 클래스의 멤버 변수로 쓰이며 각각 유닛의 공격범위, 유닛의 적 탐색 범위 역할을 수행하는 UnitAcquisitionCollider

...
...
    // 공격범위와 적 포착범위
    std::weak_ptr<UnitAcquisitionSphereCollider> attackRange;
    std::weak_ptr<UnitAcquisitionSphereCollider> acquisitionRange;
    std::weak_ptr<yunutyEngine::graphics::Animator> animatorComponent;
 ...
 ...

 

( 지면에서 문어발을 소환해 유닛을 중심으로 끌어모으는 스킬 )

 

 유닛의 스킬은 코루틴으로 구현했다.

 

코드 보기

더보기

지면에서 문어발을 소환해 유닛을 중심으로 끌어모으는 스킬의 코드

 

UrsulaParalysisSkill.cpp

coroutine::Coroutine UrsulaParalysisSkill::operator()()
{
    colliderEffectRatio = 3.0f * 0.5f;
    auto blockFollowingNavigation = owner.lock()->referenceBlockFollowingNavAgent.Acquire();
    auto blockAnimLoop = owner.lock()->referenceBlockAnimLoop.Acquire();
    auto disableNavAgent = owner.lock()->referenceDisableNavAgent.Acquire();
    animator = owner.lock()->GetAnimator();
    paralysisAnim = wanderResources::GetAnimation(owner.lock()->GetFBXName(), UnitAnimType::Skill2);

    owner.lock()->SetDefaultAnimation(UnitAnimType::None);
    owner.lock()->PlayAnimation(UnitAnimType::Skill2);

    effectColliderCoroutine = owner.lock()->StartCoroutine(SpawningFieldEffect(dynamic_pointer_cast<UrsulaParalysisSkill>(selfWeakPtr.lock())));
    effectColliderCoroutine.lock()->PushDestroyCallBack([this]()
        {
            tentacleAnimator->GetGI().SetPlaySpeed(1);
            waveAnimator->GetGI().SetPlaySpeed(1);
            waveVFXAnimator.lock()->SetSpeed(1);

            FBXPool::Instance().Return(tentacleObject);
            FBXPool::Instance().Return(waveObject);
            UnitAcquisitionSphereColliderPool::Instance().Return(damageCollider);
            UnitAcquisitionSphereColliderPool::Instance().Return(knockBackCollider);
        });

    while (!animator.lock()->IsDone())
    {
        co_await std::suspend_always{};
    }

    disableNavAgent.reset();
    blockFollowingNavigation.reset();
    owner.lock()->Relocate(owner.lock()->GetTransform()->GetWorldPosition());
    if (owner.lock()->GetPendingOrderType() == UnitOrderType::None)
        owner.lock()->OrderAttackMove();
    co_return;
}

 

 Unit 클래스에서 스킬 코루틴 객체를 실행하고 coroutineSkill 변수에 집어넣는 코드

 

Unit.cpp

template<>
void Unit::OnStateEngage<UnitBehaviourTree::SkillCasting>()
{
    assert(pendingSkill.get() != nullptr);
    SetDesiredRotation(pendingSkill.get()->targetPos - GetTransform()->GetWorldPosition());
    onGoingSkill = std::move(pendingSkill);
    coroutineSkill = StartCoroutine(onGoingSkill.get()->operator()());
    onSkillActivation(onGoingSkill);
}

 

 스킬 코루틴이 현재 진행중이라면 유닛 행동 트리의 SkillOnGoing 노드가 활성화된다.

 

Unit.cpp

...
...
  unitBehaviourTree[UnitBehaviourTree::Skill][UnitBehaviourTree::SkillOnGoing].enteringCondtion = [this]()
        {
            return !coroutineSkill.expired();
        };
...
...

유닛과 투사체같이 재활용할 수 있는 객체들은 오브젝트 풀에서 관리되도록 만들었다.

 

오브젝트 풀 관련 코드 보기

더보기

GameObjectPool.h

#pragma once
#include "Component.h"
#include "GameObject.h"
#include "Scene.h"
#include "SingletonClass.h"
#include <functional>
#include <unordered_set>
#include <deque>
#include <vector>

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

using namespace std;
using namespace yunutyEngine;
namespace yunutyEngine
{
    // 오브젝트가 필요하면 생성하고, 생성된 오브젝트의 용도가 다 끝나면 폐기하는 대신 비활성화만 시켜놨다가,
    // 다시 오브젝트에 대한 요청이 들어오면 재활성화 시키는 오브젝트 풀 객체입니다. 
    template<typename RepresenstativeComponent>
    class GameObjectPool
    {
        static_assert(std::is_base_of<Component, RepresenstativeComponent>::value, "only derived classes from component are allowed");
    public:
        virtual GameObject* GameObjectInitializer() { return Scene::getCurrentScene()->AddGameObject(); };
        // 빌려줄 오브젝트가 단 하나도 없을 경우, 활성화된 씬에서 게임 오브젝트를 생성한 뒤 RepresentativeComponent를 붙여줍니다.
        // 이 작업 이후 추가적으로 실행할 초기화 함수를 정의합니다.
        virtual void ObjectInitializer(std::weak_ptr<RepresenstativeComponent> comp) = 0;
        // 오브젝트를 하나 빌렸을 때 실행될 함수를 정의합니다. 굳이 정의 안해도 됩니다.
        virtual void OnBorrow(std::weak_ptr<RepresenstativeComponent> comp) {};
        // 게임 오브젝트 풀에 저장된 게임 오브젝트를 활성화합니다.
        std::weak_ptr<RepresenstativeComponent> Borrow();
        // 게임 오브젝트 풀에서 관리하는 게임 오브젝트를 되돌려 줍니다.
        void Return(std::weak_ptr<RepresenstativeComponent>);
        int poolObjectsSize() { return poolObjects.size(); };
        int expendableObjectsSize() { return expendableObjects.size(); };
        void Clear();

    protected:
        const deque<std::weak_ptr<RepresenstativeComponent>>& GetPoolObjects() {
            return poolObjects;
        }
    protected:
        std::deque<std::weak_ptr<RepresenstativeComponent>> poolObjects;
        std::deque<std::weak_ptr<RepresenstativeComponent>> expendableObjects;
        std::unordered_set<RepresenstativeComponent*> expendableObjectSet;
    };

    template<typename RepresenstativeComponent>
    std::weak_ptr<RepresenstativeComponent> GameObjectPool<RepresenstativeComponent>::Borrow()
    {
        if (expendableObjects.empty())
        {
            auto gameObject = GameObjectInitializer();
            auto component = gameObject->AddComponentAsWeakPtr<RepresenstativeComponent>();
            ObjectInitializer(component);
            poolObjects.push_back(component);
            expendableObjects.push_back(component);
        }
        auto target = expendableObjects.front();
        expendableObjects.pop_front();
        expendableObjectSet.erase(target.lock().get());
        OnBorrow(target);
        target.lock()->GetGameObject()->SetSelfActive(true);
        return target;
    }

    template<typename RepresenstativeComponent>
    void GameObjectPool<RepresenstativeComponent>::Return(std::weak_ptr<RepresenstativeComponent> obj)
    {
        if (expendableObjectSet.contains(obj.lock().get()))
        {
            return;
        }
        expendableObjects.push_back(obj);
        expendableObjectSet.insert(obj.lock().get());
        obj.lock()->GetGameObject()->SetSelfActive(false);
    }
    template<typename RepresenstativeComponent>
    inline void GameObjectPool<RepresenstativeComponent>::Clear()
    {
        for (auto each : poolObjects)
        {
            Scene::getCurrentScene()->DestroyGameObject(each.lock()->GetGameObject());
        }
        poolObjects.clear();
        expendableObjects.clear();
        expendableObjectSet.clear();
    }
}

 

UnitPool.h

#pragma once
#include "YunutyEngine.h"

namespace application
{
    namespace editor
    {
        class Unit_TemplateData;
        class UnitData;
    }
}
class Unit;

class UnitPool : public SingletonClass<UnitPool>
{
public:
    /// Editor 에서 Map 이 바뀔 때, 초기화하는 함수입니다.
    void Reset();
    std::weak_ptr<Unit> Borrow(application::editor::UnitData* data);
    std::weak_ptr<Unit> Borrow(application::editor::Unit_TemplateData* td, const Vector3d& position, float rotation);
    std::weak_ptr<Unit> Borrow(application::editor::Unit_TemplateData* td, const Vector3d& position, const Quaternion& rotation);
    void Return(std::weak_ptr<Unit>);
private:
    std::weak_ptr<Unit> Borrow(application::editor::Unit_TemplateData* td);
    class PoolByTemplate : public GameObjectPool<Unit>
    {
    public:
        application::editor::Unit_TemplateData* templateData{ nullptr };
        virtual void ObjectInitializer(std::weak_ptr<Unit> unit) override;
        virtual void OnBorrow(std::weak_ptr<Unit> unit) override;
    };
    std::unordered_map<const application::editor::Unit_TemplateData*, std::shared_ptr<PoolByTemplate>> poolsByTemplate;
};

 

ProjectilePool.h

#pragma once
#include "YunutyEngine.h"
#include "Projectile.h"
#include "ProjectileType.h"
#include "InWanderLand.h"
#include <DirectXMath.h>

class Projectile;
struct ProjectileType;
struct ProjectileHoming;
// ProjectilePool은 fbx 이름별로 여러가지 풀들을 갖고 있다.
// ProjectilePool은 풀 집합체와 같다고 할 수 있다.
//template <typename T>
class ProjectilePool : public SingletonClass<ProjectilePool>
{
public:
    std::weak_ptr<Projectile> Borrow(std::weak_ptr<Unit> owner, std::weak_ptr<Unit> opponent);
    void Return(std::weak_ptr<Projectile>);
private:
    class PoolByMesh : public GameObjectPool<Projectile>
    {
    public:
        string fbxname;
        virtual void ObjectInitializer(std::weak_ptr<Projectile> projectile) override;
    };
    std::unordered_map<string, std::shared_ptr<PoolByMesh>> poolsByFBX;
    std::unordered_map<Projectile*, std::weak_ptr<PoolByMesh>> poolsByProjectile;
};

 

 

 

 

 

 

6. Wander 맵 에디터

 게임에 쓰이는 맵 에디터는 워크래프트 3의 맵 에디터와 유사하게 개발했다.

( 통행 가능한 지형 마킹, 좌 : 워크래프트3 에디터, 우 : Wander 맵 에디터 )

 

( 밸런스 수치 변경 )
( 장식물 배치 )
( 유닛 배치 )
( 트리거 볼륨 역할을 하는 지역 배치 )


 워크래프트3 에디터에서 특히나 유용하게 참고한 기능은 블록 코딩 기반 스크립팅 기능이었다. 이를 통해 레벨 디자이너가 게임의 특수 이벤트와 컷신을 쉽게 만들 수 있었다.

워크래프트 3 에디터의 블록 코딩 스크립트로 구현한 컷신

 

( Wander 에디터의 블록 코딩 스크립트로 구현한 컷신 )

 

 또 우리 게임에 필요한 기능으로 유니티에서 배치된 장식물들의 트랜스폼 정보와 라이트맵 정보를 임포트하는 기능, 특정 지역에 진입하면 투명하게 처리할 장식물들을 지정하는 기능, 적군 웨이브를 배치하는 기능을 구현했다.

 [ WanderEditor가 Import Unreal Data를 통해 장식물의 배치와 라이트맵 정보를 들이는 모습 ]

( 에디터에서 지역별로 비활성화시킬 장식물들을 지정해두면 인게임에서 주인공 일행이 해당 지역에 진입할 때 지정된 장식물들이 투명처리된다. )
( 에디터에서 웨이브 유닛들을 시간별로 배치해두면 인게임 웨이브가 진행되면서 유닛들이 순차로 등장한다. )

 

 

 

 

 

 

7. UI

( UI는 유니티에서 배치된 UI 객체들의 정보를 임포트하는 방식으로 구현했다. )

 

 유니티에서 배치된 UI 정보들을 게임에서 임포트할 수 있도록 만들었다. 또한 UI 객체에 속성을 부여할수 있게 만들어 UI 창이 등장하거나 사라질 때 다양한 연출을 넣을 수 있도록 했다. UI 객체를 특정할 수 있어야 하는 경우, Enum으로 ID를 부여했다.

 

UI 익스포트 관련 코드 보기

더보기

JsonUIData.h

#pragma once
#include "Storable.h"
#include "JsonUIFloatType.h"

struct JsonUIData
{
    // string imageName;
    std::string uiName;
    std::array<float, JsonUIFloatType::NUM> floats;
    int uiIndex;
    // 부모가 없다면 부모를 1920 * 1080 크기의 스크린 스페이스로 가정하게 된다.
    int parentUIIndex = -1;
    std::string imagePath;
    std::string koreanImagePath;
    std::string englishImagePath;
    std::string japaneseImagePath;
    int imagePriority;
    // 만약 플래그에 openingButton이 있다면 버튼을 눌렀을 때 활성화시킬 UI 창을 의미한다.
    std::vector<int> openTargets;
    // 만약 플래그에 diablingButton이 있다면 버튼을 눌렀을 때 비활성화시킬 UI 창을 의미한다.
    std::vector<int> closeTargets;
    std::vector<int> disablePropagationTargets;
    std::vector<int> enablePropagationTargets;
    std::vector<int> hoverEnableTargets;
    std::vector<float> color;
    std::vector<float> pivot;
    std::vector<float> anchor;
    std::vector<float> anchoredPosition;
    std::vector<float> enableOffset;
    std::vector<float> disableOffset;
    bool popUpX, popUpY, popUpZ;
    bool popDownX, popDownY, popDownZ;
    // 숫자를 표현할 때, 각 자릿수별로 숫자 폰트 이미지를 갈아치울 UI 객체들을 의미합니다.
    std::vector<int> numberDigits;
    // 숫자를 표현할 때 사용할 숫자 폰트 이미지 세트를 의미합니다.
    int numberFontSet;
    // 0~9까지의 숫자 폰트 이미지를 매핑합니다.
    std::vector<int> numberFontSetImages;
    // 숫자를 표현할 때, 올림처리하면 true, 내림처리하면 false입니다.
    bool numberCeil;
    // 0을 표시할지 여부입니다.
    bool numberShowZero;
    std::string soundOnClick;
    std::string soundOnHover;
    std::string soundOnEnable;
    std::string soundOnDisable;
    int enableCurveType;
    int disableCurveType;
    // 업그레이드 버튼의 경우, 활성화하기 위해 필요한 다른 버튼의 인덱스를 의미합니다.
    int dependentUpgrade{ -1 };
    vector<float> linearClipOnEnableStart;
    vector<float> linearClipOnEnableDir;
    int linearClipOnEnableCurveType;
    vector<float> linearClipOnDisableStart;
    vector<float> linearClipOnDisableDir;
    int linearClipOnDisableCurveType;
    vector<float> colorTintOnEnableStart;
    vector<float> colorTintOnEnableEnd;
    int colorTintOnEnableCurveType;
    vector<float> colorTintOnDisableStart;
    vector<float> colorTintOnDisableEnd;
    int colorTintOnDisableCurveType;
    // 전체 셀의 갯수
    int barCells_CellNumber;
    // 임의로 사용하게 될 사용자 플래그
    bool disableOnStartEdtior;
    bool disableOnStartExe;
    string musicPlayOnEnable_musicClip;
    string musicPlayOnDisable_musicClip;
    std::vector<int> exclusiveEnableGroup;
    string animatedSpriteFolderPath;
    bool animatedSpriteIsRepeat;
    bool duplicate_poolable;
    string videoPath1;
    string videoPath2;
    bool videoUnscaledDeltaTime;
    // 숫자를 가운데 정렬할지의 여부입니다.
    bool centerAlign;
    bool reAlignOnDisable;

    int customFlags;
    int customFlags2;
    // UI의 고유한 EnumID
    int enumID;
    // 사운드 매핑을 위한 ID
    int soundEnumID;
    FROM_JSON(JsonUIData);
};

(Json 형식으로 저장되는 UI 객체 하나의 정보)

 

UIExportFlag.h

enum class UIExportFlag
{
    None = 0,
    // 마우스가 올라갔을 때, 마우스가 클릭했을 때 등의 상호작용을 받습니다.
    IsButton = 1 << 0,
    // 특정 UI들을 닫는 용도로 쓰입니다.
    CloseButton = 1 << 1,
    // 특정 UI들을 여는 용도로 쓰입니다.
    OpeningButton = 1 << 2,
    // 비활성화시 그레이 스케일 적용된 이미지를 출력합니다.
    GrayScaleDisable = 1 << 4,
    // 활성화될때 팝업 효과를 줍니다.
    IsPoppingUp = 1 << 5,
    // 비활성화될때 사이즈를 줄입니다.
    IsPoppingDown = 1 << 6,
    // 버튼 위에 마우스 포인터가 올라왔을 때 자식 오브젝트들을 보여줍니다.
    IsIncludingTooltips = 1 << 7,
    // 게임을 시작할때 비활성화된 채로 시작합니다.
    DisableOnStart = 1 << 8,
    // 버튼이 토글 동작을 할때 사용됩니다.
    IsToggle = 1 << 9,
    // UI 이미지가 활성화될때 트랜슬레이션 오프셋 애니메이션을 재생합니다.
    IsTranslatingOnEnable = 1 << 10,
    // UI 이미지가 비활성화될때 트랜슬레이션 오프셋 애니메이션을 재생합니다.
    IsTranslatingOnDisable = 1 << 11,
    // 체력, 마나와 같은 값을 70 / 100과 같은 형태로 표시하는 텍스트입니다.
    // Adjuster에 들어가는 실수 값을 분자로 사용하며, 최대값이 갱신되면 분모도 갱신됩니다.
    //IsGuageText = 1 << 12,
    // 0~1 사이의 값으로 UI 이미지의 세로 크기를 조절할 수 있습니다.
    CanAdjustHeight = 1 << 13,
    // 0~1 사이의 값으로 UI 이미지의 가로 크기를 조절할 수 있습니다.
    CanAdjustWidth = 1 << 14,
    // 0~1 사이의 값으로 UI 이미지의 Radial Fill 각을 조절할 수 있습니다.
    CanAdjustRadialFill = 1 << 15,
    // 원본 이미지가 그저 사각형 흰색 이미지인지 나타냅니다.
    IsBlankImage = 1 << 16,
    // 이 이미지 아래에 깔린 버튼은 클릭이 가능합니다.
    NoOverlaying = 1 << 17,
    // 이미지가 커졌다가 작아지는 애니메이션을 반복적으로 재생합니다.
    IsPulsing = 1 << 18,
    ///////////////////////////
    // 여러 자리의 숫자를 표현합니다. 기본적으로 내림으로 연산이 되나, 0~1 사이의 값을 올림처리할지, 내림처리할지는 속성값에 따라 결정됩니다.
    IsNumber = 1 << 19,
    // 클릭할 때 효과음을 재생합니다.
    PlaySoundOnClick = 1 << 20,
    // 마우스가 올라갔을 때 효과음을 재생합니다.
    PlaySoundOnHover = 1 << 21,
    // UI 객체가 활성화될때 효과음을 재생합니다.
    PlaySoundOnEnable = 1 << 22,
    // UI 객체가 비활성화될때 효과음을 재생합니다.
    PlaySoundOnDisable = 1 << 23,
    // 자식 게임 객체들의 위치를 인덱스별로 저장해뒀다가, 활성화된 자식 객체들을 인덱스별로 저장된 위치에 자식 인덱스 순서대로 배치합니다.
    PriorityLayout = 1 << 24,
    // 0~9까지의 숫자 폰트를 의미하는 폰트 이미지 세트를 자식으로 가집니다. 이 이미지 세트는 IsNumber 속성을 가진 UI객체가 폰트 이미지를 바꾸는 데에 사용됩니다.
    IsDigitFont = 1 << 25,
    // 스킬 업그레이드에 쓰이는 버튼입니다. 선행 업그레이드를 가질 수 있습니다.
    IsSkillUpgrade = 1 << 26,
    // UI의 크기가 화면의 해상도에 따라 달라집니다.
    // 1920, 1080 해상도에서 만들어진 UI 이미지를 1280, 720 해상도에서 사용할 때, 이미지의 크기를 0.6667배로 조정합니다.
    ScaledByResolution = 1 << 27,
    // UI가 활성화될때 시간을 멈춥니다.
    TimeStopOnEnable = 1 << 28,
    // UI가 비활성화될때 시간정지를 해제합니다.
    TimeContinueOnDisable = 1 << 29,
    // UI가 활성화될때 투명도 애니메이션을 재생합니다.
    ColorTintOnEnable = 1 << 30,
    // UI가 비활성화될때 투명도 애니메이션을 재생합니다.
    ColorTintOnDisable = 1 << 31,

};
enum class UIExportFlag2
{
    None = 0,
    LinearClipOnEnable = 1 << 0,
    LinearClipOnDisable = 1 << 1,
    Duplicatable = 1 << 2,
    // 체력바, 마나 바 등 연속적인 데이터의 수치를 추정하기 위해 게이지의 일정단위마다 셀을 끊어 표시하고 싶을때 사용됩니다.
    IsBarCells = 1 << 3,
    // 0일때 하나도 클립 안함, 1일때 완전히 클립함
    AdjustLinearClip = 1 << 4,
    PlayMusicOnEnable = 1 << 5,
    PlayMusicOnDisable = 1 << 6,
    PauseMusicOnEnable = 1 << 7,
    UnPauseMusicOnDisable = 1 << 8,
    MultiplyMusicVolumeOnEnableDisable = 1 << 9,
    ExclusiveEnable = 1 << 10,
    DisableAfterEnable = 1 << 11,
    Dialogue_Manual = 1 << 12,
    Dialogue_Timed = 1 << 13,
    RedundantEnable = 1 << 14,
    AnimatedSprite = 1 << 15,
    ScriptUI = 1 << 16,
    EnumSound = 1 << 17,
    Rotating = 1 << 18,
    StartGameButton = 1 << 19,
    ReturnToTitleButton = 1 << 20,
    Video = 1 << 21,
    PropagateEnable = 1 << 22,
    PropagateDisable = 1 << 23,
    CapsuleClip = 1 << 24,
    DisableOnlyOnImport = 1 << 25,
    MultiLanguage = 1 << 26,
};

( UI 객체의 속성을 나타내는 플래그 )

 

UIEnumID

#pragma once
#include "PodStructs.h"

enum class UIEnumID
{
    // 아무것도 아님.
    None = 0,
    // 로빈, 우르술라, 헨젤의 초상화, 초상화 위의 이름 태그, 초상화를 가리는 적색 부상 오버레이, 체력바, 체력을 표시하는 텍스트 UI
    CharInfo_Robin,
    CharInfo_Ursula,
    CharInfo_Hansel,
    CharInfo_Robin_Left,
    CharInfo_Ursula_Left,
    CharInfo_Hansel_Left,
    CharInfo_Portrait,
    CharInfo_PortraitBloodOverlay,
    CharInfo_NameTag,
    CharInfo_HP_Fill,
    CharInfo_HP_Number_Current,
    CharInfo_HP_Number_Max,
    CharInfo_Buff_Bleeding,
    CharInfo_Buff_Blinding,
    CharInfo_Buff_Paralysis,
    CharInfo_Buff_KnockBack,
    CharInfo_Buff_Taunted,
    CharInfo_Buff_UrsulaSelf,
    CharInfo_Buff_HanselBuff,
    CharInfo_Buff_HanselDebuff,
    CharInfo_Skill_Use_Q,
    CharInfo_Skill_Use_Q_Overlay,
    CharInfo_Skill_Use_Q_Cooltime_Number,
    CharInfo_Skill_Use_W,
    CharInfo_Skill_Use_W_Overlay,
    CharInfo_Skill_Use_W_Cooltime_Number,
    // 전술모드 토글버튼
    Toggle_TacticMode,
    // 전술모드 토글버튼 흑백 오버레이
    Toggle_TacticMode_Overlay,
    Toggle_TacticMode_Cooltime_Number,
    // 화면 하단 마나 게이지
    ManaBar1,
    // 전술 모드 마나 게이지
    ManaBar2,
    // 화면 하단 마나 게이지 중 사용 대상이 되는 마나 오버레이
    ManaBarSpendOverlay1,
    // 전술 모드 마나 게이지 중 사용 대상이 되는 마나 오버레이
    ManaBarSpendOverlay2,
    // 현재 마나량을 나타내는 텍스트
    Mana_Text_CurrentMP,
    Mana_Text_CurrentMP_Tactic,
    // 최대 마나량을 나타내는 텍스트
    Mana_Text_MaxMP,
    Mana_Text_MaxMP_Tactic,
    // 사운드가 켜진 상태에서 표시할 UI버튼
    Sound_On,
    // 사운드가 꺼진 상태에서 표시할 UI버튼
    Sound_Off,
    // 음악이 켜진 상태에서 표시할 UI버튼
    Music_On,
    // 음악이 꺼진 상태에서 표시할 UI버튼
    Music_Off,
    // 인게임에서 사용되는 캐릭터 상태창, 전술모드 진입 버튼을 포함하는 하단 레이아웃
    Ingame_Bottom_Layout,
    // 인게임에서 사용되는 메뉴버튼
    Ingame_MenuButton,
    // 콤보 횟수를 표시함
    Ingame_Combo_Number,
    // "Combo"라는 문자를 표시함
    Ingame_Combo_Text,
    Ingame_Combo_DescriptionTitleImg,
    // 어떤 콤보를 달성해야 하는지 설명하는 텍스트, 몇 콤보를 달성해야 하는지에 대한 표시, 콤보 달성을 표시하는 체크박스의 V자 문양
    Ingame_Combo_Description1,
    Ingame_Combo_DescriptionImageUnfinished1,
    Ingame_Combo_DescriptionImageFinished1,
    Ingame_Combo_TargetNumUnfinished1,
    Ingame_Combo_TargetNumFinished1,
    Ingame_Combo_Check1,
    Ingame_Combo_Description2,
    Ingame_Combo_DescriptionImageUnFinished2,
    Ingame_Combo_DescriptionImageFinished2,
    Ingame_Combo_TargetNumUnfinished2,
    Ingame_Combo_TargetNumFinished2,
    Ingame_Combo_Check2,
    Ingame_Combo_Description3,
    Ingame_Combo_DescriptionImageUnFinished3,
    Ingame_Combo_DescriptionImageFinished3,
    Ingame_Combo_TargetNumUnfinished3,
    Ingame_Combo_TargetNumFinished3,
    Ingame_Combo_Check3,
    Ingame_Vinetting,
    BlackMask_Alpha,
    BlackMask_RightToLeft,
    BlackMask_TopToBottom,
    BlackMask_LeftToRight,
    BlackMask_BottomToTop,
    // 게임을 시작할 때, 메인화면으로 돌아갈 때에 사용되는 블랙마스크, 단순 연출용이 아니라 실제 게임로드, 종료를 위한 기능이 있다.
    BlackMask_GameLoad,
    BlackMask_GameEnd,
    LetterBox_Top,
    LetterBox_Bottom,
    // 인게임 하단에서 클릭할 시 스킬트리 메뉴를 여는 메뉴버튼
    InGame_SkiltreeMenu_Active,
    InGame_SkiltreeMenu_InActive,
    SkillPoint_Number,
    SkillUpgradeButtonRobin00,
    SkillUpgradeButtonRobin11,
    SkillUpgradeButtonRobin12,
    SkillUpgradeButtonRobin21,
    SkillUpgradeButtonRobin22,
    SkillUpgradeButtonUrsula00,
    SkillUpgradeButtonUrsula11,
    SkillUpgradeButtonUrsula12,
    SkillUpgradeButtonUrsula21,
    SkillUpgradeButtonUrsula22,
    SkillUpgradeButtonHansel00,
    SkillUpgradeButtonHansel11,
    SkillUpgradeButtonHansel12,
    SkillUpgradeButtonHansel21,
    SkillUpgradeButtonHansel22,
    SkillUpgradeButton_UpgradedImage,
    SkillUpgradeButton_Upgradable,
    SkillUpgradeButton_InUpgradableImage,
    PopUpMessage_NotEnoughSP,
    PopUpMessage_RequirementNotMet,
    PopUpMessage_PermissionForUpgrade,
    PopUpMessage_PermissionForUpgradeProceedButton,
    PopUpMessage_WarningForRestart,
    PopUpMessage_WarningForRestart_ProceedButton,
    StatusBar_Elite,
    StatusBar_MeleeEnemy,
    StatusBar_Hero_Robin,
    StatusBar_Hero_Ursula,
    StatusBar_Hero_Hansel,
    StatusBar_Boss,
    StatusBar_LeftDoor,
    StatusBar_RightDoor,
    StatusBar_Boss_Tactic,
    StatusBar_LeftDoor_Tactic,
    StatusBar_RightDoor_Tactic,
    StatusBar_HP_Number_Current,
    StatusBar_HP_Number_Max,
    StatusBar_HP_Cells,
    StatusBar_HP_Fill,
    StatusBar_SelectionName,
    TitleRoot,
    Quit_Proceed,
    MouseCursor,
    MouseCursor_Free,
    MouseCursor_Skill,
    MouseCursor_AttackMove,
    MouseCursor_OnButton,
    MouseCursor_OnEnemy,
    MouseCursor_OnAlly,
    MoveTargetFeedbackAnimSprites,
    AttackMoveTargetFeedbackAnimSprites,
    ErrorPopup_NoMana,
    ErrorPopup_Cooltime,
    ErrorPopup_CantLand,
    ErrorPopup_TacticQueueFull,
    VictoryPage,
    DefeatPage,
    TacticModeIngameUI,
    TacticModeRevertButton_Active,
    TacticModeRevertButton_InActive,
    TacticModeCommandIcon1,
    TacticModeCommandIcon2,
    TacticModeCommandIcon3,
    TacticModeCommandIcon4,
    TacticModeCommandIcon5,
    TacticModeCommandIcon6,
    TacticModeCommandIcon7,
    TacticModeCommandIcon8,
    TacticModeCommandIcon9,
    TacticModeCommandIcon10,
    TacticModeCommandIcon11,
    TacticModeCommandIcon12,
    BeaconOutside_Robin,
    BeaconOutside_Ursula,
    BeaconOutside_Hansel,
    BeaconOutside_Arrow,
    // 일반 잡졸들에 적용되는 데미지 표시기
    DamageIndicator_Default,
    DamageIndicator_Critical,
    DamageIndicator_Default_RedFont,
    DamageIndicator_Critical_RedFont,
    DamageIndicator_Default_BlueFont,
    DamageIndicator_Critical_BlueFont,
    DamageIndicator_Default_Cyan,
    DamageIndicator_Critical_Cyan,
    DamageIndicator_Default_BlackWhiteFont,
    DamageIndicator_Critical_BlackWhiteFont,
    DamageIndicator_Missed,
    DamageIndicator_Number,
    // 보스전때에만 출력되는 UI들
    BossUI_Default,
    BossUI_Tactic,
    CheckPointReached,
    LoadCheckPointButton1,
    LoadCheckPointButton2,
    InGameMenu,
    Localization,
};

( 특정 UI들의 고유한 ID를 나타내는 Enum )

 

UIYutility.cs, 유니티에서 UI 데이터를 내보내는데 쓰이는 클래스

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditor;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.IO;
using UnityEngine.Windows.Speech;
using Newtonsoft.Json.Serialization;
using UnityEngine.UI;
using UnityEngine.Assertions;
using JetBrains.Annotations;
using System;
using System.Linq;

public class UIYutility : MonoBehaviour
{
    [Serializable]
    public class UIDataList : List<UIExportJsonData>
    {
        public List<UIExportJsonData> dataList = new List<UIExportJsonData>();
    }
    [MenuItem("Yutility/Export ui data")]
    private static void ExportUIData()
    {
        string path = EditorUtility.SaveFilePanel("Save file as...", "", "new iwui data", "iwui");
        if (path == "")
        {
            return;
        }
        Dictionary<GameObject, int> uiIDDictanary = new Dictionary<GameObject, int>();
        HashSet<string> foundManualDialogues = new HashSet<string>();
        HashSet<string> foundTimedDialogues = new HashSet<string>();
        HashSet<string> foundScriptUI = new HashSet<string>();
        HashSet<SoundEnumComp.SoundEnumID> foundSounds = new HashSet<SoundEnumComp.SoundEnumID>();
        HashSet<UIEnumIDComp.UIEnumID> foundUIIds = new HashSet<UIEnumIDComp.UIEnumID>();
        UIDataList uilist = new UIDataList();
        //List<UIExportJsonData> uIExportJsonDatas = new List<UIExportJsonData>();
        bool failedAlready = false;
        string jsonString = "";
        List<GameObject> roots = new List<GameObject>();
        List<GameObject> taggedObjects = new List<GameObject>();
        UnityEngine.SceneManagement.Scene currentScene = SceneManager.GetActiveScene();
        currentScene.GetRootGameObjects(roots);
        foreach (var each in roots)
        {
            DFS(each, "UIExportTarget", taggedObjects, uiIDDictanary);
        }
        foreach (var each in taggedObjects)
        {
            try
            {
                each.transform.localScale = Vector3.one;
                UIExportJsonData data = new UIExportJsonData();
                data.floats = new float[(int)JsonUIFloatType.NUM];
                data.uiName = each.name;
                data.uiIndex = uiIDDictanary[each];
                if (each.transform.parent && each.transform.parent.tag == "UIExportTarget")
                {
                    data.parentUIIndex = uiIDDictanary[each.transform.parent.gameObject];
                }
                var imageComp = each.GetComponent<UnityEngine.UI.Image>();
                if (imageComp)
                {
                    data.imagePath = AssetDatabase.GetAssetPath(imageComp.sprite);
                    data.imagePath = data.imagePath.Replace("Assets/Resources/", "");
                    if (data.imagePath == "")
                    {
                        data.customFlags |= (int)UIExportFlag.IsBlankImage;
                    }
                    data.color = new float[] { imageComp.color.r, imageComp.color.g, imageComp.color.b, imageComp.color.a };
                }
                var rectTransform = each.GetComponent<RectTransform>();
                Assert.IsTrue(rectTransform.anchorMin == rectTransform.anchorMax);
                data.pivot = new float[] { rectTransform.pivot.x, rectTransform.pivot.y };
                data.anchor = new float[] { rectTransform.anchorMax.x, rectTransform.anchorMax.y };
                data.anchoredPosition = new float[] { rectTransform.anchoredPosition.x, rectTransform.anchoredPosition.y };
                data.floats[(int)JsonUIFloatType.rotation] = rectTransform.localRotation.eulerAngles.z;
                data.floats[(int)JsonUIFloatType.width] = rectTransform.sizeDelta.x;
                data.floats[(int)JsonUIFloatType.height] = rectTransform.sizeDelta.y;
                // 임의로 사용하게 될 사용자 플래그


                if (each.GetComponent<AnchoredPosMustBeZero>())
                {
                    data.anchoredPosition[0] = 0;
                    data.anchoredPosition[1] = 0;
                }
                if (each.GetComponent<UIDoesPopup>())
                {
                    var comp = each.GetComponent<UIDoesPopup>();
                    data.customFlags |= (int)UIExportFlag.IsPoppingUp;
                    data.floats[(int)JsonUIFloatType.popUpDuration] = comp.popUpDuration;
                    data.floats[(int)JsonUIFloatType.popUpFrom] = comp.popUpFrom;
                    data.floats[(int)JsonUIFloatType.popUpTo] = comp.popUpTo;
                    data.popUpX = comp.popUpX;
                    data.popUpY = comp.popUpY;
                    data.popUpZ = comp.popUpZ;
                }
                if (each.GetComponent<UIDoesPopDown>())
                {
                    var comp = each.GetComponent<UIDoesPopDown>();
                    data.customFlags |= (int)UIExportFlag.IsPoppingDown;
                    data.floats[(int)JsonUIFloatType.popDownDuration] = comp.popDownDuration;
                    data.floats[(int)JsonUIFloatType.popDownFrom] = comp.popDownFrom;
                    data.floats[(int)JsonUIFloatType.popDownTo] = comp.popDownTo;
                    data.popDownX = comp.popDownX;
                    data.popDownY = comp.popDownY;
                    data.popDownZ = comp.popDownZ;
                }
                if (each.GetComponent<UIIncludingTooltips>())
                {
                    var comp = each.GetComponent<UIIncludingTooltips>();
                    data.customFlags |= (int)UIExportFlag.IsButton;
                    data.customFlags |= (int)UIExportFlag.IsIncludingTooltips;
                    data.hoverEnableTargets = comp.tooltips.Select(each => uiIDDictanary[each]).ToArray();
                }
                if (each.GetComponent<UIIsOpeningButton>())
                {
                    var comp = each.GetComponent<UIIsOpeningButton>();
                    data.customFlags |= (int)UIExportFlag.IsButton;
                    data.customFlags |= (int)UIExportFlag.OpeningButton;
                    if (each.GetComponent<UIIsOpeningButton>().canToggle)
                    {
                        data.customFlags |= (int)UIExportFlag.IsToggle;
                    }
                    if (comp.targets != null)
                    {
                        foreach (var eachOpenTarget in comp.targets)
                        {
                            if (eachOpenTarget == null)
                            {
                                Debug.LogError("null target at " + comp.gameObject.name, comp.gameObject);
                            }
                        }
                        data.openTargets = comp.targets.Where(each => each != null).Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<UICloseButton>())
                {
                    var comp = each.GetComponent<UICloseButton>();
                    data.customFlags |= (int)UIExportFlag.IsButton;
                    data.customFlags |= (int)UIExportFlag.CloseButton;
                    if (comp.targets != null)
                    {
                        foreach (var eachCloseTarget in comp.targets)
                        {
                            if (eachCloseTarget == null)
                            {
                                Debug.LogError("null target at " + comp.gameObject.name, comp.gameObject);
                            }
                        }
                        data.closeTargets = comp.targets.Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<PropagateDisable>())
                {
                    var comp = each.GetComponent<PropagateDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.PropagateDisable;
                    if (comp.targets != null)
                    {
                        data.disablePropagationTargets = comp.targets.Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<PropagateEnable>())
                {
                    var comp = each.GetComponent<PropagateEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.PropagateEnable;
                    if (comp.targets != null)
                    {
                        data.enablePropagationTargets = comp.targets.Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<UITranslatingOnEnable>())
                {
                    var comp = each.GetComponent<UITranslatingOnEnable>();
                    data.customFlags |= (int)UIExportFlag.IsTranslatingOnEnable;
                    data.floats[(int)JsonUIFloatType.enableDelay] = comp.delayTime;
                    data.floats[(int)JsonUIFloatType.enableDuration] = comp.transitioningDuration;
                    data.enableOffset = new float[] { comp.offset.x, comp.offset.y };
                    data.enableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<UITranslatingOnDisable>())
                {
                    var comp = each.GetComponent<UITranslatingOnDisable>();
                    data.customFlags |= (int)UIExportFlag.IsTranslatingOnDisable;
                    data.floats[(int)JsonUIFloatType.disableDelay] = comp.delayTime;
                    data.floats[(int)JsonUIFloatType.disableDuration] = comp.transitioningDuration;
                    data.disableOffset = new float[] { comp.offset.x, comp.offset.y };
                    data.disableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<CanAdjustHeight>())
                {
                    data.customFlags |= (int)UIExportFlag.CanAdjustHeight;
                    data.floats[(int)JsonUIFloatType.adjustingRate] = each.GetComponent<CanAdjustHeight>().adjustingRate;
                }
                if (each.GetComponent<CanAdjustWidth>())
                {
                    data.customFlags |= (int)UIExportFlag.CanAdjustWidth;
                    data.floats[(int)JsonUIFloatType.adjustingRate] = each.GetComponent<CanAdjustWidth>().adjustingRate;
                }
                if (each.GetComponent<CanAdjustRadialFill>())
                {
                    data.customFlags |= (int)UIExportFlag.CanAdjustRadialFill;
                    data.floats[(int)JsonUIFloatType.adjustingRate] = each.GetComponent<CanAdjustRadialFill>().adjustingRate;
                }
                if (each.GetComponent<DisableOnStart>())
                {
                    data.customFlags |= (int)UIExportFlag.DisableOnStart;
                    data.disableOnStartEdtior = each.GetComponent<DisableOnStart>().disableOnStartEditor;
                    data.disableOnStartExe = each.GetComponent<DisableOnStart>().disableOnStartExe;
                    if (each.GetComponent<DisableOnStart>().disableOnlyOnImport)
                    {
                        data.customFlags2 |= (int)UIExportFlag2.DisableOnlyOnImport;
                    }
                }
                if (each.GetComponent<UINoOverlaying>())
                {
                    data.customFlags |= (int)UIExportFlag.NoOverlaying;
                }
                if (each.GetComponent<CapsuleClip>())
                {
                    data.customFlags2 |= (int)UIExportFlag2.CapsuleClip;
                }
                if (each.GetComponent<IsPulsing>())
                {
                    var comp = each.GetComponent<IsPulsing>();
                    data.customFlags |= (int)UIExportFlag.IsPulsing;
                    data.floats[(int)JsonUIFloatType.pulsingMax] = comp.pulsingSizeMax;
                    data.floats[(int)JsonUIFloatType.pulsingMin] = comp.pulsingSizeMin;
                    data.floats[(int)JsonUIFloatType.pulsingPeriod] = comp.pulsingPeriod;
                }
                if (each.GetComponent<PlaySoundOnClick>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnClick;
                    data.soundOnClick = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnClick>().sound);
                    data.soundOnClick = data.soundOnClick.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<PlaySoundOnHover>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnHover;
                    data.soundOnHover = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnHover>().sound);
                    data.soundOnHover = data.soundOnHover.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<PlaySoundOnEnable>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnEnable;
                    data.soundOnEnable = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnEnable>().sound);
                    data.soundOnEnable = data.soundOnEnable.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<PlaySoundOnDisable>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnDisable;
                    data.soundOnDisable = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnDisable>().sound);
                    data.soundOnDisable = data.soundOnDisable.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<IsNumber>())
                {
                    var comp = each.GetComponent<IsNumber>();
                    data.customFlags |= (int)UIExportFlag.IsNumber;
                    data.numberCeil = comp.ceil;
                    data.numberShowZero = comp.showZero;
                    try
                    {
                        data.numberDigits = comp.digits.Select(each => uiIDDictanary[each.gameObject]).ToArray();
                    }
                    catch (Exception e)
                    {
                        Debug.LogError(e.Message, each.gameObject);
                        throw e;
                    }
                    data.numberFontSet = uiIDDictanary[comp.font.gameObject];
                }
                if (each.GetComponent<PriorityLayout>())
                {
                    var comp = each.GetComponent<PriorityLayout>();
                    data.customFlags |= (int)UIExportFlag.PriorityLayout;
                    data.floats[(int)JsonUIFloatType.layoutNormalizingTime] = comp.normalizingTime;
                    data.centerAlign = comp.centerAlign;
                    data.reAlignOnDisable = comp.reAlignOnDisable;
                }
                if (each.GetComponent<SkillUpgradeButton>())
                {
                    var comp = each.GetComponent<SkillUpgradeButton>();
                    data.customFlags |= (int)UIExportFlag.IsSkillUpgrade;
                    if (comp.dependentButton)
                    {
                        data.dependentUpgrade = uiIDDictanary[comp.dependentButton.gameObject];
                    }
                    else
                    {
                        data.dependentUpgrade = -1;
                    }
                }
                if (each.GetComponent<DigitFont>())
                {
                    data.customFlags |= (int)UIExportFlag.IsDigitFont;
                    data.numberFontSetImages = each.GetComponentsInChildren<Image>().Select(each => uiIDDictanary[each.gameObject]).ToArray();
                }
                if (each.GetComponent<ImagePriority>())
                {
                    var comp = each.GetComponent<ImagePriority>();
                    data.imagePriority = comp.priority;
                }
                if (each.GetComponent<TimeStopOnEnable>())
                {
                    var comp = each.GetComponent<TimeStopOnEnable>();
                    data.customFlags |= (int)UIExportFlag.TimeStopOnEnable;
                    data.floats[(int)JsonUIFloatType.timeStoppingDuration] = comp.timeStoppingDuration;
                }
                if (each.GetComponent<TimeContinueOnDisable>())
                {
                    data.customFlags |= (int)UIExportFlag.TimeContinueOnDisable;
                }
                if (each.GetComponent<ColorTintOnEnable>())
                {
                    var comp = each.GetComponent<ColorTintOnEnable>();
                    data.customFlags |= (int)UIExportFlag.ColorTintOnEnable;
                    data.colorTintOnEnableStart = new float[] { comp.startColor.r, comp.startColor.g, comp.startColor.b, comp.startColor.a };
                    data.colorTintOnEnableEnd = new float[] { comp.endColor.r, comp.endColor.g, comp.endColor.b, comp.endColor.a };
                    data.colorTintOnEnableCurveType = (int)comp.curveType;
                    data.floats[(int)JsonUIFloatType.colorTintOnEnableDuration] = comp.duration;
                }
                if (each.GetComponent<ColorTintOnDisable>())
                {
                    var comp = each.GetComponent<ColorTintOnDisable>();
                    data.customFlags |= (int)UIExportFlag.ColorTintOnDisable;
                    data.colorTintOnDisableStart = new float[] { comp.startColor.r, comp.startColor.g, comp.startColor.b, comp.startColor.a };
                    data.colorTintOnDisableEnd = new float[] { comp.endColor.r, comp.endColor.g, comp.endColor.b, comp.endColor.a };
                    data.colorTintOnDisableCurveType = (int)comp.curveType;
                    data.floats[(int)JsonUIFloatType.colorTintOnDisableDuration] = comp.duration;
                }
                if (each.GetComponent<LinearClipOnEnable>())
                {
                    var comp = each.GetComponent<LinearClipOnEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.LinearClipOnEnable;
                    data.linearClipOnEnableStart = new float[] { comp.startPos.x, comp.startPos.y };
                    data.linearClipOnEnableDir = new float[] { comp.clipDirection.x, comp.clipDirection.y };
                    data.floats[(int)JsonUIFloatType.linearClipOnEnableDuration] = comp.clipDuration;
                    data.linearClipOnEnableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<LinearClipOnDisable>())
                {
                    var comp = each.GetComponent<LinearClipOnDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.LinearClipOnDisable;
                    data.linearClipOnDisableStart = new float[] { comp.startPos.x, comp.startPos.y };
                    data.linearClipOnDisableDir = new float[] { comp.clipDirection.x, comp.clipDirection.y };
                    data.floats[(int)JsonUIFloatType.linearClipOnDisableDuration] = comp.clipDuration;
                    data.linearClipOnDisableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<IsDuplicatable>())
                {
                    var comp = each.GetComponent<IsDuplicatable>();
                    data.customFlags2 |= (int)UIExportFlag2.Duplicatable;
                    data.duplicate_poolable = comp.poolable;
                }
                if (each.GetComponent<BarCells>())
                {
                    var comp = each.GetComponent<BarCells>();
                    data.customFlags2 |= (int)UIExportFlag2.IsBarCells;
                    data.floats[(int)JsonUIFloatType.barCells_BarWidth] = comp.gaugeFill.rectTransform.sizeDelta.x;
                    data.floats[(int)JsonUIFloatType.barCells_BarHeight] = comp.gaugeFill.rectTransform.sizeDelta.y;
                    data.barCells_CellNumber = comp.cellCount;
                    data.floats[(int)JsonUIFloatType.barCells_GaugePerCell] = comp.gaugePerCell;
                }
                if (each.GetComponent<AdjustLinearClip>())
                {
                    var comp = each.GetComponent<AdjustLinearClip>();
                    data.customFlags2 |= (int)UIExportFlag2.AdjustLinearClip;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipDirectionX] = comp.clipDirection.x;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipDirectionY] = comp.clipDirection.y;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipStartX] = comp.clipStart.x;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipStartY] = comp.clipStart.y;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipAdjustingRate] = comp.adjustingRate;
                }
                if (each.GetComponent<UIPlayMusicOnEnable>())
                {
                    var comp = each.GetComponent<UIPlayMusicOnEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.MusicPlayOnEnable;
                    data.floats[(int)JsonUIFloatType.musicPlayOnEnable_fadeIn] = comp.fadeInTime;
                    data.floats[(int)JsonUIFloatType.musicPlayOnEnable_fadeOut] = comp.fadeOutTime;
                    if (comp.clip != null)
                    {
                        data.musicPlayOnEnable_musicClip = AssetDatabase.GetAssetPath(comp.clip);
                        data.musicPlayOnEnable_musicClip = data.musicPlayOnEnable_musicClip.Replace("Assets/Resources/", "");
                    }
                }
                if (each.GetComponent<UIPlayMusicOnDisable>())
                {
                    var comp = each.GetComponent<UIPlayMusicOnDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.MusicPlayOnDisable;
                    data.floats[(int)JsonUIFloatType.musicPlayOnDisable_fadeIn] = comp.fadeInTime;
                    data.floats[(int)JsonUIFloatType.musicPlayOnDisable_fadeOut] = comp.fadeOutTime;
                    if (comp.clip != null)
                    {
                        data.musicPlayOnDisable_musicClip = AssetDatabase.GetAssetPath(comp.clip);
                        data.musicPlayOnDisable_musicClip = data.musicPlayOnDisable_musicClip.Replace("Assets/Resources/", "");
                    }
                }
                if (each.GetComponent<MusicUnpauseOnDisable>())
                {
                    data.customFlags2 |= (int)UIExportFlag2.MusicUnpauseOnDisable;
                }
                if (each.GetComponent<MusicPauseOnEnable>())
                {
                    data.customFlags2 |= (int)UIExportFlag2.MusicPauseOnEnable;
                }
                if (each.GetComponent<MusicMultiplyVolumeOnEnableDisable>())
                {
                    var comp = each.GetComponent<MusicMultiplyVolumeOnEnableDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.MusicMultiplyVolumeOnEnableDisable;
                    data.floats[(int)JsonUIFloatType.musicMultiplyVolumeOnEnableDisable_enableFactor] = comp.enableFactor;
                    data.floats[(int)JsonUIFloatType.musicMultiplyVolumeOnEnableDisable_fadeDuration] = comp.fadeDuration;
                }
                //        ExclusiveEnable = 1 << 10,
                //DisableAfterEnable = 1 << 11,
                //Dialogue_Manual = 1 << 12,
                //Dialogue_Timed = 1 << 13,
                //RedundantEnable = 1 << 14,
                if (each.GetComponent<ExclusiveEnable>())
                {
                    var comp = each.GetComponent<ExclusiveEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.ExclusiveEnable;
                    data.exclusiveEnableGroup = comp.exclusiveGroup.Select(each => uiIDDictanary[each]).ToArray();
                }
                if (each.GetComponent<DisableAfterEnable>())
                {
                    var comp = each.GetComponent<DisableAfterEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.DisableAfterEnable;
                    data.floats[(int)JsonUIFloatType.disableAfterEnable_delayUntilDisable] = comp.delayUntilDisable;
                }
                if (each.GetComponent<Dialogue_Manual>())
                {
                    var comp = each.GetComponent<Dialogue_Manual>();
                    if (foundManualDialogues.Contains(comp.gameObject.name))
                    {
                        Debug.LogError("Dialogue_Manual이 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundManualDialogues.Add(comp.gameObject.name);
                    data.customFlags2 |= (int)UIExportFlag2.Dialogue_Manual;
                }
                if (each.GetComponent<Dialogue_Timed>())
                {
                    var comp = each.GetComponent<Dialogue_Timed>();
                    if (foundTimedDialogues.Contains(comp.gameObject.name))
                    {
                        Debug.LogError("Dialogue_Timed이 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundTimedDialogues.Add(comp.gameObject.name);
                    data.customFlags2 |= (int)UIExportFlag2.Dialogue_Timed;
                }
                if (each.GetComponent<ScriptUI>())
                {
                    var comp = each.GetComponent<ScriptUI>();
                    if (foundScriptUI.Contains(comp.gameObject.name))
                    {
                        Debug.LogError("ScriptUI의 이름이 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundScriptUI.Add(comp.gameObject.name);
                    data.customFlags2 |= (int)UIExportFlag2.ScriptUI;
                }
                if (each.GetComponent<SoundEnumComp>() && each.GetComponent<SoundEnumComp>().soundEnumID != SoundEnumComp.SoundEnumID.None)
                {
                    var comp = each.GetComponent<SoundEnumComp>();
                    if (foundSounds.Contains(comp.soundEnumID))
                    {
                        Debug.LogError("sound id가 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundSounds.Add(comp.soundEnumID);
                    data.soundEnumID = (int)comp.soundEnumID;
                    data.customFlags2 |= (int)UIExportFlag2.EnumSound;
                }
                if (each.GetComponent<RedundantEnable>())
                {
                    var comp = each.GetComponent<RedundantEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.RedundantEnable;
                }
                if (each.GetComponent<RotatingUI>())
                {
                    var comp = each.GetComponent<RotatingUI>();
                    data.customFlags2 |= (int)UIExportFlag2.Rotating;
                    data.floats[(int)JsonUIFloatType.rotatingSpeed] = comp.rotateSpeed;
                    data.floats[(int)JsonUIFloatType.rotatingInitialRotation] = comp.transform.localRotation.eulerAngles.z;
                }
                if (each.GetComponent<StartGameButton>())
                {
                    var comp = each.GetComponent<StartGameButton>();
                    data.customFlags2 |= (int)UIExportFlag2.StartGameButton;
                }
                if (each.GetComponent<ReturnToTitleButton>())
                {
                    var comp = each.GetComponent<ReturnToTitleButton>();
                    data.customFlags2 |= (int)UIExportFlag2.ReturnToTitleButton;
                }
                if (each.GetComponent<Video>())
                {
                    var comp = each.GetComponent<Video>();
                    data.customFlags2 |= (int)UIExportFlag2.Video;
                    data.floats[(int)JsonUIFloatType.videoDuration1] = comp.videoDuration1;
                    data.videoUnscaledDeltaTime = comp.isDeltatimeUnscaled;
                    if (comp.video1 != null)
                    {
                        data.videoPath1 = AssetDatabase.GetAssetPath(comp.video1);
                        data.videoPath1 = data.videoPath1.Replace("Assets/Resources/", "");
                    }
                    if (comp.video2 != null)
                    {
                        data.videoPath2 = AssetDatabase.GetAssetPath(comp.video2);
                        data.videoPath2 = data.videoPath2.Replace("Assets/Resources/", "");
                    }
                }
                if (each.GetComponent<AnimatedSprite>())
                {
                    var comp = each.GetComponent<AnimatedSprite>();
                    data.customFlags2 |= (int)UIExportFlag2.AnimatedSprite;
                    data.animatedSpriteFolderPath = AssetDatabase.GetAssetPath(comp.Sprite);
                    data.animatedSpriteFolderPath = Path.GetDirectoryName(data.animatedSpriteFolderPath);
                    data.animatedSpriteFolderPath = data.animatedSpriteFolderPath.Replace("\\", "/");
                    data.animatedSpriteFolderPath = data.animatedSpriteFolderPath.Replace("Assets/Resources/", "");
                    data.animatedSpriteIsRepeat = comp.repeat;
                    Debug.Log(data.animatedSpriteFolderPath);
                }
                if (each.GetComponent<MultiLanguageImage>())
                {
                    var comp = each.GetComponent<MultiLanguageImage>();
                    data.customFlags2 |= (int)UIExportFlag2.MultiLanguage;
                    data.koreanImagePath = AssetDatabase.GetAssetPath(comp.koreanImg);
                    data.koreanImagePath = data.koreanImagePath.Replace("Assets/Resources/", "");
                    data.englishImagePath = AssetDatabase.GetAssetPath(comp.englishImg);
                    data.englishImagePath = data.englishImagePath.Replace("Assets/Resources/", "");
                    data.japaneseImagePath = AssetDatabase.GetAssetPath(comp.japaneseImg);
                    data.japaneseImagePath = data.japaneseImagePath.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<UIEnumIDComp>())
                {
                    var id = each.GetComponent<UIEnumIDComp>().uiEnumID;
                    data.enumID = (int)id;

                    if (id != UIEnumIDComp.UIEnumID.None && foundUIIds.Contains(id))
                    {
                        Debug.LogError("UI Enum ID가 다음 객체에서 중복됩니다! : ", each);
                    }
                    var parentUI = each.transform.parent;
                    bool skip = false;
                    while (parentUI)
                    {
                        if (parentUI.GetComponent<IsDuplicatable>())
                        {
                            skip = true;
                            break;
                        }
                        parentUI = parentUI.transform.parent;
                    }
                    if (!skip)
                    {
                        foundUIIds.Add(id);
                    }
                }
                uilist.dataList.Add(data);
            }
            catch (Exception e)
            {
                Debug.LogError(e.Message, each);
                failedAlready = true;
                break;
            }
        }
        if (!failedAlready)
        {
            jsonString = JsonUtility.ToJson(uilist, true);
            System.IO.File.WriteAllText(path, jsonString);
        }
    }
    private static void DFS(GameObject gameObject, string tagName, List<GameObject> list, Dictionary<GameObject, int> uiIDDict)
    {
        if (gameObject.tag == tagName)
        {
            list.Add(gameObject);
            uiIDDict.Add(gameObject, uiIDDict.Count);
        }
        foreach (Transform each in gameObject.transform)
        {
            DFS(each.gameObject, tagName, list, uiIDDict);
        }
    }
}

 

 

 

 

 

8. 성과

 

8.1. 게임 플레이 영상

 

8.2. 개발 과정 영상

 

8.3. 도쿄 게임 쇼

Tokyo Game Show 2024 행사에 게임이 전시된 모습

 

9. 느낀 점

 팀원이 지속가능한 코드를 개발할 수 있는 역량이 있는지 냉정하게 판단하는 것이 중요하다. 기술적 문제를 자존심 문제와 별개로 생각해야 한다. 타인의 기분을 배려해야 한다는 명분으로 필요한 판단을 제때 내리지 못하면 이는 나중에 더 큰 재앙이 되어 돌아온다.

 내 말과 권위에 주눅드는 사람, 순응하는 사람보다는 나에게 따지고 들고, 추궁하는 사람이 낫다. 프로젝트에 진지하게 임하려는 사람만이 미주알 고주알을 따진다.

  일에 가시적인 성과가 보이지 않는 채로 프로젝트 기간이 늘어지면 모두가 지치고, 지친 팀원들은 유지보수가능한 코드를 짤 수 없다. 유지보수가 힘든 코드가 쌓이다 보면 결국 프로젝트 자체가 돈좌된다. 사람의 근성과 열정은 유한하다. 팀원의 역량을 객관적으로 파악하고, 공세종말점을 잘 계산한 다음 일정을 세워야 한다.

 항상 정신적인 여유를 가지고 판단력을 유지해야 한다. 운동 + 독서 + 명상 + 개발이 순수 개발보다 효율이 더 좋다. 마음이 욕심, 분노, 어리석음으로 가득 차 있었기 떄문에 일을 그르칠 뻔 했던 적이 한두번이 아니다.

 분할 컴파일, 분할 테스팅에 대한 이해가 확실히 필요하다. 매번 게임을 테스트할 때마다 어마어마한 빌드 시간 + 로딩 시간을 거치니 정신이 나태해지고 업무 효율이 떨어진다.

 테스트 자동화는 테스트케이스들도 계속 업데이트해줘야 하는 단점이 극명하게 있다.

깃허브 주소 : https://github.com/yunu95/GuardianShooter

유튜브 주소 : https://youtu.be/tS9Ps5plkps?si=ffo03FuEch4AwP40 

 

 목차

 

1. 프로젝트의 시작

 

2. Yunuty 게임엔진

2.1. Collider2D

2.2. D2DAnimatedSprite

2.3. YunutyCycle

2.4. D2DCamera

 

3. 인게임 로직 코드

3.1. 플랫포머 시스템

3.2. 디버그 출력

 

4. 게임 에디터 기능

4.1. 에디터 버튼 기능

4.2. 플랫폼 배치

4.3. 장식물 배치

4.4. 카메라 레일

4.5. 적군 마커

4.6. 적군 웨이브

 

5. 소감

 

6. 기타 설계문서


 

1. 프로젝트의 시작

 이번에 맡게 된 게임의 기획은 2D 플랫포머 슈팅 게임을 만들자는 것으로, 한 마디로 메탈슬러그와 유사한 게임을 만들자는 것이었다. 자체엔진의 안정성을 시험하고 싶었던 나에게 있어서는 좋은 기회였다. 주어진 개발 기간은 3주일이었다.

팀 구성  
프로그래밍 팀장
프로그래밍 팀원 (레벨 에디터, 전체 UI) A
프로그래밍 팀원 (인게임 로직) B
프로그래밍 팀원 (인게임 로직) C
아트 팀장( 캐릭터 ) D
아트 팀원( 배경 ) E
기획 팀장 F
기획 팀원 G

외국어 이름은 모두 가명임.

 

 A는 대학에서 기계공학과와 시각디자인학과를 복수 전공한 분으로, 동료 학생 중 가장 큰 맏형님이셨다. 코딩을 배운지 얼마되지 않으셨지만, 의지와 재능이 매우 출중하신데다 솔직하면서도 인간적인 정이 많아 여러모로 의지가 되는 분이어서 언젠가 현업에서 꼭 다시 뵈었으면 하는 마음이 있었다.

 B는 해야 하는 과제가 있으면 천천히, 그리고 묵묵히 해치우는 타입이었다. 가끔씩 작업에 대한 큰 줄기만 짚어주면 일일히 디테일하게 지시를 하지 않아도 되어 좋았다.

 C는 음악을 좋아하는 유쾌하고 싹싹한 성격의 건실청년이었다. 생각을 코드로 옮기는 것은 많이 미숙한 친구였지만, 이 친구와 이야기를 할 때마다 날카로운 포인트를 잘 짚어낼 줄 안다는 느낌을 종종 받았다.

 

2. Yunuty 게임엔진

 

 게임인재원에서 2학기를 보내면서 여유시간이 날 때마다 종갓집 김치를 개발할 때 썼던 자체 게임엔진의 기능을 개선하고 개발해왔었다. 2학기 프로젝트를 시작하기 전 구현한 게임엔진의 기능으로는 OOBB 기반 2차원 박스 충돌체, 스프라이트 애니메이션, 카메라, A*알고리즘 기반 길찾기 등이 있었다.

카메라, 스프라이트 애니메이션, 길찾기 기능을 모두 결합해 자체엔진으로 간단한 RTS 예제를 만들어본 모습. 아쉽게도 길찾기 기능은 이번 프로젝트에서 쓰이지 않았다.

2.1. Collider2D

2D 충돌체

 

 제대로 된 2D게임을 만들기 위해서는 어느정도 최적화가 고려된 2D 충돌처리 모듈들을 개발할 필요가 있었다. 따라서 OOBB(Object Oriented Bounding Box) 사각형 콜라이더와 원형 콜라이더들을 만들고 쿼드트리를 만들어 같은 구역에 있는 충돌체들만 서로 겹침 여부를 체크하게 만들었다.

상세 코드

 

#pragma once
#include <Windows.h>
#include <vector>
#include <unordered_set>
#include "Component.h"
#include "Vector2.h"
#include "Vector3.h"
#include "Rect.h"
#include "D2DGraphic.h"
#include "Interval.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif


using namespace std;
namespace YunutyEngine
{
    class BoxCollider2D;
    class CircleCollider2D;
    class LineCollider2D;
    class RigidBody2D;
    class YUNUTY_API Collider2D abstract : public Component
    {
    public:
        struct YUNUTY_API QuadTreeNode
        {
            static unique_ptr<QuadTreeNode> rootNode;
            double GetArea() { return xInterval.GetLength() * yInterval.GetLength(); }
            Interval xInterval;
            Interval yInterval;
            unique_ptr<QuadTreeNode> leftTop;
            unique_ptr<QuadTreeNode> rightTop;
            unique_ptr<QuadTreeNode> leftBottom;
            unique_ptr<QuadTreeNode> rightBottom;
            vector<Collider2D*> colliders;
        };
        const unordered_set<Collider2D*>& GetOverlappedColliders() const;
        virtual double GetArea() const = 0;
        virtual bool isOverlappingWith(const Collider2D* other) const = 0;
        virtual bool isOverlappingWith(const BoxCollider2D* other) const = 0;
        virtual bool isOverlappingWith(const CircleCollider2D* other) const = 0;
        virtual bool isOverlappingWith(const LineCollider2D* other) const = 0;
    private:
        // called by yunuty cycle
        static void InvokeCollisionEvents();
    protected:
        static unordered_set<Collider2D*> colliders2D;
        unordered_set<Collider2D*> overlappedColliders;

        Collider2D();
        virtual ~Collider2D();

        virtual bool isInsideNode(const QuadTreeNode* node)const = 0;
        static bool isOverlapping(const BoxCollider2D* a, const BoxCollider2D* b);
        static bool isOverlapping(const BoxCollider2D* a, const CircleCollider2D* b);
        static bool isOverlapping(const CircleCollider2D* b, const BoxCollider2D* a) { return isOverlapping(a, b); }
        static bool isOverlapping(const BoxCollider2D* a, const LineCollider2D* b);
        static bool isOverlapping(const LineCollider2D* b, const BoxCollider2D* a) { return isOverlapping(a,b); }
        static bool isOverlapping(const CircleCollider2D* a, const CircleCollider2D* b);
        static bool isOverlapping(const CircleCollider2D* a, const LineCollider2D* b);
        static bool isOverlapping(const LineCollider2D* b, const CircleCollider2D* a) { return isOverlapping(a,b); }
        static bool isOverlapping(const LineCollider2D* a, const LineCollider2D* b);

        friend YunutyCycle;
    };
}

 

 Collider2D는 모든 유형의 2차원 충돌체 인스턴스들의 기본 클래스다. Collider2D::InvokeCollisionEvents() 함수는 YunutyCycle 객체로부터 호출되는 스태틱 함수로 게임에 존재하는 모든 쿼드트리 노드들을 순회하며 같은 노드에 속한 충돌체들이 서로서로 겹치는지 확인하고, 콜라이더들 간에 충돌이 일어나거나, 충돌이 끝나는 등 특기할만한 이벤트가 생기면 그에 맞는 콜백 함수들을 호출하게 한다.

namespace YunutyEngine
{
        class YUNUTY_API Component : public Object
        {
		.....
        // 충돌체들이 서로 충돌을 시작할 때 호출되는 콜백 함수입니다.
        virtual void OnCollisionEnter(const Collision& collision) {};
        virtual void OnCollisionEnter2D(const Collision2D& collision) {};
        // 충돌체들이 서로 겹쳐진 상태로 있을때 매 프레임마다 호출되는 콜백 함수입니다.
        virtual void OnCollisionStay(const Collision& collision) {};
        virtual void OnCollisionStay2D(const Collision2D& collision) {};
        // 충돌체들이 충돌을 마치고 서로 떨어져 나갈때 호출되는 콜백 함수입니다.
        virtual void OnCollisionExit(const Collision& collision) {};
        virtual void OnCollisionExit2D(const Collision2D& collision) {};
        ....
        }
}

 

 이벤트가 생기면 콜라이더를 포함하는 게임오브젝트의 모든 컴포넌트들의 콜백함수가 호출되며, 호출될 수 있는 콜백함수들의 목록은 위 코드에서 확인할 수 있다.

 

2.2. D2DAnimatedSprite

좌 : 교전을 끝내고 이동하는 병사의 애니메이션, 우 : 이번 프로젝트 플레이어 캐릭터의 Idle 애니메이션.

 

 폴더에 프레임별로 이미지를 넣고 폴더의 경로를 제공하면 2D 스프라이트 애니메이션을 재생할 수 있게 하는 애니메이션 재생 컴포넌트를 만들었다. 

코드 보기
#pragma once
#include "D2DGraphic.h"
#include "YunutyEngine.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

using namespace YunutyEngine::D2D;

namespace YunutyEngine
{
    namespace D2D
    {
        typedef vector<pair<double, wstring>> SpriteAnim;
        class YUNUTY_API D2DAnimatedSprite : public D2DGraphic
        {
        public:
            void SetIsRepeating(bool repeating) { this->isRepeating = repeating; }
            bool GetIsRepeating() { return this->isRepeating; }
            void LoadAnimationFromFile(wstring folderName, double interval = 0.0);
            void Play();
            void SetWidth(double width);
            void SetHeight(double width);
            double GetWidth() { return width; }
            double GetHeight() { return height; }
        protected:
            virtual void Update() override;
            virtual void Render(D2D1::Matrix3x2F transform) override;
            static const SpriteAnim* LoadAnimation(wstring folderName, double interval = 0.0);
            void SetAnimation(const SpriteAnim* animation);
            const SpriteAnim* GetAnimSprites() const;
        private:
            wstring loadedAnimFilePath;
            bool isRepeating = true;
            const SpriteAnim* animSprites = nullptr;
            int index = 0;
            double elapsed = 0;
            double width = 100;
            double height = 100;
            static unordered_map<wstring, vector<pair<double, wstring>>> cachedAnims;
        };
    }
}

 

 D2D AnimatedSprite는 이미지 파일 경로 하나가 아니라 폴더 경로를 매개변수로 받아 폴더 내부의 이미지들을 스프라이트 애니메이션의 매 프레임 이미지들로 등록한다. Play 함수를 호출하면 게임 오브젝트의 위치에 이미지들을 촤라라락 넘기며 스프라이트 애니메이션을 재생한다.

실상 동작은 공책 애니메이션과 다를 바 없다.
void D2DAnimatedSprite::Update()
{
    if (!animSprites)
        return;

    if (animSprites->empty())
        return;

    if (index >= animSprites->size())
        return;

    elapsed += Time::GetDeltaTime();
    if ((*animSprites)[index].first < elapsed)
    {
        index++;
        if (index >= animSprites->size())
        {
            if (isRepeating)
            {
                index = 0;
                elapsed = 0;
            }
            else
            {
                index--;
            }
        }
    }
}

 

 업데이트 함수에서는 게임엔진의 델타 타임을 누적시키며 시간이 충분히 찼다 싶으면 다음 스프라이트로 그림을 전환한다.

const SpriteAnim* D2DAnimatedSprite::LoadAnimation(wstring folderName, double interval)
{
    HANDLE dir;
    WIN32_FIND_DATA file_data;
    if (cachedAnims.find(folderName) != cachedAnims.end())
        return &cachedAnims[folderName];

    cachedAnims[folderName] = SpriteAnim();
    if ((dir = FindFirstFile((folderName + L"/*").c_str(), &file_data)) == INVALID_HANDLE_VALUE)
        return nullptr; /* No files found */

    double time = 0;
    bool intervalFixed = interval > 0;
    do {
        const wstring file_name = file_data.cFileName;
        const wstring full_file_name = folderName + L"/" + file_name;
        if (file_name == L"." || file_name == L"..")
            continue;

        if (!intervalFixed)
        {
            wsmatch match;
            smatch match2;
            interval = 0;
            regex_match(file_name, match, wregex(L".*?(\\d+)ms.*"));
            //string a(file_name.begin(), file_name.end());
            //regex_match(a, match2, regex("\\D(\\d+)ms.*"));
            if (match.size() >= 2)
                interval = 0.001 * stoi(match[1]);
            if (interval <= 0)
                interval = 0.1;
        }
        const bool is_directory = (file_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;

        if (file_name[0] == '.')
            continue;

        if (is_directory)
            continue;

        cachedAnims[folderName].push_back(make_pair(time, full_file_name));
        time += interval;
    } while (FindNextFile(dir, &file_data));

    FindClose(dir);
    return &cachedAnims[folderName];
}

 

 LoadAnimation은 폴더명을 매개변수로 받아 애니메이션 정보를 가져와 스프라이트 애니메이션 객체에 적용시킨다. 이미 불러온 정보라면 캐싱된 데이터를 쓴다.

 

2.3. YunutyCycle

#pragma once
#include <thread>
#include "Object.h"
#include <functional>

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

using namespace std;

namespace YunutyEngine
{
    class Component;
    class YUNUTY_API YunutyCycle : Object
    {
    private:
        thread updateThread;
        bool isGameRunning = false;
        void ActiveComponentsDo(function<void(Component*)> todo);
        void ActiveComponentsDo(void (Component::* method)());
        vector<Component*> GetActiveComponents();
        vector<GameObject*> GetGameObjects(bool onlyActive = true);
        static void UpdateComponent(Component* component);
        static void StartComponent(Component* component);
        void ThreadFunction();
    protected:
        static YunutyCycle* _instance;
        YunutyCycle();
        virtual ~YunutyCycle(); 
        virtual void ThreadUpdate();
    public:
        static YunutyCycle& GetInstance();
        virtual void Initialize();
        virtual void Release();
        void Play();
        void Stop();
        void Pause();
        void SetMaxFrameRate();
        bool IsGameRunning();
    };
}

( YunutyCycle 클래스의 헤더파일 )

void YunutyEngine::YunutyCycle::ThreadUpdate() 
{
	Time::Update();

	for (auto i = GlobalComponent::globalComponents.begin(); i != GlobalComponent::globalComponents.end(); i++)
		(*i)->Update();
	//i->second->Update();

	for (auto each : Scene::getCurrentScene()->destroyList)
	{
		for (auto each : each->GetComponents())
			each->OnDestroy();
		each->parent->MoveChild(each);
	}

	Scene::getCurrentScene()->destroyList.clear();

	for (auto each : GetGameObjects(false))
		each->SetCacheDirty();
	//ActiveComponentsDo(&Component::Update);
	for (auto each : GetActiveComponents())
		UpdateComponent(each);

	Collider2D::InvokeCollisionEvents();

	if (Camera::mainCamera)
		Camera::mainCamera->Render();
}

( YunutyCycle 클래스의 스레드가 한 틱마다 돌리는 함수, ThreadUpdate )

 

 모든 게임엔진에는 매 프레임마다 게임의 진행상황을 갱신하기 위해 실행시키는 게임 루프가 있다. Yunuty 엔진에서는 YunutyCycle이라는 싱글톤 객체에 게임 루프를 정의했다.

기본적인 YunutyCycle의 업데이트 한 틱 동작의 도식도

코드 보기
void YunutyEngine::D2D::D2DCycle::ThreadUpdate()
{
    YunutyCycle::ThreadUpdate();
    if (IsGameRunning())
        RedrawWindow(hWnd, NULL, NULL, RDW_INVALIDATE | RDW_UPDATENOW);
}

 

 게임루프는 엔진이 동작하는 기반환경에 따라 어떻게 내용이 바뀔지 모른다고 생각했기 때문에 YunutyCyle 클래스는 상속을 고려해 만들었다. 이번 프로젝트에서는 DirectX 2D 그래픽스 인터페이스를 사용해서 만들었기 때문에 D2DCycle이라는 YunutyCycle의 파생 클래스에서 게임 루프를 정의했다. D2DCycle은 YunutyCycle과 동일하게 틱 동작을 진행한 후 RedrawWindow라는 윈도우API 함수를 불러 화면의 렌더링을 유도한다.

 

2.4. D2DCamera

카메라 이동

 

 D2D카메라는 모든 DirectX 2D 그래픽스 객체들의 트랜스폼 정보를 카메라 중심의 뷰 공간으로 변환한 다음 화면에 그림을 그리는 역할을 수행했다.

코드 보기
void D2DCamera::Render()
{
}
LRESULT CALLBACK D2DCamera::Render(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    YunuD2D::YunuD2DGraphicCore::GetInstance()->BeginDraw();

    YunuD2D::YunuD2DGraphicCore::GetInstance()->ResizeResolution(resolutionW, resolutionH);
    SetNearPlane(Rect(resolutionW, resolutionH));

    auto halvedRenderSize = YunuD2D::YunuD2DGraphicCore::GetInstance()->GetRenderSize();
    halvedRenderSize.width /= 2;
    halvedRenderSize.height /= 2;

    vector<D2DGraphic*> graphics;

    // render world space graphics
    for (auto each : D2DGraphic::D2DGraphics[(int)CanvasRenderSpace::WorldSpace])
    {
        if (each->GetGameObject()->GetActive() && each->GetGameObject()->GetScene() == GetGameObject()->GetScene())
            graphics.push_back(each);
    }

#if _DEBUG
    GameObject::messyIndexingCalled = 0;
#endif
    sort(graphics.begin(), graphics.end(), [](const D2DGraphic* item1, const D2DGraphic* item2)->bool
        {
            return item1->GetGameObject()->GetSceneIndex() < item2->GetGameObject()->GetSceneIndex();
        });

    D2D1::Matrix3x2F eachTransform;
    Vector3d camPos;
    Vector3d pos;
    Vector3d scale;
    for (auto each : graphics)
    {
        eachTransform = D2D1::Matrix3x2F::Identity();
        camPos = GetTransform()->GetWorldPosition();
        pos = each->GetTransform()->GetWorldPosition();
        scale = each->GetTransform()->GetWorldScale();
        eachTransform = eachTransform * ScaleTransform(scale.x * 1 / zoomOutFactor, scale.y * 1 / zoomOutFactor);
        eachTransform = eachTransform * RotationTransform(each->GetTransform()->GetWorldRotation().Euler().z);
        eachTransform = eachTransform * TranslationTransform(halvedRenderSize.width + (pos.x - camPos.x) * 1 / zoomOutFactor, halvedRenderSize.height - (pos.y - camPos.y) * 1 / zoomOutFactor);

        each->Render(eachTransform);
    }
    graphics.clear();

    // render camera space graphics
    for (auto each : D2DGraphic::D2DGraphics[(int)CanvasRenderSpace::CameraSpace])
    {
        if (each->GetGameObject()->GetActive() && each->GetGameObject()->GetScene() == GetGameObject()->GetScene())
            graphics.push_back(each);
    }
    sort(graphics.begin(), graphics.end(), [](D2DGraphic*& item1, D2DGraphic*& item2)->bool
        {
            return item1->GetGameObject()->GetSceneIndex() < item2->GetGameObject()->GetSceneIndex();
        });

    for (auto each : graphics)
    {
        eachTransform = D2D1::Matrix3x2F::Identity();
        camPos = Vector2d::zero;
        pos = each->GetTransform()->GetWorldPosition();
        scale = each->GetTransform()->GetWorldScale();
        eachTransform = eachTransform * ScaleTransform(float(scale.x / zoomOutFactor), float(scale.y / zoomOutFactor));
        eachTransform = eachTransform * RotationTransform(float(each->GetTransform()->GetWorldRotation().Euler().z));
        eachTransform = eachTransform * TranslationTransform((halvedRenderSize.width + pos.x - camPos.x) * 1 / zoomOutFactor, (halvedRenderSize.height - (pos.y - camPos.y)) * 1 / zoomOutFactor);

        each->Render(eachTransform);
    }

    YunuD2D::YunuD2DGraphicCore::GetInstance()->EndDraw();
    return 0;
}

 

 이전 프로젝트에서는 카메라의 멤버 함수 Render()가 렌더링 동작을 담당했지만 이번에는 윈도우의 Redraw 이벤트의 콜백함수 LRESULT CALLBACK Render(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)가 렌더링을 담당한다.

 

3. 인게임 로직 코드

3.1. 플랫포머 시스템

 교수님께서는 자체 엔진으로 플랫포머 게임을 만들때 플랫폼 그 자체를 구현하는 것이 큰 난관이 될 것이라고 하셨다. 나는 플랫폼을 "유닛들을 수직방향으로 밀어내고, 수평방향으로 이동하게 만드는 객체"라고 정의하고 개발했다. 생각보다 처리해야할 예외사항이 많아 힘들었다.

플랫폼은 플레이어 콜라이더를 수직방향으로 밀어내고, 플레이어의 이동방향을 플랫폼의 수평방항으로 향하게 한다.

 

코드 보기
#pragma once
#include "YunutyEngine.h"

using namespace YunutyEngine;
class Ground : public Component
{
public:
    static const double groundHeight;
    static Ground* CreateGround(Vector2d position, double rotationRadian = 0, double length = 300, bool isPenetrable = false);
    bool isPenetrable;
    bool isSteppable()
    {
        return GetTransform()->rotation.Up().y > 0.4;
        //&&
            //Vector3d::Dot(footPosition - GetTransform()->GetWorldPosition(), GetTransform()->rotation.Up()) > 0;
    }
    bool isUnderFoot(Vector3d footPosition) { return Vector3d::Dot(footPosition - GetTransform()->GetWorldPosition(), GetTransform()->rotation.Up()) > 0; }
    BoxCollider2D* groundCollider;
protected:
    void OnCollisionStay2D(const Collision2D& colliision) override;
};
void Ground::OnCollisionStay2D(const Collision2D& collision)
{
    auto player = collision.m_OtherCollider->GetGameObject()->GetComponent<Player>();
    auto enemy = collision.m_OtherCollider->GetGameObject()->GetComponent<Threat>();
    auto position = GetTransform()->GetWorldPosition();
    auto rotation = GetTransform()->GetWorldRotation();
    if (!enemy && !player)
        return;
    if (player &&
        player->GetMovementState() == PlayerMovementState::JUMP &&
        Vector3d::Dot(player->GetPlayerSpeed(), GetTransform()->GetWorldRotation().Up()) > 0)
        return;
    if (player &&
        isPenetrable &&
        (player->GetMovementState() == PlayerMovementState::JUMP ||
            (!isUnderFoot(player->GetPlayerFootPos()))&& !isUnderFoot(player->lastFramePlayerPos)))
    {
        return;
    }
    if (player &&
        abs(Vector3d::Dot(player->GetPlayerFootPos() - position, rotation.Right())) > groundCollider->GetWidth() * 0.5
        )
    {
        return;
    }

    // 아래의 코드를 통해 지형이 플레이어나 적을 지형 반대방향으로 밀어냅니다.
    auto otherTransform = collision.m_OtherCollider->GetTransform();
    BoxCollider2D* otherBoxCollider = collision.m_OtherCollider->GetGameObject()->GetComponent<BoxCollider2D>();
    Vector3d delta;
    Vector3d delta_norm = Vector2d::DirectionByAngle((GetTransform()->GetWorldRotation().Euler().z + 90) * YunutyMath::Deg2Rad);
    //Vector3d delta_norm2 = GetTransform()->GetWorldRotation().Euler().;
    Vector3d otherBox_DeltaPoint = 0.5 * Vector3d(otherBoxCollider->GetWidth(), -otherBoxCollider->GetHeight(), 0);
    if (GetTransform()->GetWorldRotation().Up().x > 0)
        otherBox_DeltaPoint.x *= -1;
    Vector3d centerDelta = otherTransform->GetWorldPosition() - GetTransform()->GetWorldPosition();
    // 살짝 파고 들게 하기 위한 식
    const double diggingRate = 0.9;
    delta = delta_norm * ((groundCollider->GetHeight() * 0.5 + abs(Vector3d::Dot(otherBox_DeltaPoint, delta_norm))) * diggingRate - abs(Vector3d::Dot(centerDelta, delta_norm)));
    otherTransform->SetWorldPosition(otherTransform->GetWorldPosition() + delta);
}

 

 플레이어가 점프상태인지, 플랫폼이 점프로 뚫고 올라갈 수 있는 플랫폼인지의 여부에 따라 플랫폼이 플레이어와 상호작용하는 방법을 다르게 설정해줘야 했다.

 

3.2. 디버그 출력

 나는 B와 C에게 각각 플레이어 캐릭터의 구현과 적군 유닛의 구현을 맡길 생각이었다. 각 객체들의 현재 상태가 어떤지, 공격을 하면 어느 범위에 맞게 되는지, 유닛과 탄환들의 충돌 크기는 어떻게 되는지, 이벤트가 일어났는지, 안 일어났는지 일일이 코드에 중단점을 찍으며 디버깅을 하도록 시킬 수는 없었기 때문에 디버깅에 도움이 될만한 출력기능들을 몇가지 만들었다. 디버그 박스나 텍스트 필드, 팝업 효과를 간편한 인터페이스를 통해 출력할 수 있게 만들고 이런 디버깅 기능들의 활용법을 B와 C에게 알려주고 나니 이제 가만히 있어도 클라이언트 코드가 척척 만들어졌고 남은 역량을 맵 에디터 기능 개발에 쓸 수 있게 되었다.

캐릭터 위에 뜨는 난잡한 숫자들은 각각 캐릭터의 이동상태, 전투상태, 필살기 충전횟수, 필살기 충전율, 현재 목숨의 갯수 등의 정보를 나타낸다.
캐릭터 앞에 달린 노란색 박스는 근접공격의 범위, 초록색 박스는 필살기 공격의 범위를 뜻한다. 플레이어나 적이 피격당하면 파란색 팝업이, 플레이어가 새로운 발판을 밟으면 노란색 팝업이 일어난다.

코드 보기

 

class DebugObject : public Component
{
public:
    DebugObject();
    ~DebugObject();
    static void EnableDebugmode();
    static void DisableDebugmode();
    static void ToggleDebugmode();
    static void CreateDebugRectImage(GameObject* parent, double width, double height, D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
    static void CreateColliderImage(BoxCollider2D* collider,  D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
    static void CreateDebugCircleImage(GameObject* parent, double radius,  D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
    static void CreateDebugText(GameObject* parent, function<wstring()>, Vector3d relativePosition = Vector3d::zero, double fontSize = 20, D2D1::ColorF color = D2D1::ColorF::Black);
    static void CreateArrow(GameObject* parent, Vector3d origin, Vector3d destination, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static void CreateArrow(GameObject* origin, GameObject* destination, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static void CreatePopUpCircle(GameObject* parent, double radius = 50, double duration = 0.5, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static void CreatePopUpCircle(Vector3d position, double radius = 50, double duration = 0.5, D2D1::ColorF color = D2D1::ColorF::Yellow);
protected:
    void Update() override;
    void OnDestroy() override;
private:
    static void _CreatePopUpCircle(GameObject* parent, Vector3d position, double radius, double duration, D2D1::ColorF color);
    static tuple<GameObject*, DebugObject*, D2DRectangle* ,GameObject*,GameObject*> _CreateArrow(double lineLength, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static bool debugMode;
    static unordered_set<DebugObject*> debugObjects;
    function<void()> onUpdate = []() {};
    function<void()> onDebugEnabled = []() {};
    function<void()> onDebugDisabled = []() {};
    function<void()> onDestory = []() {};

}

  디버그 사각형, 원, 화살표, 팝업 등을 생성하는 함수들은 아예 한 클래스의 정적 함수들로 몰아서 선언해 놓았다. 디버그 텍스트를 띄워야 하는 경우 어떤 정보를 어떻게 띄워야 하는지 알아야 하므로 string을 반환하는 functor를 매개변수로 받게 했다.

 

4. 게임 에디터 기능

 내가 한동안 클라이언트 팀원들의 개발을 독려하는데에 집중하고 있을 때 A는 에디터의 역할은 무엇인지, 코딩을 어디서부터 시작해야 하는지 고민하고 있었다. 에디터 개발이라는 큰 과제를 소단위 과제로 분할하지도 않은 채 A에게 너무 덩그러니 맡겨서 그런지, 개발의 진척도가 많이 부진한 상태였다. 기획자들에게 레벨디자인 툴을 빠르게 넘겨줘야 할 필요가 있었기 때문에 맵 에디터의 기초적인 틀과 기능들은 내가 빠르게 만들기로 했다.

 

4.1. 에디터 버튼 기능

면의 UI 버튼과 마우스로 상호작용하는 모습,

 

 먼저 맵 에디터의 UI를 조작하거나 장식물이나 지형을 배치하기 위해 마우스 클릭이 가능한 버튼을 만들었다.

코드 보기
#pragma once
#include "YunutyEngine.h"

class Button :
    public Component
{
public:
    virtual ~Button();
    virtual void Update() override;
    static Button* CreateButton(GameObject* parentPanel, const Vector2d& pos, const wstring& str, double width = 250, double height = 75, D2DText** text = nullptr);
    static Button* CreateToggleButton(GameObject* parentPanel, const Vector2d& pos, const wstring& str, double width = 250, double height = 75, D2DText** textOut=nullptr);
    static Button* CreateToggleIconButton(GameObject* parentPanel, const Vector2d& pos, const wstring& animSpritePath, double width = 150, double height = 150);
    static Button* CreateToggleSimpleIconButton(GameObject* parentPanel, const Vector2d& pos, const wstring& spritePath, double width = 150, double height = 150);
    static Button* AddDraggableButton(GameObject* parent, double radius = 50);
    static Button* AddDraggableButton(GameObject* parent, double width,double height,BoxCollider2D** colliderOut);

    function<void(void)> onClick = []() {};
    function<void(void)> onDrag = []() {};
    function<void(void)> onSelect = []() {};
    function<void(void)> onDeselect = []() {};
    function<void(void)> onEnable = []() {};
    function<void(void)> onMouseOver = []() {};
    function<void(void)> onMouseExit = []() {};
    function<void(void)> onUpdate = []() {};
    function<void(void)> onUpdateWhileSelected = []() {};
    virtual void OnMouseDrag();
    virtual void OnLeftClick();
    virtual void OnSelected();
    virtual void OnDeselected();
    virtual void OnMouseOver();
    virtual void OnMouseExit();

    virtual void OnEnable() { onEnable(); }
    virtual void OnCollisionEnter2D(const Collision2D& collision);
    virtual void OnCollisionExit2D(const Collision2D& collision);
    //void OnMiddleClick();
    //void OnRightClick();

    bool toggleButton = false;
    bool selected = false;
    bool deselectOnESC=false;
    vector<Button*> radioSelectGroup;

    struct Comp
    {
        bool operator()(Button* const input1, Button* const input2)const
        {
            return input1->GetGameObject()->GetSceneIndex() < input2->GetGameObject()->GetSceneIndex();
        }
    };
private:
    D2DRectangle* buttonPanel = nullptr;
};

 

  버튼은 마우스 포인터와 버튼 객체에 각각 충돌체를 달아두고 충돌이벤트가 뜨면 버튼을 하이라이트시키는 방식으로 만들었다. 각 버튼이 클릭되거나 하이라이트되었을 때 어떻게 어떻게 동작할지 커스터마이징하고 싶다면 onClick, onDrag, onMouseOver 등 버튼 객체의 std::function 멤버변수들에 적절한 콜백 함수를 등록시키면 된다.

 

 4.2. 플랫폼 배치

지도의 지점마다 노드를 찍어 사이를 잇는 플랫폼을 만드는 모습

 

 플랫폼은 뚫고 올라갈 수 있는 플랫폼과 가로막히게 되는 플랫폼, 두가지 종류로 나누었다. 레벨 디자이너는 맵에 노드들을 위치를 찍어가며 플랫폼을 배치할 수 있다. 이 지형들의 배치는 디버그 정보 표시를 끄면 보이지 않으므로 나중에 장식물 이미지를 적절히 배치해 캐릭터가 의도한 위치에 서 있을 수 있다는 당위성을 제공해야 한다.

좌 : 디버그 표시를 끈 게임 화면, 우 : 디버그 표시를 켜 지형 플랫폼들의 배치가 드러나는 게임 화면

4.3. 장식물 배치

이미지를 복사해 끌어다 놓고, 회전시켜 배치하는 모습

 

 장식물 이미지들은 빈 이미지를 배치하고 이미지 경로를 적절하게 입력하면 해당 이미지가 출력되도록 만들었다. 이미지의 위치나 회전값을 임의로 설정할 수 있으며, 캐릭터들을 가릴 것인지 캐릭터들에 가려질 것인지 설정할 수 있다.

좌 : 캐릭터의 앞에 놓이는 장식물의 배치, 우 : 캐릭터의 뒤에 놓이는 장식물의 배치

 

 카메라로부터 멀리 있는 장식물들은 스크롤 속도를 느리게 줄 필요가 있고, 카메라로부터 가까이 있는 장식물들은 스크롤 속도를 빠르게 줄 필요가 있다. 원근 투영을 하지 않는 카메라라도 이런 디테일을 넣어주면 연출에 정성을 들인 티가 난다. 

카메라보다 앞에 있는 장식물은 더 빨리 뒤로 스크롤되고 멀리 떨어져 있는 장식물들은 느리게 스크롤되는 모습

4.4. 카메라 레일

게임을 진행하면서 카메라가 따라 움직이게 될 궤적을 설정하는 모습

 

 메탈슬러그와 같은 일직선 레일 슈팅 게임은 게임의 진행경로가 고정되어 있으며 한번 앞으로 나아간 카메라가 다시 뒤로 돌아오지 못한다. 따라서 게임의 진행 방향과 카메라의 궤적을 설정할 수 있는 카메라 레일 기능을 개발할 필요가 있었다. 나는 A에게 "레벨 디자이너가 카메라 중점이 거치게 될 경유지를 노드로 찍을 수 있게 에디터 기능을 확장해달라. 카메라 중점이 나아갈 궤적을 디버그 이미지로 표시해야 하니 노드와 노드 사이에 화살표를 그려주고, 카메라의 위치는 캐릭터의 위치에 따라 화살표를 따라서 업데이트되어야 하니 이런 사양에 맞게 동작하는 카메라 컴포넌트의 코드도 짜 달라."고 부탁했다. 내가 어느정도 만들어 둔 맵 에디터의 코드들을 참고하여 A는 어렵지 않게 추가 기능을 확장할 수 있었으며 디버그 이미지 표시, 카메라 업데이트 코드 구현과 같은 코드도 어렵지 않게 만들어냈다.

 

4.5. 적군 배치

에디터에서 적 유닛 마커를 배치하는 모습

 

 게임을 진행하면서 적들을 마주해야 할테니 적 유닛을 생성하는 마커를 맵에 배치할 수 있게 했다. 카메라에 비춰지는 화면 영역에 사각형 콜라이더를 달았는데, 이 화면 콜라이더가 마커와 부딪히면 적이 생성된다. 일반병을 생성하는 마커 코드를 내가 짰고, 나머지 병사들에 대한 코드는 A에게 구현을 부탁했다.

 

4.6. 적군 웨이브 배치

적군들이 시간차를 두고 대규모로 출몰하는 웨이브 구간을 설정하는 모습. 후반에 출몰하는 엘리트 병사 두명을 필수 척결대상으로 지정하여, 반드시 이 유닛들을 처치해야만 카메라 고정이 풀리게 만들어 두었다.

 

 적군들이 스테이지를 전진하면서 앞에서만 나타난다면야 아무래도 게임의 긴장감이 떨어질 것이다. 한번씩 카메라를 고정시킨 다음 적들이 제파식 공격을 가하게 만드는 웨이브 구간을 중간 중간에 설정할 필요가 있는 것이다. 웨이브 구간을 클릭한 채로 특정 키를 누르면 웨이브의 시점을 조절할 수 있고, 이 때 병사 마커들을 배치하면 해당 시점에 적 유닛들이 출몰하게 된다. 웨이브와 관계가 있는 적 마커는 디버그 이미지로 줄을 달아 웨이브와의 연관성을 표시하게 만들었다. 반드시 처치해야만 웨이브를 넘길 수 있게 만든 필수척결 대상 마커들은 빨간색 줄로 연관성이 표시된다.

 

5. 소감

 

 개인적으로 게임 인재원 생활중 가장 기억에 남는 프로젝트였다. 내가 팀원들에게 제시한 로드맵이나, 팀원들의 생산성 향상을 위한 노력에 대한 팀원들의 호응이 매우 좋아 내심 뿌듯한 기분이 많이 들었기 때문이다. 

잘된 점 아쉬운 점
 팀원들의 적극적인 프로젝트 참여에 대한 독려가 잘 되었고, 프로젝트 진행 중 사기가 높았음.
 게임엔진의 안정성을 검증함. 
 충돌체가 너무 많아지면 FPS가 급격히 떨어졌음. 충돌 체크 코드의 최적화에 아쉬운 부분이 많았음.
 기획자들에게 에디터를 너무 늦게 줘 레벨 디자인의 완성도가 낮아짐.
 에디터 기능으로 적이나 플레이어의 공격력, 체력 등을 조절할 수 있는 수치 조절 기능을 제공하지 못함.

 

6. 기타 설계 문서

 

맵 에디터를 어떻게 구현해야 하는지 설명하기 위해 만든 ppt 이미지

 

 

적군이 등장하는 로직을 어떻게 구현하고 또 어떻게 에디터에서 배치해야 하는지 설명하기 위해 만든 ppt 이미지

 

에디터 기능을 구현하기 전에 draw.io로 그린 클래스 다이어그램

 

스토브 인디 게시 페이지 : https://store.onstove.com/ko/games/2070

깃허브 주소 : https://github.com/yunu95/Stresh

유튜브 주소 : https://youtu.be/BMZWLyMs3lc?si=edAJFXUATMV_2R46 

게임 플레이 영상

 목차

1. 배경

2. C언어 기반 라이프사이클 개발

3. 클라이언트 구현

4. 소감

 

 1. 배경

 게임 인재원은 총 2년 8학기 커리큘럼으로 운영되었고, 첫 1년의 4학기는 학기말마다 기획반 학생, 아트반 학생, 프로그래밍반 학생들이 연합해 팀을 짜 2주~ 4주의 시간에 걸쳐 게임을 개발하는 미니게임 프로젝트를 진행했다. 이 글이 소개하는 프로젝트는 내가 첫 1학기때 개발한 프로젝트로 개발완료까지 1주일 반의 기간이 주어졌고, 학생들이 C++을 아직 안 배웠기 때문에 뒤쳐지거나 소외되는 사람들이 생기지 않도록 하기 위해 사용 가능한 언어가 C언어로 강제되었다.

팀 구성  
프로그래밍 팀장
프로그래밍 부팀장 A
프로그래밍 팀원 B
아트 팀장 C
아트 팀원 D
기획 팀장 E
기획 팀원 F

외국어 이름은 다 가명임

 

 A는 비전공자 출신임에도 엄청난 의욕과 역량을 가진 친구로 팀의 부선장 역할을 맡길 수 있었다. 리소스를 관리하는 로직을 짜는 것, UI 버튼 기능을 구현하는 것, 컷씬을 연출하는 것 등 시간도 오래 걸리고 프로그래밍 역량도 필요한 과업을 믿고 맡길 수 있어 매우 든든했다. B는 아직 프로그래밍에 익숙지 않아 반복문, 조건문, 함수 모듈화와 같이 기초적인 프로그래밍 기법에 익숙해질 수 있도록 작업의 범위를 조절해 가며 일을 맡겼다.

 

2. C언어 기반 라이프사이클 개발

C언어에서 만든 게임 루프 함수, 그냥 무한루프를 돌리고 업데이트 함수들만 주구장창 불렀다.

 일반적인 게임 루프라면 여러가지 역할들을 하는 클래스들을 같은 인터페이스로 묶어 일괄적으로 업데이트 함수를 불렀겠지만, C언어는 상속이나 다형성 같은 개념이 언어에 반영되어 있지 않기 때문에 그런 추상화된 구조를 적용하는 게 힘들었다. 어떻게 구조체와 함수 포인터를 잘 사용하면 C++의 클래스처럼 코드를 짤 수 있었겠으나 애초에 C언어를 쓰는 취지가 프로그래밍 초심자를 배려해 코드를 알아보기 쉽게 짜란 것이니 속 편히 포기하기로 했다. 사용자 입력, 컷씬, UI 창, 사운드 시스템 등 다양한 모듈들의 업데이트 함수들은 모두 서로 다른 이름의 전역 함수로 구현하고 그것을 게임 루프에서 차례대로 호출했다.

 

3. 클라이언트 구현

윈도우에 그림을 그리는 함수는 모두 전역으로 선언된 GDIEngine.h 헤더 파일

 그림을 그리는 함수들은 모두 Wingdi.h 헤더의 함수들을 응용하여 전역함수로 만들어 놓았다. 함수에 매개변수로 출력대상 이미지 파일의 경로를 문자열로 넣으면 그 문자열을 키값으로 캐싱된 이미지 리소스를 사용해 화면에 이미지를 그린다. 문자열 해싱의 구현은 부팀장 A에게 맡겼다.

 Stage는 주인공의 상태, 주인공의 위치, 스테이지 타일의 배치와 스테이지 위 오브젝트들의 위치 등, 현재 스테이지를 진행하는데 필요한 모든 맥락을 저장했다. 인게임 로직이 크게 어려울 것이 없었기 때문에, 구현을 여러 코드에 산재시키지 않기 위해 모든 원죄를 짊어진 구조체를 만든 것이다.

메모장으로 작성한 레벨 디자인이 스테이지에 적용된 모습

 각 스테이지마다 타일과 오브젝트의 배치도는 메모장으로 작성했다. 문자 4개로 하나의 타일에 대한 정보를 기록했다.

 그 외 주인공의 애니메이션, 오브젝트들이 둥실거리는 움직임, 사운드 모듈 등 게임의 완성도를 끌어올릴 수 있는 디테일적인 부분들을 개선했다.

 

4. 소감

 이 프로젝트 진행중에는 A의 존재감이 매우 컸다. 실력도 있고 힘도 있는데, 욕심도 많고 본인이 참가한 게임은 절대 남에게 얕보이면 안된다는 자존심까지 있어서 처음에는 꽤 부담스러운 면도 있었다. 유능한 이를 동료나 수하로 두면 시기하는 마음이 들기 마련이라더니, 불꽃같이 강렬한  A 모습에 프로그래밍 팀장으로서의 내 위치가 위협받는 것 같아 두려운 마음도 들었지만 '항상 유능해야만 하는 나'라는 나의 자의식 문제를 제대로 직면할 수 있는 기회라는 생각도 들었다.

 불교에는 자신의 문제를 남에게 덮어씌워 투사하지 말고, 스스로의 마음을 비추어 바라보라는 말이 있다. 과연 나의 문제를 A에게 그만 덮어씌우고 가만히 스스로의 마음을 관찰하며 바라보니 A가 나를 도모하려는 불온세력에서 든든한 조력자로 바뀌는 인식의 전환을 경험할 수 있었다.

 

깃허브 주소 : https://github.com/yunu95/EndingFamily-Kimchi

유튜브 주소 : https://www.youtube.com/watch?v=mDR3Lyo6fZk&ab_channel=Floater 

목차

  1. 계획 단계
  2. 사전 작업- Yunuty 게임 엔진의 개발
  3. 프로젝트의 시작
  4. 소감

 

1. 계획단계

 2022년 11월 게임인재원 1학기, 약 1주~1주 반 정도의 시간을 들여 간단한 게임을 만들어 보는 시간이 있었다. 오후 5시까지 배정된 수업은 수업대로 진행하고 남는 시간에 게임을 개발하는 과제였다. 할당된 개발 시간이 절대적으로 부족하고, 팀원들도 모두 프로그래밍 초심자였기 때문에 개발역량의 견적이 나오지 않는 상황에서 떠올린 나의 전략은 "최소기능의 구현은 매우 간단하지만 확장이 용이한 게임을 만들자."였다. 이런 전략에 맞는 게임의 기획은 바로 클리커 게임이었다.

그저 쿠키를 많이 만들기만 하면 되는 게임, 쿠키 클리커

 유명한 웹 게임, 쿠키 클리커로부터 유래한 클리커 장르 게임은 생산 시설을 구입하고 시설의 효율을 증가시켜 자원 수치를 끝없이 올리는 것에서 재미를 찾게 만드는 게임이다. 사실상 이런 게임은 사용자가 스페이스 바를 눌렀을 때 어떤 숫자가 1씩 증가하는 시스템만 만들어도 게임이 성립되기 때문에 최소기능 구현이 매우 간단하다고 볼 수 있다. 이렇게 먼저 최소기능만 구현한 후, 시설, 업그레이드, 쿠키 생산량에 따라 출력되는 뉴스 내용과 같은 시스템들은 모두 추가 기능으로 두고 하나씩 개발하는 것을 계획으로 잡았다.

 원본 게임의 설정까지 완전히 다 베낄 수는 없으니 쿠키가 아니라 김치를 만드는 게임으로 설정을 바꿨다.

 

2. 사전작업 - Yunuty 게임엔진의 개발

같은 솔루션에서 게임 엔진 프로젝트와 게임 클라이언트 프로젝트를 분리하여 작성했다. 왼쪽이 클라이언트, 오른쪽이 게임 엔진.

 나는 애초부터 게임 엔진을 밑바닥부터 만들어보고 싶다는 생각으로 게임 인재원이라는 기관에 들어왔기 때문에, 평소에도 유니티를 모방한 자체 게임엔진 개발을 진행하고 있었다. 그래서 이번 프로젝트를 시작하기 전에 먼저 팀원들에게 양해를 구해 이 게임엔진 위에 클라이언트 코드를 얹는 방식으로 개발을 진행하기로 했다.

상용엔진 유니티의 라이프 사이클
게임엔진 사이클을 정의하는 싱글톤 클래스 YunutyCycle의 선언부(좌)와 구현부(우), 객체의 구조만 잡기 위해 구현부는 비어있는 함수들도 많다.
게임 스레드가 돌리는 함수인 ThreadFunction의 구현부, while(true)로부터 그 성질을 알 수 있는 함수로, 프로세스가 끝날때까지 이 함수의 반복문 내부는 무한정 실행된다.

 먼저, 유니티처럼 엔진이 끊임없이 동작하게 만들고 싶다면 스레드가 끊임없이 동작하며 게임 오브젝트 객체들의 함수들을 순서대로 부를 수 있어야 했다. 한마디로 엔진의 라이프 사이클을 정의해야 했는데, 시간 여건상 Start, Update 정도만 구현하고 넘기기로 했다.

게임 씬과 게임오브젝트 클래스의 선언부

 게임 씬과 게임 오브젝트는 자식 게임 오브젝트들을 계층적으로 배치할 수 있는 구조로 만들었다.

컴포넌트 클래스의 코드, 게임 사이클의 특정 이벤트마다 실행할 함수들인 Start,Update,OnEnable 등의 함수들이 가상 함수로 정의되어 있다.
게임 오브젝트 클래스의 코드 중 AddComponent, GetComponent등의 함수로 컴포넌트를 추가하거나 레퍼런스를 가져올 수 있게 만들었다.

 그 다음 게임엔진에서 가장 핵심적인 코드라 할 수 있는 컴포넌트 코드를 만들었다. 컴포넌트는 게임 오브젝트에 붙일 수 있는 부품같은 존재로, 게임 사이클의 특정 주기마다 내부의 함수들이 호출된다. 클라이언트 코드 개발자는 컴포넌트 클래스를 상속받는 임의의 컴포넌트 객체를 만들고 각 게임 사이클마다 호출될 멤버 함수들을 재정의하는 것으로 클라이언트 로직을 구현할 수 있다.

콘솔 창 프로젝트에서만 쓰일 코드가 필터로 분리된 모습

 이번 프로젝트는 "게임 화면을 콘솔창에서만 출력해야 한다."는 특수 룰이 적용되어 있었다. 나중에 알게 된 사실이지만 게임화면의 경우 게임 엔진과 별도로 그래픽스 엔진을 개발해 화면 출력의 역할을 전담시키는 것이 일반적인 개발방식이라고 한다. 당시 그래픽스에 대한 개념이 전혀 없었던 나는 화면 출력과 관련된 코드를 따로 라이브러리로 뺄 생각까지는 하지 못했지만, 게임 엔진의 핵심적 동작과 이질적인 코드들을 따로 관리해야겠다는 생각은 하고 있었다. 따라서 나는 콘솔창에서 게임 화면이 출력되는 것을 염두에 두는 코드들은 언제든지 떼어버릴 수 있도록 따로 Console 필터에 몰아넣어 분류하였다.

카메라 클래스와 그를 상속받는 콘솔창 카메라

 카메라는 카메라의 위치선정, 메인 카메라의 지정 로직을 넘어 아예 렌더링 함수까지 카메라에 넣었다. 물론 콘솔창에다가 렌더링을 하는 코드는 바깥으로 빼고 싶었기 때문에 콘솔창용 카메라는 카메라의 파생 클래스로  따로 만들었다. 환경이 달라질때마다 렌더링 방법도 달라져야 했기에 베이스 카메라 클래스의 렌더 함수는 완전 가상함수로 선언했다.

사용자 입력을 처리하는 인풋 클래스와 그를 상속받는 콘솔창 인풋 클래스

 사용자 입력을 처리하는 클래스도 나는 추상클래스로 만들어 처리했다. 콘솔 창에서 사용자 입력을 받는 로직이 나중에 환경이 달라짐에 따라 얼마든지 바뀔 수 있다고 생각했기 때문이다. 지금은 게임 엔진을 안드로이드, 리눅스에서도 쓸 생각을 하지 않는다면야 굳이 이렇게까지 할 필요는 없었다는 생각이 든다. GetAsyncKeyState라는 함수가 윈도우 운영체제 위에서의 사용자 입력은 충분히 다 받아줄 수 있었기 때문이다.

 어쨌든 게임엔진 사이클, 게임 오브젝트 - 컴포넌트 구조, 사용자 입력 감지 기능, 콘솔창의 카메라 출력 기능과 콘솔창의 그래픽스 기능을 거의 다 개발한 상태에서 프로젝트 팀이 구성되었다.

 

3. 프로젝트의 시작

팀 구성  
팀장
클라이언트 근간 시스템, UI A
사운드, 이미지 등 리소스 탐색 B
외부 라이브러리 연동 C

외국어 이름은 모두 가명임

 

 A는 대학에서 경영을, B는 건축을 배웠으며 C는 러시아어를 전공했다. 이들은 모두 코딩을 게임 인재원에 들어와서 처음 배운 초심자들로, 코딩을 배운 지 한 달 반 정도 뒤에 이 프로젝트를 시작했으니 다들 C++은 고사하고 C언어의 개념에도 익숙지 않은 상태였다. 큰 문제는 아니었다. 상황이 주어지면 상황에 맞게 계획을 짜면 된다. 팀원들의 개발 역량을 많이 기대할 수 없었고 그들의 현재 상태를 파악하는 것도 힘든 상황이었지만, 작업을 적절한 단위로 쪼갠 후 가장 작은 일부터 시작해 점진적으로 큰 일을 맡기면서 자연스럽게 작은 책임으로부터 큰 책임으로 역할의 확장을 유도하면 되는 일이었다.

 프로젝트 시작후 2일에서 3일정도 엔진 개발을 마무리하는 시간을 가지면서 그동안 팀원들에게는 쿠키 클리커를 플레이해보고 아이디어를 구상해보도록 했다. 나의 작업 때문에 전체 인원의 작업에 병목이 생기는 게 싫긴 했지만, 작업의 규모와 영역을 체계적으로 잘라 분배하기 위해서는 어느 정도 프레임워크가 있는 것이 없는 것보단 나을 거라고 판단했다.

반복문을 활용해 텍스트박스를 그리는 함수, 몇몇 클래스의 경우 함수의 선언만 내가 하고 구현은 팀원들에게 맡겼다. 위 함수는 A가 작업했다.

 팀원들은 프로그래밍 실력을 따지기에 앞서 프로젝트 자체에 대단히 큰 부담감을 느끼고 있는 것 같았다. 아마 자신이 코드를 어설프게 짜면 팀플을 망치게 되지 않을까 그랬던 것 같았다. 때문에 팀원들에게 먼저 자기확신이 들 수 있는 작업경험을 느끼도록 유도하고, 그 동력을 살려 자신감의 스노우볼을 굴려야겠다는 생각을 했다. 나의 이런 시도에 가장 고맙게 호응해 준 친구가 A였다. 나는 A에게 함수 기능 중 특정 반복문의 중괄호 내부를 채워달라고 부탁했는데 그 작업을 성공하면서부터 코딩에 대한 자신감이 붙더니 나중에 가서는 함수 전체를 구현하질 않나, 내가 짠 예시 코드를 보고 화면들의 UI 구성을 다 해주지 않나, 나중에 가서는 반복되는 코드를 함수로 모듈화하는것까지 너끈하게 해내는 것이 여간 든든한 게 아니었다.

 C는 다른 의미에서 나를 든든하게 도와줬다. 나보다 한살 많은 형님이셨는데, 사운드출력 기능을 구현해야 하는 일이 생기자 "개발이 끝나기 전까지 사운드 라이브러리 FMOD를 공부해 오겠다."라는 말을 남기고 며칠 뒤 라이브러리 기능을 가져오고 사용법을 설명해 주시는 호방한 형님으로, 실로 뜨거운 술이 식기 전에 안량의 목을 취한 관우의 기상이 있는 분이셨다. 프로그래밍을 갓 시작하신 분 치고는 대단한 독립심과 자주성을 갖고 계셨으며, 목표달성에 필요하면 가리지 않고 공부하고 배우는 자세는 나도 배울점이 많았던 것 같다

 B는 이번 프로젝트에서 큰 성취를 이뤄내지는 못했다. 의기소침해있던 B에게 A와 비슷하게 접근해 가장 작은 단위의 작업을 먼저 맡겨 거기서부터 코딩에 대한 자신감을 심어주고 싶었지만, 내가 B에게 믿음을 주지 못했는지 그는 결국 한 줄의 코드도 짜지 못하고 프로그래밍 작업에서 완전히 소외되고 말았다. 리소스를 찾는 역할, 기획을 보조하는 역할을 주긴 했지만 결국 프로그래밍을 배우러 온 친구에게 잡일만 시킨 꼴이니, 마음 한켠에 항상 미안한 마음이 들었다.

커밋 메시지 목록과 커밋 비율

 어쨌든 팀의 응집력을 최대한 끈끈하게 유지하려 했던 나의 노력이 부족했을 수도 있고 세련되지 못했을 수도 있지만 어떻게든 팀은 굴러갔고, 게임의 윤곽이 서서히 드러나기 시작했다.

게임화면 중 생산시설 구매창

마지막으로 생산시설이나 업그레이드의 가격, 효율을 세심하게 세팅하는 것도 중요했다. 이 수치들을 아무렇게나 설정할 수는 없으니, 빠르게 해당 부분에 대해서만 기획문서를 작성했다.

 수치 조절까지 적절하게 끝내고 나니 상당히 재미있는 게임플레이가 탄생했다. 업그레이드들의 효율개선 수치가 각자 2배~256배까지 달랐기 때문에 업그레이드를 할 때마다 생산시설들의 효율이 극적으로 달라져 굉장히 역동적으로 생산 시설과 업그레이드를 구매하게 만드는 플레이를 유도했던 것 같다.

 

4. 소감

 처음으로 진행한 게임 개발 팀플이었는데, 팀 분위기도 기간 내내 괜찮았고, 결과물도 생각보다 잘 나와서 좋았다. 자동사냥게임 같은 느낌이 있어서 지금도 간간히 하는 게임이다.