게임을 개발하다보면 네트워크 관련 지식이 필요할 것이라 생각하여 게임 네트워킹에 대한 개념을 익히기로 했다. 나는 클라이언트 프로그래머이지만, 게임을 만들다보면 클라이언트와 서버를 연동하는 작업도 필요할 것이고, 협업을 조금 더 원활하게 하기 위해서는 네트워크 관련 지식도 필요할 것이라 생각한다.

 

관련해서 찾아보다가 유튜브에서 Tucker라는 분이 녹화하여 올리신 '게임 네트워킹의 이해' 라는 영상을 발견했는데, 크게 어렵지 않고 꽤나 흥미로운 내용들로 이루어져있다. 총 13개로 이루어져있고 현재까지 5개 정도 본 것 같다.

 

https://youtu.be/lAhAdnsIN6I

 

이전에 학교 다니면서 데이터통신, 컴퓨터네트워크 라는 과목을 배우기도 했었는데, 관련된 지식도 살짝살짝 나오긴 했지만 주로 새로 접한 내용들이 많았다.

 

아무튼, 영상을 보면서 접했던 내용들이나 생각했던 내용들에 대해서 정리해보고자 한다.

 

1. 네트워킹이란?

주로 컴퓨터끼리 데이터를 주고 받는 것을 네트워킹이라고 한다. 컴퓨터 내부에서 CPU<->RAM<->HDD 등 데이터를 주고 받는 것도 네트워킹이라고 하는데, 물리적인 거리가 가깝고 말 그대로 '연결'되어있기 때문에 일반적인 네트워킹과는 차이가 있다. 일반적인 네트워킹에서 크게 3가지 문제점이 발생하는데, 지연시간, 연결 안정성, 순서 비보장이다. 게임 네트워킹에서 이 3가지를 어떻게 처리하느냐가 중요한 이슈이다. (추후에 패킷 전송 비보장 얘기도 잠깐 나왔음)

 

먼저 지연시간은 빛의 물리적인 속도의 한계에 의해 생길 수 밖에 없다. 빛이 지구를 1바퀴 도는데 걸리는 시간이 대략 133ms이기 때문에, 지구 반대편에 있는 컴퓨터에 데이터를 전송하려면 아무리 빨라도 67ms가 걸린다. 특히나 게임의 경우, 데이터를 주고 받아야하기 때문에 이 시간의 2배까지 걸리게 된다. 이 왕복하는 시간 RTT(Round Trip Time)이라고 한다. 물론 실제로는 모든 컴퓨터들이 직접 연결된 것이 아니기 때문에, 도달하기 위해 여러 거점들을 거쳐야 하고, 일반적인 경우 지구 반대편이라면 500~600ms 정도가 걸린다고 한다.

 

두 번째로 연결 안정성에 대한 문제이다. 요즘에는 주로 생략해서 잘 쓰지 않는 것 같지만 www, world wide web이라는 표현이 있다. 이 이름처럼 인터넷 세상은 거미줄처럼 넓게 연결되어 있다. 때문에 내 컴퓨터나 상대방 컴퓨터에 문제가 없더라도, 데이터가 이동하는 사이 중간 거점에 문제가 생기면 연결에 문제가 생길 수 있다는 것이다. 한국에서 미국으로 데이터를 보낸다고 치면, 일본을 거쳐 태평양 회선을 타고 미국으로 도달한다고 하는데, 해저 케이블이 끊긴다거나 하는 사고가 발생하면 연결이 끊길 수 있다는 것이다. 이외에도 모바일 게임을 한다고 치면, 지하철을 타고 있을 때 중간중간 데이터가 잡히지 않는 구간이 있어서 끊김이 발생한다.

 

세 번째로는 순서 비보장이다. 광자가 순서대로 이동하기 때문에 컴퓨터 내부에서는 데이터가 순서대로 이동하겠지만, 인터넷 상에서는 순서가 보장되지 않는다. 두 명이 동시에 입력을 했다고 해도, 물리적인 거리가 멀다면 지연시간으로 인해 거리가 더 가까운 사람의 데이터가 먼저 도달할 것이다. 이렇게 물리적인 거리의 차이로 문제가 생길 수도 있고, 같은 사람이 보냈다고 하더라도 보내던 회선이 고장나서 다른 회선으로 우회하게 된다면 마찬가지로 나중에 보낸 데이터가 먼저 도달할 수도 있다.

 

===================================================

 

여기까지가 첫 번째 영상 관련 내용인데, 뒷 내용들에 비해서 내용이 조금 많은 것 같다. 전체적으로 어찌보면 상당히 당연한 내용이지만 여태까지 생각해본 적이 거의 없었던 내용들이었다. 먼저 지연시간과 관련해서 든 생각은, 두 플레이어 정 중앙에 서버가 존재한다면, 지연시간을 절반까지 낮출 수 있지 않을까 하는 생각이었다. 

그림을 그려보자면 이런 식인데, 중간에 서버가 양쪽의 데이터를 받아서 처리하고, 서로에게 보내준다면 데이터 왕복에 걸리는 시간을 절반까지 낮출 수 있을 것 같다. 다만 비용이 문제일 것 같다.

두 번째로 든 생각은 지구 내부에 구멍을 뚫어서 다이렉트로 연결하면 더 빠르게 연결할 수 있지 않을까 라는 생각이다. 지구 표면을 따라 이동한다면 반지름x3.14의 거리를 이동하겠지만, 이렇게 이동한다면 반지름x2의 거리를 이동할테니 1.5~1.6배 정도 빠르게 이동할 수 있을 것이다. 다만 아직 인간이 지표면에서 별로 깊이 들어가지도 못했고, 중심에는 핵이 있어서 케이블이 녹을 가능성이 매우 높다는 것이다. 아마 아직까지는 현실성 없는 얘기인 것 같다. ㅋㅋ.

 

=============================

 

2. TCP & UDP

 

TCP와 UDP는 각기 다른 통신 방식인데, 간단히 말하자면 TCP는 안정성을 중시하는 방식이고, UDP는 안정성을 포기하는 대신 속도를 중시하는 방식이다. 위에서 언급했듯 컴퓨터 네트워크에서는 연결 자체가 보장되지 않고, 순서도 보장되지 않으며 보낸 패킷들이 전부 도달한다는 보장도 없다. TCP방식은 이런 것들을 하드웨어 차원에서 보장해준다.

https://seongonion.tistory.com/74

먼저 연결의 경우 3-way-handshake라는 방식을 사용한다고 한다. 연결을 요청하면, 요청받은 쪽에서 응답하고, 이 응답을 잘 받았다는 것을 마지막으로 전달하면서 연결을 하는 방식인 것 같다. 이렇게 총 3번의 절차를 걸쳐 연결을 하기 때문에 3-way-handshake라고 하는 것 같다.

 

두 번째로는 순서 보장인데, 패킷을 보낼 때 번호를 매겨서 보내는 방식을 사용하여 받은 쪽에서는 해당 번호를 기준으로 정렬하면 되기 때문에 순서가 보장된다고 한다. 또, 3, 4, 5번 패킷이 왔는데 1, 2번 패킷이 안왔다면, 1, 2번 패킷을 못받았다는 요청을 보내서 모든 패킷을 전송받을 수 있는 보장도 가능하다는 것 같다.

 

 

다만, TCP 방식의 경우 이런식으로 확인에 대한 절차가 걸리기 떄문에 아무래도 성능적인 면에서 떨어질 수밖에 없다. 때문에 UDP 방식은 이런 보장을 해주지 않는 대신 속도가 빠르다는 장점이 있다. UDP는 연결 확인은 전혀 하지 않고, 바로 데이터를 보내고 '도달하든 말든 보냈으니 땡이다' 라는 방식인 것 같다. 상당히 무책임한 방식이지만... 복잡한 절차가 없으니 속도가 굉장히 빠를 것 같다. 데이터 유실률이 7%정도라고 하니 생각보다 그렇게 높지는 않은 것 같다. 때문에 반응성이 중요한 FPS나 격투 게임 등 대부분의 게임 장르에서는 UDP 방식을 사용한다고 한다. 요즘은 UDP방식을 소프트웨어적으로 보완한 Reliable UDP라는 방식을 사용한다고 한다. 게임에서 안정성이 필요한 부분의 안정성을 높여 순수 UDP에 비해서는 느리지만, 안정성은 좋은 방식이라고 한다. TCP 방식에 비해서는 불필요한 부분을 뺐을테니 TCP에 비하면 속도도 빠를 것이다. 옛날에는 이런 기술을 가진 회사들이 많지 않았다는 것 같은데, 지금은 오픈 소스도 많고 Unity, Unreal에서 지원해주기 때문에 많이들 사용한다고 한다.

 

 

3. Deterministic (+Delay, Rollback 방식)

 

게임 네트워킹 방식 중에 Deterministic이라는 방식이 있다. 이 방식은 반응성이 좋은 UDP를 사용하는 경우에 많이 사용한다고 한다. TCP를 사용하는 경우에는 부정을 방지하기 위해 서버의 강력한 권한이 필요한 MMORPG 등에서 Server Authority라는 방식을 사용한다고 한다.

 

Deterministic 방식은 똑같은 입력이 들어오면 똑같은 결과를 보장해준다. 만약 격투게임에서 원거리 통신 플레이를 할 때, 거리가 멀어서 지연시간이 길다고 가정해보자. 공격 버튼을 누른 후 적에게 공격이 닿을 때까지 100ms가 걸리는 경우, 지연시간이 150ms라면 무슨 일이 벌어질까? A가 100이라는 시간에 공격을 입력하고, B가 200이라는 시간에 공격을 입력하면 A에서는 200이라는 시간에 B를 때리는 결과가 나타날 것이고, B에서는 200이라는 시간에 B가 공격을 시작하고 A는 250에 입력이 들어와서 공격을 시작하기 때문에 결과적으로 300이라는 시간에 B가 A를 때리는 결과가 나타날 것이다. 온라인 게임에서 이렇게 두 플레이어의 결과가 다른 문제가 나타나면 안되기 때문에, 이를 해결하기 위한 방식을 적용한다.

 

