우선은 RPG에서 사용되는 기본적인 기능들을 직접 구현해보면서 배운 내용들을 복습하며 3D 개발을 몸에 익히는 것이 목표이다. 루나 모바일이라는 게임의 기능들을 분석해서 일부 기능들을 구현해보기로 했다.
아래는 구현할 기능들을 정리한 내용이다.
==========================================
[필요한 에셋] 공격모션 3+ 이상, 대기 모션 2개 정도는 있는 플레이어 캐릭터 에셋. 플레이어 캐릭터, 방어구는 바꿀 수 있으면 좋고, 무기나 방패 정도는 바뀌었으면 좋겠지만 불가능해도 괜찮을 듯. 아이템, 포션 등 각종 아이콘은 이미지만 있으면 되니 따로 구해오면 될듯
10초마다 기본 대기모션 <-> 특수 대기모션 반복 ★가상 조이스틱(화면에 터치한 지점에 생성)/WASD를 이용한 캐릭터 이동 ★1초 이내 입력 시 최대 3회 연속 공격) 무기/방어구 변경할 때 캐릭터 모델에 적용(아이템 이미지와 이미지에 맞는 모델을 구해야 할 듯) 구현하려는 게임에서는 캐릭터의 피격모션이 없는 것 같음.
실제 RPG의 느낌을 주기 위한 배경 에셋도 사용하기로 했다. 크기가 좀 작지만 임의로 넓혀서 사용해도 괜찮을 것 같다.
==================================
다음으로는 코드의 구조를 계획한 내용이다.
==================================
카메라 관리
TopDownCamera
플레이어 캐릭터 행동 관리 PlayerCharacterController PlayerStateMachine State IdleState_Player, AttackState_Player, MoveToTargetState_Player, DeadState_Player
적 캐릭터 행동 관리 EnemyController EnemyController_Melee EnemyStateMachine (적의 State를 관리) State IdleState_Enemy, AttackState_Enemy, MoveToTargetState_Enemy, MoveToWaypointState_Enemy, DeadState_Enemy
IDamagable, IAttackable(피격, 공격 가능) (플레이어 캐릭터, 적 캐릭터 모두 적용)
인벤토리 관리 PlayerEquipment: 장착한 장비 정보 저장 StaticInventory: 장비창과 같은 고정된 인벤토리 창 DynamicInventory: 아이템이 저장되는 인벤토리 창
InventoryObject: 장비창, 소지품창 같은 인벤토리의 정보를 저장하는 Scriptable 객체 Inventory: 인벤토리의 Slot들을 저장하고 있는 클래스 InventorySlot: 인벤토리 각각의 칸의 슬롯을 관리 InventoryUI: UI를 표시해주며, 이것을 상속받아 StaticInventory, DynamicInventory 구현
ItemObjectDatabase: ItemObject들에 id를 매겨주고 정보를 저장, 관리해주는 Database (ScriptableObject) ItemObject: 각각의 아이템에 대한 정보 전체를 담고 있는 ScriptableObject Item: ItemObject의 이름, 능력치, id와 같은 기본적인 정보들을 보관 ItemStat: Item의 능력치의 최소/최댓값을 정하고, 이 값을 결정해주고, 능력치와 값에 대한 정보를 저장
=================================================
구현할 핵심적인 내용들을 정리해보니 대부분 강의에서 배우고 다루었던 내용들이었다. 그래서 구조도 강의에서 사용했던 구조와 거의 비슷하게 계획하게 된 것 같다. 이렇게 돌아보니 굉장히 알차고 도움이 되는 내용들인 것 같다는 점이 새삼 느껴졌다.
오늘 계획은 먼저 기본 프로젝트 세팅과 에셋들을 추가하여 Github에 업로드를 하는 것이다. 이후에는 PlayerCharacter의 움직임을 구현하고, TopDownCamera를 구현할 것이다.
내일인 화요일에는 PlayerStateMachine, State, IdleState_Player, AttackState_Player, MoveToTargetState_Player, DeadState_Player 등 플레이어의 상태를 관리하는 기능을 구현할 것이다.
수요일에는 EnemyController, EnemyController_Melee, EnemyStateMachine, State, IdleState_Enemy, AttackState_Enemy, MoveToTargetState_Enemy, MoveToWaypointState_Enemy, DeadState_Enemy 등 적 캐릭터를 구현할 것이다.
목요일에는 Item과 Inventory 기능을 구현할 것이다.
금요일에는 개발한 내용들을 다듬고, 우선 순위에서 밀린 기능들을 추가로 구현해볼 생각이다.
대략적인 일정을 잡았는데, 조금씩 일찍 하거나 늦게 하는 등 약간의 차이는 있을 것 같다. 구현하다보면 생각지 못한 문제를 만나서 시간이 오래 걸릴 수도 있기 마련이기 때문이다. 그래도 한 번 구현해보았던 내용들이기 때문에 이번 주 안에는 마무리를 지을 수 있으면 좋을 것 같다.
애니플러스 합정점의 블루 아카이브 스토어 입장 예약이 토요일, 일요일이라 일요일로 예약을 해서 다녀왔다.
다른 친구의 부탁을 받아서 장패드를 하나 샀고, 이즈나 아크릴 스탠드와 이즈나 멀티 클리너를 구매했다. 봉투에 담긴 랜덤 클리어 파일과 은박에 싸인 랜덤 캔뱃지도 구입했는데, 원하던 캐릭터가 나오지는 않았다. 이외에도 아루나 게임개발부 학생들 등 마음에 든 다른 굿즈들도 많았지만, 샬레스토어도 오픈했고 다음 달에 온리전도 있기 때문에 금액이 약간 부담이 되어서 가장 좋아하는 캐릭터 중 하나인 이즈나 굿즈만 두 개 구입했다. 오른쪽 아래에 있는 것은 2만원 당 1장씩 랜덤으로 증정되는 미니 브로마이드라고 하는데, 말이 브로마이드지 사실상 포토 카드와 비슷하다. 처음엔 미유 대신에 사오리가 있었는데, 사오리를 좋아하는 것으로 추정되는 분과 교환했다.
시로코(라이딩) 복장에 복면을 하고 자전거를 끌고 오신 분이나, 히비키 코스프레를 하신 분도 보였는데, 블루 아카이브를 좋아하시는 분들이 굉장히 많이 있구나, 하는 것을 새삼 느낄 수 있었다.
점심으로는 오레노라멘을 먹었다. 원래는 우동 카덴이라고 하는 우동을 굉장히 잘 하는 곳에서 먹으려고 계획했으나 일요일이 휴무였던 관계로 오레노라멘에 갔다. 예전부터 맛이 조금 궁금했었는데, 삼계탕 국물을 2~3배 정도 농축해서 먹는 느낌이었다. 상당히 맛있었다. 닭가슴살 차슈(?) 도 수비드로 조리를 했는지 굉장히 부드러웠고, 맛계란이 굉장히 독특한 식감이어서 이걸 어떻게 만들었을까 친구를 얘기를 나눠봤는데 달걀도 혹시 수비드로 조리를 했나? 싶은 생각이 들었다. 반숙으로 계란을 삶아도 흰자 바깥부분은 약간 단단하고 노른자는 거의 익지 않는데, 흰자 바깥부분조차 굉장히 말랑하고 노른자 표면도 젤리같은 느낌으로 약간 익어있었기 때문에 아마 저온에서 천천히 조리를 하지 않았나 싶다. 식감이 굉장히 유니크해서 나중에 한 번 그렇게 계란을 요리해보면 좋을 것 같다는 생각이 들었다. 더구나 면 추가가 무료였고, 양이 조금 부족해서 면 추가를 했는데, 면만 오는 것이 아니라, 목이 버섯과 파, 차슈까지 같이 제공되어서 굉장히 기분이 좋았다. 먹다보니 약간 느끼하긴 했지만 전반적으로 굉장히 만족스러운 식사였다.
이후에는 홍대에 있는 퍼니랜드라는 오락실에 갔다. 친구가 스위치로 태고를 즐겨하는데, 실제 채로 치는 것이 아무래도 손맛이 좋다보니 태고가 있는 오락실로 갔다. 그러나 막상 태고를 치면 손과 팔이 아파서 한~두 판 밖에는 못 한다는 것이 문제다. ㅋㅋ. 그래서 예전에 했던 댄스러시라는 게임도 한 판 선보였다. 바닥의 일정 부분의 인식이 고장나있는 상태라 플레이가 약간 어려웠다. 친구는 어려워보인다며 플레이를 꺼렸다. 그리고 친구가 예전에 비트세이버를 해 보고 싶다는 얘기를 했었는데, 마침 여기에 비트세이버 기기가 있어 한 판 하는 것을 지켜보았다. 재미는 있지만 어지럽다는 얘기를 했다. 이후에는 예전에 즐기던 사운드 볼텍스라는 리듬 게임을 했다. 친구에게도 권해봤는데, 리듬 게임을 예전부터 해 오던 친구라 어렵지 않게 적응하는 모습이 인상적이었다. 금방 쉬운 9레벨 정도는 깰 수 있을 정도의 수준에 도달했다. 나는 예전에 17레벨에 조금씩 도전했었는데, 거의 5년? 혹은 그 이상 쉬어서 그런지 적응이 살짝 어려웠다. 예전에 주구장창 하던 쉬운 16레벨 노래는 2번만에 클리어를 했는데, 잘 하지 않던 노래에는 개박살이 났다. 근데 확실히 굉장히 재미있었다. 옛날에 열심히 했던 기억이 떠오르기도 했다. 가끔 플레이를 하러 가는 것도 좋겠다는 생각이 들었다. 다만 데이터가 저장된 코나미 카드를 집에 놓고 간 점이 살짝 아쉬웠다. 이후에는 지하에 있는 노래방에 가서 노래를 불렀다. 위층은 오락실, 지하는 노래방... 말 그대로 퍼니랜드라는 이름에 어울리는 모습이었다. 다른 오락실들은 시끄러운 오락기 근처에 노래방이 있곤 해서 굉장히 시끄러운데, 지하에 있어서 조용한 점은 좋았다. 다만 방음이 조금 약한 점이 아쉬웠다.
이후에는 홍대에 있는 애니메이트에 방문했다. 합정 바로 근처가 홍대이기도 하고, 합정 애니플러스가 사실 규모가 굉장히 작아서 볼 것이 별로 없다보니까 가는 김에 한 번 구경이나 해보자는 생각에 방문했다. 홍대 애니메이트에는 예전에 한 번 방문해본 적이 있었는데, 당시에는 합정 애니플러스의 2배 정도 밖에 안되는 작은 매장 하나만 있었던 것 같다. 근데 이번에 가본 홍대 애니메이트는 굉장히 많이 바뀌어있었다. 층 자체가 확장이 된 느낌이고 아예 층 한 개 전체가 애니메이션 테마로 바뀌어있었다. 게임을 판매하는 곳에 원신 굿즈가 있었고, 원피스 팝업 스토어나 이치방쿠지, 중고 굿즈샵, 각종 콜라보 카페 등이 있었다. 애니메이트 매장 자체도 엄청나게 확장을 해서 실제 일본의 애니메이트가 생각나는 모습이었다. 애니메이트 뿐만 아니라 층 전체가 애니메이션 관련 가게들로 늘어져있다보니 마치 라디오 회관의 한 개 층을 보는 것과 같았다. 옛날에 애니메이트의 작은 매장에서 굿즈를 소소하게 구경하는 것도 나름 즐거웠는데, 한국에서 이정도 규모로 구성되어 있는 서브컬쳐 스토어를 볼 수 있다는 점에 마음이 굉장히 벅차올랐다. 그리고 늑대와 향신료를 재밌게 읽고 난 후에 서점에 갈 때마다 늑대와 양피지를 찾아도 보이지 않았는데, 여기서 발견해서 1권을 구입했다.
그리고 중고 굿즈 샵에서 하츠네 미쿠의 피규어를 발견했는데, 보통은 예쁜 피규어를 봐도 그냥 넘어가지만 가격도 싸고 퀄리티도 꽤 괜찮은 것 같아서 관심이 많이 갔다. 그 자리에서 당장 사고 싶을 정도로 마음이 혹했는데, 인터넷에 검색해보니 더 싼 신품이 있어서 신품으로 바로 구매했다. 미쿠는 굉장히 옛날 캐릭터지만 아직까지도 많은 사람들에게 사랑받는다는 것이 굉장히 놀랍다는 생각이 새삼 들었다. 개인적인 생각이지만 앞으로도 이런 서브컬쳐라는 장르가 유지되는 한, 하츠네 미쿠라는 캐릭터는 언제까지나 사랑받을 것 같다. 나도 이렇게 많은 사람들에게 오래오래 사랑받는 캐릭터 혹은 컨텐츠를 만들 수 있으면 굉장히 행복할 것 같다.
돌아가는 길에 홍대입구 4번 출구에 블루 아카이브 광고를 크게 해놓은 모습도 보았다. 방문했던 애니메이트를 떠올리며 마치 한국의 작은 아키하바라에 온 것 같은 느낌이 들었다. 옛날같으면 상상도 못 할 모습인데, 확실히 예전에 비해서 이런 서브컬쳐 장르가 많이 커지고 양지로 나오게 된 것 같아 기뻤다. 앞으로도 서브컬쳐 장르가 많이 성장하여 더 큰 규모로, 더 많은 곳에서 이런 광경을 볼 수 있으면 좋을 것 같다고 생각했다.
오늘은 인게임 UI 설정과 다이얼로그, 퀘스트 등의 대화 시스템, 문 함정 등의 레벨 디자인에 대해 배웠다.
그리 새롭거나 어려운 내용은 아니었다. 다이얼로그는 직접 만들어보기도 했었고, UI에서 작동하는 것이다보니 구현이 비슷비슷했다. 다만, 다이얼로그를 저장하고 불러오는 방식에 약간 차이가 있었다. 퀘스트는 아이템과 비슷하게 구현이 되었는데, 이전에 구현했던 기능을 활용해서 구현했기 때문에 그렇게 어렵지는 않은 내용이었다.
문과 함정도 상대적으로 간단하게 구현이 가능했고, 전부 이전에 다뤘던 내용들을 활용한 것이라 오늘 내용들은 전체적으로 어렵지 않은 내용이었다.
때문에 오늘은 작성한 스크립트들을 간단하게 정리하는 시간이 될 것 같다.
AttributeType: 능력치들의 타입을 저장한 enum이다. 힘, 지능 등 6개의 능력치를 가지고 있다. ModifiableInt: 능력치들의 각각의 값을 저장하는 클래스이다. Attribute: AttributeType과 ModifiableInt를 세트로 가져 능력치의 종류와 값을 가지고 있는 클래스이다. StatsObject: Attribute 배열을 가지고 있어 능력치들에 대한 정보를 저장하게 되는 ScriptableObject이다. 강의에서는 플레이어의 능력치를 저장하는 데에 사용한다. PlayerStatsUI: 플레이어의 스탯을 스테이터스창에 표시해주며, 아이템을 장착하거나 해제할 때 능력치를 갱신한다. PlayerInGameUI: 플레이어의 레벨, 체력, 마나 등의 UI를 갱신해준다.
Dialogue: 이야기를 하는 NPC의 이름과 대화 내용 배열을 저장하는 클래스이다. DialogueManager: 대화 시스템을 관리해주며, 대화의 시작이나 한글자씩 표시해주거나 다음 문장을 나타내주는 등의 기능을 한다. DialogueNPC: NPC의 컴포넌트에 추가되어 상호작용을 관리해준다.
QuestType: 퀘스트의 타입을 저장한 enum이다. 몬스터를 처치하거나, 아이템을 수집하는 2종류의 타입을 가지고 있다.
QuestStatus: 퀘스트의 현재 상태를 저장한 enum이다. 아무 상태도 아닌지, 수락했는지, 완료했는데, 보상을 받았는지의 상태가 있다. Quest: 퀘스트의 id, QuestType, 보상 정보 등의 정보를 가지고 있는 클래스이다. QuestObject: Quest와 QuestType을 한 쌍으로 가지는 ScriptableObject이다. 실제 퀘스트는 이 오브젝트를 통해 만든다. QuestObjectDatabase: QuestObject가 등록되어 관리할 수 있는 데이터베이스이다. QuestManager: 아이템을 획득하거나 적을 처치했을 때 QuestManager의 함수를 참조하여 퀘스트의 진행상태를 갱신하고, 완료했으면 완료 상태로 변경해주는 역할을 한다. QuestNPC: 퀘스트를 주는 NPC에 등록되는 스크립트로, 퀘스트의 정보와 대화 정보를 가지고 있다. DialogueManager를 활용하여 퀘스트 진행 상태에 따라 대화창을 띄워준다.
DoorEventObejct: 문이 열리고 닫힐 때 이벤트를 저장하고 처리하는 ScriptableObject이다.
DoorController: 문이 열리고 닫히는 기능이 구현되어있는 스크립트로, DoorEventObject를 통해 이벤트를 처리한다.
DoorTriggerArea: Collider를 활용하여 캐릭터가 Collider에 들어오거나 나갈 때 문을 열고 닫는 기능을 호출한다.
TrapController: 함정에 컴포넌트로 추가되는 스크립트로, 데미지를 주는 간격이나 데미지 등의 정보를 가지고 있다. Collider 내에 다른 오브젝트가 들어오면 적에게 피해를 주고, 밖으로 나가면 더이상 피해를 주지 않는다.
오늘 구현했던 내용은 여기까지다. 구현한 내용 자체는 어렵진 않았지만 이번에도 구조가 약간 복잡한 느낌이 있었다. 이제 내일부터 기획을 세우고 이런 구조를 짜서 기능을 구현할 생각에 약간 부담이 되기는 한다. 여태까지 배운 내용들을 종합해서 필요한 기능들을 나열하고, 이에 알맞은 구조를 짜야겠다.
약 9일 간 패스트캠퍼스의 강의를 들으며 3D 게임 개발에 대해서 공부했다. 아주 많이 도움이 되었던 것 같다. 이전에 구현했던 게임의 구조도 개선할 부분이 많다고 느꼈다.
그리고 RegidBody, CharacterController, Nav Mesh Agent, Animation StateMachine, Camera, Lighting, Terrain, Navigation, Finite State Machine, 적 캐릭터의 시야 구현, 전투 시스템 구현, 인벤토리, 아이템 구현, 인게임 UI, 다이얼로그, 퀘스트, 문, 함정 등의 기능들을 구현해보았다. 이전에 다뤄본 기능도 있었지만 대개 처음 다뤄보는 생소한 것이었기 때문에 어려움이 있었다. 이런 어려움을 극복하면서 한층 성장한 것 같다. 그렇지만 아직은 보고 따라하면서 기본적인 방법에 대해서만 배운 것이기 때문에 앞으로 직접 실제 게임을 구현해나가며 나의 능력을 갈고 닦아야겠다.
내일부터는 하나의 게임을 분석해서 몇 가지 기능을 추려 구현할 기획을 세워야겠다. 이후에는 그에 맞는 에셋들을 찾아야겠다. 무료 에셋이 있으면 좋겠지만 여의치 않으면 유료 에셋을 구매할 필요도 있을 것 같다. 구현할 기능들의 기획이 완료되면 각 기능들에 대한 구조를 짜야겠다. 구조를 짠 이후에는 배웠던 내용들을 토대로 실제 구현에 들어가겠다. 많은 내용을 구현하지는 않을 것이기 때문에 아마 내일 하루면 구현할 기능의 기획을 세우고, 에셋들을 찾는 단계에 진입할 것 같다. 에셋을 찾고 나서 구조를 짜는 데에는 시간이 조금 걸릴 수도 있을 것 같다. 그래도 하루 정도면 충분할 것 같다. 빠르면 내일, 늦어도 모레에는 개발에 착수하고, 계획을 세워 차근차근 기능을 구현해야겠다. 아마 1주일 이상 걸리지는 않을 것 같기 떄문에 다음 주 중으로 마무리할 수 있을 것 같다. 이를 토대로 포트폴리오를 만들고, 자기소개서 등을 작성해서 지원하면 아마 4월 24일 경이면 지원할 수 있을 것 같다. 빠르면 4월 안으로 결과가 나올 것 같다. 결과가 어떻게 될지는 모르겠지만, 이전에 지원할 때와 비교하면 많이 성장한 것 같다. 그 점만으로도 만족한다. 아마 이번에는 취업에 성공할 수 있을 것 같다. 만약 안 된다면, 이전에 만들던 TSCB를 끝까지 완성할 수도 있을 것 같고, 새로운 게임을 하나 기획해서 출시하는 것을 목표로 할 계획이다.
이전에 간단하게 RPG를 만들어 본 경험이 있어서 어렵지 않게 구현할 수 있을 것이라고 생각했는데, 인벤토리와 아이템도 제대로 된 구조를 짜기 위해서는 꽤나 많은 노력이 필요했다. 거기다 3D게임이다보니, 모든 게임이 그런 것은 아니지만 아이템을 장착할 때마다 캐릭터의 모델링을 바꿔주는 등의 작업이 필요했기 때문에 예상했던 것보다 굉장히 어려웠다. 모델링 관련 지식이 부족하기 때문인지 저번에 구현했던 전투 시스템을 구현하는 것보다 어려웠던 것 같다.
오늘은 어쩌다보니 시간도 꽤나 늦어져서... 이번에는 그림 없이 글로만 정리해야할 것 같다. 먼저 작성한 스크립트 17개에 대한 정리부터 시작하겠다.
ItemObjectDatabase: ItemObject들을 보관하는 데이터베이스이며 각각의 아이템에 id를 매겨줌(기본적으로 1개만 존재 가능하나 부위별로 나누어 id가 겹치지 않도록 해주면 여러 개도 가능) ItemObject: 아이템을 찍어낼 수 있는 기본 틀 Item: ItemObject의 정보를 가지고 있는, 실질적으로 유저에게 드롭되고 보여지는 아이템이 가지게 되는 스크립트 (id, 이름, 효과와 같은 게임 내 처리에서 필요한 정보를 담고 있음(각각의 생성된 아이템을 처리할 때는 이 Item을 사용)) => 여기서 id는 ItemObject, 즉 틀에 대한 id를 가지고 있는 것임. ItemBuff: Item 내에 있는 효과에 대한 정보를 생성하고 저장(힘, 스태미너 등의 스탯)
InventoryObject: 가방, 장비창, 퀵슬롯, 상자 등 아이템이 들어가는 InventorySlot을 여러 개 가질 수 있는, 흔히 생각하는 인벤토리의 정보를 가지고 있는 ScriptableObject이다. Inventory: InventorySlot들에 대한 정보를 가지고 있다. InventorySlot: InventoryObject 내에서 아이템을 놓을 수 있는 각각의 한 칸에 대한 정보를 가지고 다. InventoryUI: 시작할 때 각각의 InventorySlot을 초기화해줌(parent, 슬롯 내 아이템의 이미지, 개수 등 표시). 또한, 아이템을 드래그하는 이벤트 등도 구현되어있으며, 이 InventoryUI를 상속받아 구현하도록 하는 추상 클래스이다.
DynamicInventoryUI_New: InventoryUI를 상속받아 구현하며, 플레이어의 인벤토리나 혹은 상자와 같이 칸의 수를 변경하여 적용할 수 있는 인벤토리를 구현하는 데 사용한다.
StaticInventoryUI_New: InventoryUI를 상속받아 구현하며, 플레이어의 장비창처럼 정해진 개수의 정해진 위치에 인벤토리가 있는 경우에 사용하는 인벤토리를 구현하는 데 사용한다.
TestItem: ItemObjectDatabase에 있는 아이템을 랜덤으로 생성하고 전부 없애주는 등 기본적인 기능을 테스트하기 위한 스크립트이다.
EquipmentCombiner: 장비의 부위마다 bone들이 있고, 장비들마다 bone이 겹치는 부위가 있는데, 따로 장비를 추가하게 되면 중복되는 bone들이 많아지게 되기 때문에 효율적으로 bone을 추가하기 위해 사용되는 스크립트다. 이름을 HashCode로 변환하여 Dictionary에 저장하기 때문에 중복되는 bone이 있어도 하나만 저장된다. bone들의 최상위 부모인 rootGameObject가 있고, 본들의 이름을 Hash로 변환하여 int형 값을 통해 해당 bone의 transform 정보를 저장하는 Dictionary 데이터를 저장하고 있다. 이외에 처리에 필요한 각종 함수들이 구현되어 있다.
PlayerEquipment: 시작할 때, 혹은 아이템을 장착하거나 해제할 때 EquipmentCombiner의 기능을 사용하여 캐릭터의 모델링 관련 처리를 해주는 스크립트다. 캐릭터의 장비 등은 연결된 bone에 따라 움직이는 애니메이션이 다르기 때문에 SkinnedMeshRenderer를 사용하지만, 무기 등은 통째로 움직이기 때문에 MeshRenderer를 사용한다. 이런 것들을 분리하여 처리해주기도 한다. SkinnedMeshRenderer는 EquipmentCombiner를 통해 하나의 Transform에 bone들을 추가하여 해당 transform을 ItemInstances에 추가하여 저장한다.
ItemInstances: bone들로 구성된 SkinnedMeshRenderer 혹은 MeshRenderer를 컴포넌트로 가지고 있는 transform의 정보를 부위별로 저장한다.
IInteractable: 상호작용할 수 있도록 구현할 때 상속받아 구현하도록 사용하는 인터페이스이다.
PickupItem: 우클릭으로 상호작용하여 주울 수 있는 아이템에 등록하는 스크립트로, 어떤 아이템으로 설정할 것인지, 주울 수 있는 거리 내에 있는지 등을 판별하는 기능을 제공한다. GroundItem: 근처에 가면 주울 수 있는 아이템에 등록하는 스크립트로, 어떤 아이템을 등록할 것인지의 정보가 등록되어 있다.
오늘은 대부분의 기능을 직접 씬 내에서 등록해보고 사용해봤기 때문에 새로 알게 된 내용, 어려웠던 내용에 대해서만 정리해보겠다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "New Item Database_New", menuName = "Inventory System/Items/DataBase_New")]
publicclassItemObjectDatabase_New : ScriptableObject
{
public ItemObject_New[] _itemObjects;
publicvoidOnValidate()
{
for (int i = 0; i < _itemObjects.Length; ++i)
{
_itemObjects[i]._data._id = i;
}
}
}
먼저 ScriptableObject와 CreateAssetMenu에 대해서 알게 되었다. ScriptableObject는 적은 양의 데이터를 유니티에서 효율적으로 처리할 수 있도록 해주는 것 같다. CreateAssetMenu를 통해 우클릭으로 해당 오브젝트를 생성할 수도 있다. 여기서는 아이템이라든지, 아이템을 관리해주는 데이터베이스 등을 프로젝트 내에 오브젝트로 생성해서 편리하게 등록하고 관리할 수 있었다.
FirstOrDefault라는 것에 대해서도 배웠다. for문을 활용하지 않고 한 줄로 간단하게 for문을 사용하는 것과 같은 효과를 내는 것 같다. 위의 코드는 Slots[i]._item._id를 순회하면서 0보다 작은 것이 있는지를 찾는 느낌이다. 한 눈에 보기에 명료한 코드는 아니라고 느껴지지만 간단하고 직관적으로 이해할 수 있는 코드인 것 같다.
이것과 비슷하게 SingleOrDefault도 있는데, 이 안에 해당하는 값이 딱 1개만 있는지를 찾아오는 함수라고 한다. 중복되는 값이 없어야 하는 경우에 사용하면 좋을 것 같은데, 하나만 있는지 확인하기 위해서는 전체를 순회해야 할테니까 굉장히 비효율적일 것이라는 생각이 들었다. 다만 그만큼 안전성을 확보할 수 있는 것 같다.
public Action<InventorySlot_New> _OnPostUpdate;
Action에 대해서도 알게 되었는데, delegate와 비슷하지만 약간 다른 부분이 있는 것 같다. 반환값이 없는 메소드를 추가할 수 있다고 하는데... 등록해놓고 외부에서 해당 Action에 함수들을 추가하여 여러 기능을 수행할 수 있도록 해주는 것 같다.
이외에도 static을 사용해왔는지 정확하게 무엇이라고 설명하기 어려워서 찾아보았다. static은 하나만 가질 수 있는 것이다. 예를 들어 static을 사용해서 변수를 생성하면, 해당 변수를 가지고 있는 클래스가 여러 개 있더라도 그 변수는 공용으로 사용된다는 것이다. 아! 그리고 이전에 배웠던 것이 생각났는데, 클래스를 생성하지 않아도 static 변수는 사용이 가능하다.
그리고 const와 readonly의 차이점도 알아보았다. const는 컴파일 타임 때 값이 고정되는 것이고 readonly는 런타임 때 메모리에 등록되는 시점에 값이 고정되는 것이라는 차이가 있다고 한다. 때문에 성능 상의 이점을 보기 위해서는 const를 사용하는 것이 좋다고 한다. 다만 readonly는 생성자에서 초기화를 하여 값을 정할 수 있기 때문에 훨씬 유동적으로 프로그래밍이 가능하다는 것 같다.
이외에는 변수 명명에 대해서도 고민을 했다. 여태까지 전역 변수 앞에는 무조건 _를 붙였는데, 프로퍼티에는 붙이면 안되기도 하고, 헷갈리는 경우가 종종 있기도 해서, 규칙을 어떻게 정할지가 상당히 고민스럽다. 보통은 어떻게 하는지가 상당히 궁금하다.
이렇게 정리를 해보았는데, 새로 알게된 기능보다, 구조에 대해서 새로 알게 된 것이 굉장히 많은 것 같다. 이전에 아이템 관련 기능을 구현했을 때는, 벌써 4년이나 된 일인 것 같지만, 이런 구조를 신경쓰지 않고 주먹구구식으로 구현했는데, 이런 식으로 구조를 확실하게 정해놓고 프로그래밍을 하는 것이 훨씬 좋은 것 같다. 상당히 어렵고 복잡하고 난해했지만 실무에서 구현하는 방법에 대해서 자세히 배우게 된 것 같다. 다만 내가 생각한 게임에서는 사용되지 않는 기능들이 제법 있어서 필요 이상으로 배운 부분도 있는 것 같은데, 그래도 한 번이라도 구현해 본 경험이 있으면 좋을 것 같다고 생각한다.
어제 오늘 강의를 들으며 굉장히 힘들었는데, 그래도 어떻게든 듣고 구현을 하고 정리까지 완료했다. 이제 강의가 5개 남았기 때문에 내일이면 RPG 구현에 대한 기초적인 공부를 마칠 수 있을 것 같다. UI와 다이얼로그, 퀘스트, 레벨 디자인 등인데... 오늘만큼 구조가 복잡하지는 않았으면 하는 바람이다. 아무튼, 내일까지 강의를 다 듣고, 모레부터 3D개발에 착수할 생각이다. 물론 게임의 시작부터 끝까지는 아니고, 특정 게임의 특정 기능을 구현할 생각이다. 캐릭터의 움직임과, 적 캐릭터와 전투, 스킬, 아이템 획득, 장착, 판매 등 여태까지 배운 내용들을 활용하여 구현해볼 생각이다. 그리고 이것을 포트폴리오로 다시 취업에 도전할 생각이다. 1주일 전만 해도 앞으로 얼마나 성장해있을지 기대된다고 글을 썼었는데, 1주일만에 상당히 많은 것을 배운 것 같다. 지금이라면 리소스와 시간만 있으면 내가 플레이했던 게임들 대부분의 기능을 구현할 수 있을 것 같다. 물론 여전히 어려움은 있겠지만, 예전같았으면 어떻게 저런 걸 구현했지? 라는 생각을 했던 기능들을 지금은 아 이런이런 기능을 사용하면 구현할 수 있겠다 라는 지식정도는 생긴 것 같다. 최근에 즐길만한 게임이 없어서 며칠 전 디아블로3를 구매했는데, 상당히 재미있었다. 디아블로2와도 상당히 비슷한 느낌인데, 확실히 디아블로2에 비하면 최근 게임이라는 느낌이 강하게 들었다. 그리고 이 디아블로3를 플레이하면서 배운 내용들이 계속 생각났다. 이런 부분은 이렇게 구현했겠구나, 이런 부분은 이렇게 구현할 수 있겠구나... 하는 것들 말이다. 덕분에 게임에 조금은 집중할 수 없게 되었지만, 이런 생각들을 하면서도 꽤나 재미있었다. 내일 강의를 마치고 빨리 내가 배운 것들을 활용해서 포트폴리오를 만들고, 내가 이런 것들을 할 수 있다는 것을 어필하고 싶은 마음이 넘친다. 그리고 실무를 하면서 게임 개발에 기여하고, 그 과정에서 많은 난관들을 만나 극복하면서 성장하고 싶다. 빠르면 다음 주 안에도 개발을 완료해서 지원이 가능할 것 같다. 힘내야겠다!
어제와 오늘은 전투를 할 때 데미지를 주고 받는 기능이나, 근접 공격, 원거리 공격, UI 구성 등 전투 시스템을 구현했다.
강의가 기존에 구현했던 기능이 있는 프로젝트에서 코드를 새로 작성하고, 실제 기능은 기존 코드를 통해서 보여주기 때문에 스크립트들의 의존 관계나 작동 방식을 확실하게 파악하기 어려워 오늘 강의까지 총 8개의 강의를 전부 듣고 요약하기로 하였다. 추가로 작성하거나 수정한 스크립트가 약 20개에 달하기 때문에 상당히 복잡했다. 정리하는 데에도 아마 시간이 꽤나 걸릴 것 같다.
먼저 저번에 작성했던 부분까지의 그림을 살펴보겠다. MoveToWaypoint라는 새로운 State가 추가되었는데, 그림에는 그려져있지 않지만, FieldOfView라는 스크립트에서 시야 내에 있는 적을 발견하는 기능을 수행한다. EnemyController에서 FieldOfView 컴포넌트를 찾아서 저장하고, MoveState와 AttackState에서는 이 EnemyController에 있는 FieldOfView에서 찾은 target을 가져와 이동과 공격을 수행하도록 하는 보조적인 역할을 한다.
오늘 추가했던 기능들을 나열해보겠다.
1. 캐릭터가 데미지를 주거나 데미지를 받는 기능
2. 근접 공격
3. 원거리 공격(투사체를 적이 있는 위치까지 발사)
4. 원거리 공격(투사체가 적을 따라다님)
5. 체력바, 데미지 텍스트 등 UI 구성
이를 구현한 스크립트들의 구조를 먼저 정리해보겠다.
처음에는 캐릭터가 데미지를 주거나 받는 기능을 구현하기 위해서 IAttackable, IDamagable을 추가했다. 이 두 가지는 이름에서 짐작할 수 있다시피 interface로, 공격을 하여 데미지를 주거나 공격을 받아 데미지를 입는 기능이 포함되어있다. 따라서 공격할 수 있는 Controller는 IAttackable을, 공격받을 수 있는 Controller는 IDamagable을 상속받도록 하였다. 강의에서 구현한 CharacterController와 EnemyController는 둘 다 공격을 할 수 있고, 공격을 받을 수 있기 때문에 둘 다 상속받아 구현해주었다.
그리고 기존의 AttackState와 현재 수행 중인 공격을 관리하기 위한 AttackStateController를 추가했다. 각각의 공격이 시작되고 끝날 때 함수를 실행시켜줄 AttackStateMachineBehaviour도 추가하였다.
이후에는 근접 공격과 원거리 공격을 구현했는데, 각각 AttackBehaviour_Melee, AttackBehaviour_Projectile 이라는 이름으로 구현했다. 이 두 개의 공격은 공격의 기본 틀을 구현한 AttackBehaviour라는 클래스를 상속받아 구현했다.
그 다음으로는, 근접 공격을 할 때 전방의 Box 범위 내에 있는 적들을 가져오는 ManualCollision 라는 스크립트를 추가해주었다. 이를 AttackBehaviour_Melee에서 활용하여 근접 공격을 구현하였다.
이후에는 원거리 공격의 투사체를 구현하기 위한 Projectile과 FollowProjectile을 구현하였다. Projectile은 투사체의 Prefab에 등록되며, 원거리 공격을 수행할 때 인스턴스화된다. Projectile_Follow는 Projectile을 상속받아 적을 따라가는 기능을 추가한 것이다.
이후에는 적의 체력바와 체력바를 관리해 줄 NPCBattleUI 라는 이름의 스크립트를 추가했다. 이것들은 데미지를 입을 때 값이 수정되고, 체력바를 갱신해주는 역할을 한다. 또, 적이 입은 데미지를 표시해 줄 Text를 추가하고, 마찬가지로 이를 관리해 줄 DamageText 라는 스크립트를 추가했다. 또, 카메라의 각도가 바뀌더라도 체력바 등의 UI가 카메라를 향하도록 CameraFacing이라는 스크립트도 추가해주었다.
마지막으로, 이동할 위치를 지정하거나 적을 지정할 때 커서를 표시하기 위해 PlaceTargerWithMouse라는 스크립트도 추가해주었다.
이 또한 그림으로 정리하면 굉장히 깔끔할 것 같은데, 굉장히 구성이 복잡해질 것 같아 하지 않기로 했다. 전체적인 틀은 위 그림과 동일하고, 각각의 그림에 몇 가지씩 기능을 추가한 것이기 때문에 머리속으로 이해하는 것도 크게 어렵지는 않기도 하다.
그러나 문제가 하나 있었는데, 동작 구조를 완전히 파악하지 못한 채로 스크립트들을 연결하는 것이 어려워 기존에 프로젝트에 구현되어 있는 스크립트들을 살펴보면서 정리해보기로 했다.
먼저 적 캐릭터를 살펴보기로 했다.
적 캐릭터의 컴포넌트에 있는 스크립트는 EnemyController_Range, AttackStateController, ProjectileAttackBehaviour, MeleeAttackBehaviour, FieldOfView 총 5개이다.
using FastCampus.Core;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespaceFastCampus.AI
{
publicclassFieldOfView : MonoBehaviour
{
#region Variables
[Header("Sight Settings")]
publicfloat viewRadius = 5f;
[Range(0, 360)]
publicfloat viewAngle = 90f;
[Header("Find Settings")]
publicfloat delay = 0.2f;
public LayerMask targetMask;
public LayerMask obstacleMask;
privateList<Transform> visibleTargets = new List<Transform>();
private Transform nearestTarget;
privatefloat distanceToTarget = 0.0f;
#endregion Variables#region Propertiespublic List<Transform> VisibleTargets => visibleTargets;
public Transform NearestTarget => nearestTarget;
publicfloat DistanceToTarget => distanceToTarget;
#endregion Properties#region Unity Methods// Start is called before the first frame updatevoidStart()
{
StartCoroutine("FindTargetsWithDelay", delay);
}
#endregion Unity Methods#region Logic MethodsIEnumerator FindTargetsWithDelay(float delay)
{
while (true)
{
yieldreturnnewWaitForSeconds(delay);
FindVisibleTargets();
}
}
voidFindVisibleTargets()
{
distanceToTarget = 0.0f;
nearestTarget = null;
visibleTargets.Clear();
Collider[] targetsInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
for (int i = 0; i < targetsInViewRadius.Length; i++)
{
Transform target = targetsInViewRadius[i].transform;
Vector3 dirToTarget = (target.position - transform.position).normalized;
if (Vector3.Angle(transform.forward, dirToTarget) < viewAngle / 2)
{
float dstToTarget = Vector3.Distance(transform.position, target.position);
if (!Physics.Raycast(transform.position, dirToTarget, dstToTarget, obstacleMask))
{
if (target.GetComponent<IDamagable>()?.IsAlive ?? false)
{
visibleTargets.Add(target);
if (nearestTarget == null || (distanceToTarget > dstToTarget))
{
nearestTarget = target;
}
distanceToTarget = dstToTarget;
}
}
}
}
}
public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal)
{
if (!angleIsGlobal)
{
angleInDegrees += transform.eulerAngles.y;
}
returnnew Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0, Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
}
#endregion Logic Methods
}
}
FieldOfView는 이전에 구현한 것과 동일하게 시야 내에 공격할 수 있는 적을 찾지만, 적이 공격받을 수 있는 경우에만 공격하도록 IDamagable이 있는 경우에만 타겟으로 등록하도록 수정된 것을 확인했다. ?. 연산자 외에 ?? 에 대해서도 알았는데, ?.는 null이 아니면 그 뒤를 수행하는 것이고, ??는 왼쪽이 null이면 오른쪽을 반환한다.
using FastCampus.Characters;
using FastCampus.Core;
using System.Collections;
using UnityEngine;
publicclassAttackStateController : MonoBehaviour
{
publicdelegatevoidOnEnterAttackState();
public OnEnterAttackState enterAttackHandler;
publicdelegatevoidOnExitAttackState();
public OnExitAttackState exitAttackHandler;
publicbool IsInAttackState
{
get;
privateset;
}
privatevoidStart()
{
enterAttackHandler = new OnEnterAttackState(EnterAttackState);
exitAttackHandler = new OnExitAttackState(ExitAttackState);
}
publicvoidOnStartOfAttackState()
{
IsInAttackState = true;
enterAttackHandler();
}
publicvoidOnEndOfAttackState()
{
IsInAttackState = false;
exitAttackHandler();
}
privatevoidEnterAttackState()
{
}
privatevoidExitAttackState()
{
}
publicvoidOnCheckAttackCollider(int attackIndex)
{
GetComponent<IAttackable>()?.OnExecuteAttack(attackIndex);
}
}
다음은 AttackStateController에 대해서 살펴보았다. 여기서는 delegate라는 것을 사용하는데, 함수의 포인터와 같은 개념이라고 한다. 다만 실질적으로 작동하는 기능을 구현하지는 않은 것 같다. 애니메이션을 수행할 때, AttackStateMachineBehaviour에서 이 스크립트를 호출하여 IsInAttackState에 현재 공격이 수행 중인지, 아닌지 정보를 저장하는 역할을 한다.
MeleeAttackBehaviour와 ProjectileAttackBehaviour는 AttackBehaviour를 상속받았기 떄문에 AttackBehaviour를 먼저 살펴보겠다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SocialPlatforms;
namespaceFastCampus.Characters
{
//[Serializable]//public enum AttackType : int//{// Melee,// Range,//}publicabstractclassAttackBehaviour : MonoBehaviour
{
#if UNITY_EDITOR
[Multiline]
publicstring developmentDescription = "";
#endif // UNITY_EDITORpublicint animationIndex;
//public AttackType type;publicint priority;
publicint damage;
publicfloat range = 3f;
[SerializeField]
privatefloat coolTime;
public GameObject effectPrefab;
protectedfloat calcCoolTime = 0.0f;
// [HideInInspector]public LayerMask targetMask;
[SerializeField]
publicbool IsAvailable => calcCoolTime >= coolTime;
protectedvirtualvoidStart()
{
calcCoolTime = coolTime;
}
// Update is called once per frameprotectedvoidUpdate()
{
if (calcCoolTime < coolTime)
{
calcCoolTime += Time.deltaTime;
}
}
publicabstractvoidExecuteAttack(GameObject target = null, Transform startPoint = null);
}
}
어떤 애니메이션을 재생할 것인지 animationIndex, 공격의 우선순위를 정할 priority가 있고, damage, range, coolTime 등의 기본적인 정보를 저장한다. 이 정보들은 인스펙터에서 수정할 수 있다. calcCoolTime이 coolTime보다 크다면 스킬을 사용할 준비가 된 것이다. 어떤 방식으로 공격할 것인지 ExecuteAttack를 구현해주어야 한다.
using FastCampus.Core;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespaceFastCampus.Characters
{
publicclassMeleeAttackBehaviour : AttackBehaviour
{
public ManualCollision attackCollision;
publicoverridevoidExecuteAttack(GameObject target = null, Transform startPoint = null)
{
Collider[] colliders = attackCollision?.CheckOverlapBox(targetMask);
foreach (Collider col in colliders)
{
col.gameObject.GetComponent<IDamagable>()?.TakeDamage(damage, effectPrefab);
}
calcCoolTime = 0.0f;
}
}
}
MeleeAttackBehaviour를 살펴보면, 근접 공격 범위를 체크할 ManualCollision과 ExecuteAttack만 구현되어있다. ManualCollision은 박스 범위 내에 있는, LayerMask에 해당하는 적들의 정보를 가져온다. 이렇게 가져온 적들의 IDamagable을 활용하여 적에게 데미지를 입힌다. 마지막으로 calcCoolTime을 0으로 초기화해주어 쿨타임을 계산한다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespaceFastCampus.Characters
{
publicclassProjectileAttackBehaviour : AttackBehaviour
{
publicoverridevoidExecuteAttack(GameObject target = null, Transform startPoint = null)
{
if (target == null)
{
return;
}
Vector3 projectilePosition = startPoint?.position ?? transform.position;
if (effectPrefab != null)
{
GameObject projectileGO = GameObject.Instantiate<GameObject>(effectPrefab, projectilePosition, Quaternion.identity);
Projectile projectile = projectileGO.GetComponent<Projectile>();
if (projectile != null)
{
projectile.owner = this.gameObject;
projectile.target = target;
projectile.attackBehaviour = this;
}
}
calcCoolTime = 0.0f;
}
}
}
다음은 ProjectileAttackBehaviour를 살펴보겠다. ExecuteAttack만 구현되어있는데, 적이 있다면 발사체를 나타낼 부분에서 발사체 Prefab을 생성하여 적의 방향을 향하게 한다. 그리고 이 Projectile에 적의 GameOjbect와 이 공격을 발사한 Gameobject의 정보를 전달한다. Projectile은 정면으로 곧장 나아가거나 적을 추격하며, 적에게 닿으면 효과를 나타내며 사라진다. 수정할 부분은 발사체가 충돌하지 않으면 계속 나아가기 때문에 소멸 시간을 설정해주고, 오브젝트 풀링 기법을 적용하면 상황에 따라 메모리를 효율적으로 사용할 수 있을 것 같다.
using FastCampus.Characters;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespaceFastCampus.Characters
{
publicinterfaceIAttackable
{
AttackBehaviour CurrentAttackBehaviour
{
get;
}
voidOnExecuteAttack(int attackIndex);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespaceFastCampus.Core
{
publicinterfaceIDamagable
{
bool IsAlive
{
get;
}
voidTakeDamage(int damage, GameObject hitEffect);
}
}
EnemyController_Range에 대해 살펴보기 전에 이 스크립트가 상속받는 IAttackable과 IDamagable을 살펴보겠다. 둘 다 간단한 정보를 담고 있다. 현재의 공격 상태와 간단한 공격 함수, 현재 살아있는지의 여부와 데미지를 받는 기능을 구현해야한다.
using FastCampus.AI;
using FastCampus.Core;
using FastCampus.UIs;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.UIElements;
namespaceFastCampus.Characters
{
publicclassEnemyController_Range : EnemyController, IAttackable, IDamagable
{
#region Variables
[SerializeField]
public Transform hitPoint;
public Transform[] waypoints;
publicoverridefloat AttackRange => CurrentAttackBehaviour?.range ?? 6.0f;
[SerializeField]
private NPCBattleUI battleUI;
publicfloat maxHealth => 100f;
privatefloat health;
privateint hitTriggerHash = Animator.StringToHash("HitTrigger");
[SerializeField]
private Transform projectilePoint;
#endregion Variables#region Proeprtiespublicoverridebool IsAvailableAttack
{
get
{
if (!Target)
{
returnfalse;
}
float distance = Vector3.Distance(transform.position, Target.position);
return (distance <= AttackRange);
}
}
#endregion Properties#region Unity MethodsprotectedoverridevoidStart()
{
base.Start();
stateMachine.AddState(new MoveState());
stateMachine.AddState(new AttackState());
stateMachine.AddState(new DeadState());
health = maxHealth;
if (battleUI)
{
battleUI.MinimumValue = 0.0f;
battleUI.MaximumValue = maxHealth;
battleUI.Value = health;
}
InitAttackBehaviour();
}
protectedoverridevoidUpdate()
{
CheckAttackBehaviour();
base.Update();
}
privatevoidOnAnimatorMove()
{
// Follow NavMeshAgent//Vector3 position = agent.nextPosition;//animator.rootPosition = agent.nextPosition;//transform.position = position;// Follow CharacterController
Vector3 position = transform.position;
position.y = agent.nextPosition.y;
animator.rootPosition = position;
agent.nextPosition = position;
// Follow RootAnimation//Vector3 position = animator.rootPosition;//position.y = agent.nextPosition.y;//agent.nextPosition = position;//transform.position = position;
}
#endregion Unity Methods#region Helper MethodsprivatevoidInitAttackBehaviour()
{
foreach (AttackBehaviour behaviour in attackBehaviours)
{
if (CurrentAttackBehaviour == null)
{
CurrentAttackBehaviour = behaviour;
}
behaviour.targetMask = TargetMask;
}
}
privatevoidCheckAttackBehaviour()
{
if (CurrentAttackBehaviour == null || !CurrentAttackBehaviour.IsAvailable)
{
CurrentAttackBehaviour = null;
foreach (AttackBehaviour behaviour in attackBehaviours)
{
if (behaviour.IsAvailable)
{
if ((CurrentAttackBehaviour == null) || (CurrentAttackBehaviour.priority < behaviour.priority))
{
CurrentAttackBehaviour = behaviour;
}
}
}
}
}
#endregion Helper Methods#region IDamagable interfacespublicbool IsAlive => (health > 0);
publicvoidTakeDamage(int damage, GameObject hitEffectPrefab)
{
if (!IsAlive)
{
return;
}
health -= damage;
if (battleUI)
{
battleUI.Value = health;
battleUI.TakeDamage(damage);
}
if (hitEffectPrefab)
{
Instantiate(hitEffectPrefab, hitPoint);
}
if (IsAlive)
{
animator?.SetTrigger(hitTriggerHash);
}
else
{
if (battleUI != null)
{
battleUI.enabled = false;
}
stateMachine.ChangeState<DeadState>();
}
}
#endregion IDamagable interfaces#region IAttackable Interfaces
[SerializeField]
privateList<AttackBehaviour> attackBehaviours = new List<AttackBehaviour>();
public AttackBehaviour CurrentAttackBehaviour
{
get;
privateset;
}
publicvoidOnExecuteAttack(int attackIndex)
{
if (CurrentAttackBehaviour != null && Target != null)
{
CurrentAttackBehaviour.ExecuteAttack(Target.gameObject, projectilePoint);
}
}
#endregion IAttackable Interfaces
}
}
마지막으로 EnemyController_Range를 살펴보겠다. 피격 지점과 기본 공격 사거리, UI 정보, 최대 체력, 현재 체력 등의 정보를 담고 있다. fieldOfView에서 target을 찾았고, 사거리 내에 있다면 공격이 가능하다고 판단한다. 이 ISAvailableAttack은 Idle에서 체크하여 AttackState로 넘어간다. 시작할 때는 각종 초기 세팅을 해주고, Update 함수에서 현재 공격 상태를 계속 체크해준다. 공격 상태가 비어있거나, 공격 상태가 현재 쿨타임일 때, 공격 상태를 비워주고, 현재 공격 가능한 공격 상태를 가져온다. 이 중에서도 가장 우선순위가 높은 공격 상태를 가져와 현재 공격 상태를 결정한다.
이 공격 상태는 AttackState에서 체크하며, 공격 상태가 null이 아니라면 공격 애니메이션을 실행하도록 한다.
OnExecuteAttack은 AttackStateController의 OnCheckAttackCollider에서 호출하는데, 이 함수는 애니메이션을 수행하는 도중, 공격에 맞는 순간에 호출된다. 이때 현재 공격 상태가 있고, 공격할 적이 있다면 해당 적에 대해서 공격 상태별로 구현되어 있는 ExecuteAttack을 수행하는 식으로 작동한다. 그리고 이 ExecuteAttack에서는 적이 피해를 입는 IDamagable의 TakeDamage가 있어 이런 방식으로 내부에서 전투가 수행되는 것을 확인할 수 있었다.
다음은 PlayerCharacter에 구성되어있는 컴포넌트들을 살펴보았다. 먼저 EnemyController에 해당하는 PlayerCharacter 스크립트를 살펴보았다. 좌클릭을 해서 이동, 우클릭을 하여 적을 지정하고 공격하는 기능 외에는 전반적으로 EnemyController와 비슷한 기능이 구현되어있었다. 나머지 스크립트는 동일한 AttackStateController와 MeleeAttackBehaviour가 3개 포함되어있었다. 해당 캐릭터에는 공격 모션이 3가지 있어서, 3가지 스크립트를 등록하여 각기 다른 우선순위로 두고, 애니메이션을 다르게 재생하며, 데미지, 쿨타임 등에 차이를 두는 식으로 구현되어 있었다. 여기서는 자동으로 전투를 하는 것을 구현했는데, 버튼을 눌러 작동한다면 더 간단하게 구현이 가능할 것으로 보였다.
어제, 오늘 이틀에 걸쳐서 이렇게 전투 시스템을 구현해보았다. 아마 내가 구상하고 처음부터 구현한다면 시간이 더 오래 걸릴 것 같다. 하지만 전투 시스템을 어떤 식으로 구현하는지 갈피를 잡았기 때문에 혼자 힘으로도 충분히 구현할 수 있을 것 같다. 또, 이를 응용해서 아군에게 힐을 준다든지, 버프를 준다든지, 범위 공격을 한다든지 등 다양한 스킬을 구현할 수 있을 것 같다. 머리 속에서 여러 가지 게임들이 떠오르며 아, 그 게임의 그런 기능 or 스킬은 이런 식으로 구현할 수 있겠구나, 하는 생각이 든다. 꽤나 신나는 기분이다. 이전에는 막막한 안개로 가려져있던 3D 게임이 비로소 내 머리 속에 자리를 잡은 것 같다. 물론 구현을 해보면서 많이 배우고 익혀가야겠지만, 지금이라면 충분히 그 과정을 헤쳐나갈 준비가 된 것 같다. 앞으로 남은 강의는 총 13개로 약 3일 분량인데, 인벤토리/상점/아이템/장비/퀘스트/대화 등의 기능이기 때문에 어렵지 않게 구현할 수 있을 것 같다. 벌써부터 3D 게임들을 만들어보고 싶어서 살짝 근질거린다. 기획에 상당히 큰 노력이 들어간다는 걸 안 지금은 이미 존재하는 게임을 한 번 따라 만들어보고 싶다. 어떤 게임을 구현해보면 좋을지 찬찬히 고민해봐야겠다.
어제까지 구현한 부분은 방향에 상관없이 감지 거리 내에 플레이어가 접근하면 적이 발견하고 다가와서 공격을 했는데, 적의 정면에 플레이어가 있는 경우에만 플레이어를 감지하는 기능을 구현했다. 이 뿐만 아니라, 플레이어가 적의 정면에 있어도 벽 등의 장애물로 인해 가려지면 플레이어를 인식하지 못하도록 하는 기능 또한 구현했다.
이후에는 적이 일정한 위치를 반복해서 움직이는 패트롤 기능도 구현했다. 약간 잡임형 게임에 적합한 시스템인 것 같다.
적을 찾는 기능은 다음과 같은데, 찾은 타겟이나 저장한 거리를 초기화해주고, 이전에 사용했던 OverlapSphere를 사용해서 근처에 있는 타겟을 저장한다. 이후 transform을 기준으로 target이 서있는 위치를 정규화해준다. 이후 이 값을 Angle 함수를 통해 target이 정면으로부터 각도로 몇 도 이내에 있는지 계산한다. 이를 _viewAngle을 2로 나눈 값과 비교하여 이보다 작다면 시야 내에 있는 것으로 간주한다. 정면을 기준으로 왼쪽과 오른쪽이 있기 때문에 _viewAngle을 2로 나누어 사용한다. 이후 타겟까지의 거리를 저장하고, 해당 거리만큼 해당 방향으로 Raycast를 쏴서 장애물이 있으면 보이지 않는 것으로 간주하고, 장애물이 없으면 보이는 것으로 간주한다. 만약 장애물이 없다면, 현재 인식가능한 타겟 리스트에 추가해주고, 가장 가까운 거리에 있는 타겟과 거리를 저장한다.
public Transform _Target => _fov?._NearestTarget;
이전에 작성했던 EnemyController에서 단순히 거리 내에 있는 target을 찾았는데, 이제는 시야 범위 내에 있는 타겟을 대상으로 할 것이기 때문에 fieldOfView에서 찾은 NearestTarget을 사용하게 되었다. 위의 식은 람다식으로 프로퍼티를 나타낸 것인데, get만 가능한 것 같다. getter만 필요할 때 유용하게 사용할 수 있을 것 같다. 이전에 작성했던 코드 중 일부도 이 방법으로 수정하였다.
그리고, 에디터로 이 시야각을 볼 수 있게 해주었다. 먼저 인식 범위만큼 하얀색 원을 그리고, 인식 범위를 표시해주었다.
인식 범위를 표시할 때는 삼각함수를 사용하였는데, 반지름과 각도를 알고 있기 때문에 Sin, Cos 함수를 사용하여 각도에 대한 값을 Vector3값으로 저장하고, 여기에 반지름을 곱해서 구한 위치까지 선을 긋도록 했다. 또, 발견한 적들에게 빨간색으로 선을 연결해주기도 하였다.
이를 통해서 이런식으로 하얀색으로 인식 범위와 시야각을 표시해주었다. 적을 인식하는 방식만 변경해주었기 때문에 다른 부분은 변경되지 않았다.
IdleState도 약간 변경을 해주었는데, 패트롤 기능이 켜져있으면 랜덤한 시간을 저장하여 waypoint까지 이동이 끝났을 때 랜덤한 시간동안 Idle상태로 있다가, Idle상태에서 해당 시간이 경과하면 다시 다음 waypoint로 이동해주도록 바뀌었다.
이렇게 상태 머신에 새로운 상태를 추가하는 방법에 대해서도 배웠다. 엄청 어렵지는 않았으나 구조에 대한 이해가 필요할 것 같았고, 상태가 많아질수록 구조가 복잡해질 것 같다는 생각이 들었다.
이전에 그려놨던 그림에 MoveToWaypoint를 추가했다. 이제보니 이름 뒤에 State가 붙어야할 것 같다. 또, IdleState에서 왔다갔다 하는 것은 좋지만, MoveState와 AttackState로 바로 전환되는 것이 약간 아쉬웠다. 어차피 IdleState에서 AttackState로, MoveState로 전환하는 것과 동일하게 구현되어 있어서, 바로 상태를 전환하여 구조가 복잡해진 것 같아 아쉬웠다. MoveToWaypoint에서 IdleState로 돌아가고, 이후 다시 MoveState나 AttackState로 전환하는 것이 구조상으로는 더 간결할 것 같은 느낌이다. 다만, MoveToWaypoint를 종료하고 IdleState로 넘어가서 다시 MoveState나 AttackState로 넘어가야하기 때문에 아주 약간의 반응속도의 차이는 있을 것 같다. 구조가 간결한 것이 좋을지, 약간이라도 반응속도를 향상시키는 것이 좋을지는 아직 잘 모르겠다. 다만, 반응속도에 큰 차이가 없다면 구조가 간결한 편이 낫지 않을까라고 생각했다. 실제로 MoveState나 AttackState로 바로 전환하지 않고 IdleState로 돌아가게 했음에도 정상적으로 작동하는 것을 확인할 수 있었다.
오늘은 이렇게 적의 시야각도 구현해보고, 상태 머신에 상태를 추가해서 패트롤하는 기능도 추가해보았다. 시야각은 게임에서 종종 볼 수 있는 기능이라 꽤나 신기하게 다가왔다. 그 중에 떠오른 게임이 세키로인데, 적의 뒤로 몰래 접근해서 인살하는 부분도 생각나고, 수풀에 숨어서 이동할 때도 있는데 세키로에서도 마찬가지로 이런 RayCast를 사용해서 풀을 장애물로 인식하여 적이 캐릭터를 인식하지 못하게 했을까? 싶은 생각이 들었다. 내일은 전투 시스템 구현에 대해 배울 게획이다. 어떤 방식으로 구현할지 궁금하다.
추가로 요즘 드는 생각이, 조금 내가 안일한가? 싶은 생각도 든다. 하루 4개 정도의 강의, 시간으로 따지면 1~2시간 내외되는 시간인데, 다시 듣거나 하는 부분이 많아서 2배 정도의 시간이 걸리고, 정리하는 데에도 강의 시간 정도는 걸리는 것 같다. 내용에 따라 다르지만 하루 3~6시간 정도 공부를 하는 것 같은데, 너무 적은가? 하는 생각이 이따금씩 든다. 특히나 잘 모르는 내용을 찾아보거나, 다른 뛰어난 개발자들을 볼 때마다도 종종 드는 생각이다. 약간 걱정이 되기도 하지만, 꾸준함을 좇고자 한다. 이전에도 이따금씩 개발 관련 공부를 하거나, 개발을 하기도 했지만 일정 기간 하루 종일 몰입하며 불태우면서 번아웃이 오고, 그 반동으로 쉬게 되고... 그런 경우가 많았던 것 같다. 작년도 사실 쉬면서 개발 공부를 거의 안 했던 것 같다. 지금처럼 꾸준히 했더라면 아마 지금과는 많이 다른 모습이었을 것 같다. 약간은 후회도 되긴 하지만, 지금부터라도 꾸준히 매일 조금씩이라도 배워간다면 머지 않아 괜찮은 개발자가 될 수 있으리라 생각한다. 그리고 개발을 재개하고 개발 공부를 시작한 지 딱 한 달이 되었는데, 요 한 달간은 주말이라고 쉰다거나 한 적은 거의 없는 것 같다. 개발을 배우는 것이 재밌기도 하고, 무리하지 않고 공부와 생활을 밸런스있게 가져간 덕분인 것 같기도 하다. 시간에 쫓기기보다는 조금 느리더라도 꾸준히 계속 배우겠다는 마인드로 임해야겠다.
저번에 Animator를 사용할 때 StateMachine을 간단하게 다루었는데, 이번에는 스크립트에서 좀 더 체계적인 StateMachine을 구현했다.
구현하는 스크립트의 수가 7개로 상당히 많은데, StateMachine이라는 스크립트 내에서 State라는 클래스도 구현하기 때문에 총 8개의 클래스를 구현했다. 서로 얽힌 부분이 상당히 복잡하기 때문에 그림으로 나름대로 정리해보았다.
파란색 화살표는 들어가는 방향으로 데이터를 저장하여 활용한다는 의미고, 빨간색은 상태 전환, 검은색은 상속을 받는다는 의미다. 노란색은 직접적으로 영향을 주진 않지만 간접적으로 도움을 준다는 의미로 그려봤다.
먼저 적 캐릭터의 AI를 구현하기 위해 적 객체에 EnemyController를 추가해준다. 이 EnemyController에서 상태 머신을 관리하기 위해서 StateMachine이라는 클래스를 구현해줬다. StateMachine은 제네릭 타입으로 Controller 컴포넌트를 받아서 context를 저장하고, 이를 통해서 Controller의 데이터에 접근한다. 이 StateMachine에서 사용될 State를 구현하기 위한 틀로써 State라는 클래스를 만들어주었다. StateMachine에는 이전 State와 현재 State 정보를 저장하고, 각 State에는 현재 속해있는 StateMachine의 정보를 저장한다. 이 State를 상속받아 구현한 클래스는 총 3가지로, IdleState와 MoveState, AttackState이다. 이름 그대로 IdleState는 적이 대기 중일 때의 상태이고, MoveState는 적이 이동할 때의 상태, AttackState는 적이 공격할 때의 상태이다. 현재까지 구현한 내용은 IdleState 상태에서 적(여기서는 EnemyController의 적이기 때문에 플레이어를 의미한다)이 근처에 있어서 감지를 하였으나, 공격 사거리 밖에 있는 경우 MoveState로 상태를 전환하여 적에게 이동한다. 이동이 종료되면 IdleState 상태로 전환된다. 만약 IdleState 상태에서 적이 공격 사거리 내에 있는 경우 AttackState로 전환하여 적을 공격한다.
이때, AttackState는 애니메이터 내의 StateMachine에 스크립트를 추가하여 StateMachineBehaviour의 상속을 받아 구현한 OnStateExit에서 현재 State를 IdleState로 전환해주도록 구현했다. MoveState의 경우는 Update에서 상태 변환이 이루어지고, 빠져나갈 때 OnExit()라는 함수를 이용하여 구현했는데, Attack은 빠져나가는 조건이 애니메이션이 종료되었을 때라 애니메이터 내의 상태 머신에서 애니메이션이 종료되어 상태를 종료할 때 StateMachine의 상태를 전환하는 것 같다.
그리고 제네릭을 사용하는 이유는, 아마 재활용하기 편하기 때문인 것 같다. EnemyController 뿐만 아니라, 아군 NPC 캐릭터를 만든다고 치면, 감지할 적 캐릭터만 변경하면 그대로 똑같이 활용할 수 있기 때문에 이런식으로 구현해놓으면 재사용하기도 굉장히 편리할 것 같다고 생각했다.
아무튼, 처음엔 좀 헷갈리는 부분이 많았는데 이렇게 정리하고 나니 머리 속에 쏙 잘 들어오는 것 같다. 앞으로도 StateMachine을 구현하게 되면 이런 식으로 참고할만한 그림이나 문서를 만들어두면 도움이 될 것 같다.
애니메이터 부분은 구현하는 모습을 따로 보여주진 않았지만, 이전의 Animation 관련하여 다룬 부분과 비슷하게 구성되어있었다.
이후에는 구현했던 코드들 중 새롭게 알게 된 내용들을 정리해보겠다. 위의 그림에서의 순서대로 EnemyController -> StateMachine -> State -> State 3종 순으로 살펴보겠다.
namespaceFastCampus.Characters
{
// 이하 생략
우선 namespace에 대한 내용이다. 그냥 막연히 썼었는데, 직역하면 이름 공간으로... 해당 공간 내에서 이름이 중복되지 않도록 보장해주고, using 키워드로 namespace를 불러와서 해당 namespace 내의 클래스 등을 사용할 수 있게 해준다. C나 C++의 헤더 파일과도 비슷한 것 같다.
LayerMask는 이전에도 사용했었는데, Layer들을 선택해서 저장한다. 현재 구현한 기능에서는 일정 범위 내의 LayerMask에 해당하는 객체를 찾는데, 이 LayerMask에 어떤 Layer를 선택하느냐에 따라 부하를 많이 줄여줄 수 있다고 한다. 어떤 함수에 대해 찾아봤는데, 이 LayerMask를 잘못 지정해서 너무 많은 것들을 검사하는 바람에 프레임이 엄청나게 많이 떨어졌었다는 사람도 있었다.
그리고 이런 프로퍼티에 대해서도 알게 되었다. 보통은 어떤 변수를 관리할 때 보안 등의 이유로 private으로 접근을 제한하고 Get함수나 Set함수 등을 사용하도록 했는데, 위와 같은 방식으로 구현하면 따로 Getter&Setter를 구현할 필요 없이 간편하게 Getter나 Setter를 구현할 수 있다. 또한, get 부분만 구현하여 set을 설정하지 못하도록 할 수도 있다. position.x나 y, z 등만 설정하고 싶어도 값을 바꾸기 위해서는 position에서 세 가지 값을 다 넣어주어야 했는데, 아마 get만 구현되어 있고 set은 구현되지 않은 것 같다. 보통은 변수를 private으로 구현하고, 프로퍼티는 이 변수와 동일한 이름인 대신, 첫 글자를 대문자로 하여 사용하는 것 같다.
이것은 적을 찾는 알고리즘인데, Physics.OverlapSphere라는 함수에 대해서 알게 되었다. transform.position에서 _viewRadius 거리만큼 구를 그리고, 그 안에 _targetMask에 해당하는 Layer를 가진 객체가 있는지 검사하고 해당 Collider를 저장하는 것 같다. 그런데 경고는 아니고 수정 권장 느낌으로 밑줄이 떴는데, OverlapSphereNonAlloc를 권장하는 것 같다. 찾아보니 OverlapSphereNonAlloc는 가져올 Collider의 크기가 정해져있을 때 사용하면 좋은 함수인 것 같다. 메모리를 재활용하기 때문에 쓰레기도 적게 나오는 듯 하다. 다만, 정해진 양 만큼만 받아올 수 있다는 단점이 있어서, 예를 들자면 탄막 시뮬레이션 게임에서 일정 범위 내의 총알을 없애는 스킬을 사용하는 기능을 구현한다고 했을 때, NonAlloc 함수를 사용하면 범위 내의 총알을 전부 커버할 수가 없어서 일부 총알이 남아있을 수 있게 되는 문제가 있을 것 같다. 다만 그게 아니라 지금처럼 플레이어 한 명을 찾는 경우에는 NonAlloc 함수를 사용하는 것이 더 효율적일 것이다.
public Transform SearchEnemy()
{
_target = null;
Collider[] targetInViewRadius = new Collider[1];
Physics.OverlapSphereNonAlloc(transform.position, _viewRadius, targetInViewRadius, _targetMask);
if (targetInViewRadius[0])
{
_target = targetInViewRadius[0].transform;
}
return _target;
}
때문에 이런 식으로 수정을 해봤다. Collider 배열을 인자로 받아 저장해야되는데, 플레이어 객체는 하나만 존재하기 떄문에 1칸짜리 배열을 만들었다. 1칸짜리 배열을 만들어본 건 프로그래밍을 시작한 이래로 처음인 것 같다...
abstract class와 sealed class에 대해서도 알게 되었다. abstract class는 추상 클래스로, 인스턴스를 생성할 수 없고, 자식 클래스에서 상속을 통해서만 구현이 가능하다고 한다. sealed class는 봉인된 클래스로, 더 이상 상속이 불가능한 class라고 한다.
internal이라는 접근 제어자에 대해서도 알게 되었는데, 약간은 어려운 내용이었다. exe나 dll이라는 어셈블리 단위 내에서 접근이 가능하다고 하는데, dll은 사용해본 적이 없고, exe는 유니티 실행파일을 만들면 exe가 생성되긴 하는데, 이 안에서 접근이 가능한 것이면 다 접근이 가능한 것 아닌가...? 싶은 생각이 든다. 큰 프로그램을 만들 때 사용되는 것 같은데, 자주 쓰이는 것 같지는 않다.
그리고 virtual 함수와 abstract 함수이다. virtual 함수는 자식 클래스에서 재정의해서 사용할 수 있도록 허용해주는 키워드이고, abstact 함수는 자식 클래스에서 반드시 정의해서 사용하도록 제한을 걸어주는 함수이다. C++에서는 virtual 함수가 컴파일을 할 때 함수 주소가 고정되서 자식 클래스의 함수를 불러도 부모 클래스의 함수가 불려지는 문제가 있어서 사용 되는 것이라고 본 것 같은데, C#에서는 찾아봐도 딱히 그런 내용은 없는 것 같다.
privateDictionary<System.Type, State<T>> _states = new Dictionary<System.Type, State<T>>();
그리고 Dictionary다. 이전에 사용은 해봤는데, 나름 유용해보이지만 좀처럼 써본 적이 별로 없다. Key값과 Value값을 가지는데, Key값을 넣으면 그에 맞는 Value 값을 반환해준다. 여기서는 각각 상태들을 하나씩만 저장하기 때문에 System.Type을 키값으로 해서 각 상태들을 편하게 찾을 수 있도록 해준 것 같다.
public R ChangeState<R>() where R : State<T>
{
var newType = typeof(R);
if (_currentState.GetType() == newType)
{
return _currentState as R;
}
if (_currentState != null)
{
_currentState.OnExit();
}
_priviousState = _currentState;
_currentState = _states[newType];
_currentState.OnEnter();
_elapsedTimeInState = 0.0f;
return _currentState as R;
}
그 다음은 where랑 as라는 키워드이다. where R : State<T>는 R은 State<T> 타입이어야 한다고 제한을 걸어주는 느낌이고, as는... 캐스팅이랑 비슷한 느낌인데, as가 효율성이 더 좋다고 한다. 다만 캐스팅에 실패해도 오류를 띄우지 않고 null을 반환하여 오류 처리에는 약간 약할 수 있다. 반대로 오류가 나지 않고 null을 반환하기 때문에 구성하기에 따라서는 더 안정적인 방식이 될 수도 있을 것 같다.
그리고 이런 부분도 있었다. StateMachine 클래스의 Update는 EnemyController의 Update에서 StateMachine의 Update를 호출해주는데, deltaTime을 매개변수로 받도록 해서 Update를 호출할 때 deltaTime의 값을 수정하여 변화를 줄 수도 있다는 듯 하다.
_animator?.SetBool(_hashMove, false);
null 조건 연산자에 대해서도 배웠다. ?는 삼항연산자에 대한 쓰임새만 알고 있었는데, null 처리를 간편하게 해주는 연산자였다. 여기서는 _animator가 null이면 null을 리턴하고, null이 아니라면 .뒤에 있는 코드를 실행한다.
if (!enemy || _agent.remainingDistance <= _agent.stoppingDistance)
{
_stateMachine.ChangeState<IdleState>();
}
코드의 잘못된 부분을 수정하기도 했다. 이 부분은 MoveState 내의 Update에 포함된 부분인데, 원래는 (!enemy && _agent.remainingDistance <= _agent.stoppingDistance) 로 구현되어 있었다. 이렇게 되면 목적지까지 이동을 완료해도 Idle상태로 변경되지 않아서 Attack상태로 넘어가지 않는 문제가 있었다.
아직 적 캐릭터 AI 구현에서 캐릭터 가시선 시뮬레이션 구현&동적 AI 캐릭터 구현(패트롤 등)이 남긴 했는데, 코드들의 구조가 약간 복잡해서 제대로 정리한 뒤에 마저 듣고 싶었다. 또, 스크립트를 작성하면서 새로 알게 된 것들도 많아서 여기까지 끊고 작성하게 되었다. 사실 internal이라든지, ?. 연산자라든지, sealed class라든지, 이런 것들을 사용하지 않아도 되는데도 불구하고 사용하시는 것을 보면 이것저것 다양한 기능들을 알려주시려고 하는 것 같았다. 다만 이런 것에 대한 설명은 없고, 직접 이건 뭘까? 찾아봐야 알 수 있는 것이기는 하다. 뭐가 되었든 그런 것 같다. 본인 스스로 배우려는 의지가 있어야 더 많은 것을 알 수 있는 법이다. 비록 강의에서 구현한 것을 따라한 것이기는 하나, 상태 머신을 구현해 본 경험이 생겼다. 이후에 3D 게임을 개발할 때는 직접 상태 머신을 구현해봐야겠다.
아, 그리고 깃허브에 fork된 repository에 push하는 것은 contribution에 포함되지 않는 것을 어제 알았다. 그래서 이틀 전에 공부를 하고 github에 올리긴 했는데 기록이 안되서 잔디밭이 하나 비었다. 한 달간 하루도 빠짐없이 할 수 있었는데... 약간 아쉬운 마음도 든다. 그래도 뭐... 하나쯤은 빠져있는 게 더 인간미가 있어보이지 않을까? ㅋㅋ. 아무튼, 어제부터 그래서 개발했던 게임을 살펴보면서 수정할 부분들을 찾아서 하루에 하나씩 수정을 하는 중이다. 어제는 반복되는 Find 함수를 하나 찾아서 지웠고, 오늘은 can으로 시작하는 bool 변수의 이름을 isAvailable로 변경하였다. 이를 통해서 bool 변수는 모두 is로 시작하도록 통일되었다. 이후로도 꾸준히 하나씩 개선해볼 생각이다.