안녕하세요! '언리얼로 만들어보는 RPG'의 필자입니다. 이번 글에서는 전에 작성하던 Task노드의 나머지 두개를 정리하러 돌아왔습니다. 폭풍같은 회사일정이 지나고 나니까 정신이 없네요. 이전 글에서는 새로운 내용도 많았으나 이번에는 크게 새로울건 없어서 내용이 그렇게 길지 않을것 같습니다.
UCustomMove.h
당시 필자가 어떤 생각으로 작업을 했는지는 모르겠으나 엔진에서 기본제공하는 MoveTo Task노드를 상속받아서 구현을 했었습니다.
UCustomMove.cpp
지금 보면 정말 이해안가는 방식의 작업물이긴 합니다. 결국 하고싶었던건 AcceptableRadius 변수에 몬스터의 사거리를 대입하고 싶었던것 뿐이니까요. 이 외에는 부모클래스인 MoveTo 쪽에서 다 처리합니다. 이럴꺼면 차라리 다른 방법을 사용하는게 나았을 수도 있을것 같습니다.
UAttackSelector.h
UAttackSelector.cpp
공격을 선택하는부분이 상당히 빈약한데 이건 몬스터랑 애니메이션 클래스를 같이 설명해야 하는부분이라서 크게 적을게 없습니다. 하지만 역시나 여기서도 이상한 부분이 존재하는데 그걸 포함해서 앞으로 있을 작업을 미리보는 느낌으로 설명하겠습니다.
우선 AttackType이라는 변수를 AttackAble이라는 bool배열의 크기의 범위로 랜덤한 값을 구해서 대입합니다. 이 글을 읽고 계신 여러분이라면 당연히 떠올릴법한 궁금증이 하나 있으실텐데요. '왜 랜덤값을 유지를 하는가?' 그건 필자의 잘못된 애니메이션 설계때문이라고 답해드릴수 있습니다.
저번 AI(1) 글에서 유한상태기계에 대해서 이야기 한적이 있습니다. 그런 스타일로 작업을 하다보니까 거미줄로 가득한 아름다운 애니메이션BP를 만들게 되었고 그 덕에 저 값을 유지해야 한다는 사실입니다.
AI의 마지막인데 이렇게 짧아서 너무 날로 먹는거 아닌가 싶긴 합니다. 하지만 격무에 치이는동안 글쓰는 감각을 잃은 필자의 재활기간이라 생각해주시면 좋을것 같습니다. 그럼 다음 글에서 뵙도록 하고 이만 글을 줄이도록 하겠습니다.
안녕하세요. '언리얼로 만들어보는 RPG'의 필자입니다. 7~10일 정도의 텀으로 글을 작성하려고 했으나 그건 욕심이 좀 컸던거 같습니다. 필자가 너무 게을러서 그런거겠지만 거두절미하고 바로 본론으로 들어가서 이번 포스팅을 시작해 봅시다!
저번 작성글에서는 AIController와 AnimInstance를 구성하려고 했었으나, AIController를 설명하다가 언리얼에서 AI를 구성할때 사용하는 시스템들에 대한 소개글로 마무리를 지었습니다. 원래 흐름대로라면 AnimInstance를 해야겠지만 노선을 좀 변경해서 행동트리를 먼저 하도록 하겠습니다.
이번 글을 작성하기전에 간략한 견적을 잡고 가도록 하겠습니다.
1. 적대상대가 없을시 임의 위치 산정해내는 Task 작성
2. 몬스터의 상태변경을 관리하는 Task 작성
3. 몬스터의 이동에 관여하는 Task 작성
4. 공격방식을 선택하는 Task 작성
Task 노드의 작업은 위 4가지 정도로 생각이 되고 행동트리에서 사용할 변수들은 블랙보드에 하나하나 담아가 보도록 하겠습니다.
앞으로 작업을 통해서 만들게 될 행동트리의 결과물이 바로 위 사진입니다. 보시면 CustomMove라는 Task노드가 존재합니다. 언리얼에서 기본적으로 MoveTo라는 노드를 제공해 주나 작업당시 필자는 변화를 좀 주고싶었습니다. 자세한 내용은 차근차근 알아가 보도록 하겠습니다.
EMonsterState
UChangeState.h
몬스터의 상태를 변경하는 ChangeState 부터 시작해보도록 하겠습니다. 헤더파일에서는 변경될 상태를 설정할 State변수와 블랙보드의 변수이름을 지정하는 StateName 변수가 있습니다. StateName변수의 경우 필자가 처음 작업한것이다 보니 아쉬운 부분이 많은데 그 내용은 CPP쪽에서 풀겠습니다.
UChangeState.cpp
ExecuteTask 함수에서는 컨트롤러, 블랙보드, 몬스터클래스 여부를 확인한 다음에 하나라도 통과를 못하면 즉시 결과를 실패처리 합니다. 필자도 약간 과하지 않나 싶기는 하지만 나중에 제거하자는 마음으로 남겨두고 그대로 잊었습니다. 사람인데 어떻게 하겠습니까?
아무튼 블랙보드에 있는 변수설정은 타입별로 함수를 불러서 해줘야 하는데 이름과 변수를 인자로 받아들입니다. 아까 언급한 StateName이 여기서 불편함을 유발하게 되는것이죠. StateName은 에디터에서 작업자가 설정해 주어야 하는데 원하는 변수의 이름을 정확히 적지 않는다면 문제가 발생하기 마련입니다. 이런 문제는 블랙보드에 있는 변수를 직접 선택할 수 있도록 해줌으로써 해결이 가능한데 당시의 필자는 거기까진 찾아보지 않았다는게 안타까운 점이죠.
UBTTask_BlackboardBase.h
UBTTask_MoveTo.cpp
2-3 사진을 보시면 FBlackboardKeySelector 라는 자료형을 볼 수 있습니다. 해당 자료형은 아까 언급한대로 블랙보드내에 있는 변수를 선택할 수 있도록 해주는 자료형입니다. 이 자료형을 이용했었다면 작업자가 일일히 블랙보드 변수의 이름을 적는 불필요한 작업을 하지 않을 수 있었겠죠.
그리고 이 자료형을 쓰는게 좋은 이유는 2-4 사진에 나와있는것처럼 해당 KeySelector가 어떤 유형의 변수를 선택 할 수 있는지에 대해서 지정해 줄 수 있기때문 입니다. 필터 기능까지 이용한다면 더더욱 작업자의 실수로 인한 문제발생의 여지가 줄어드리라 믿습니다. 이 글을 보시는 여러분도 필자와 같이 이름을 입력하는게 아닌 KeySelector를 사용하면 좋겠습니다.
USetDestination.h
필자는 헤더파일을 보고 정신이 혼미해질 수 밖에 없었습니다. 몬스터의 상태를 바꾸는 Task를 작성해 두고서 임의위치를 산정하는 Task에서 상태를 바꾸려고 블랙보드에서 가져올 변수 이름을 받으려고 저러고 있습니다. 아 정말 아찔하네요 StateName 변수는 실제로 쓰지도 않고있으니 무시하시면 됩니다.
USetDestination.cpp
현재 사용하고 있는 방식은 0~360 사이의 각을 구해서 해당 방향벡터를 만들고 '정찰 범위/2 ~ 정찰 범위' 만큼의 값을 곱해서 해당 위치로 가는 그런 형태를 취하고 있습니다. 작동이야 하겠지만 좀 불필요한 내용이 많은것 같기도 합니다. 도달가능한 위치를 구하는건 GetRandomReachablePointInRadius라는 함수를 통해서 얻을 수 있기도 하고 말이죠
그래서 이왕 마음에 안드는거 위에서 언급한 FBlackboardKeySelector를 이용해서 필자가 생각하기에 흡족한 방식으로 다시 작성해보기로 했습니다.
(New)USetDestination.h
우선 DestKey는 블랙보드에서 목적지의 용도로 사용할 FVector형 변수를 선택하는데 쓸것입니다. 밑의 OriginKey의 경우 기준점을 스폰 당시의 위치로 하거나 현재 몬스터의 위치로 하거나 두가지 선택권을 가지게 할 것입니다. 마지막 변수는 정찰 범위를 의미합니다.
그럼 이제 흔하지 않은 함수 InitializeFromAsset가 있습니다. 이친구의 역할은 필자가 블랙보드로부터 변수를 가져다 쓸것이기 때문에 행동트리에 블랙보드가 설정이 안돼있는 만일의 경우 생기는 문제를 막기위해서 사용합니다. 자세한 내용은 밑의 사진에서 확인해보겠습니다.
(New)USetDestination.cpp
아까 언급한 함수는 노드를 초기화 하는과정에서 블랙보드가 없다면 경고 로그를 찍게됩니다. 이런식으로 블랙보드의 변수를 가져다 써야하는 노드이니 잊지말고 넣으라고 알려주는 작업이 되는것입니다.
이 Task 노드의 가장 핵심부분인 실행 함수를 한번 살펴보도록 합시다. 여기서도 도움이 될만한 부분이 있는데요 빨간색으로 밑줄이 쳐있는 if문을 보시면 블랙보드 셀렉터가 선택한 변수가 어떤 타입인지 비교를 할 수 있습니다.
이를 통해서 필자는 FVector형일때는 Origin변수에 블랙보드로부터 해당키 이름을 통해 값을 가져와서 대입하고 만약 아니라면 위에서 설정한것처럼 ACharacter형태의 Object만 설정이 가능하니 해당 액터로부터 위치를 가져와 대입합니다.
여기서도 필자가 미흡하게 넘어간 부분이 존재하는데 만약 도달가능한 임의위치를 찾지 못했다면 현재 몬스터의 위치를 목적지로 설정해주는게 생길지 모르는 문제들을 막아줄 것 같습니다.
이번 글은 여기까지만 작성하고 마무리를 하기전에 실제로 작성한 Task 노드를 사용하려고 빌드를 하면 성공이 뜨는걸 확인하실 겁니다. 하지만 에디터에서 확인해보면 노드는 보이지도 않고 혹시몰라 컴파일을 하면 LNK 2001, 2019 등의 링크에러가 뜨는걸 확인하실텐데요. 이유는 간단하게도 모듈을 추가해주지 않아서 입니다.
UNavigationSystemV1클래스는 "NavigationSystem"모듈을 추가해주어야 하고 BTTaskNode를 상속받아 쓰려면 "AIModule"을 추가해야합니다. 다음글에는 남은 두개의 Task노드를 정리해보도록 하겠습니다.
안녕하세요! '언리얼로 만들어보는 RPG'의 필자입니다. 정말 부끄러우나 마지막 글로부터 1년 3개월 정도만에 나머지 내용을 정리하려고 돌아왔네요. 변명을 조금 해보자면 회사에서 폐 안끼치려고 부족한 실력 끌어모아 몇달을 달려왔더니 그럴 여유가 전혀 없었습니다. 지금은 이미 시간이 너무 지나버려서 계속 보시는 분들이 없겠지만 그분들에게 심심한 사죄의 말씀을 올립니다.
<대충 지친사회인 짤>
너무 긴시간 글을 안썼더니 어떻게 시작해야할지 모르겠어서 잠깐 제 작업물을 확인하고 올 필요성이 있습니다.
피자도 좀 먹고 그간의 게시글도 확인좀 하느라 늦었습니다. 예전(약 1년전)의 방식대로 저희는 간략한 견적을 작성해보고 시작하도록 하겠습니다.
1. 몬스터의 행동을 컨트롤할 AIContoller 작성
2. 생명을 불어넣어주는 AnimInstance 작성
우선적으로 쓸데없는 이야기인데 제목과 넘버링을 어떻게 해야할지 조금 고민했습니다. 몬스터(1)로 갈지 AI(1)로 갈지 말이죠 어짜피 생각해보면 몬스터를 구성하기 위한 모든 과정이라 몬스터를 넘버링으로 사용해도 될거 같았는데 나중에 확인하기 쉽도록 분할해서 넘버링을 메기는게 나을것 같아서 AI로 가기로 했습니다.
정말 쓸데없는 이야기는 이정도로 끝내도록 하고 몬스터 클래스를 만들기도 전에 AIController를 만들러 출발하도록 하겠습니다.
AEnemyAI.h
이것이 앞으로 몬스터를 조종하게될 Controller입니다. 지금 보니까 전방선언 해두고 쓰지도 않는게 두개나 있네요. 거기에 더해서 사실 저 AIOwner는 따로 선언해서 사용할 필요는 없었습니다. AIController의 2계층 위에 있는 Controller에 빙의할때 대상을 Pawn이라는 변수에 저장하거든요. 거기까지 확인하지 않았던 필자의 잘못이니 지금은 그러려니 하고 넘어가도록 합시다.
사실 필자 작업한 AEnemyAI 자체에서 하는일은 크게 없습니다. 그걸 왜 이야기 하냐면 BTData라는 변수때문에 나온 이야기입니다. 해당 BehaviorTree는 실제 AI의 행동을 관리하고 상태를 변경시키는데 활용하는 객체입니다. 물론 AIController가 아무것도 하지 않는다는것은 아닙니다.
어째서냐? 하면, 플레이어의 눈을 Camera가 대신 하는것처럼 몬스터 같은 NPC에게도 눈과 같은 역할을 할 수 있는게 있습니다. 해당 친구는 PerceptionComponent 인데 시각, 청각 등의 감각에 대한 반응을 설정할 수 있습니다. 좀더 디테일하게 파고들어가면 좋겠지만 필자 본인도 거기까지는 하지 않아서 말을 줄이겠습니다.
함수가 두가지 구현되어있는데 Possess는 몬스터에 빙의할때 실행되고 나머지 하나인 UpdatePerception 함수가 중요하다고 볼 수 있습니다.
AEnemyAI.cpp
몬스터에게 빙의를 하면 BTData를 이용해 RunBehaviorTree함수를 실행합니다. 해당 함수는 AIController에 선언 및 정의되어 있으며 인자로 들어온 BehaviorTree객체를 BrainComponent로 사용하도록 합니다. 그리고 PerceptionComponent가 Null이 아니라면 자극이 들어올때에 대한 처리를 하기위해 작성한 UpdatePerception 함수를 AddDynamic으로 델리게이트에 바인딩 시켜줍니다.
필자가 바인딩 시킨 함수를 보시면 새로운 자극을 가한 객체들이 TArray<AActor*> 형태로 전달됩니다. 몬스터는 싸워야할 대상인 Player인지 확인해보고 맞다면 BlackBoard에서 해당 변수를 가져와 NULL인지 아닌지에 따라서 처리가 나뉘게 됩니다. 이러한 작업에는 이유가 있는데 이 Perception이 자극을 받는 경우가 시각을 예로들자면 범위 안에 들어올때와 나갈때 둘다 반응하기 때문입니다. 시야 밖으로 나가는 경우에는 BlackBoard에 있는 Player변수를 NULL로 변경해야 그에 따른 행동을 취할 수 있기 때문이죠
이쯤 오니까 마구잡이로 글을 쓰기전에 몬스터의 AI를 구성하기 위해서 필자가 사용했던 엔진 기능들에 대해서 먼저 정리를 할 필요성이 있다는걸 느꼈습니다. 이번 첫글에서는 AIController, BehaviorTree, BlackBoard, AIPerception에 대해서 필자 나름대로 이해한 내용들을 적어내려가겠습니다.
AIController
크게 설명할것은 없습니다. 플레이어블 캐릭터를 조종할때 사용하는 컨트롤러가 PlayerController라면 반대로 NPC를 조종하기 위해서 사용하는 컨트롤러는 AIController입니다. 지금까지 글을 작성하며 언급한 BehaviorTree나 AIPerception등 상태의 변경이나 관리, 행동을 변경시키는 객체들을 담아두고 관리하는 용도로 사용하면 된다고 생각합니다.(?)
문서의 링크를 남겨두기는 하지만 문서에도 딱히 적힌 내용은 별로 없어서 실망하실 수 있습니다.
NPC에게 행동을 지시하는 두뇌와 같은 역할을 하는 객체입니다. 언리얼에서의 행동트리는 Task, Decorator, Service 세가지의 실행 노드가 존재하며 Task는 가장 마지막인 잎 위치에 있어야하고 Decorator나 Service 노드의 경우 Composite라고 부르는 흐름제어 노드 (Selector등)와 Task에 함께 사용할 수 있습니다. Decorator는 일종의 조건문이라고 생각하시면 편하고 Service는 필자가 사용은 하지 않았으나 상당히 중요한 기능인 EQS를 실행시키거나 BlackBoard에 있는 정보의 최신화를 하는등에 사용가능합니다.
행동트리 문서는 안타깝게도 한글로 번역이 안되어 있는데 필자가 설명한 정도만 알고 있어도 크게 어렵지는 않으리라 예상합니다. 또한 퀵 스타트 가이드를 따라가다 보면 금새 감을 잡으실꺼라 믿습니다.
잠깐 옆길로 빠져서 한창 필자가 공부할때 (물론 지금도 공부를 안할 수 없지만) FSM이라 하는 유한상태기계를 통한 AI를 구성하는 방식을 사용하기도 했었습니다. 그게 정말 FSM인지 아닌지는 잘 모르겠으나 AI 구성에 관해서 관심이 있다면 이거저거 찾아보시는것도 나쁘지 않으리라 생각합니다. 물론 언리얼에서는 행동트리를 쓰는게 좋지 않을까요?
BlackBoard
행동트리를 설명하며 두뇌와 같다는 이야기를 했었습니다. 아쉽게도 행동트리 혼자서는 온전한 두뇌는 어렵고 신경망정도라고 생각하면 이해가 쉽지 않을까 생각합니다. 그 이유라하면 BlackBoard는 두뇌의 저장공간과도 같은 역할로써 자신의 현재 상태와 기억하고 있어야할 다양한 정보를 관리하는 객체이기 때문입니다. BlackBoard에서 적에 대한 정보가 변경이 되면 그걸 기반으로 행동트리는 필요한 행동을 하도록 지시를 내리는 것이 되는것이죠
AIPerception
몬스터의 상태에 따른 행동을 관리하는게 BehaviorTree였다면 상태의 변경에 관여를 하는건 방금 언급한 PerceptionComponent입니다. 이 Perception에 새로운 자극 예를들어 설정한 시야범위 안에 어느 객체가 등장했다거나 청각범위안에서 소리가 들리는등의 일들이 벌어지면 OnPerceptionUpdate라는 델리게이트를 호출하게 됩니다.
뭐 얼마 적은거 같지도 않은데 시간은 시간대로 지나고 허리는 허리대로 아프군요. 예전처럼 2~3일에 한개씩 올리는건 좀 어려울거 같고 7~10일 정도에 한번씩 올릴거 같습니다. 시간을 내어서 이 글을 읽어주신 분들에게 감사의 말씀을 남기며 필자는 다음에 다시 돌아오겠습니다!
안녕하세요 '언리얼로 만들어보는 RPG'의 필자입니다. 저번 글 마지막 부분에서 Drag&Drop이 잘 되는 것과 함께 스킬 사용을 확인했었습니다. 이번 글에서는 FSkillData::Use함수를 사용했을 때의 코드와 애니메이션 처리를 좀 하겠습니다. 그리고 여유가 좀 된다면 스킬의 이펙트와 사운드 처리를 위한 Actor 또한 만들어서 이용해 보겠습니다.
그러면 시작하기전에 앞으로 어떤 식으로 작업할지에 대해서 간략하게 구상을 하도록 하겠습니다. 필자는 스킬 애니메이션을 (*)AnimationMontage(이하 몽타쥬)로 사용해서 코드로 관리하려 합니다.
이를 위해서 우선 AnimInstance를 상속받아서 필자 입맛대로 작성을 좀 해야합니다.
AnimSwordMan.h
필자는 스킬을 사용하면 해당 데이터를 상속받아 만든 AnimSwordMan에 SkillData포인터 형으로 들고 있게 해주어서 스킬을 사용했는지 검증하는 과정을 거칠 것입니다. 그리고 Player로 부터 받아올 데이터가 있을 수 있기때문에 마찬가지로 포인터를 만들어 둡니다.
AnimSowrdMan.cpp
위 코드에서 눈여겨 보아야 할 부분은 빨간색 박스로 테두리를 감싼 부분입니다. NotifyQueue는 실행중인 몽타쥬에서 감지된 Notify들을 담아두는 배열입니다. 저희는 해당 배열에서 Notify들의 이름을 검사하여 원하는 동작을 수행할 것입니다. 필자의 경우 Action이라는 이름을 가진 Notify를 이용할 예정입니다.
AnimSwordMan.cpp
필자는 이런식으로 Action이라는 이름을 가진 Notify가 감지될 경우 위 함수를 호출하여 들고있던 현재 사용한 SkillData로부터 Type을 확인하여 무언가 작업을 하려 합니다. 지금은 딱히 할게 없으니까 비워두었습니다.
PlayerBase.h
PlayerBase.cpp
AnimP(애니메이션 BP)
여기까지 작업을 하셨다면 몽타쥬에서 발생하는 Notify를 감지할 수 있는 준비가 끝났습니다. 이제 다음으로는 스킬 Actor를 만들어 보고 SkillData에 해당 Actor형을 받아서 사용시 스폰시켜줄 수 있도록 준비해보겠습니다.
SkillBase.h
SkillBase클래스는 스킬이 사용될때 원하는 타이밍에 맞춰서 스폰시킬 액터입니다. 지금은 이펙트와 사운드 두가지만 필요합니다. 그외에 AutoKill라는 불변수와 LifeTime 변수를 이용해서 일정 시간이 지나면 사라지게 만들것 입니다.
SkillBase.cpp
SkillBase.cpp
InGameData.h(SkillData)
TestSkill(블루프린트)
BpP(블루프린트)
이제 SkillData::Ues를 작성하면 완벽합니다.
InGameData.cpp(SkillData)
이제 정말로 준비는 끝입니다. 스킬을 사용하면 상속받아 만든 AnimationInstance의 PlaySome함수로 스킬 정보를 보내고 확인을 위해서 즉각적으로 해당 스킬액터를 생성해 줍니다.
이로써 스킬에 대한부분도 끝이났습니다. 아마 다음으로는 드디어 UI로부터 탈출하여 적을 만들고 AI를 구성할거 같습니다. 기다려주신 모든분들에게 감사의 말씀을 남기며 이만 물러가겠습니다.
안녕하세요 '언리얼로 만들어보는 RPG'의 필자입니다. 저번글에 이어서 오늘은 스킬창 UI를 마무리 하고 퀵슬록에 등록해서 사용하기 위해 Drag&Drop 작업을 할 예정입니다.
SkillTree.h
SkillTree에서는 크게 해줄일은 없고 SkillSlot을 담아주기만 하면 됩니다. 현재 플레이어에게 스킬포인트가 얼마나 남았는지 보여주는것 정도만 해주면 SkillTree는 자신의 할일을 다 했다 보시면 됩니다. SkillPointChanged함수는 스킬을 투자하고나서 갱신해주는 용도의 함수입니다. 이걸 각 버튼에 delegate로 연결시킬 예정입니다.
SkillTree.cpp
초기화에서는 다른 UI들과 마찬가지로 Player포인터를 대입해주고 해당 UI의 Init함수를 불러줍니다. 그리고 아까 언급한것처럼 SkillSlot이 가지고있는 버튼 두개에 delegate로 SkillPointChanged함수를 연결해줍니다. 해당함수는 버튼이 작동할때마다 UI상에서 남은 SkillPoint를 갱신해서 보여줄 것입니다.
보시면 뭔가 이상한점을 하나 느끼실 수 있는데 SkillPoint라는 변수를 선언한 기억이 없으실 겁니다. 네 필자가 실수를한 것 같군요. int형으로 SkillPoint라는 변수를 하나 만들어 주시면 됩니다.
SkillTree(UMG에디터)
GameUI.h
GameUI.cpp
이정도면 UI는 준비가 다 됐습니다. 이제 Drag&Drop 처리를 해보러 가도록 하지요.
SlotDrag.cpp
Drop당시 Drag를 시작한 슬롯의 타입이 Skill이거나 Q_Skill인 경우에 대한부분을 추가해줍니다.
SlotDrag.cpp
불필요한 중복코드가 좀 있는 것 같기는 하지만 정상적으로 작동하니 우선은 냅둡니다. 일단 돌아가게 만들면 나중에 수정해서 해결하면 되는것이니까요!
SlotDrag.cpp
이번글을 작성하면서 불필요했던 중복코드를 좀 정리하게 되었습니다. 역시 작업한걸 다시한번 보고 천천히 정리하면 해결할 방법을 찾게되는거 같습니다. 여기도 마찬가지로 슬롯의 Type이 Q_Skill인경우에 대한것만 추가해주면 별로 달라질것은 없습니다.
저번글로부터 텀이 꽤 길었던것 같은데 요즘따라 개인적인 일이 생겨서 집중할 시간이 부족하다보니 생기는 문제인것 같습니다. 너그럽게 이해해주시면 감사드리고 다음글을 마지막으로 Skill은 마무리가 될것 같습니다. 이번글은 여기까지 마무리하고 다음글에서 다시 찾아뵙겠습니다.
안녕하세요 '언리얼로 만들어보는 RPG'의 필자입니다. 약 2주간 장례식도 다녀오고 삽질도 하는등 시간이 순식간에 지나가 버렸습니다. 그래도 고민하던 스킬구현이 끝나서 이렇게 정리하여 글로 올리러 온것이죠.
이번글에서는 스킬이 가지고 있어야할 데이터를 구조체로 구성하고 스킬창 UI를 만드는것까지 해보겠습니다. 아마 개인적인 생각이지만 스킬창을 다 만들지는 못할 것 같습니다. 필자가 글을 쓸때마다 늘 작성했던 이야기이지만 작업을 시작하기전에는 어떤식으로 돌아갈지 전체적인 그림을 그려두어야 합니다. 뼈대를 만들고 나중에 살을 붙이는것이죠.
SkillData에는 ItemData와 기본적인건 똑같이 필요할것입니다. 필요한건 스킬의 설명, 필요레벨, 스킬의 최대레벨과 현재레벨, 그리고 실행할 애니메이션몽타주 정도면 될것 같네요. 작성해보도록 합시다.
InGameData.h
아까 이야기한것보다 뭔가 내용이 좀 많아진것 같으시다면 기분탓이 아니니 걱정하지 마세요. 스킬을 어떤 방식으로 사용하는지에 따라 타입을 나눠줄 필요가 있었습니다. 이 스킬의 범위와 데미지를 얼마나 줄지에 대한것도 필요했구요.
이건 여담이지만 무한 구조체 패턴의 폐해가 여기서 발생하게 되었습니다. 이걸 해결하려면 꽤나 대규모 작업이 필요해서 지금은 분노를 참고 다음작업할때 다시는 이런식으로 안하려고 생각중입니다.
InGameData.h
필자는 스킬의 타입을 3가지로 지정해 두었는데
Melee : 무기를 이용한 타격스킬
Scope : 사용자 중심으로부터 일정 반경의 적을 공격하는 스킬
Fire : 무언가를 발사하여 공격하는 스킬 ex) 파이어볼, 검기 등등
이를 이용해서 애니메이션몽타주의 Notify를 통해 작업할 예정입니다. 데이터가 준비되었으니까 이걸 플레이어한테 넣어줍시다.
PlayerBase.h
이번에는 배열을 만들어 두기만 하고 크기를 미리 지정해주지 않을 것입니다. 필자의 경우에는 캐릭터마다 사용할 스킬의 갯수가 다르기 때문입니다.
BpSkillTree(UMG 에디터)
<미리보는 완성본>
위 이미지가 바로 목표로 할 스킬창의 최종 모습입니다. 인벤토리나 장비창, 퀵슬롯과는 다르게 스킬창의 경우 보여줄 것도 많고 버튼을 이용해서 스킬투자를 할 수 있어야 하기때문입니다. 이를 위해서 스킬창은 Slot을 바로 쓰는게 아니라 Slot을 포함하고 있는 새로운 UI를 만들어서 그걸 담아줄 것입니다.
SkillSlot.h
SkillSlot의 경우 Slot의 Slotnum이 에디터상에서 계속 초기화 되기때문에 SkillSlot을 통해서 넘겨주어야 했습니다. 그걸 제외한 나머지는 에디터에서 생성하여 바인딩해줄 위젯들 입니다.
보시면 SkillUp, Down 함수가 있는데 해당 함수 두개는 각각 버튼에 delegate로 연결해줄 함수입니다.
SkillSlot.cpp
<Init함수 코드>
처음에 UI가 생성되어 초기화할때 플레이어의 레벨을 확인하여 스킬설명 텍스트를 초기화 하는것과 위에서 언급한 함수 두개를 delegate로 연결하는것 말고는 다른 UI와 크게 다르지 않습니다.
SkillSlot.cpp
SkillUp과 SkillDown 함수는 버튼UI를 이용해서 OnClicked 이벤트가 발생할때 실행하여 플레이어가 지니고 있는 SkillData의 Level값과 Cost를 갱신하고 UI에 반영하는 역할을 합니다.
이제 코드로 준비해야할 부분은 다 처리가 끝난것 같아 보이니 UMG에디터를 이용해서 UI를 만들어 보러 가겠습니다.
UMG 에디터를 이용해서 만드는걸 하나하나 캡쳐해서 올리기에는 너무 무리가 있다 싶어서 간략하게 만들기만 하는 영상을 찍었습니다.
이번글은 여기까지 작성하고 다음에 다시 돌아오도록 하겠습니다. 저 별거아닌 영상을 찍는데 예상치 못한 방해가 몇번 일어나고 실수 몇번이 더해지니까 너무 많은 시간을 들여서 그런가 진이 다 빠지는군요.
이건 별로 쓸데없는 여담이긴 하지만 필자는 가급적 영상을 안올리고 gif형태로 올리려고 했습니다. 그래야 글을 읽다가 흐름이 안끊길거라고 생각했기 때문입니다. 이번엔 어쩔 수 없었으니 너그러운 마음으로 이해 부탁드립니다.
안녕하세요 '언리얼로 만들어보는 RPG'의 필자입니다. 저번글에 이어서 이번글에서는 만들어둔 Item클래스를 이용해 무기 블루프린트를 하나 만들어보고 아직 줍는기능을 구현하지 않았으니 인벤토리에 임시로 데이터를 넣어두고 장착과 해제를 해보겠습니다.
그러고보니 저번글에서 빼먹은 부분이 하나 있었습니다. 장비UI를 만들었으면 당연히 해야하는 과정이다보니 무심코 지나가버렸네요. GameUI에 장비UI를 위한 변수를 마련해주고 바인드 시켜주면 됩니다.
GameUI.h
<장비UI를 위한 변수선언과 Bind를 위한 meta설정>
GameUI.cpp
<Player포인터를 채워주고 초기화시킨다>
BasicSword(블루프린트 에디터)
필자의 무기는 SkeletalMesh이기 때문에 SkeletalIem클래스를 기반으로 BasicSword라는 블루프린트를 만들어 주었습니다. 필요한 Mesh를 지정해준다음 ItemData를 채워줍니다. 여기서 가장 중요한 부분은 ItemClass 변수를 잘 매칭해서 넣어주셔야 한다는것입니다. 그래야 장비를 착용했을때 엉뚱한게 나타나지 않게 되니까요.
필자는 장비 교체의 테스트를 위해서 두개의 무기를 만들었습니다. 참고하시면 좋을 것 같네요.
BpP(블루프린트 에디터)
<테스트를 위해 준비한 무기 두개>
이제 장비 장착과 해제를 위한 준비는 다 됐군요. 앞으로 해야할건 장비를 사용하면 지정해준 ItemClass를 통해서 World상에 스폰시켜주어 Mannequin에 마련해둔 Weapon소켓에 붙이는 것 과 해제하면 인벤토리에 다시 들어가는걸 해주면 됩니다.
Slot.cpp
<초기화 부분에 Type이 Gear인 경우를 추가해준다>
<갱신할때 Type이 Gear인 경우를 추가해준다>
<Slot 사용시 Type이 Gear인 경우를 추가해준다>
<이제 우클릭하면 조건없이 사용하자>
Slot UI쪽에서 해줘야할 준비는 이정도가 다인거 같습니다. 이제 장비를 사용했을때 스폰시키고 장착시키는 것만 하면 되겠군요.
InGameData.cpp(ItemData)
필자가 보기에도 좀 지저분해보이는건 인정합니다. 아직 코드가 완벽히 정리된건 아니기도 하고 필자가 현재 설계한 부분이 좀 마음에 안드는 게 있는데 이걸 건드리자니 너무 오래걸릴 것 같아 임시로 처리해둔 것입니다.
변명은 이정도면 충분한 것 같으니 코드에 대한 설명이 좀 필요할 것 같네요. Slot UI 쪽에서 조건없이 FItemData::Use 함수를 부를 수 있도록 하였으니 해당 함수쪽에서 예외처리를 해줘야 했습니다. ItemType이 None이거나 Equipment인 경우는 비어있기 때문에 return시켜줍니다.
inven변수는 현재 플레이어의 인벤토리에서 비어있는칸을 찾아내서 저장해주는 변수입니다. 밑줄친 함수가 해당 역할을 하는데 설명하는걸 깜빡했군요. 이건 차례 차례 순서대로 이야기 하겠습니다.
isTakeoff변수는 장비를 착용하는건지 해제하는건지에 따라서 아이템의 Count수를 조절하도록 한것인데 먼저 조금 이야기하자면 이미 착용중인 장비와 같은 부위의 장비를 사용할때 교체하는 부분에서 문제가 생겨서 저런 처리를 해준 것 입니다.
index변수는 ItemType을 이용해서 장비일경우 배열에 접근할때 사용하려 만들어둔 변수입니다. 이덕분에 for문을 쓰거나 if문을 잔뜩쓰는 방법은 피할 수 있게됐습니다.
이제 테두리친 1번박스부터 살펴보도록 하겠습니다. 아이템을 사용했는데 해당 아이템 정보를 가지고 있던 Slot의 Type을 확인해서 Gear인경우 장비를 해제하는것입니다. 해제하면 인벤토리에 들어가야하니 어느 위치에 넣어야 할지 알아야 합니다. 그때 이용하는 함수가 FindEmptySlot입니다.
PlayerBase.cpp
위함수는 들어온 ItemData의 Type을 이용해서 비어있는 자리를 찾아줍니다. 포션같은 경우는 한칸에 여러개가 있을 수 있기때문에 예외 처리를 따로 해주어야합니다.
이후에는 별거 없습니다. 인벤토리의 Slot이 들고있던 ItemData로부터 1:1로 연결되어있는 Slot의 주소를 새롭게 넣어줄 Data에 대입해주고 해당 Data를 인벤토리에 다시 넣어주면 끝입니다. 물론 이후에 끼고있던 장비를 Destroy시켜주는것과 새롭게 넣은 인벤토리를 갱신해주는것 또한 잊으면 안됩니다.
다음은 테두리친 2번박스를 확인할 시간입니다. 인벤토리에 있는 장비를 사용했는데 이미 같은부위의 장비가 착용되어있다면 교체를 해주어야 합니다. 필자가 여기서 이상하게 시간을 많이 투자하게 됐는데 isTakeoff변수가 이를 위해서 만들어진것 입니다.
이미 착용중인 장비로부터 ItemData를 받아서 원래 인벤토리에 있는 장비와 교체가 되어야 하는데 이럴 경우 사용한것은 인벤토리의 Slot이기 때문에 갱신을 하긴 하되 Count수가 줄어들면 안되는 문제가 있습니다. 그래서 이미 착용중일경우 isTakeoff가 true로 변하여 그런일을 막아주는 것 입니다.
이외에는 평범하게 장비를 바꿔주고 인벤토리의 UI를 갱신해주기만 하면되는데 장비를 소켓에 붙여주는 역할은 밑줄친 GearUp 함수에서 해줍니다.
PlayerBase.cpp
<아이템 Type에 따라 소켓을 정해서 붙여준다>
여기까지 작업을 했다면 이제 장비를 착용하고 해제하고 교체할 준비가 완벽히 끝났습니다. 사실 Drag&Drop에 대한것도 처리해야겠지만 그건 나중을 기약하기로 했습니다.
<흡족한 결과>
이렇게 또 하나의 관문을 통과했습니다. 다음엔 전투하겠다고 설레발 안치고 스킬이나 만들예정입니다. 그럼 이번글은 여기서 마무리하도록하고 다음에 뵙겠습니다.
그럼 바로 작업을 시작하기전에 Item을 어떻게 구성할지에 대해서 대략적인 밑그림을 그리는게 좋을것 같습니다. 필자가 확인한바에 의하면 Item으로 사용되는 모델이 Skeletal인 경우도 있고 Static인 경우도 있습니다. 그러니 이 두가지의 경우를 모두 고려해서 만드는게 좋을것 같군요.
또 런타임에서 아이템을 줍기 위해서 Item은 주울 수 있는 반경을 가지고 있어야 할 거 같습니다. 아마 Sphere충돌체를 통해서 해결할 수 있을 것 같네요. 이외에는 칼같은 근접무기에 붙여줄 충돌체도 필요하겠네요. 이정도면 뭐 어느정도 준비가 된거 같습니다.
ItemBase.h
Actor클래스를 상속받아서 ItemBase클래스를 만들어줍니다. Item을 주울수 있는 범위를 위한 SpehreComponent와 무기에서 사용될 CapsuleComponent를 선언해줍니다. ArrowComponent는 CharacterBase를 만들때도 사용했었던 Component인데 에디터상에서만 정면이 어디인지를 알려주는 용도로 사용됩니다.
ItemBase.cpp
SceneComponent를 Root에 생성해서 할당해주고 충돌체 두개는 공통적으로 사용될 것이기 때문에 여기서 만들어줍니다. 이제 이 ItemBase를 기반으로 StaticMesh를 이용하는 Item과 SkeletalMesh를 이용하는 Item클래스를 따로 만들어 줄 것입니다.
SkeletalItem.h
필요한 충돌체 Component는 ItemBase에서 만들어두었기 때문에 여기에서는 필요한 MeshComponent만 하나 선언해 줍니다.
필자가 중요한 부분을 깜빡할 뻔 했습니다. 이제 데이터상으로만 있던 정보를 기반으로 Item을 스폰시켜야 하기때문에 어떤 Item을 스폰시켜줄지 정해줘야 합니다. 그 부분은 빨간색박스 안에있는 변수를 에디터에서 설정해주어서 사용할 것입니다.
필자의 경우 무기모델이 SkeletalMesh여서 StaticItem은 작업한걸 보여드리지 않았습니다. 하지만 눈치빠른 분들이라면 StaticItem또한 별로 다를게 없다는걸 아시리라 믿습니다. 첫번째 준비가 끝났으니까 이제는 소켓을 만들러가면 됩니다.
UE4_Mannequin(Skeleton 에디터)
필자는 언리얼에서 기본으로 제공해주는 마네킹을 이용하고있습니다. 오른손(hand_r)에 'Weapon'이라는 소켓을추가했습니다. 혹시 궁금하신분들이 있을것 같아서 소켓의 위치값은 X : -10 Y : 5 입니다.
두번째 준비까지 끝났습니다. 이건 너무 쉬운부분이라서 날로먹는 기분이 드네요. 이제 장비 UI를 만들어야 하는데 필자가 작업을 하다보니 부족한부분이 몇개 있었습니다. 그부분들을 보완하고 가도록 하겠습니다.
InGameData.h
<SlotType에 Gear를 추가해주었다>
<ItemType에 Weapon을 추가하고 순서를 변경했다>
필자의 경우 장비를 착용하면 인벤토리에서 빠지고 장비창으로 들어가는 방식을 하기로 했습니다. 지금 생각해보면 그럴필요 있나 싶긴 하지만 뭔가 그러고 싶었거든요. 아무튼 다시 본론으로 돌아가서 Skill에 대한 Type이 따로 지정되어있는데 장비에 대한 Type이 없으니까 작업할때 애로사항이 좀 생겼습니다. 어찌보면 당연한 거였는데 필자가 너무 대충 설계했던거 같습니다.
ItemType의 경우에는 순서를 좀 바꾸었는데 Type을 이용해서 장비관련 배열의 크기를 정하고 Index로 활용해서 접근하려고 했습니다. 글로만 적으니 뭔가 확 와닿지 않는게 설명이 좀 부실한거 같습니다. 자세한건 다음 작업을따라가면서 확인하도록 하고 이제 장비UI를 만들러 가보겠습니다.
PlayerBase.h
스폰해서 장비한 Item을 들고있을 배열과 UI에서 이용할 Data배열을 선언해줍니다.
PlayerBase.cpp
아까 ItemType의 순서를 바꿔서 배열크기와 접근에 이용할거라 했었습니다. 현재 착용할 수 있는 장비는 무기 하나뿐이기 때문에 저런방식으로 크기를 만들어주면 딱 알맞게 배열이 생성됩니다.
GearSet.h
CustomUI를 상속받아서 저번에 만들었던 Inventory와 마찬가지의 구성을 취합니다. 기본으로 쓰일 이미지와 Slot들을 담아둘 배열을 준비해 주도록 하지요.
GearSet.cpp
이후의 작업은 저번 인벤토리떄와 비슷합니다. Player포인터를 채워주고 Type을 지정해주고 초기화시킨다음 배열에 집어넣어주는 작업을 하면 끝입니다.
이제 위젯블루프린트를 만들어서 UI작업만 해주면 됩니다! 필자의 끔찍한 디자인적 감각을 보완하기위해서 참고한 영상이 있는데 이걸 보시는 분들에게도 도움이 되었으면 합니다.
BpGearSet(UMG 에디터)
영상을 보시면서 천천히 따라하시다 보면 이렇게 그럴싸해보이는 UI가 나오게 됩니다. 사실 의미가 있나 싶기는 하지만 보기좋은떡이 먹기도 좋다고 하지않습니까? 그런의미로 이번글은 여기서 마무리 짖도록 하고 다음에는 장비의 착용과 해제 그리고 Item을 주울 수 있도록 작업해 보도록 하겠습니다.
안녕하세요 '언리얼로 만들어보는 RPG'의 필자입니다. 저번글에 이어서 QuickSlot이 갱신되지 않는 문제를 해결하기위해 바로 들어가도록 하겠습니다.
<저번과 동일한 문제>
<문제 해결을 위한 방법>
위 이미지 처럼 ReferenceSlots 배열을 Swap해 준다음에 QuickSlot들만 다시 옮겨줘야 하는것입니다. 그래야 갱신이 정상적으로 이루어지기 때문이지요. 이제 방법을 알아냈으니까 코드를 작성해봅니다.
InGameData.cpp
필자가 생각해도 깔끔한 방법은 아니지만 별다른 좋은방법이 떠오르지 않아서 이런식으로 처리하게 되었습니다. Drag를 시작한 슬롯과 Drop한 슬롯으로부터 Index를 받아오고 이를 통해 두 ItemData가 가지고 있던 참조중인 Slot 목록에서 Type이 Q_Item인 것들만 따로 골라낸후 다시 넣어주는 방식을 취했습니다. 뭔가 더 좋은 방법이 있는거 같으시면 필자에게 알려주세요!
SlotDrag.cpp
<변경된 함수에 맞춰서 인자를 넣어준다>
<아이템을 옮겨도 QuickSlot도 잘 갱신이 된다>
이제 Inventory 내의 ItemData를 옮김으로써 생기는 문제들을 해결했습니다. 다음으로 해야할 작업은 QuickSlot 끼리의 Swap처리를 해주어야 하는데요. 이건 그렇게 어렵지 않을것 같았습니다. 네 '같았었습니다' 참조중인 Slot 목록에서 빼고 넣어주기만 하면 될 줄 알았는데 생각만큼 쉽지 않았던 작업이었던거 같습니다.
SlotDtag.cpp
저번 글에서 학인하지 않았던 SwapQuickSlot 함수입니다. Drag를 시작한 Slot의 Type이 Q_Item일 경우에 대해서만 작성해 두었으며 Drop한 Slot의 Type이 Q_Item일 경우 이미 참조중인 ItemData가 있는것이기에 서로 참조중인 ItemData가 가진 목록에서 추가, 삭제를 하도록 했습니다.
그다음에 필자의 경우 goto문을 이용해서 작업을 처리하는데 생소한분들이 좀 있으리라 생각합니다. 마땅한 출처를 달기가 애매하여서 혹여 궁금하신분들은 찾아보시면 도움이 좀 될것 입니다. 필자가 사용한 이유는 if를 통해서 분기가 나눠지지만 결국 해야할 작업은 똑같기 때문에 저런식으로 했습니다.
Slot 두개의 Type과 Index를 서로 바꿔주고 갱신시키도록 했습니다. 이 다음에는 다시 goto문을 이용해 함수가 끝나는 부분으로 이동합니다. 이제 빌드를 해보고 잘 작동하나 확인해봅시다.
<잘되는가 싶더니 또 생기는 문제>
QuickSlot은 참 사람힘들게 만드는 재주가 있는 것 같습니다. 다른 ItemData를 참조하고 있는 Slot끼리 Swap할 경우는 문제없이 잘 되는데 같은 ItemData를 참조하는 Slot끼리 Swap을 하면 하나가 갱신이 안되는 문제가 생깁니다.
코드를 천천히 보면서 생각해 보니 두 QuickSlot이 같은 ItemData를 참조하고 있다면 딱히 바꿀필요가 없다는 사실을 깨닫게 되었습니다. 해당부분만 예외 처리를 해주면 큰 문제없이 편안하게 해결 가능하리라 봅니다.
SlotDrag.cpp
<단 한줄>
아주 귀엽게 딱 한줄만 추가하면 문제 해결입니다. QuickSlot 둘이 같은걸 참조하고있다면? 굳이 바꿀필요 없기때문입니다.
<드디어 끝난 QuickSlot과의 싸움>
생각보다 고려해야할 부분도 많았고 어려움이 좀 있었던 작업이었지만 CharacterMovement를 직접 만들던 때처럼 재미있는 작업이었습니다. 물론 그때에 비하면 훨씬 쉬운 난이도지만 말이지요.
이렇게 QuickSlot을 완성했으니 이제 지긋지긋한 UI와는 한동안 멀어져도 될것 같습니다. 다음부터는 전투를 하기위해 플레이어 캐릭터가 공격할 수 있도록 만들어야겠습니다. 아마도 애니메이션 작업이 주를 이루지 않을까 싶습니다.