첫 번째로는 Delay방식으로, 지연시간만큼 기다렸다가 입력을 처리하는 방식이다. 위의 경우를 똑같이 가정해보면 A의 입장에서는 100에서 입력을 했기 때문에 250에서를 처리를 할 것이다. 250까지 B의 입력이 오지 않았기 때문에 250에서 A의 공격이 나가고, 350에서 A가 B를 공격하는 결과가 나올 것이다. 마찬가지로 B의 입장에서는 200에 공격을 입력했고, 350까지 기다리는데, 250에 A의 공격 입력이 들어오기 때문에 마찬가지로 350에서 B가 A를 때리는 결과가 나타나게 된다. 즉, 두 플레이어에게 똑같은 결과를 보장해주는 것이다. 다만, 문제가 있다면 지연시간이 길 경우, 입력 후 실제 처리까지 대기시간이 있기 때문에 지연시간이 길수록 플레이어 입장에서는 답답하게 느껴질 수 있다.

 

두 번째로는 Rollback 방식인데, 위의 경우에서 B의 입장을 보겠다. B가 200에 공격을 시작하면 300에 A에게 공격이 도달할 것이다. 그런데, 250에 A의 입력이 들어왔을 때 A의 입력이 100에 입력됐다는 정보가 함께 오기 때문에, A가 정보를 보낸시간까지 되감기를 하고, 해당 정보를 넣은 후에 현재 시점까지 앞감기를 해서 적용을 시키는 방식이다. 그러면, A는 100에서 공격을 했기 때문에 200에 B가 맞은 판정이 날 것이다. 마찬가지로 A와 B가 동일한 결과를 보장받는 것이다. 다만, 이 경우의 문제는 B의 입장에서는 게임이 상당히 불합리하게 느껴질 수 있다는 것이다. B는 200에서 250까지 주먹을 날리고 있었는데, 되감기와 앞감기를 한 결과 200에서 공격을 맞게 되고, B가 보는 250의 순간에는 갑자기 주먹을 날리던 내 캐릭터가 적의 공격을 맞아서 날아가는 장면을 보게 될 것이다. 사실은 굉장히 공정한 처리가 이루어졌지만, 플레이어 입장에서는 전혀 공평하게 느껴지지 않을 것이다. 렉 때문에 죽었다고 생각하는 경우가 이런 경우라고 한다. 물론 이 예시가 지연시간이 굉장히 긴 경우를 예시로 들었기 때문에, 지연시간이 짧다면 이렇게까지 큰 차이는 나지 않을 것이고, Delay방식에 비해 훨씬 게임을 깔끔하게 진행시킬 수 있을 것이다.

단, Delay 방식에 비해서 구현하기가 굉장히 복잡하다는 문제가 있고, 중간 접속 처리를 하기가 까다롭다는 문제가 있다. 플레이어의 연결이 끊겼다가 다시 접속되는 경우, Delay방식에서는 게임이 잠시 중단될 것이다. 하지만 Rollback 방식에서는 접속한 플레이어가 이전까지의 다른 플레이어의 모든 입력을 가져와서 빠르게 앞감기를 하며 적용시켜야하기 때문에 중간 접속 처리가 굉장히 어려워지고, 이 과정에서 문제가 발생할 가능성도 높아진다.

 

다음으로는 실무에 많이 사용되는 방식에 대한 이야기인데, Delay 방식과 Rollback 방식과 비슷하지만 약간의 차이가 있다. 첫 번째는 중계 서버를 활용하는 방식이다. Relay Server 혹은 Broadcast Server라고 한다. 모든 입력을 서버를 거쳐서 처리하게 되는데, 서버는 데이터를 받아서 전달하기만 한다. 클라이언트는 입력을 할 때 서버에 데이터를 보내고, 서버에서는 받은 데이터를 일정 시간동안 취합하여 다시 클라이언트로 보낸다. 클라이언트는 서버에서 데이터를 받을 때 입력을 처리한다. 즉, Delay 방식과 거의 유사한 방식으로 보인다.

두 번째는 Rollback 방식에서 약간의 변화를 준 방식이다. 기존의 Rollback방식은 상대방의 입력이 들어오면 해당 입력이 입력된 시점으로 되감기를 하고, 다시 앞감기를 하는 처리를 거치는데, Rollback을 일정 시간마다 계속 반복하도록 하는 것이다. 입력이 들어올 때마다 특별한 동작을 수행하도록 하는 것이 아니라 항상 같은 동작을 수행하기 때문에 구현도 더욱 단순해지고, 문제가 생길 가능성도 낮아진다고 한다. 단점이 있다면 성능에 대한 문제인데, 매 프레임마다 되감기&앞감기를 하는 것이 성능에 큰 영향을 주지는 않는다고 한다. 게임에서 성능 대부분을 차지하는 부분이 그래픽이기 때문에, 되감기&앞감기를 하는 과정에서 화면을 다시 그리는 것이 아니라 연산만 하는 것이기 때문에 성능에 대한 부담은 거의 없다고 한다.

 

 

들은 내용의 기억을 되살려서 내 나름대로 요약을 해보았다. 누락된 부분도 있을 것이고, 왜곡되어 틀린 부분이 있을 수도 있다. 영상에서는 그림과 함께 실제 서버 프로그래머분이 설명을 해주시기 때문에 아마 영상을 보는 것이 더 좋을 것이다. 아무튼, 얕지만 게임 네트워킹에 대한 지식을 조금 알게 되었다. 앞으로 서버 관련 지식들을 익히는 데에 많은 도움이 될 것 같다. 현재 5번째 영상까지 보았는데, 총 13개로 이루어져있으니 더 많은 것들을 배울 수 있을 것 같다. 특히 MMORPG 서버 관련 내용이 기대된다.

Github 링크(스크립트만 포함)

https://github.com/Mepkatatsu/First_3D_RPG_OnlyScript

 

GitHub - Mepkatatsu/First_3D_RPG_OnlyScript: My First 3D RPG / Script Only

My First 3D RPG / Script Only. Contribute to Mepkatatsu/First_3D_RPG_OnlyScript development by creating an account on GitHub.

github.com

*사용한 리소스의 저작권 등의 문제로 현재 개발한 부분까지의 스크립트만 따로 모아 public으로 업로드 한 Repository의 주소임

전체 기능 영상

구현한 기능

[이동] 00:00 ~ 00:07

화면의 터치한 지점에 생성되는 조이스틱 혹은 키보드 WASD로 이동

 

[카메라] 00:08 ~ 00:20

TopDown 형식의 카메라

휠을 사용한 줌 인/줌 아웃

인벤토리를 열고 닫을 때 빈 공간의 중심에 플레이어 캐릭터가 위치하도록 카메라 위치 변경

 

[특수모션] 00:17 ~ 00:20

플레이어 캐릭터가 10초간 가만히 있으면 특수 모션 재생(2종류의 춤 중에 랜덤으로 재생)

 

[공격] 00:26 ~ 00:44

공격 버튼을 눌러 공격, 1초 이내 입력시 최대 3번까지 연속 공격 실행

 

[적] 00:45 ~ 01:18

적을 클릭하거나 공격하면 타겟으로 지정되며 빨간색 테두리를 추가

정해진 지역 내에서 랜덤 주기로 랜덤한 거리만큼 이동

플레이어에게 선제 공격을 당하면 전투 상태에 돌입

전투 상태에서 플레이어가 공격 사거리 밖에 있으면 플레이어를 추격

플레이어가 지역 밖으로 나가면 원래 위치로 돌아가며 전투 상태 해제

적이 사망하면 자동으로 타겟 해제, 타겟이 변경되면 타겟 상태 유지

 

[인벤토리&아이템] 02:55 ~ 03:30

적을 처치하면 일정 확률로 아이템 획득

일정 확률로 성급 아이템 드랍

성급 아이템 감정

아이템 장착시 빈 공간이 생기면 뒤에 있는 아이템들을 1칸씩 앞으로 이동

인벤토리창 스크롤

 

[기타]

아이템 장착시 스탯 변경되며 공격력이 증가하면 적에게 주는 데미지 증가 03:34, 04:52

플레이어 사망 05:15

 

사용한 에셋

플레이어 캐릭터 에셋: Casual RPG Character - 04 Avelyn

https://www.assetsready.com/product-page/casual-rpg-character-04-avelyn

 

Casual RPG Character - 04 Avelyn | Assets ready main

Update Ver. 1.1Animations : Characters are humanoid mecanim ready. (See Mixamo motion test) Polys: 2948Verts: 2549Texture: 1024x1024-1, 256x256-1Character Icon: 256x320-1Animations(x8) :(Animation Type_Generic)Sword_F_BattleIdle_01Sword_F_Run_01Sword_F_Att

www.assetsready.com

 

몬스터1(리치) 에셋: Mini Legion Lich PBR HP Polyart

https://assetstore.unity.com/packages/3d/characters/humanoids/fantasy/mini-legion-lich-pbr-hp-polyart-91497

 

Mini Legion Lich PBR HP Polyart | 캐릭터 | Unity Asset Store

Get the Mini Legion Lich PBR HP Polyart package from Dungeon Mason and speed up your game development process. Find this & other 캐릭터 options on the Unity Asset Store.

assetstore.unity.com

 

몬스터2(스켈레톤) 에셋: Fantasy Monster - Skeleton

https://assetstore.unity.com/packages/3d/characters/humanoids/fantasy-monster-skeleton-35635

 

Fantasy Monster - Skeleton | 3D 휴머노이드 | Unity Asset Store

Elevate your workflow with the Fantasy Monster - Skeleton asset from Teamjoker. Find this & other 휴머노이드 options on the Unity Asset Store.

assetstore.unity.com

 

지형 에셋: Casual Tiny Environment - Jungle Set

https://www.assetsready.com/product-page/casual-tiny-environment-jungle-set

 

Casual Tiny Environment - Jungle Set | Assets ready main

■ Jungle 1Verts: 19971Tris: 15510Textures :Diffuse map_ 1024x1024-2, 512x512-1Light map_ 1024x1024-1■ Jungle 2Verts: 24777Tris: 13232Textures :Diffuse map_ 1024x1024-2Light map_ 1024x1024-1■ Jungle 3Verts: 21875Tris: 11403Textures :Diffuse map_ 1024x

www.assetsready.com

 

조이스틱 Sprite: Joystick Pack

https://assetstore.unity.com/packages/tools/input-management/joystick-pack-107631

 

Joystick Pack | 입출력 관리 | Unity Asset Store

Get the Joystick Pack package from Fenerax Studios and speed up your game development process. Find this & other 입출력 관리 options on the Unity Asset Store.

assetstore.unity.com

 

빌드 후 발생한 오류 수정: Log Viewer

https://assetstore.unity.com/packages/tools/integration/log-viewer-12047

 

Log Viewer | 기능 통합 | Unity Asset Store

Use the Log Viewer from dreammakersgroup on your next project. Find this integration tool & more on the Unity Asset Store.

assetstore.unity.com

 

TextMeshPro

 

+

 

소울게임즈의 루나 모바일 게임 내 UI 및 시스템 사용

https://play.google.com/store/apps/details?id=com.t3.luna&hl=ko&gl=US

 

루나 모바일(12) - Google Play 앱

로맨스 MMORPG 루나 모바일

play.google.com

 

일자별 활동

2023. 04. 04. ~ 2023. 04. 13. : 3D 개발 공부

2023. 04. 14. ~ 2023. 04. 17. : 기능, 구조 계획, 에셋 준비

2023. 04. 18. : 캐릭터 이동, 대기 모션, 카메라 구현

2023. 04. 19. : 캐릭터 공격 모션, 사망 상태 구현

2023. 04. 20. : 적 캐릭터 AI, 전투, 적 캐릭터 필드 구현

2023. 04. 21. ~  2023. 04. 23. : 아이템, 인벤토리 시스템, 스탯 시스템, UI 등 구현

2023. 04. 24. : UI 및 아이템 추가, 빌드, 최종 녹화, 코드 정리 및 구조도 작성

 

 

프로그램 구조도

적 AI 구현: Finite State Machine

적 캐릭터는 StateMachine을 가지며 현재 상태로 CurrentState를 저장합니다. State는 IdleState, MoveToTargetState, MoveToWaypointState, AttackState, DeadState로 총 5종류가 있습니다. 기본적으로 IdleState로 시작하며, 10~30초간 IdleState가 유지되면 Waypoint를 생성하여 MoveToWaypointState로 전환하여 Waypoint로 이동합니다. 적에게 공격을 당하면 전투 상태에 돌입하며, 적이 사거리 내에 없을 경우 MoveToTargetState로 전환하여 적에게 이동하고, 적이 사거리 내에 있을 경우 AttackState에 돌입하여 적을 공격합니다. 각 State가 종료되면 IdleState로 전환됩니다. 적의 체력이 0이 되어 사망하면 DeadState로 넘어가고, 3초 후 GameObject를 Destroy합니다.

 

 

 

아이템 시스템

ItemDatabaseObject와 ItemObejct는 ScriptableObject를 상속하여 구현하였습니다. ItemDatabaseObject는 하나만 존재하고, 이 안에 ItemObject들이 저장되어 있습니다. ItemObject는 아이템을 생성하는 틀이며, 실제 플레이어에게 드롭되는 아이템은 ItemData 형식으로 저장됩니다. itemDatabaseObject에 등록될 때 ItemObject의 ItemData 내의 id가 중복되지 않도록 id를 부여합니다. ItemData에는 ItemStat이라는, 아이템의 능력치 각각의 정보가 저장되어 있습니다. 아이템의 등급, 성급, 강화 수치에 따라 최종 수치가 결정됩니다.

인벤토리 시스템

InventoryObject라는 ScriptableObject가 있으며, 이를 생성하여 플레이어의 장비창과 인벤토리를 구현하였습니다. InventoryObject는 ItemDatabaseObject를 가지며 아이템의 각종 비교에 사용합니다. 또한 하나의 인벤토리 슬롯의 정보를 저장한 InventorySlot을 여러 개 가진 Inventory를 가지고 있습니다. InventorySlot은 해당 슬롯에 저장된 ItemData를 저장합니다. 하나의 InventoryObject는 각각 장비창, 인벤토리 등 하나의 인벤토리 창의 정보를 가지고 있습니다.

 

InventoryUI는 InventoryObject를 가지며 인벤토리에 아이템이 추가되거나 제거될 때 UI를 갱신합니다.

이를 상속받아 구현하는 StaticInventory는 고정적인 인벤토리 창을 갖는 장비창에 등록되는 스크립트로, 등록된 GameObject들인 staticSlots를 InventoryObject의 Inventory 안에 저장된 slots의 slotUI에 순서대로 저장합니다. 마찬가지로 InventoryUI를 상속받아 구현하는 DynamicInventory는 크기가 변하는 인벤토리 창을 갖는 플레이어 인벤토리에 등록되는 스크립트로, 최초에 인벤토리들을 생성하고 아이템을 정렬하는 등의 기능을 수행합니다.

 

스탯 시스템

ScriptableObject인 StatObject를 생성하여 플레이어의 스탯을 관리하는 PlayerStat에 등록하였다. StatObject에는 현재 레벨과 경험치, 체력, 마나, attributes가 저장되어 있습니다. Attribute는 플레이어의 각 스탯들의 타입과 값을 저장합니다. 값은 ModifiableFloat의 형태로 저장되어 있는데, 기본 값인 baseValue에 아이템을 장착할 때마다 스탯별로 modifiers에 IModifier가 추가되어 최종 능력치로 modifiedValue를 반환합니다.

 

 

 

 

 

================================================================

 

 

 

 

 

더보기

3주 전에 느꼈던 것과 마찬가지로 지난 3주간 정말 많은 것들을 배웠다. 3D 개발 관련 강의를 보면서 조금 더 현업에 어울리는 사람으로 성장한 것 같다. 상태 머신과 인벤토리, 아이템 시스템을 만들면서 체계적인 프로그램의 구조와 객체 지향 프로그래밍에 어울리는 방식에 조금 익숙해진 것 같다. 지난 1주일간 개발한 개발물에 부족한 부분과 개선해야할 부분이 아직 많지만, 시간만 들인다면 충분히 해결할 수 있을 것 같다. 3주 전에 취업을 고민했었는데, 3D 개발 공부를 한 것이 아주 좋은 선택이었던 것 같다. 굉장히 힘들고 어려운 시간이었지만 어려움을 극복하면서 많이 성장한 것 같다. 아마 조급함을 느꼈으면 이렇게 성장할 수는 없었을 것이다. 나에게 주어진 시간들에 감사함을 느낀다. 취업을 하게 되면 지금보다도 훨씬 많은 것들을 배울 수 있을 것이다. 지난 2달 간처럼, 앞으로도 많은 것들을 배우고 성장해나갈 것이다.

 

전체적인 부분을 녹화하기 위해서 빌드를 했는데, 예상치 못한 문제들이 나타나서 겪고 해결한 문제들을 간단히 정리해보려고 한다.

 

그냥 쓰는 블로그 :: 빌드 후 로그 쉽게확인하기 (Log Viewer) (tistory.com)

 

빌드 후 로그 쉽게확인하기 (Log Viewer)

빌드 후에 로그를 보려고하면 불편했던 경우가 많을것이다.그때 사용하면 좋은게 바로 로그 뷰어. 에셋스토어에서 무료로 다운받을 수 있다. 다운 후 Reporter 탭에서 Create를 누르면 Hierachy뷰에 Rep

icechou.tistory.com

빌드 후에 로그를 쉽게 확인할 수 있는 Log Viewer라는 에셋을 활용하였다.

 

 

먼저 첫 번째는 Shader 관련 이슈이다.

 

Unity Build했는데 Editor에서만 된다? (tistory.com)

 

Unity Build했는데 Editor에서만 된다?

이걸 가지고 만 하루는 보낸것같다 ㅠㅠㅠUnity Editor에서는 잘 되는데 Build하면 안된다.그런데 오류도 안난다.로그도 안뜬다. 왜인지 모르겠지만 개발자모드로 빌드도 안되서(앱이 실행이 안됨;)

youn-codingnote.tistory.com

적을 선택했을 때 테두리를 그리는 부분이 작동하지 않았는데, 글에 나온 것처럼 Edit - Project Settings - Graphics - Always Included Shaders에 사용하는 쉐이더를 등록해주니 해결되었다.

 

두 번째는 OnValidate() 관련 문제였다.

 

작동이 제대로 되지 않는 부분이 있어 디버그를 띄우면서 찾아보니 OnValidate() 부분이 실행이 되지 않았다.

혹시나해서 마우스를 갖다대보니 편집기에서만 호출된다는 설명이 있었다. 때문에 해당 부분을 OnEnable() 함수로 변경해주었다.

 

apk파일로도 빌드해서 이전에 사용하던 기기로 테스트를 해보았다. 지형의 텍스쳐가 상당히 밝아진 것을 제외하면 정상적으로 작동했다.

 

해상도 대응 작업을 해볼까 싶었는데, Reference Resolution이 1280x1080이라는 근본없는 해상도로 되어있었다. 1920x1080으로 바꾸니 UI들의 위치가 영 맞지 않는 문제가 있었다. 아마 UI들의 위치를 재배치해야 될 것 같은데, 너무 시간이 오래 걸릴 것 같아서 일단 보류하기로 하였다. 다음부터는 해상도 대응을 먼저 생각하고 제대로 확인해야겠다.

오늘은 마지막으로 전투에 디테일을 더하기 위해 데미지 텍스트 UI를 추가했다.

 

데미지 텍스트는 Prefab으로 만들었으며, DamageText 스크립트와 Animator를 컴포넌트로 가지고 있고 이미지와 텍스트를 자식으로 가지고 있다.

 

using TMPro;
using UnityEngine;

public class DamageText : MonoBehaviour
{
    #region Variables
    private TextMeshProUGUI _textMeshPro;

    public float destroyDelayTime = 1.0f;

    #endregion Variables

    #region Properties

    public int Damage
    {
        get
        {
            if (_textMeshPro != null)
            {
                return int.Parse(_textMeshPro.text);
            }

            return 0;
        }
        set
        {
            if (_textMeshPro != null)
            {
                _textMeshPro.text = value.ToString();
            }
        }
    }

    #endregion Properties

    #region Unity Methods
    private void Awake()
    {
        _textMeshPro = transform.GetChild(1).GetComponent<TextMeshProUGUI>();
    }

    private void Start()
    {
        Destroy(gameObject, destroyDelayTime);
    }
    #endregion Unity Methods
}

데미지 텍스트는 생성되고나서 1초 후에 자동으로 파괴되도록 하였다. 프로퍼티를 통해 편하게 데미지 텍스트의 수치를 변경할 수 있도록 하였다.

 

public void ShowDamageText(GameObject target, int damage)
        {
            if (target == null) return;

            GameObject damageTextGO = Instantiate(damageTextPrefab, camera.WorldToScreenPoint(target.transform.position), Quaternion.identity, damageTextParent.transform);
            DamageText damageText = damageTextGO.GetComponent<DamageText>();

            int randNum = UnityEngine.Random.Range(30, 60);
            damageTextGO.GetComponent<RectTransform>().position = new Vector2(damageTextGO.GetComponent<RectTransform>().position.x + randNum, damageTextGO.GetComponent<RectTransform>().position.y + randNum);

            damageText.Damage = damage;
        }

UIManager에서 ShowDamageText 함수를 추가하여, 적이 피격될 때 TakeDamage에서 해당 함수를 호출하도록 하였다.

ShowDamageText가 호출되면 호출한 target의 World 좌표를 Screen 좌표로 변경하여 데미지 텍스트를 생성한다. 이후 데미지 텍스트 x, y 좌표값에 랜덤한 값을 더해 랜덤한 위치에 데미지 텍스트가 생성되도록 하고, 데미지 수치를 반영하도록 하였다.

 

애니메이션은 간단하게 잠시 표시됐다가 흐려지면서 위로 사라지는 연출로 만들었다.

 

그리고 아이템 추가가 이미지만 있으면 어렵지 않게 가능하기 때문에 완성도를 높이는 차원에서 아이템 8종을 추가해주었다. 추가된 아이템은 레벨 제한이 21이기 때문에 21레벨 아이템을 드랍하는 스켈레톤을 잡으면 드랍된다.

 

 

오늘 구현된 내용은 다음과 같다.

이번에도 무기가 좀처럼 안떠서 아이템 드롭 확률을 90%로 높였다. 기존엔 50%였고, 장비가 8종이 있었으니 평균적으로 16마리는 잡아야 무기가 나왔으니 그럴 법도 한 것 같다. 어제 녹화하지 못했던 무기 교체, 데미지 수치 변경 등까지 담은 것 같다.

 

이제 1주일 전에 계획했던 기능들을 대부분 구현한 것 같다. 이번에도 만드는 과정에서 굉장히 많은 것들을 배운 것 같다. 이제 이것들을 요약해서 총정리를 할 계획이다.

마지막 글을 쓴 이후로 무려 4일이나 경과했다.

아이템과 인벤토리 시스템 구현이 생각보다 복잡하고 어려워서 시간이 좀 많이 걸렸다.

인벤토리를 구현하기 위해서는 아이템이 구현되어있어야 하기 때문에 두 작업을 모두 마치는 데에 시간이 상당히 오래 걸린 것 같다. 이전에 다뤄본 아이템과 인벤토리 시스템을 활용했지만 구현하는 방식이 기존 방식과 상당히 동떨어져있어 구조를 뜯어고치는 등 복잡한 작업이 필요했다.

 

목요일에는 아이템의 전반적인 기능들을 구현했다.

금요일에는 루나모바일 게임 내에서 인벤토리와 아이템 등 UI를 가져왔고, Static / Dynamic 등 인벤토리 스크립트를 구현했다. (각각 플레이어 장비창, 아이템창)

토요일에는 아이템이 인벤토리에 추가되거나, 아이템의 등급과 감정하는 기능 등 아이템과 인벤토리 전반적인 기능 구현을 마쳤고, 인벤토리를 여닫을 때 카메라의 위치를 수정했다.

오늘인 일요일에는 아이템 장착, 해제와 더불어 플레이어의 스탯 시스템을 구현했다. 또한 플레이어와 적의 체력바 등 UI도 구현하였으며, 적을 클릭하거나 공격하여 적을 선택하고 테두리를 강조하는 기능도 구현했다. 아이템을 장착하거나 해제할 때 인벤토리에 빈 공간이 생기지 않도록 정렬 기능까지 구현했다. 클릭이나 공격, 피격, 장착, 해제, 움직임 등에 사용되는 효과음도 적용하였다.

 

 

이렇게 정리하고나니 굉장히 많은 것을 한 것 같다. 조금씩 정리했으면 좋았을 것 같은데, 전체적으로 기능이 부족하고, 체계적으로 작동하지 않는 것 같아서 정리를 미뤄뒀던 것 같다. 이 구조들을 정리하려고 하니 벌써부터 머리가 아파오는 것 같다. 우선은 처음부터 순서대로 정리해보겠다.

 

http://draw.io

 

Flowchart Maker & Online Diagram Software

Flowchart Maker and Online Diagram Software draw.io is free online diagram software. You can use it as a flowchart maker, network diagram software, to create UML online, as an ER diagram tool, to design database schema, to build BPMN online, as a circuit d

app.diagrams.net

프로그램 구조도를 그리는 데에 꽤나 괜찮은 사이트가 있어 이곳에서 구조도를 작성해보았다.

 

먼저 Item 시스템의 구조도이다. ItemDatabaseObject와 ItemObejct는 ScriptableObject를 상속받아 구현하였다. ItemDatabaseObject는 하나만 존재하고, 이 안에 ItemObject들이 저장되어있다. ItemObject는 아이템을 생성하는 틀이며, 실제 플레이어에게 드롭되는 아이템은 ItemData 형식으로 저장된다. itemDatabaseObject에 등록될 때 ItemObject의 ItemData 내의 ItemObject마다 id가 중복되지 않도록 id를 부여한다. ItemData에는 ItemStat이라는, 아이템의 능력치 정보가 여럿 저장되어있다. 아이템의 등급, 성급, 강화 수치에 따라 최종 수치가 결정된다.

 

IModifier는 플레이어의 스탯 시스템을 구현하기 위한 인터페이스로, ItemStat이 상속받아 AddValue라는 함수를 구현한다. 또한, 게임 내에 강화석을 옵션석을 장착하는 슬롯이 있어 생성은 해놓았지만 아직 구현하지 않았다. ItemRank, CharacterClass, ItemType, AttributeType은 enum으로 각종 기능시 체크할 떄 사용된다.

 

다음은 Inventory 시스템의 구조도이다. InventoryObject라는 ScriptableObject가 있으며, 이를 생성하여 플레이어의 장비창과 인벤토리를 구현하였다. InventoryObject는 ItemDatabaseObject를 통해 아이템의 각종 비교에 사용한다. 또한 인벤토리 각각의 슬롯의 정보를 저장한 InventorySlot을 여러 개 가진 Inventory를 가지고 있다. InventorySlot은 해당 슬롯에 저장된 ItemData를 저장한다. 하나의 InventoryObject는 각각 장비창, 인벤토리 등 하나의 인벤토리 창의 정보를 가지고 있다.

 

InventoryUI는 InventoryObject를 가지며 인벤토리에 아이템이 추가되거나 제거될 때 UI를 갱신한다.

이를 상속받아 구현하는 StaticInventory는 고정적인 인벤토리 창을 갖는 장비창에 등록되는 스크립트로, 등록된 GameObject들인 staticSlots를 InventoryObject의 Inventory 안에 저장된 slots의 slotUI에 순서대로 저장해준다. 마찬가지로 InventoryUI를 상속받아 구현하는 DynamicInventory는 크기가 변하는 인벤토리 창을 갖는 플레이어 인벤토리에 등록되는 스크립트로, 최초에 인벤토리들을 생성하고 아이템을 정렬하는 등의 기능을 수행한다.

 

 

마지막으로 스탯 시스템의 구조도이다. 마찬가지로 ScriptableObject인 StatObject가 있다. 이를 생성하여 플레이어의 스탯을 관리하는 PlayerStat에 등록하였다. StatObject에는 현재 레벨과 경험치, 체력, 마나, attributes가 저장되어있다. Attribute는 플레이어의 각 스탯들의 타입과 값을 저장하고 있다. 값은 ModifiableFloat의 형태로 저장되어 있는데, 기본 값인 baseValue에 아이템을 장착할 때마다 스탯별로 modifiers가 추가되어 최종 능력치로 modifiedValue를 반환한다.

 

 

막상 이렇게 정리를 하니까 생각보다 그렇게 복잡하진 않은 것 같다. 구조도를 그리는 데에 2시간 정도 소요되긴 했지만.

EnemyController나 전투 시스템도 이렇게 구조도를 그리면 좋을 것 같기도 하다.

 

 

이외에는 대부분 기능을 UIManager에서 처리한다. 화면의 버튼을 누르는 기능들, 플레이어의 체력바를 업데이트하며 현재 주시 중인 적의 정보를 업데이트한다. 적의 정보를 업데이트하는 것은 적을 클릭하거나 적에게 피해를 주었을 때 작동한다.

 

그리고 처음 다뤄보는 것이 두 가지 정도 있었다. 먼저 ScrollView다. 일정 범위 내에서 스크롤을 하면 해당 범위 내에서만 데이터가 수평, 혹은 수직 방향으로 움직이며 많은 데이터를 한 곳에서 볼 수 있게끔 해준다. 사용법은 인터넷에 자세히 나와있어서 해당 부분을 참조했다. 게임에서 이전 대화 기록을 볼 때 해당 기능을 사용하면 될 것 같다.

 

Unity UGUI 스크롤뷰(ScrollView) 사용법 간단 정리 : 네이버 블로그 (naver.com)

 

Unity UGUI 스크롤뷰(ScrollView) 사용법 간단 정리

Unity에서 UI 작업시 가장 까다로우면서도 손이 많이 가는 작업으로 스크롤뷰( ScrollView ) 작업을 ...

blog.naver.com

 

두 번째로 다뤄본 것은 테두리이다. Shader를 이용하는데, 아무래도 각기 다른 에셋을 사용해서 그런지 테두리가 일정하고 균일하게 표시되진 않았으나 Shader에 대해서도 간단하게 다뤄본 것 같다. 이것도 마찬가지로 인터넷을 찾다보니 참고할만한 예제가 있어서 약간 변형하여 사용하였다.

 

유니티 - 오브젝트를 선택된 상태로 만들기 : (3) Shader Outline (tistory.com)

 

유니티 - 오브젝트를 선택된 상태로 만들기 : (3) Shader Outline

Unity 전체 링크 오브젝트를 선택된 상태로 만들기 : (1) bool 오브젝트를 선택된 상태로 만들기 : (2) 이미지로 만들기 오브젝트를 선택된 상태로 만들기 : (3) Shader Outline 오브젝트를 선택된 상태로

bloodstrawberry.tistory.com

 

using System.Collections.Generic;
using UnityEngine;

namespace SingletonPattern
{
    public class OutlineManager : Singleton<OutlineManager>
    {
        private Material _outline;

        private GameObject _target;
        private Renderer _renderer;
        private List<Material> _materialList = new();

        public void OnDrawOutline(GameObject target)
        {
            if (target == null) return;
            if (_outline == null) _outline = new Material(Shader.Find("Draw/OutlineShader"));

            DisableOutline();

            _target = target;
            _renderer = target.GetComponent<Renderer>();

            _materialList.Clear();
            _materialList.AddRange(_renderer.sharedMaterials);
            _materialList.Add(_outline);

            _renderer.materials = _materialList.ToArray();

            Debug.Log("Here");
        }

        private void DisableOutline()
        {
            if (_target == null || _renderer == null) return;

            _materialList.Clear();
            _materialList.AddRange(_renderer.sharedMaterials);
            _materialList.Remove(_outline);

            _renderer.materials = _materialList.ToArray();
        }
    }
}

나는 이런식으로 선택한 적이 바뀔 때마다 기존의 테두리를 없애주고 새로운 적에게 테두리를 생성해주었다. OnDrawOutline은 적을 클릭하거나 적을 공격할 때 호출된다.

 

이외에는 전투 시스템을 약간 수정했는데, 스탯을 가져와서 공격력을 계산하는 부분만 약간 바꾸었다.

 

 

지난 4일간에 걸쳐 구현된 부분은 다음과 같다.

UI와 사운드가 추가되니 훨씬 봐줄만한 게임이 된 것 같다. 무기가 나와야 적을 빨리 잡아서 아이템 파밍을 빨리 할텐데, 무기가 좀처럼 나오지 않아서 녹화가 약간 길어졌다. 너무 길어져서 중간에 끊었는데, 생각해보니 무기를 장착하면 데미지가 더 강해지는 것을 담지 못한 것 같다. 다음 번 녹화때는 전부 담을 수 있도록 해야겠다.

 

사실 이렇게 녹화하고보니 모든 내용을 다 정리하진 못한 것 같다. 아이템 등급은 짧게 통계를 내보고 추측하여 아이템이 드랍되도록 하였고, 성급은 표기된 확률대로 구현했다. 아이템 도감이 있어서 이미지나 등급/성급별 수치 변화도 쉽게 찾아볼 수 있었다. 아이템 스탯도 스크롤 뷰를 사용해서 구현했는데 해당 부분도 담지 못한 것 같다. 아직 구현할 것들이 조금 남아있기도 하니 내일 구현을 마무리한 후에 구현한 모든 내용들을 담을 수 있게끔 정리하고 녹화해야겠다. 또, 포트폴리오로 사용할 것이니 더 깔끔하게 잘 정리할 필요가 있을 것 같다. 지금 남은 것은 전투 부분의 UI 개선 정도이다. 데미지 텍스트, 공격 이펙트 정도만 추가하면 계획했던 기능들을 거의 구현한 것 같다. 사실 이 부분도 꼭 필요하지는 않은 것 같은데, 아무래도 전투가 밋밋해보여서 추가하는 게 좋을 것 같다. 게임에서 시각적인 부분도 꽤나 중요하기도 하고 말이다. 아이템을 더 추가해야하나 싶기도 한데, 이 부분은 일단 보류해야겠다. 내일 개발을 마무리하고 나머지 부분들을 정리하여 글을 써야겠다.

 

오늘은 어제 계획했던 적의 AI 구현과 전투에 더해 적들이 소환되는 필드를 구현하였다.

 

먼저 오늘 개발한 것들에 대해 정리하기 전에 구현한 적의 행동 패턴에 대해서 설명하겠다.

적이 종류별로 소환되는 일정한 구역이 있고, 해당 범위 내에 적이 일정 수만큼 스폰된다. (기본값 5) 적은 지역 안에서 랜덤한 시간 주기로(10~30초) 조금씩 움직인다. 적은 플레이어의 공격을 받으면 전투 상태에 돌입하여 플레이어를 따라온다. 플레이어가 지역 밖으로 나가면 추격을 중지하고 원래 위치로 되돌아간다. 이미 전투 상태에 돌입한 적은 시간이 지나도 전투 상태가 유지된다.

 

 

적의 AI 구현에는 이전에 강의에서 배울 때 구현했던 EnemyController와 IdleState, MoveState, AttackState, DeadState, MoveToWaypointState 등을 사용했다. MoveState는 명칭이 명확하지 않은 것 같아 MoveToTarget으로 이름을 변경했다. 그리고 기존에 구현했던 기능은 주변에 적을 발견하면 쫓아오는 것이었기 때문에, 위에 서술한 내용대로 AI를 구성하기 위해서 내부 코드들을 조금씩 수정해주었다.

 

public Transform Target
    {
        get
        {
            if (target.GetComponent<PlayerCharacterController>().IsAlive && _enemyAreaController.IsPlayerInArea && isInBattle)
            {
                return target;
            }
            else return null;
        }
    }

우선 Target을 찾는 방법이 조금 달라졌다. 플레이어가 살아있어야함은 물론이고, 플레이어가 지역 내에 있어야한다. 또, 현재 전투 상태에 돌입한 상태여야 Player를 Target으로 반환하도록 하였다.

public override void Update(float deltaTime)
    {
        // if searched target
        // change to move state

        if (context.Target)
        {
            if (context.IsAvailableAttack)
            {
                // check attack cool time
                // and transition to attack state
                stateMachine.ChangeState<AttackState>();
            }
            else
            {
                stateMachine.ChangeState<MoveToTargetState>();
            }
        }
        else if (_isPatrol)
        {
            if (stateMachine.ElapsedTimeInState > _idleTime)
            {
                context.SetPatrolPosition();
            }
            if (Vector3.Distance(context.transform.position, context.patrolPosition) > _agent.stoppingDistance)
            {
                stateMachine.ChangeState<MoveToWaypointState>();
            }            
        }
    }

그리고 가장 크게 바뀐 것은 IdleState인 것 같다. 타겟을 찾았을 때의 동작은 동일하지만, 타겟을 찾지 못했을 경우, 패트롤을 하는지 확인하고, IdleState에서 일정 시간이 지나면 PatrolPosition을 설정해준다. 그리고 patrolPosition 위치에 있지 않을 경우, MoveToWaypointState로 전환하여 patrolPosition까지 이동한다.

 

몬스터가 움직이는 반경은 중심으로부터 15까지, 적을 감지하는 범위는 20까지로 설정하여 적의 감지범위 밖에서 적을 공격할 수 없도록 하였다.

 

public void TakeDamage(int damage, GameObject hitEffectPrefab, Transform attackFrom)
    {
        isInBattle = true;
        target = attackFrom.transform;

        if (!IsAlive)
        {
            return;
        }

        _currentHP -= damage;

        if (_healthBar)
        {
            _healthBar.Value = _currentHP;
        }

        if (hitEffectPrefab)
        {
            Instantiate(hitEffectPrefab, hitPoint);
        }

        if (IsAlive)
        {
            if (stateMachine.CurrentState is not AttackState)
            {
                animator.SetTrigger(_hitTriggerHash);
            }
        }
        else
        {
            if (_healthBar != null)
            {
                _healthBar.enabled = false;
            }

            stateMachine.ChangeState<DeadState>();
        }
    }

그리고 TakeDamage의 기능도 수정해주었다. attackFrom이라는 매개변수를 추가하여 어떤 상대로부터 공격받았는지를 저장하도록 하였다. 또, AttackState가 아닐 때만 피격 모션을 재생하도록 하였다. 이 부분을 없애면 연속으로 공격을 받을 때 공격을 하지 못하고 계속해서 피격당하는 굶지마의 거미 같은 몬스터를 구현할 수 있을 것 같다.

 

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

public class EnemyAreaController : MonoBehaviour
{
    #region Variables

    private Transform _player;
    public GameObject MonsterPrefab;

    public int numberOfEnemy = 5;

    public float checkPlayerRange = 20;
    public float enemyMoveRange = 15;

    #endregion Variables

    #region Properties

    public float DistanceToPlayer => Vector3.Distance(transform.position, _player.transform.position);

    public bool IsPlayerInArea
    {
        get
        {
            if (checkPlayerRange > DistanceToPlayer) return true;
            else return false;
        }
    }

    #endregion Properties

    #region Unity Methods

    void Start()
    {
        _player = PlayerCharacterController.Instance.transform;

        StartCoroutine(CheckEnemyCoroutine());
    }

#if UNITY_EDITOR
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, checkPlayerRange);
    }
#endif

    #endregion Unity Methods

    private IEnumerator CheckEnemyCoroutine()
    {
        for (int i = 0; i < numberOfEnemy; ++i)
        {
            SpawnEnemy();
        }

        while(true)
        {
            while (transform.childCount < numberOfEnemy)
            {
                yield return new WaitForSeconds(1f);
                SpawnEnemy();
            }
            yield return new WaitForSeconds(5f);
        }
    }

    private void SpawnEnemy()
    {
        float randomX = Random.Range(-10, 10f);
        float randomZ = Random.Range(-10, 10f);
        float randomRotation = Random.Range(0, 360);

        Vector3 spawnPosition = new Vector3(randomX, MonsterPrefab.transform.position.y, randomZ) + transform.position;
        Quaternion spawnRotation = Quaternion.Euler(0, randomRotation, 0);

        GameObject enemy = Instantiate(MonsterPrefab, spawnPosition, spawnRotation, transform);
        enemy.GetComponent<EnemyController>().patrolPosition = spawnPosition;
    }

    public bool CheckEnemyMoveRange(Vector3 position)
    {
        return Vector3.Distance(position, transform.position) < enemyMoveRange;
    }
}

적이 소환되는 구역은 이런식으로 구현하였다. 중심부를 기준으로 거리를 측정하여 원형 범위로 적이 소환되고 움직일 수 있도록 하였다. 위에 언급했듯, 적이 움직일 수 있는 범위보다 지역 내 플레이어 감지 범위를 넓게 하여 플레이어가 감지범위 밖에서 때릴 수 없도록 하였다.

또, 5초마다 범위 내의 몬스터 수를 체크하도록 하여 성능에 부담을 적게 주도록 하였다.

 

이외에도 여기저기서 수정한 부분들이 많긴 한데, 여기저기 흩어져있어서 정리하기가 어려운 것 같다.

 

오늘까지 구현된 부분은 다음과 같다.

 

뭔가 전반적인 전투가 갖춰지긴 했으나.. UI라든가 공격 효과, 효과음 등이 없어서 전투가 상당히 밋밋해보이는 것 같다. 공격 버튼 효과도 아직 구현하지 못했다. 내일 계획은 Inventory와 Item을 구현하는 것인데, 우선 이것들을 먼저 구현해야겠다. UI라든가 효과 등은 부가적인 부분이고, 핵심적인 기능들을 구현하는 것이 우선이라고 생각한다. 스크롤형 인벤토리를 구현하는 것이나, 장비의 소켓, 장비를 클릭하여 장착하거나 판매하는 것 등... 이전 구현 때와는 달라진 부분들이 상당히 많아서 구현이 조금 어려울 수도 있을 것 같다. 적을 처치하면 일정 확률로 캐릭터의 인벤토리에 아이템이 추가되는 기능도 추가해야할 것 같다. UI작업 등 해야할 것들이 상당히 많아서 하루보다 더 걸릴 수 있을 것 같다. 먼저 Inventory와 Item의 기본 구현을 마무리하고, UI라든지, 효과음, 공격 효과, 버튼 효과 등의 작업을 해야할 것 같다.

오늘은 어제 계획했던 대로 캐릭터의 공격과 사망 상태를 구현했다. 그리고 어제 발견했던 버그들도 수정했다.

 

먼저 카메라 줌 관련 수정사항부터 되짚어보려고 한다.

using UnityEngine;

public class TopDownCamera : MonoBehaviour
{
    public Transform target;

    public float height = 15f;
    public float distance = 15f;
    public float lookAtHeight = 0f;
    public float wheelValue = 1;

    private Vector3 _cameraPosition = new();
    private Vector3 _finalTargetPosition = new();

    private void LateUpdate()
    {
        // ToDo: wheelInput의 단계를 나누어 height, distance, angle 값을 정해놓는 것이 좋을 듯. 혹은 비율을 똑같이 맞추든가.
        float wheelInput = Input.GetAxis("Mouse ScrollWheel");

        if (wheelInput > 0)
        {
            wheelValue -= 0.025f;
        }
        else if (wheelInput < 0)
        {
            wheelValue += 0.025f;
        }
        if (wheelValue < 0) wheelValue = 0;
        else if (wheelValue > 1) wheelValue = 1;

        height = 2 + 13 * wheelValue;
        distance = 6 + 9 * wheelValue;
        lookAtHeight = 1 - wheelValue;

        _cameraPosition = target.position;
        _cameraPosition.y += height;
        _cameraPosition.z -= distance;
        transform.position = _cameraPosition;

        _finalTargetPosition = target.position + Vector3.up * lookAtHeight;
        transform.LookAt(_finalTargetPosition);
    }
}

원래는 angle이라는 변수를 통해서 0~45도 각도로 카메라의 각도를 변경했는데, 카메라가 캐릭터의 위치를 바라보도록 스크립트를 수정하였다. 이렇게 하니 가깝게 다가갈수록 각도가 크게 변하는 것을 자연스럽게 구현할 수 있었다. 줌이 최대로 당겨진 상태에서는 캐릭터의 발끝을 봐서 캐릭터가 중앙에 위치하도록 하였고, 캐릭터와 가까워질수록 캐릭터의 중심을 바라보며 캐릭터의 정면 모습이 담길 수 있도록 구현하였다.

 

 

두 번째는 반대로 방향을 전환하는 경우 애니메이션이 멈추는 이슈를 해결하였다.

	_inputX = Input.GetAxis("Horizontal");
        _inputZ = Input.GetAxis("Vertical");

        _previousMoveDirection = _moveDirection;
        _moveDirection = new Vector3(_inputX, 0, _inputZ).normalized;
        if(_joystickInputX != 0 || _joystickInputY != 0)
        {
            _moveDirection = new Vector3(_joystickInputX, 0, _joystickInputY);
        }

        // 자연스러운 애니메이션 연결을 위해 공격 중에도 이동 애니메이션 체크
        if (_moveDirection == Vector3.zero)
        {
            // 반대로 방향을 전환할 때 Idle 모션으로 전환되지 않도록 직전 프레임 상태 확인
            if (_previousMoveDirection == Vector3.zero)
            {
                _animator.SetBool(_moveHash, false);
            }                
        }
        else
        {
            _animator.SetBool(_moveHash, true);
        }

 

 

방향을 반대로 전환하는 경우, input값이 +에서 -로 전환되는 사이에 0을 거쳐가기 때문에 멈춘 것으로 간주되어 Idle 애니메이션이 재생되는 문제가 있었다. 때문에 직전 프레임의 이동 값을 가져와 이동하고 있던 도중이라면 Move 애니메이션을 계속 재생하도록 하였다. 다만 이렇게 하면 멈추는 경우 1프레임 뒤에 Idle로 넘어가긴 하겠지만, 딱히 그 문제가 눈에 띄지는 않았다.

 

그리고 각종 애니메이션들의 연결을 자연스럽게 수정해주었다. 저 파란색 겹치는 구간을 조절하면 애니메이션을 조금 더 자연스럽게 연결할 수 있다.

 

그리고 플레이어의 공격을 구현했다. 공격을 할 떄 사용되는 AttackStateController나 AttackBehaviour는 강의에서 사용했던 구조를 거의 그대로 들고 와서 사용했다. 처음부터 구현해보는 것이 좋지 않을까? 라는 생각이 조금 들기는 했지만, 다른 사람이 작성한 코드를 가져와서 사용하는 일은 종종 있는 일이기 때문에 그러진 않았다. 다만 이럴 때마다 늘 코드를 내가 필요한대로 바꾸어 사용할 수 있을 정도의 이해는 하면서 사용하도록 한다.

 

다음으로 했던 기본 공격을 연속해서 사용하면 콤보로 3번 연속 공격이 나가도록 하는 작업은 굉장히 힘들었다. 하지만 어떻게든 해냈다. 여러 문제들을 해결한 과정을 정리해보겠다.

먼저 공격 버튼을 추가해서 버튼을 눌러 공격 기능을 수행하도록 하였다.

public void OnClickAttackButton(int buttonIndex)
    {
        if (!IsAlive) return;

        CurrentAttackBehaviour = null;

        foreach(AttackBehaviour behaviour in _attackBehaviours)
        {
            if (behaviour.attackIndex == buttonIndex)
            {
                if (behaviour.IsAvailable)
                {
                    if ((CurrentAttackBehaviour == null) || (CurrentAttackBehaviour.priority < behaviour.priority))
                    {
                        CurrentAttackBehaviour = behaviour;
                    }
                }
            }
        }

        Attack();
    }

공격 버튼을 누르면 PlayerCharacter의 OnClickAttackButton이 호출된다. 공격버튼 이외에 스킬 버튼도 추가할 것이기 때문에 buttonIndex를 매개변수로 가져오도록 하였다. 기본 공격 버튼의 buttonIndex는 0이다. 따라서 attackIndex가 0인 공격들을 찾게 된다. 해당 공격이 현재 재사용 대기 중이 아니라면, 우선 순위에 따라 수행할 공격을 가져온다.

같은 attackIndex가 있는 경우는 기본 공격을 수행하는 경우밖에는 없을 것으로 생각된다.

3가지 기본 공격은 첫 번째 공격이 가장 우선순위가 높도록, 2번째 공격이 2번째, 3번째 공격이 3번째 우선순위를 가지게 하여 1번째 기본공격 -> 2번째 기본공격 -> 3번째 기본공격 순으로 공격을 수행하게끔 설계하였다. 각각의 공격을 수행할 때마다 쿨타임이 적용되어 다음 공격으로 자연스럽게 넘어가도록 하였다. 또, 3번째 공격을 수행하거나 마지막 공격이 종료된 뒤 1초 이상이 지나면 전체 기본 공격의 쿨타임을 초기화하여 처음부터 공격을 수행하도록 하였다.

 

private void Attack()
    {
        if (CurrentAttackBehaviour == null) return;

        // 기본 공격 콤보 연결
        if (IsInNornalAttackState && CurrentAttackBehaviour.attackIndex == 0 && CurrentAttackBehaviour.attackAnimationIndex != 0 && !_animator.GetBool(_comboAttackTriggerHash))
        {
            _animator.SetTrigger(_comboAttackTriggerHash);
            StartCoroutine(CurrentAttackBehaviour.StartCooltime());

            // 3연속 기본공격을 성공하면 다시 처음부터 수행할 수 있도록 1, 2번째 기본 공격의 쿨타임을 초기화
            if (CurrentAttackBehaviour.attackAnimationIndex == 2)
            {
                ResetNormalAttack();
            }
            
            return;
        }

        if (!IsInAttackState && CurrentAttackBehaviour.IsAvailable)
        {
            _attackStateController.IsInAttackState = true;

            StartCoroutine(CurrentAttackBehaviour.StartCooltime());
            _animator.SetTrigger(_attackTriggerHash);
            _animator.SetInteger(_attackAnimationIndexHash, CurrentAttackBehaviour.attackAnimationIndex);

            ResetIdleTime();

            // 기본 공격을 일정 시간 이내에 연결하지 않으면 첫 번째 기본공격부터 다시 공격하도록 함
            if (CurrentAttackBehaviour.attackIndex == 0)
            {
                _normalAttackComboTime = 1;
                IsInNornalAttackState = true;

                // 3연속 기본공격을 성공하면 다시 처음부터 수행할 수 있도록 1, 2번째 기본 공격의 쿨타임을 초기화
                if (CurrentAttackBehaviour.attackAnimationIndex == 2)
                {
                    ResetNormalAttack();
                }
            }
        }
    }

OnClickAttackButton의 마지막에는 Attack()을 호출하여 공격을 수행하도록 하였다.

먼저 준비된 공격이 없다면 공격하지 않도록 하였다. 그리고 공격을 수행하는 부분을 앞에 두면 기본 공격 상태로 들어간 이후에 콤보 공격을 체크하기 때문에 기본적으로 2연속 공격을 수행하게 된다.

때문에 기본 공격 콤보를 연결하는 부분을 먼저 체크해주었다. 현재 기본 공격을 수행중이고, 수행할 공격이 기본 공격이며 첫 번째 기본 공격이 아니면서 현재 Trigger가 체크되어있지 않은 경우에 콤보 공격을 수행하도록 하였다.

조건이 상당히 복잡한데, 각 조건의 이유를 하나씩 살펴보겠다. 첫 번째 조건은 기본 공격이 수행중이어야 콤보 공격을 이을 수 있다. 두 번째 조건은 기본 공격 수행 중, 기본 공격에 대한 입력이 들어오면 콤보 공격을 수행하도록 한 것이다. 세 번째 조건은, 3번째 공격을 수행하는 도중에 기본 공격 버튼을 누르면 1번째 공격에 대한 연속 공격을 수행하는데, 3번째 공격이 끝난 이후로는 잠시 멈춰야하기 때문에 이것을 막아준 것이다. 그리고 이미 연속 공격이 입력된 경우에 중복 입력되는 경우가 없도록 트리거를 체크해주었다.

 

연속 기본 공격을 체크한 이후에, 현재 공격 상태이지 않고, 공격이 준비되었다면 해당 공격을 수행하도록 하였다.

원래는 IsInAttackState가 AttackState에 진입할 때 자동으로 true가 되는데, 해당 상태에 전이될 때 까지 약간의 텀이 있어서 그 사이에 버튼을 연속으로 입력하면 의도치 않은 상황이 발생하여 공격을 수행하면서 동시에 IsInAttackState를 설정해주도록 하였다.

 

public IEnumerator StartCooltime()
    {
        calcCoolTime = 0;
        while (calcCoolTime < coolTime)
        {
            yield return new WaitForSeconds(0.1f);
            calcCoolTime += 0.1f;
        }
    }

그리고 쿨타임 계산은 기존 강의에서는 Update를 사용했는데, 스킬마다 쿨타임 중이지도 않은데 Update가 계속 돌아가는 것은 성능상 좋지 않을 것 같아서 쿨타임 중에만 0.1초마다 호출되는 코루틴을 사용하도록 수정했다.

 

쿨타임을 시작한 이후에는 애니메이션을 재생하고, 대기 시간을 초기화하였다. 또, 기본 공격인 경우 연속 공격을 위한 타이머를 설정해주고 현재 기본 공격 상태임을 저장하도록 하였다. 3번째 기본 공격이면 1, 2번째 공격의 쿨타임을 초기화하여 다시 처음부터 기본 공격을 수행할 수 있도록 하였다.

 

기본 공격은, 공격 상태인 AttackState 내부에 추가로 NormalAttackState를 만들어 사용했다. 해당 State에서 빠져나갈 때 IsInNormalAttackState를 false로 바꾸어주기 위함이다. 기본적으로는 종료되면 Exit으로 나가고, Trigger가 켜져있으면 다음 공격 동작을 수행하도록 하였다.

추가로, 애니메이션에서 Transitions의 순서에 따라 우선순위가 바뀌는 것도 주의해야겠다고 생각했다.

 

private void Update()
    {
        if (!IsAlive) return;

        _inputX = Input.GetAxis("Horizontal");
        _inputZ = Input.GetAxis("Vertical");

        _previousMoveDirection = _moveDirection;
        _moveDirection = new Vector3(_inputX, 0, _inputZ).normalized;
        if(_joystickInputX != 0 || _joystickInputY != 0)
        {
            _moveDirection = new Vector3(_joystickInputX, 0, _joystickInputY);
        }

        // 자연스러운 애니메이션 연결을 위해 공격 중에도 이동 애니메이션 체크
        if (_moveDirection == Vector3.zero)
        {
            // 반대로 방향을 전환할 때 Idle 모션으로 전환되지 않도록 직전 프레임 상태 확인
            if (_previousMoveDirection == Vector3.zero)
            {
                _animator.SetBool(_moveHash, false);
            }                
        }
        else
        {
            _animator.SetBool(_moveHash, true);
        }

        if (IsInAttackState) return;

        if (_moveDirection == Vector3.zero)
        {
            _idleElapsedTime += Time.deltaTime;
            CheckIdleElapsedTime();
        }
        else
        {
            MoveCharacter(_moveDirection);
            ResetIdleTime();
        }

        // 특수 대기 모션의 종료 지점이 원점이 아니기 때문에 시작 지점의 rotation과 position을 맞춰줌
        if (_isPlayedSpecialIdle && _idleElapsedTime > 0)
        {
            _controller.enabled = false;
            transform.rotation = Quaternion.Slerp(transform.rotation, _idleStartRotation, Time.deltaTime * 5);
            transform.position = Vector3.Lerp(transform.position, _idleStartPosition, 0.1f);
            _controller.enabled = true;
        }

        if (_normalAttackComboTime > 0)
        {
            _normalAttackComboTime -= Time.deltaTime;

            if(_normalAttackComboTime <= 0)
            {
                ResetNormalAttack();
            }
        }
    }

Update 함수도 수정하였다. 공격 중이더라도 이동 키를 입력 중인지 체크하도록 하였다. 만약 체크하지 않으면 공격이 끝난 이후 Idle 상태를 거쳐 이동하는 문제가 있어 애니메이션을 자연스럽게 연결해주기 위해서 이동키 입력을 체크해주도록 하였다. 그리고 연속 기본 공격 대기시간을 갱신해주어 1초가 넘어가면 다시 처음부터 기본공격을 수행하도록 해주었다.

 

 

이외에도 IDamageable이나 IAttackable과 같은 피격, 공격에 필요한 인터페이스를 추가하고 연결해주는 작업도 하였다. 근접 공격 범위를 설정하고 해당 범위 내의 적을 가져오는 부분도 가져왔다. 다만 아직까지 적이 구현되지 않았기 때문에 아마 이 부분은 내일 적을 구현한 이후에 추가로 작업을 해줄 것 같다. 애니메이션의 중간에 OnExecuteAttack을 수행하도록 이벤트를 넣어주는 것을 기억해야겠다. 또, 적 캐릭터를 추가하기도 하였고, 이전에 사용하던 AudioManager를 가져와서 배경음악도 넣어주었다. 배경음악은 I'm Home이라는 비상업용 무료 BGM을 사용하였다.

 

https://youtu.be/9K0SBnbkkCA

 

 

오늘까지 구현한 부분은 다음과 같다.

구현하면서 애니메이션이나 행동이 부자연스러운이 많이 있었는데, 대부분 수정하여 지금은 꽤나 자연스럽게 작동하는 것 같다. 애니메이션의 변화 등을 담기 위해서 전체 화면이 아닌 에디터 화면을 담았다. 현재는 체력이 깎이는 부분이 없어서 죽음 트리거를 직접 설정하고 해제하였기 떄문에 마지막에 부활하는 모습이 있긴 한데, 이 부분은 내일 구현을 마치면 문제없을 것 같다.

 

 

오늘도 꽤나 많은 것을 배우고 경험한 것 같다. 겪으면서 해결했던 문제들도 하나씩 적어놔서 배운 내용들에 대한 정리도 잘 할 수 있었던 것 같다. 오늘이야말로 쉽게 구현하지 않을까 싶었는데, 어제와 마찬가지로 쉽지 않았다. 아무래도 강의에서 다루었던 내용과 차이점들이 있다보니 그런 것 같다. 원래는 지점 클릭을 통해 이동했는데 키 입력을 받아서 이동하는 방식으로 바뀌었고, 적을 지정하면 자동으로 전투를 진행했으나 버튼을 클릭하여 적을 공격하는 식으로 바뀌었다. 이런 부분들을 새롭게 다뤄보면서 약간씩 어려움을 마주치는 것 같다. 아마 적 AI는 기존 구현과 비슷할테니 그렇게까지 어려운 부분은 없을 것 같은데, 실제로 해보면 또 어떻게 다를지 모르겠다. 아, 그리고 오늘 만든 부분 중에 버튼을 클릭하는 모션도 추가할 필요가 있어보인다. 내일도 부지런히 만들어서 하나씩 차곡차곡 기능들을 쌓아나가야겠다.

 

내일은 기존에 계획했던 대로 적의 AI를 구현할 생각이다. 플레이어와 적이 서로 공격을 주고 받는 기능까지도 구현하면 좋겠다.

오늘 구현한 내용은 목표로 했던 PlayerCharacter의 움직임과 카메라를 구현했다.

 

처음에는 너무 적나? 내일 할 것도 미리 해야지~ 하는 생각을 하고 있었는데, 예상치 못한 문제들을 만나서 생각보다 시간이 굉장히 오래 소요되었다.

 

 

첫 번째 문제는 애니메이션과 Character Controller와 관련된 문제였다.

 

춤을 추는 애니메이션은 Mixamo에 있는 애니메이션을 가져왔는데 애니메이션의 종료 시점에서 Idle 모션으로 돌아올 때, Rotation이 변경되지 않기 때문에 애니메이션 종류에 따라 다르지만 약간씩 캐릭터가 회전하는 이슈가 있었다.

이를 맞춰주기 위해 해당 애니메이션을 시작할 때 캐릭터의 Rotation을 저장하고, 끝나면 원래대로 되돌리는 작업을 추가로 해주었다.

이외에도 비슷한 문제로 position 또한 약간씩 움직이는 이슈가 있었다. 이도 마찬가지로 시작할 때 position을 저장하고 해당 position으로 맞춰주는 작업을 했는데, 캐릭터의 position이 전혀 움직이지 않았다. 여러 방면으로 시도를 했으나 문제는 Character Controller가 직접적인 position 변경을 막은 것이 문제였다. 

_controller.enabled = false;
transform.rotation = Quaternion.Slerp(transform.rotation, _idleStartRotation, Time.deltaTime * 5);
transform.position = Vector3.Lerp(transform.position, _idleStartPosition, 0.1f);
_controller.enabled = true;

때문에 이런식으로 Character Controller의 enabled를 false로 바꿔줬다가 다시 true로 변경해주는 번거로운 작업이 필요했다. LateUpdate에서 실행을 하면 정상적으로 적용되긴 했지만, Update와 LateUpdate 두 번을 호출하면 너무 효율이 떨어질 것 같아서 이 방법을 사용했다.

 

 

두 번째 문제는 휠로 줌 인/줌 아웃을 할 때 카메라의 위치와 관련된 문제였다.

줌을 할 때, 캐릭터에게 가까워질수록 각도가 더 많이 변하는 방식으로 구현을 하고 싶었는데, 이 과정에서 문제가 조금 있었다. 카메라의 높이인 height, 카메라와 캐릭터와의 거리인 distance, 카메라의 각도인 angle 세 가지를 사용했는데, 세 가지 값의 가중치가 다르다보니 값이 크게 변하는 특정 시점에서 카메라 줌 인/줌 아웃을 반복하면 카메라 포지션이 이상해지는 문제가 있었다. 현재는 Linear한 움직임을 적용해놓았는데, 원하는대로 구현하기 위해서는 세 값의 비율을 완전히 동일하게 맞추든가, 구간 별로 정해진 값을 정해서 비율이 일그러지지 않도록 조정이 필요할 것 같다.

 

 

이외에도 크고 작은 문제들을 마주했었던 것 같은데, 가장 곤란했던 문제는 이 두 가지였던 것 같다.

 

이후에는 오늘 구현했던 기능들에 대해 간단히 설명해보겠다.

먼저 플레이어를 따라다니면서 캐릭터를 비추는 카메라를 구현했다. height, distance, angle 값을 가지며 이 값에 따라 카메라의 높이, 플레이어와의 거리, 비추는 각도가 변경된다. 모바일에서는 두 손을 모으거나 펼치는 식으로 줌 인/줌 아웃을 하지만 컴퓨터에서 해당 동작을 수행하기는 어렵기 때문에 휠을 사용하여 줌 인/줌 아웃을 구현하였다.

최대로 확대하면 캐릭터를 정면에서 가까운 모습으로 볼 수 있다.

 

 

그리고 캐릭터의 움직임을 구현했다. Character Controller를 활용해서 캐릭터의 이동을 구현했고, 이동할 때 캐릭터가 움직이는 애니메이션을 재생하도록 하였다. 캐릭터가 가만히 있으면 대기 모션을 취하는데, 10초간 가만히 있으면 특수한 대기 모션인 춤을 춘다. 춤은 총 2가지로 랜덤하게 재생된다.

캐릭터의 이동은 키보드 WASD키, 혹은 화면을 터치한 지점에 생성되는 조이스틱으로 움직일 수 있다.

WASD의 입력을 처리한 이후에 조이스틱 입력이 있을 경우 조이스틱 입력을 기존 값에 덧씌우기 때문에 WASD키보다 조이스틱을 통한 조작이 우선시된다.

 

using UnityEngine;
using UnityEngine.EventSystems;

public class JoystickController : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField] private RectTransform _joystick;
    [SerializeField] private RectTransform _handle;
    [SerializeField] private PlayerCharacterController _characterController;

    private Vector2 _centerPosition = new();
    private Vector2 _normalizedPosition = new();

    public void OnBeginDrag(PointerEventData eventData)
    {
        _centerPosition = eventData.position;
        _joystick.position = _centerPosition;
        _handle.position = _centerPosition;
        _joystick.gameObject.SetActive(true);
    }

    public void OnDrag(PointerEventData eventData)
    {
        _normalizedPosition = (eventData.position - _centerPosition).normalized;
        _characterController.SetJoystickInput(_normalizedPosition.x, _normalizedPosition.y);

        if (Vector2.Distance(eventData.position, _centerPosition) < 75)
        {
            _handle.position = eventData.position;
        }
        else
        {
            _handle.position = _centerPosition + (_normalizedPosition * 75);
        }
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        _characterController.SetJoystickInput(0, 0);
        _joystick.gameObject.SetActive(false);
    }
}

조이스틱을 통한 조작은 다음과 같이 구현하였다. 드래그를 시작할 때 해당 지점에 조이스틱을 생성하고, 중심으로부터 일정 거리 이내에서 핸들이 움직일 수 있도록 구현했다. 범위를 벗어나더라도 조이스틱이 올바른 위치로 이동하도록 Vector의 normalized를 사용해주었다. 또한 이동할 때도 일정한 이동 속도로 움직이기 위해서 normalized를 사용해주었다.

 

using UnityEngine;

public class PlayerCharacterController : MonoBehaviour
{

    private Animator _animator;
    private CharacterController _controller;

    private readonly int _moveHash = Animator.StringToHash("Move");
    private readonly int _specialIdleHash = Animator.StringToHash("SpecialIdle");
    private readonly int _specialIdleNumberHash = Animator.StringToHash("SpecialIdleNumber");

    private Vector3 _moveDirection = new();
    private Vector3 _idleStartPosition = new();
    private Quaternion _lookRotation = new();
    private Quaternion _idleStartRotation = new();

    private float _defaultMoveSpeed = 7f;
    private float _idleElapsedTime = 0;
    private float _inputX = 0;
    private float _inputZ = 0;

    private float _joystickInputX = 0;
    private float _joystickInputY = 0;

    private bool _isPlayedSpecialIdle = false;

    private const float TwistDanceTime = 9.433f;
    private const float SwingDanceTime = 4.4f;

    // Start is called before the first frame update
    private void Start()
    {
        _controller = GetComponent<CharacterController>();
        _animator = GetComponent<Animator>();
    }

    // Update is called once per frame
    private void Update()
    {
        _inputX = Input.GetAxis("Horizontal");
        _inputZ = Input.GetAxis("Vertical");

        _moveDirection = new Vector3(_inputX, 0, _inputZ).normalized;
        if(_joystickInputX != 0 || _joystickInputY != 0)
        {
            _moveDirection = new Vector3(_joystickInputX, 0, _joystickInputY);
        }

        if (_moveDirection == Vector3.zero)
        {
            _animator.SetBool(_moveHash, false);
            _idleElapsedTime += Time.deltaTime;
            CheckIdleElapsedTime();
        }
        else
        {
            MoveCharacter(_moveDirection);
            _isPlayedSpecialIdle = false;
            _animator.SetBool(_moveHash, true);
            _idleElapsedTime = 0;
        }

        // 특수 대기 모션의 종료 지점이 원점이 아니기 때문에 시작 지점의 rotation과 position을 맞춰줌
        if (_isPlayedSpecialIdle && _idleElapsedTime > 0)
        {
            _controller.enabled = false;
            transform.rotation = Quaternion.Slerp(transform.rotation, _idleStartRotation, Time.deltaTime * 5);
            transform.position = Vector3.Lerp(transform.position, _idleStartPosition, 0.1f);
            _controller.enabled = true;
        }
    }

    private void MoveCharacter(Vector3 moveDirection)
    {
        _controller.Move(_defaultMoveSpeed * Time.deltaTime * moveDirection);
        _lookRotation = Quaternion.LookRotation(moveDirection);
        transform.rotation = Quaternion.Slerp(transform.rotation, _lookRotation, Time.deltaTime * 10);
    }

    public void SetJoystickInput(float joystickInputX, float joystickInputY)
    {
        _joystickInputX = joystickInputX;
        _joystickInputY = joystickInputY;
    }

    private void CheckIdleElapsedTime()
    {
        if(_idleElapsedTime > 10)
        {
            _isPlayedSpecialIdle = true;
            _idleStartRotation = transform.rotation;
            _idleStartPosition = transform.position;

            int randomNumber = Random.Range(0, 2);

            _animator.SetTrigger(_specialIdleHash);
            _animator.SetInteger(_specialIdleNumberHash, randomNumber);

            if (randomNumber == 0) _idleElapsedTime = -SwingDanceTime;
            else if (randomNumber == 1) _idleElapsedTime = -TwistDanceTime;
        }
    }
}

플레이어의 움직임은 위와 같이 구현하였다.

조이스틱 입력을 유지하고 있어도 계속 움직이고, WASD를 통한 움직임과 조이스틱을 통한 움직임을 동일하게 하기 위해 Update에서 _moveDirection이라는 하나의 변수를 사용하였다.

 

원래는 플레이어의 캐릭터에도 상태 머신을 적용할 생각이었는데, 찾아보니 상태 머신은 주로 몬스터나 아군 NPC 등 AI를 가진 객체에 사용을 하는 것 같았다. 때문에 PlayerCharacterController 안에서 각종 행동들을 따로 처리해주기로 하였다. 그리고 StateMachine과 State는 기본 틀을 이전에 배웠던 내용과 거의 비슷하게 사용할 것 같다. 다만 사용되지 않은 기능들은 빼고 구현할 것이다.

 

오늘 구현한 부분은 다음과 같다.

테스트를 하면서 버그를 발견했는데, 키보드로 입력을 할 때, W를 입력한 상태에서 S로 방향을 바꾸면 잠시동안 대기 모션이 재생되어 애니메이션이 부자연스럽게 끊기는 현상이 발견되었다. 이 부분도 내일 수정해야겠다.

 

오늘 구현에 대해서 상당히 쉽게 생각했었는데, 막상 직접 해보니 어려운 부분들이 꽤나 많았다. 아마 내일 이후로도 배웠던 내용을 적용하는 과정에서 많은 어려움이 있을 것 같다. 처음엔 '너무 쉽게 완성해버리면 어떡하지?' 하는 걱정이 있었는데, 기우였던 것 같다.

 

내일은 계획했던 대로 플레이어의 공격과 사망 상태를 구현할 것이다. 다만 원래는 StateMachine을 사용할 생각이었지만 사용하지 않게 되어서 구현에 시간이 조금 덜 걸릴 것 같으므로 카메라 줌 관련 기능을 개선하고, 적 캐릭터 구현도 어느정도 해 놓으면 좋을 것 같다. 조금 어렵긴했지만 재미있었고, 배운 것도 많이 있었던 것 같다. 앞으로도 부지런히 개발에 몰두해서 계획한 기능들을 순조롭게 구현할 수 있으면 좋겠다.

+ Recent posts