포스트

섹션13. 미니 RPG - 2

위 글은 인프런에 있는 Rookiss님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part3: 유니티 엔진 강의를 듣고 남긴 필기입니다.

타게팅 락온

  • 마우스 포인터를 누르기 시작한 대상에 대해서 (땅, 혹은 몬스터) 마우스를 놓지않고 계속해서 드래그를 하고 있는 상태라면, 처음의 대상에 따라 다른 행동을 한다.
    • 땅에서 클릭을 시작하고, 마우스를 드래그한다면 ⇒ 계속해서 마우스 포인터를 향해 이동한다.
    • 몬스터에서 클릭을 시작하고 마우스를 드래그한다면 ⇒ 몬스터에게 락온, 마우스의 위치가 변해도, 계속 같은 대상을 공격하고 있는다.
  • 기존에는 마우스에 대한 입력을 받을 때, 두가지로 구분해서 받았음.
    • 마우스를 누를 때 Press
    • 눌렀다가 뗄 때 Click
    • 이제 더욱 세분화 해야 함 (+ PointerDown, PointerUp)
1
2
3
4
5
6
7
public enum MouseEvent
{
    Press,
    PointerDown,
    PointerUp,
    Click,
}
  • 이에 따라, InputManager.cs의 스크립트를 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class InputManager
{
    public Action KeyAction = null;
    public Action<Define.MouseEvent> MouseAction = null;

    private bool _pressed = false;
    // Click과 PointUp을 구분하기 위해, 마우스 버튼을 누르고 있는 시간을 기록하는 float
    private float _pressedTime = 0;
    
    public void OnUpdate()
    {
        if (EventSystem.current.IsPointerOverGameObject())  // UI가 클릭된 상황이라면 동작 X
            return;
        
        if (Input.anyKey && KeyAction != null)        // 어떠한 입력이라도 있고, KeyAction이 비어있지 않다면,
            KeyAction.Invoke();

        if (MouseAction != null)
        {
            if (Input.GetMouseButton(0))                            // 마우스가 눌린다면,
            {
                if (!_pressed)      // 마우스 버튼이 눌림 && 처음 눌림 => PointerDown
                {
                    MouseAction.Invoke(Define.MouseEvent.PointerDown);
                    // 이 때 부터, 시간을 기록.
                    _pressedTime = Time.time;
                }
                    
                MouseAction.Invoke(Define.MouseEvent.Press);    // (1)일단 Press에 해당하는 이벤트를 Invoke
                _pressed = true;                                   // (2)눌렸다가,
            }
            else                    
            {
                if (_pressed)       // 마우스가 눌리다가 안눌릴 때
                {
                    // 0.2초 내에 마우스 버튼을 다시 올리면, 그것을 클릭으로 인식
                    if(Time.time < _pressedTime + 0.2f)     
                        MouseAction.Invoke(Define.MouseEvent.Click);   // 떨어질 때, Click 이벤트를 Invoke
                    
                    // 그냥 PointerUp은 무조건 실행
                    MouseAction.Invoke(Define.MouseEvent.PointerUp);
                }

                _pressed = false;
                _pressedTime = 0;       // 마우스 쿨릭 시간 기록 초기화
            }
        }
    }

    public void Clear()
    {
        KeyAction = null;
        MouseAction = null;
    }
}
  • PlayerController.cs 내의 OnMouseClickedOnMouseEvent로 변경
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 플레이어가 따라가는 몬스터 타겟
private GameObject _lockTarget;

/// <summary>
/// 마우스의 이벤트를 처리하는 함수
/// </summary>
/// <param name="mouseEvent">마우스 이벤트의 타입</param>
void OnMouseEvent(Define.MouseEvent mouseEvent)
{
    if (_state == PlayerState.Die) 
        return;
    
    var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    //Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red,1.0f);

    bool raycastHit = Physics.Raycast(ray, out var hit, 100.0f, _mask);

    switch (mouseEvent)
    {
        case Define.MouseEvent.PointerDown:     // 마우스를 누르는 순간
        {
            // TODO 포인터가 몬스터에 있거나
            // TODO 포인터가 땅에 있거나
            
            if (raycastHit)     // ray가 맞은 곳에 무조건 이동 (몬스터, 땅 구분 X)
            {
                _destPos = hit.point;
                _state = PlayerState.Moving;

                // 몬스터와 땅 구분
                if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
                    _lockTarget = hit.collider.gameObject;
                else
                    _lockTarget = null;
            }
        }
            break;
        case Define.MouseEvent.Press:       // 마우스를 누르고 있는 상태
        {
            if (_lockTarget != null)        // _lockTarget이 있다면 따라가고
                _destPos = _lockTarget.transform.position;
            else
                if (raycastHit)             // 없다면 ray가 맞은 땅의 지점으로 이동
                    _destPos = hit.point;
        }
            break;  
        case Define.MouseEvent.PointerUp:       // 마우스를 누르는 것을 멈추었다면,
            _lockTarget = null;                 // _lockTarget을 비워서 정지
            break;
    }
    
}

공격 #1 & #2

  • 좌클릭을 통해 몬스터를 공격하는 기능을 추가.
  • 짧게 클릭하면 클릭할 때마다 공격을 1회씩 하고, 길게 누르고 있다면 공격을 연속적으로 반복하는 식으로 구현. (디아블로 스타일)

  • PlayerController 클래스 안에서 마우스 커서를 변화시키는 함수와, 이에 관련한 변수들, 그리고 열거형까지는 PlayerController의 다른 부분과 독립적으로 동작하므로, 이를 분리하고 새로운 CursorController를 생성해 이식.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class CursorController : MonoBehaviour
{
    // 마우스 클릭에 반응 할 레이어만 마스킹
    private int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster);

    private Texture2D _attackIcon; 
    private Texture2D _handIcon;

    enum CursorType
    {
        None,
        Attack,
        Hand,
    }

    private CursorType _cursorType = CursorType.None;
    
    void Start()
    {
        _attackIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Attack");
        _handIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Hand");

    }

    void Update()
    {
        // 마우스 커서를 변화시키는 로직
        
        if (Input.GetMouseButton(0))
            return;
        
        var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red,1.0f);
        
        if (Physics.Raycast(ray, out var hit, 100.0f, _mask))
        {
            if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
            {
                if (_cursorType != CursorType.Attack)
                {
                    Cursor.SetCursor(_attackIcon, new Vector2(_attackIcon.width / 5, 0), CursorMode.Auto);
                    _cursorType = CursorType.Attack;
                }
            }
            else
            {
                if (_cursorType != CursorType.Hand)
                {
                    Cursor.SetCursor(_handIcon, new Vector2(_handIcon.width / 3, 0), CursorMode.Auto);
                    _cursorType = CursorType.Hand;
                }
            }
        }
    }
}
  • 짧게 클릭하면 클릭할 때 공격을 해야하므로, PlayerController.OnMouseEvent(Define.MouseEvent mouseEvent) 함수 내부의 mouseEvent에 따른 switch문 내에서 PointerUp에 따른 분기를 삭제함
  • 공격하는 기능은 PlayerController 클래스의 Update() 함수 내에서 state 별로 분기할 때 PlayerState.Skill 에 대한 분기를 만들고, 해당 분기에서 실행할 UpdateSkill() 함수를 만들고, 실행시키면 된다! 일단 간단하게 내부에 Debug.Log() 만 넣고 돌리면 다음과 같이 잘 동작한다.

MMO_Unity-Game-WindowsMacLinux-Unity2022 3 16f1_DX11_2024-02-1522-55-19-ezgif com-cut

  • 이제 때리는 애니메이션을 추가해주자.
  • 저번에 추가한 Knight의 공격 애니메이션을 활용해보자. PlayerAnimController에 이를 추가해주고,

Untitled (12)

이렇게 어떤 상태에서도 공격으로 전환이 가능하도록 만들자. 그리고 Animator Window의 Parameter로 attack이라는 이름의 bool을 만들어, 이것을 ATTACK 애니메이션의 Transition 조건으로 활용하자.

그리고 PlayerController의 UpdateSkill() 함수 안에 Animator 컴포넌트에 접근해 SetBool함수로 이를 true로 바꿔주면, 잘 변할 수 있다.

그리고 다시 WAIT이나 RUN 애니메이션으로 전환하기 위해서

Untitled (14)

ATTACK 애니메이션의 Import Settings에서 이렇게 Animation Event를 만들어 준 후, PlayerController 스크립트 내에서 이를 받는 함수를 만들고, 여기서 다시 Animator 컴포넌트의 SetBool 함수를 활용해 다른 애니메이션으로 전환시킬 수 있겠다. 이 때, _state도 다시 idle로 바꾸는 걸 잊지 말자.

  • 그리고 state의 프로퍼티인 State를 만드는데, 이는 state를 바꾸는 코드와 Animator 컴포넌트의 Set 계열 함수들을 부르는 코드가 별도로 되어있는걸 하나로 합쳐 실수를 줄이기 위함이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 이렇게 하면 이 State의 setter가 call 될 때 마다, state에 따라 Animator의 Set 계열 함수를 Call하니, 일체화는 성공적으로 만들었다고 볼 수 있겠다.
// 이걸 추가하고나선, 당연히 기존의 _state가 사용되는 모든 곳에 State로 대체해줘야 한다.
public PlayerState State
{
    get => _state;
    set
    {
        _state = value;

        Animator anim = GetComponent<Animator>();
        switch (_state)
        {
            case PlayerState.Die:
                anim.SetBool("attack", false);
                break;
            case PlayerState.Idle:
                anim.SetFloat("speed", 0);
                anim.SetBool("attack", false);
                break;
            case PlayerState.Moving:
                anim.SetFloat("speed", _stat.MoveSpeed);
                anim.SetBool("attack", false);
                break;
            case PlayerState.Skill:
                anim.SetBool("attack", true);
                break;
        }
    }
}
  • 마우스 포인터를 꾸욱 누르고 있으면 연속적으로 때리는 기능은
    • OnHitEvent() 함수 안에서, 현재 마우스 포인터가 눌려져 있는지 여부를 가지고 공격 애니메이션을 또 발생시키거나, 그렇지 않게끔 만들면 되겠다.
    • 먼저, OnMouseEvent() 내부의 구조를 수정하자.
      • 기존의 OnMouseEvent() 함수 내부는 State가 Die가 아니라면 Raycast를 활용하는 로직을 거치지만, 이는 State가 Skill일 때도 함수가 호출될 수 있어서 동일하게 거칠 수가 있고, 그렇게 된다면 문제가 될 수 있다.
      • 그래서 Raycast 로직은 State가 Idle 혹은 Moving일 때만 동작하도록 수정하고, Skill 일 때는 별도로 동작하게 만들기 위해 구조를 수정하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// Raycast 로직은 Idle 상태와 Moving 상태에서만 동작하므로, 이를 담당하는 함수(OnMouseEvent_IdleMoving)로 따로 빼주고, 기존의 OnMouseEvent 함수는 분기점을 담당하는 함수로 역할을 변환.

/// <summary>
/// 마우스의 이벤트를 처리하는 함수
/// </summary>
/// <param name="mouseEvent">마우스 이벤트의 타입</param>
void OnMouseEvent(Define.MouseEvent mouseEvent)
{
    switch (State)
    {
        case PlayerState.Idle:
            OnMouseEvent_IdleMoving(mouseEvent);
            break;
        case PlayerState.Moving:
            OnMouseEvent_IdleMoving(mouseEvent);
            break;
    }
}

void OnMouseEvent_IdleMoving(Define.MouseEvent mouseEvent)
{
    var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    bool raycastHit = Physics.Raycast(ray, out var hit, 100.0f, _mask);

    switch (mouseEvent)
    {
        case Define.MouseEvent.PointerDown:     // 마우스를 누르는 순간
        {
            // 포인터가 몬스터에 있거나
            // 포인터가 땅에 있거나
            
            if (raycastHit)     // ray가 맞은 곳에 무조건 이동 (몬스터, 땅 구분 X)
            {
                _destPos = hit.point;
                State = PlayerState.Moving;        // 상태 변환

                // 몬스터와 땅 구분
                if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
                    _lockTarget = hit.collider.gameObject;
                else
                    _lockTarget = null;
            }
        }
            break;
        case Define.MouseEvent.Press:       // 마우스를 누르고 있는 상태
        {
            if (_lockTarget == null && raycastHit)        // _lockTarget이 없고 ray가 맞은 땅의 지점으로 이동
                _destPos = hit.point;
        }
            break;  
    }
}
  • 그리고 OnHitEvent() 함수를 수정해보자
    • 스킬을 쓰는 도중에 마우스를 뗀다면, 이는 어떻게 알 수 있을까?
    • 방금 수정하면서 역할이 변화한 OnMouseEvent 함수안에서 처리할 수 있을 것이다
      • OnMouseEvent 함수 안의 switch문에 PlayerState.Skill일 때의 분기를 추가하고, 마우스 버튼에서 손을 뗌을 저장하는 bool 변수 _stopSkill을만들어주자. 기본값은 false로 잡고, PlayerState.Skill일 때의 분기문에서 이를 true로 바꿔주자.
      • 그리고 OnHitEvent 함수 내에서 _stopSkill에 따른 조건문으로 true라면 State를 Idle로, false라면 State를 Skill로 변화시키는 코드를 짜고 테스트.

        ⇒ 그러면 이 두 구문은, 몬스터와 싸우는 Skill State일 때 동작할 것이다. 싸우는 중에 (State가 Skill일 때), 마우스를 뗀다면 이 다음 공격은 하지 않는다. (State는 Idle로 전환)

  • 몬스터를 짧게 클릭하면, 1회 공격 후 애니메이션이 ATTACK에서 멈추는 버그가 생긴다.
    • 정확히는, 위에서 구분한 0.2초 내의 짧은 클릭이 아니더라도, 몬스터를 클릭하여 공격을 지시하고 마우스를 떼면 (이때 마우스는 몬스터에게 다가가 공격을 시작하기 전에 뗀다) 위와 같은 버그가 일어난다.
    • 이는, 처음 _stopSkill을 true로 설정하지 않아서 생긴 문제이다. 처음에 false로 초기화한 후, 한번도 true로 지정하지 않아서 OnHitEvent 에서도 State = PlayerState.**Skill**; 을 실행하면서, Skill 상태에서 벗어나지 못한 것이다.

      그래서 OnMouseEvent_IdleMoving 함수 안에서 MouseEventPointerUp일때에 따른 분기를 추가하고, 이 때 _stopSkill = true; 를 해주자.

      • 그리고 마우스를 누를 때 이러한 패턴을 시작시키기 위해 OnMouseEvent_IdleMoving 함수 내에서, mouseEvent가 PointerDown 이고 이동하는 부분에 _stopSkill = false; 를 추가해주자.
    • 연속공격은 ATTACK 애니메이션의 Import Settings에서 LoopTime을 True로 수정해 줌으로써 해결.
  • 그리고 이제는 애니메이션 관리를 유니티 툴로부터 분리시켜보자.
    • 이게 코드로 관리하는데에는 좀 더 합리적인 방법.
    • 애니메이션의 수가 많아질 때에도 이게 더 관리하기 쉬울 것이다.
    • 하는 방법은
      • Animator Window내의 모든 Parameter를 지우고,
      • 모든 Animation 간의 Trasition을 지우고
      • PlayerController.csState 프로퍼티의 setter가 호출될 때마다, animatorCrossfade를 이용해 애니메이션을 재생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public PlayerState State
{
    get => _state;
    set
    {
        _state = value;

        Animator anim = GetComponent<Animator>();
        switch (_state)
        {
            case PlayerState.Die:
                break;
            case PlayerState.Idle:
                anim.CrossFade("WAIT", 0.1f);
                break;
            case PlayerState.Moving:
                anim.CrossFade("RUN", 0.1f);
                break;
            case PlayerState.Skill:
                anim.CrossFade("ATTACK", 0.1f, -1, 0);    // 이렇게 루프를 구현.
                break;
        }
    }
}
  • 공격할 때에는 몬스터를 똑바로 바라보지 않는 문제가 있다. 이를 고쳐보자
1
2
3
4
5
6
7
8
9
10
11
12
13
// Playercontroller.cs 내부
// State가 Skill일 때 Update함수에서 UpdateSkill을 실행시킨다.
void UpdateSkill()
{
    if (_lockTarget != null)
    {
				// 보간을 활용해서 대상을 보도록.

        Vector3 dir = _lockTarget.transform.position - transform.position;
        Quaternion qua = Quaternion.LookRotation(dir);
        transform.rotation = Quaternion.Lerp(transform.rotation, qua, 20 * Time.deltaTime);
    }
}

체력 게이지 #1 & #2

  • 공격을 하면 실제 체력을 닳게 하고, 체력 게이지 시각화
  • 이는 2D UI가 아닌, World Space 상의 3D UI로 만들자.
    • 이는 Canvas 컴포넌트의 Render Mode를 World Space로 바꿔서 설정 가능. Event Camera도 당연히 Main Camera가 들어가게 해야겠지?
    • Scale은 0.01로 맞춰주니까 적당한 크기가 된 것 같다. 위치도 적절히 조정해주고, Slider 자식 중 Fill 이라는 오브젝트의 이미지 컬러를 붉게 수정해주니,

      Untitled (15)

      이렇게 나름 그럴듯한 체력바의 모습이 된 것을 알 수 있다.

      • 그런데 지금 저 사진의 체력바는 Slider의 Value를 1로, 그러니까 최대로 키웠는데 약간의 여백이 남아있는 것을 알 수 있다. 이는 Handle이 필요없어서 지워서 그런것 같은데, 이를 고쳐보자

        Untitled (16)

        • FillArea의 RectTransform 중 Left와 Right가 0이 아닌, 5와 15로 설정되어있는데, 바로 이게 원인이었다.
        • 그래서 이를 모두 0으로 설정해주면, Background보다 살짝 삐져나오게 되는데, 이 때 그 하위의 Fill은 RectTransform > Left와 Right이 -5로 설정되어있어서 그렇다. 이를 0 으로 설정해주어 정확히 맞춰주자.
    • 이렇게 만든 HPBar는 Resources > Prefabs > UI > WorldSpace 경로에 프리팹으로 저장하자.
    • 그리고 여기에 맞는 스크립트를 맞춰주기 위해, Scripts > UI > WorldSpace 경로에 동일한 이름(UI_HPBar)으로 스크립트를 하나 만들어주자.
    • 그러면 UI_HPBar.cs 를 작성해주고, 새로운 형태의 UI도 관리해줘야 하니 UI_Manager를 수정해줘야겠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// UIManager.cs 스크립트 내부

/// <summary>
/// WorldSpace UI 생성용 함수
/// </summary>
/// <param name="parent">새로 생성된 서브 아이템을 추가할 부모 Transform. null일 경우 부모 없이 생성됨.</param>
/// <param name="name">인스턴스화할 Prefab의 이름. null이나 빈 문자열일 경우 T의 클래스 이름을 사용.</param>
/// <typeparam name="T">서브 아이템의 타입으로, UI_Base을 확장해야 함.</typeparam>
/// <returns>생성된 서브 아이템의 T 타입 컴포넌트.</returns>
public T MakeWorldSpaceUI<T>(Transform parent = null, string name = null) where T : UI_Base
{
    if (string.IsNullOrEmpty(name))
        name = typeof(T).Name;

    GameObject go = Managers.Resource.Instantiate($"UI/WorldSpace/{name}");
    if(parent != null)
        go.transform.SetParent(parent);
    
    // WorldSpace 설정
    Canvas canvas = go.GetComponent<Canvas>();
    canvas.renderMode = RenderMode.WorldSpace;
    canvas.worldCamera = Camera.main;
    
    return Util.GetOrAddComponent<T>(go);
}
  • 그리고 간단하게 이를 불러주는 코드를 PlayerController.cs 내부에 작성해, 테스트해보자

MMO_Unity-Game-WindowsMacLinux-Unity2022 3 16f1__DX11_2024-02-1814-14-55-ezgif com-optimize

  • 잘 들어왔지만, UnityChan의 Transform을 Parent로 받아서 Rotation의 영향을 받아 체력바가 마구 뒤집힌다.
  • 이를 고쳐보자, 그리고 실제 체력도 반영하게끔 수정해보자.

    빌보드(Billboard)라는 개념이 있다. 이는 객체가 항상 카메라를 향하도록 하는 기술인데, 주로 텍스처와 같은 물체가 관찰자의 시점에서 항상동일하게 보이도록 하는 것을 말한다. 일반적으로 많이 접했던 체력바는 바로 이렇게 빌보드라는 개념을 활용해 구현되었던 것이다. 이 체력바도 바로 빌보드로 바꿔보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class UI_HPBar : UI_Base
{
    enum GameObjects
    {
        HPBar,
    }

    private Transform parent;
    private Collider _parentCollider;
    private Stat _stat;
    private Slider _slider;

    public override void Init()
    {
        Bind<GameObject>(typeof(GameObjects));

        parent = transform.parent;
        _parentCollider = parent.GetComponent<Collider>();
        _stat = parent.GetComponent<Stat>();
        _slider = GetObject((int)GameObjects.HPBar).gameObject.GetComponent<Slider>();
    }

    private void Update()
    {
        // 체력바의 위치와 회전을 코드로 조정
        // 플레이어 캐릭터의 충돌체 높이보다 살짝 더 위로 띄워서
        transform.position = parent.position + Vector3.up * (_parentCollider.bounds.size.y + 0.2f);

        // 체력바의 회전값을 카메라와 동일하게
        transform.rotation = Camera.main.transform.rotation;
        // transform.lookAt(Camera.main.transform) 는 체력바의 좌우가 뒤집힌다.

        // 실제 체력과 slider를 동기화
        float ratio = _stat.MaxHp == 0 ? 0 : (_stat.Hp / (float)_stat.MaxHp);        // 소수부 소실방지를 위한 타입 캐스팅
        SetHpRatio(ratio);
    }

    void SetHpRatio(float ratio)
    {
        _slider.value = ratio;
    }
}
  • 몬스터도 체력바가 보여야하니, 이를 붙여주자. 몬스터에게는 컨트롤러가 없으니, 툴로 강제로 붙여주고, 이럴때는 스크립트도 직접 붙여주자.
  • 이제는 실제로 공격할 때 체력이 깎이도록 하자. 그러면 시각화하는 부분은 만들어놓았으니, 체력이 변한다면 변한 체력을 알아서 잘 보여줄 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// PlayerController.cs 내부

/// <summary>
/// Skill state일때의 Animation Event Callback 함수
/// </summary>
private void OnHitEvent()
{
    Debug.Log("On Hit Event!");
    
    // TODO 적의 체력을 깎는다.
    if (_lockTarget != null)
    {
        Stat targetStat = _lockTarget.GetComponent<Stat>();
        int damage = _stat.Attack - targetStat.Defense;
        targetStat.Hp -= (damage) <= 0 ? 1 : damage;    // 데미지가 최소 1은 들어가게끔
        
        DebugEx.Log($"Damage : {damage}");
    }

    // TODO 마우스를 놓으면, 공격을 멈추고 움직인다.
    // TODO 마우스를 계속 누르고 있다면 연속 공격

    if (_stopSkill)     // 공격을 멈추거나
    {
        State = PlayerState.Idle;
    }
    else                // 연속공격을 하거나
    {
        State = PlayerState.Skill;
    }
}

몬스터 AI #1 & #2

  • 몬스터가 일정 거리 안의 플레이어를 인식하고, 따라와서 공격하는 AI를 구현해보자
  • 생각해보면, 몬스터컨트롤러 스크립트도 플레이어와 많은 부분을 공통으로 가질 것이다. (State를 기반으로 한 캐릭터 관리)
  • 그런 점을 고려했을 때, 공통적인 부분을 BaseController라는 클래스로 빼서 구현하고, 몬스터와 플레이어가 각각 상속받아 구현할 수 있도록 하는건 어떨까?

BaseController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// BaseController를 상속받는 클래스는 Init 메서드를 무조건 구현하도록 추상 클래스와 추상 메서드로 만들어준다.
public abstract class BaseController : MonoBehaviour
{
    [SerializeField] protected Vector3 _destPos;
    
    [SerializeField] protected Define.State _state = Define.State.Idle;

    [SerializeField] protected GameObject _lockTarget;

    // 상속받는 클래스에서 이를 재정의할 수 있도록 virtual
    public virtual Define.State State
    {
        get => _state;
        set
        {
            _state = value;

            Animator anim = GetComponent<Animator>();
            switch (_state)
            {
                case Define.State.Die:
                    break;
                case Define.State.Idle:
                    anim.CrossFade("WAIT", 0.1f);
                    break;
                case Define.State.Moving:
                    anim.CrossFade("RUN", 0.1f);
                    break;
                case Define.State.Skill:
                    anim.CrossFade("ATTACK", 0.1f, -1, 0);
                    break;
            }
        }
    }

    private void Start()
    {
        Init();
    }

    private void Update()
    {
        switch (State)
        {
            case Define.State.Die:
                UpdateDie();
                break;
            case Define.State.Moving:
                UpdateMoving();
                break;
            case Define.State.Idle:
                UpdateIdle();
                break;
            case Define.State.Skill:
                UpdateSkill();
                break;
        }
    }

    // 상속받는 클래스에서 구현해야하도록 추상 메서드
    public abstract void Init();

    // 상속받는 클래스에서 재정의할 수 있도록 가상 메서드
    protected virtual void UpdateDie() { }
    protected virtual void UpdateMoving() { }
    protected virtual void UpdateIdle() { }
    protected virtual void UpdateSkill() { }
}
  • 수정한 PlayerController.cs, 여기에는 PlayerController에 종속적인 부분만 남아있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
public class PlayerController : BaseController
{
    // 마우스 클릭에 반응 할 레이어만 마스킹
    private int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster);
    
    [SerializeField] private PlayerStat _stat;
    private bool _stopSkill = false;

    private NavMeshAgent navMeshAgent;

    public override void Init()
    {
        // 기존의 _speed 변수 대신, PlayerStat을 이용한다.
        _stat = GetComponent<PlayerStat>();
        
        // InputManager에서 Event 함수를 실행하도록 맡긴다.
        Managers.Input.MouseAction -= OnMouseEvent;
        Managers.Input.MouseAction += OnMouseEvent;
        if(gameObject.GetComponentInChildren<UI_HPBar>() == null)
            Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform);
        
        navMeshAgent = gameObject.GetOrAddComponent<NavMeshAgent>();
    }

    protected override void UpdateMoving()
    {
        // 몬스터가 내 사정거리 내에 존재한다면, 공격
        if (_lockTarget != null)
        {
            float distance = (_destPos - transform.position).magnitude;

            if (distance <= 2.0f)      // 몬스터와의 거리가 내 사정거리 내에 들어온다면
            {
                State = Define.State.Skill;     // 상태를 공격상태로 변화시킨다
                return;                         // 나머지 로직은 실행 X
            }
        }
        
        // 내 사정거리에 몬스터가 없다면, 단순 이동
        Vector3 dir = _destPos - transform.position;
        if (dir.magnitude < 0.1f)   // 거리는 float, 이렇게 매우 적은 오차에 든다면 도착한것으로 간주.
        {
            State = Define.State.Idle;
        }   
        else    // 그렇지 않다면, 아직 도착하지 않은것이니 이동하도록.
        {
            // NavMesh를 사용하지 않고, 다른 방법으로 이동
            // NavMeshAgent navMeshAgent = gameObject.GetOrAddComponent<NavMeshAgent>();
            //
            // float moveDist = Mathf.Clamp(_stat.MoveSpeed * Time.deltaTime, 0, dir.magnitude);
            // navMeshAgent.Move(moveDist * dir.normalized);
            
            // Raycast로 바로 앞이 장애물로 막힌 곳인지 아닌지 검사
            Debug.DrawRay(transform.position + Vector3.up * 0.5f, dir.normalized, Color.green);
            if(Physics.Raycast(transform.position, dir, 1, LayerMask.GetMask("Block"))) 
            {
                if(Input.GetMouseButton(0) == false)        // 캐릭터 전방에 장애물이 있고 && 마우스 버튼이 눌리지 않으면
                    State = Define.State.Idle;
                return;
            }
            
            float moveDist = Mathf.Clamp(_stat.MoveSpeed * Time.deltaTime, 0, dir.magnitude);
            transform.position += dir.normalized * moveDist;
            transform.rotation =
                Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime);    // 도착지를 바라보도록.
        }
    }

    protected override void UpdateSkill()
    {
        if (_lockTarget != null)
        {
            // 공격을 할 때는 대상을 정면으로 바라본 채로 공격할 것
            Vector3 dir = _lockTarget.transform.position - transform.position;
            Quaternion qua = Quaternion.LookRotation(dir);
            transform.rotation = Quaternion.Lerp(transform.rotation, qua, 20 * Time.deltaTime);
        }
    }

    /// <summary>
    /// Skill state일때의 Animation Event Callback 함수
    /// </summary>
    private void OnHitEvent()
    {
        Debug.Log("On Hit Event!");
        
        // TODO 적의 체력을 깎는다.
        if (_lockTarget != null)
        {
            Stat targetStat = _lockTarget.GetComponent<Stat>();
            int damage = _stat.Attack - targetStat.Defense;
            targetStat.Hp -= (damage) <= 0 ? 1 : damage;    // 데미지가 최소 1은 들어가게끔
        }

        // TODO 마우스를 놓으면, 공격을 멈추고 움직인다.
        // TODO 마우스를 계속 누르고 있다면 연속 공격

        if (_stopSkill)     // 공격을 멈추거나
        {
            State = Define.State.Idle;
        }
        else                // 연속공격을 하거나
        {
            State = Define.State.Skill;
        }
    }
    
    /// <summary>
    /// 마우스의 이벤트를 처리하는 함수
    /// </summary>
    /// <param name="mouseEvent">마우스 이벤트의 타입</param>
    void OnMouseEvent(Define.MouseEvent mouseEvent)
    {
        switch (State)
        {
            case Define.State.Idle:
                OnMouseEvent_IdleMoving(mouseEvent);
                break;
            case Define.State.Moving:
                OnMouseEvent_IdleMoving(mouseEvent);
                break;
            case Define.State.Skill:
            {
                if (mouseEvent == Define.MouseEvent.PointerUp)
                    _stopSkill = true;
            }
                break;
        }
    }

    void OnMouseEvent_IdleMoving(Define.MouseEvent mouseEvent)
    {
        var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        bool raycastHit = Physics.Raycast(ray, out var hit, 100.0f, _mask);

        switch (mouseEvent)
        {
            case Define.MouseEvent.PointerDown:     // 마우스를 누르는 순간
            {
                // 포인터가 몬스터에 있거나 땅에 있거나
                
                if (raycastHit)     // ray가 맞은 곳에 무조건 이동 (몬스터, 땅 구분 X)
                {
                    _destPos = hit.point;
                    State = Define.State.Moving;        // 상태 변환
                    _stopSkill = false;

                    if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
                        _lockTarget = hit.collider.gameObject;      // 몬스터라면 대상 고정
                    else
                        _lockTarget = null;                         // 땅이라면 고정 X
                }
            }
                break;
            case Define.MouseEvent.Press:       // 마우스를 누르고 있는 상태
            {
                if (_lockTarget == null && raycastHit)        // _lockTarget이 없고 ray가 땅에 맞았다면
                    _destPos = hit.point;                     // 이동
            }
                break;
            case Define.MouseEvent.PointerUp:
                _stopSkill = true;
                break;
        }
    }
}

MonsterController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
public class MonsterController : BaseController
{
    private Stat _stat;

    [SerializeField]
    private float _scanRange = 10;

    [SerializeField]
    private float _attackRange = 2;

    public override void Init()
    {
        _stat = GetComponent<Stat>();
        
        if(gameObject.GetComponentInChildren<UI_HPBar>() == null)
            Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform);
    }

    protected override void UpdateIdle()
    {
        Debug.Log("Monster UpdateIdle");
        
        // TODO : 매니저가 생기면 옮기자
        // 위치와 거리를 기반으로 플레이어가 일정 거리 안에 들어왔는지 여부를 판단
        
        GameObject player = GameObject.FindGameObjectWithTag("Player");
        if (player == null)
            return;

        float distance = (player.transform.position - transform.position).magnitude;
        if (distance <= _scanRange)     // 플레이어가 일정 거리안에 들어옴
        {
            _lockTarget = player;           // 플레이어를 목표로 움직이도록 한다. 추적.
            State = Define.State.Moving;
            return;
        }
    }
    
    protected override void UpdateMoving()
    {
        // 플레이어가 내 사정거리안에 들어오면 공격
        if (_lockTarget != null)
        {
            _destPos = _lockTarget.transform.position;
            float distance = (_destPos - transform.position).magnitude;

            if (distance <= _attackRange)      // 플레이어와의 거리가 내 사정거리 내에 들어온다면
            {
                // 플레이어를 안 밀도록 NavMeshAgent의 목적지를 초기화
                NavMeshAgent navMeshAgent = gameObject.GetOrAddComponent<NavMeshAgent>();
                navMeshAgent.SetDestination(transform.position);
                
                State = Define.State.Skill;     // 상태를 공격상태로 변화시킨다
                return;                         // 나머지 로직은 실행 X
            }
        }
        
        // 플레이어가 공격 사거리안에는 없을 때
        Vector3 dir = _destPos - transform.position;
        if (dir.magnitude < 0.1f)   // 거리는 float, 이렇게 매우 적은 오차에 든다면 도착한것으로 간주.
        {
            State = Define.State.Idle;
        }   
        else    // 그렇지 않다면, 아직 도착하지 않은것이니 이동하도록.
        {
            NavMeshAgent navMeshAgent = gameObject.GetOrAddComponent<NavMeshAgent>();

            // NavMeshAgent를 이용해서 알아서 목적지까지 가도록.
            navMeshAgent.SetDestination(_destPos);      // 이는 플레이어를 자꾸 밀친다.

            navMeshAgent.speed = _stat.MoveSpeed;
            
            transform.rotation =
                Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime);    // 도착지를 바라보도록.
        }
    }

    protected override void UpdateSkill()
    {
        if (_lockTarget != null)
        {
            // 공격을 할 때는 대상을 정면으로 바라본 채로 공격할 것
            Vector3 dir = _lockTarget.transform.position - transform.position;
            Quaternion qua = Quaternion.LookRotation(dir);
            transform.rotation = Quaternion.Lerp(transform.rotation, qua, 20 * Time.deltaTime);
        }
    }

    void OnHitEvent()
    {
        if (_lockTarget != null)
        {
            Stat targetStat = _lockTarget.GetComponent<Stat>();
            int damage = _stat.Attack - targetStat.Defense;
            targetStat.Hp -= (damage) <= 0 ? 1 : damage;    // 데미지가 최소 1은 들어가게끔
            
            // 데미지를 주고, 
            if (targetStat.Hp > 0)      // 플레이어가 살아있으면
            {
                // 여전히 내 사정거리에 있으면 한대 더 때리고,
                // 그렇지 않으면 따라가야지

                float distance = (_lockTarget.transform.position - transform.position).magnitude;
                if (distance < _attackRange)
                    State = Define.State.Skill;
                else
                    State = Define.State.Moving;
            }
            else                        // 플레이어가 죽었으면 몬스터는 Idle상태로 돌아간다
            {
                _state = Define.State.Idle;
            }
        }
        else
        {
            State = Define.State.Idle;
        }
    }
}

Destroy #1 & #2

  • 멀티 플레이나, MMORPG와 같은 게임을 만든다고 한다면, 몬스터는 Scene에 프리팹을 끌어다 배치하지 않고 나름의 체계가 필요할 것.
    • 플레이어가 만약에 몬스터를 공격한다고하면
      • 플레이어가 특정한 몬스터를 공격한다는 정보를 서버에 전달하고
      • 서버는 수신한 정보에 맞게 적절한 연산을 처리해 줄 것이고,
      • 인접한 플레이어들에게도 이를 정상적으로 보여줄 수 있어야할 것이다.
      • ⇒ 그러한 맥락에서 아이디(ID)를 활용해 관리하는 것을 고려해보자.
  • MonsterController.cs에서, 플레이어를 공격하고나서 플레이어의 체력이 0 이하가 될 때 플레이어 GameObjectDestroy하는 코드를 작성했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void OnHitEvent()
{
    if (_lockTarget != null)
    {
        Stat targetStat = _lockTarget.GetComponent<Stat>();
        int damage = _stat.Attack - targetStat.Defense;
        targetStat.Hp -= (damage) <= 0 ? 1 : damage;    // 데미지가 최소 1은 들어가게끔
        
        // 상대방의 체력이 0이하가 된다면 사라진다.
        if(targetStat.Hp <= 0)
            Destroy(targetStat.gameObject);
            
        // 데미지를 주고, 
        if (targetStat.Hp > 0)      // 플레이어가 살아있으면
        {
            // 여전히 내 사정거리에 있으면 한대 더 때리고,
            // 그렇지 않으면 따라가야지

            float distance = (_lockTarget.transform.position - transform.position).magnitude;
            if (distance < _attackRange)
                State = Define.State.Skill;
            else
                State = Define.State.Moving;
        }
        else                        // 플레이어가 죽었으면 몬스터는 Idle상태로 돌아간다
        {
            _state = Define.State.Idle;
        }
    }
    else
    {
        State = Define.State.Idle;
    }
}
  • 이렇게 하고 테스트를 해보니 버그가 발생했다!

    Untitled (17)

    MissingReferenceException이 발생했다.

    • 왜 발생했을까?
    • 로그를 읽어보면,

    • GameObject 타입의 오브젝트는 파괴되었는데, 여전히 접근하려고 해서 문제가 되는 모양이다.

    • 그리고는 스크립트가 이를 확인해줘야 한다고 경고하는데, 이를 좀 더 자세히 살펴보기 위해 디버그를 돌려보자

      Untitled (18)

      예외가 발생하는 곳에 이렇게 작성하고 BreakPoint를 걸어서 디버그 모드로 진입해보면

      Untitled (19)

      이렇게 _player객체가 null이라고 한다. 그런데 뭔가 이상하다.

      Untitled (20)

      컴퓨터학과에서 여태까지 배운 null은 바로 이렇게 생긴 null인데, 위에서 보여준 null은 마치 string처럼 큰따옴표로 표시되어있는것이 굉장히 묘하다. 저건 뭘까?

      그리고 생각해보면, 만약에 C++였다면 _player는 Dangling Reference가 되었을 것이다. 위 CameraController에서는 이를 레퍼런스로 참조하고 있는 상태에서 레퍼런스의 PlayerController 인스턴스가 사라졌으니.

      그러나 어째선가 ‘파괴’되었다는 GameObject는 사라진 것이 아니고, 그렇다고 그에 대한 참조를 하려고 하면 오류가 왜 생기는 것일까?

      Untitled (21)

      GameObject 클래스의 정의를 살펴보면 Object라는 클래스를 상속받음을 알 수 있다.

      Object 클래스는 Unity 엔진의 모든 객체가 상속받는 기본 클래스다.

      그리고 Object클래스에서 다음을 찾을 수 있는데,

      Untitled (22)

      == 연산자가 오버로딩되어있다. 그리고 이는 내부적으로 CompareBaseObjects라는 함수를 이용해 동작함을 알 수 있는데

      해당 함수는 다음과 같이 정의되어있다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      
        private static bool CompareBaseObjects(Object lhs, Object rhs)
        {
            bool flag = (object)lhs == null;
            bool flag2 = (object)rhs == null;
            if (flag2 && flag)
            {
                return true;
            }
              
            if (flag2)
            {
                return !IsNativeObjectAlive(lhs);
            }
              
            if (flag)
            {
                return !IsNativeObjectAlive(rhs);
            }
              
            return lhs.m_InstanceID == rhs.m_InstanceID;
        }
      

      비교하는 두 객체가 모두 null이라면 true, 한쪽만 null인 경우에는 반대쪽 객체가 여전히 살아있는지 (메모리에 존재하는 지)를 확인하기 위해 IsNativeObjectAlive 함수를 활용한다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      
        private static bool IsNativeObjectAlive(Object o)
        {
            if (o.GetCachedPtr() != IntPtr.Zero)
            {
                return true;
            }
              
            if (o is MonoBehaviour || o is ScriptableObject)
            {
                return false;
            }
              
            return DoesObjectWithInstanceIDExist(o.GetInstanceID());
        }
      

      IsNativeObjectAlive 함수의 정의는 위와 같다.

      Object o에 대해 객체의 네이티브 포인터를 통해 객체가 살아있는지를 판별하고, MonoBehaviour 혹은 ScriptableObject 의 인스턴스인지 확인한다.

      그리고 마지막으로 DoesObjectWithInstanceIDExist 함수를 활용해 객체의 인스턴스 ID가 네이티브 측에서도 여전히 유효한지 확인한다.

      GameObject의 레퍼런스를 null과 == 연산자로 비교하면 위와 같은 과정을 수행하여, 이미 파괴된 GameObject 레퍼런스가 null이 아니라는 것을 확인하게 된다.

      이렇게 GameObject는 파괴된 후에도 null이 아닌, 특별한 형태의 ‘파괴된 상태’를 가지게 된다.

      이러한 특징은 Unity의 메모리 관리 방식에서 비롯된 것으로, Destroy 함수를 호출하여 GameObject를 파괴하는 경우에도 이를 참조하고 있는 레퍼런스는 여전히 ‘파괴된 상태’의 GameObject를 가리키게 된다.

      그리고 추가로, 이렇게 ‘파괴된 상태’의 GameObject는 그 GameObject에 붙어있던 Component도 참조할 수 없다. 이 경우에도 MissingReferenceException 오류가 발생하게 된다.

      따라서 GameObject가 파괴된 후에는 해당 GameObject를 참조하고 있는 레퍼런스를 적절히 처리해야 한다.

      위 정보들은 다음의 매뉴얼에서도 은유적으로 나타나있다.

      Unity - Scripting API: Object.Destroy

      Object-operator == - Unity 스크립팅 API

      이렇게, 내부적으로 복잡한 구현을 통해서 결국 우리에게 간편한 게임 개발이 가능하도록 해주는 유니티에게 오늘도 감사함을 느낀다.

  • 그러면 이제 이 컨텐츠에서 몬스터와 플레이어를 관리하기 위한 매니저를 하나 만들어주자. GameManagerEx.cs

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    
      using System.Collections.Generic;
      using UnityEngine;
        
      /// <summary>
      /// 컨텐츠에서 몬스터와 플레이어를 관리하기 위한 매니저
      /// </summary>
      public class GameManagerEx
      {
          // 플레이어는 하나뿐이니까
          private GameObject _player;
            
          // 중복을 방지하면서 몬스터를 저장하기 위한 HashSet
          private HashSet<GameObject> _monsters = new HashSet<GameObject>();
        
          /// <summary>
          /// GameObject 생성을 담당하는 함수.
          /// </summary>
          /// <returns></returns>
          public GameObject Spawn(Define.WorldObject type, string path, Transform parent = null)
          {
              GameObject go = Managers.Resource.Instantiate(path, parent);
        
              switch (type)
              {
                  case Define.WorldObject.Monster:
                      _monsters.Add(go);
                      break;
                  case Define.WorldObject.Player:
                      _player = go;
                      break;
              }
              return go;
          }
        
          /// <summary>
          /// 인자로 받은 GameObject의 타입을 반환하는 함수.
          /// </summary>
          /// <param name="go"></param>
          /// <returns></returns>
          public Define.WorldObject GetWorldObjectType(GameObject go)
          {
              BaseController bc = go.GetComponent<BaseController>();
        
              if (bc == null)
                  return Define.WorldObject.Unknown;
                
              return bc.WorldObjectType;
          }
            
          /// <summary>
          /// GameObject를 삭제하는 함수.
          /// 삭제하고자 하는 GameObject를 관리목록에서도 지우고, Destroy한다.
          /// </summary>
          /// <param name="go">삭제하고자 하는 GameObject</param>
          public void Despawn(GameObject go)
          {
              Define.WorldObject type = GetWorldObjectType(go);
        
              switch (type)
              {
                  case Define.WorldObject.Monster:
                      if (_monsters.Contains(go))
                          _monsters.Remove(go);
                      break;
                  case Define.WorldObject.Player:
                      if (_player == go)
                          _player = null;
                      break;
              }
                
              Managers.Resource.Destroy(go);
          }
      }
    

    그리고 여기서 사용하는 열거형도 Define에 추가해주자.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
      public class Define
      {
          ...
        
          public enum WorldObject
          {
              Unknown,
              Player,
              Monster,
          }
        
          ...
      }	
    

그리고 BaseController.cs 에는 다음의 프로퍼티를 추가하고

1
2
// 이 GameObject의 WorldObject 타입을 저장하는 프로퍼티
public Define.WorldObject WorldObjectType { get; protected set; } = Define.WorldObject.Unknown;

PlayerController.csMonsterController.csInit함수 내에는 각각 자신에게 맞는 값으로 초기화해주는 코드를 한 줄 작성해주자.

그리고 이를 테스트하기 위해 GameScene.csInitManagers.Game.Spawn() 함수를 이용해 플레이어와 몬스터를 하나씩 스폰시키면… (Scene의 플레이어와 몬스터는 지운다)

오류가 난다. 왜?

CameraController.cs_player가 null이 되었기 때문이다. 이는 이전에 만들 때 툴에서 드래그앤드롭으로 할당해주어 일어난 오류.

CameraController.cs에는 다음의 함수를 추가해주고

1
2
3
4
public void SetPlayer(GameObject player)
{
    _player = player;
}

플레이어가 죽어서 사라졌을 때 오류를 막기위해 일단 MonsterController.cs내부의 플레이어 삭제코드를 Destroy에서 Managers.Game.Despawn으로 수정.

그리고 Extension.cs에서 다음의 함수를 만들어준다.

1
2
3
4
5
6
7
8
9
/// <summary>
/// <see cref="Poolable"/>한 GameObject가 유효한지 검사하는 함수.
/// </summary>
/// <param name="go"></param>
/// <returns></returns>
public static bool IsValid(this GameObject go)
{
    return go != null && go.activeSelf == true;
}

위 XML 문서에서 알 수 있듯, PoolableGameObjectManagers.Resource.Destroy 함수가 Pool로 되돌려버리기 때문에, 위 함수를 추가했다.

그리고 CameraController.cs의 원래 _player로 인해 오류나는 곳에서는 IsValid를 활용해 조건문으로 이를 해결.

1
2
3
4
5
6
7
8
9
private void LateUpdate()      
{
    if (_mode == Define.CameraMode.QuarterView)
    {
        if(_player.IsValid() == false)
        {
            return;
        }
        ...

레벨업

  • MonsterController 클래스에 약간의 수정을 먼저 가해야겠다.
  • 먼저 UpdateIdle 함수

    이전에 만들었던 UpdateIdle 함수에서는 GameObject.FindGameObjectWithTag 함수를 활용해 플레이어를 찾았는데, 이렇게 하기보다 GameManagerEx 클래스가 생겼으니 이를 활용하는 방법으로 수정하자.

    먼저 GameManagerEx클래스에선 _player 변수로 플레이어 GameObject를 캐싱해서 가지고있으니, 단순히 이를 가져오는 함수(GetPlayer)를 추가해주고.

    이 함수로 기존의 GameObject.FindGameObjectWithTag 함수를 대체.

  • 그리고 OnHitEvent 함수

    여기는 공격에 대해서 좀 더 골똘히 고민해봐야 하는 부분이라고 생각하는데, 단순한 미니 게임을 만들때는 해당사항이 없을 수도 있겠지만, 강의 영상에서 말하는 부분에 대해 백번 공감한다.

    공격이라는 행위에 대해서 공격자가 피격자의 체력에 직접 접근해 조작하기보다, 피격자가 자신의 체력을 조작하도록 하는편이 더 낫다. 방어력, 보호막 등등 피해를 경감하는 개념이 생기면 그것을 모두 공격자가 처리하게 하기보다, 피격자가 처리하게끔 하는 편이 더 낫기 때문.

    위와 같이 설명한다. 단순히 코드를 어떻게 짜야할까 하고 잠시만 고민을 해봐도, 전자를 구현하려면 공격자가 공격하는 함수 안에서 정말 많은 부분을 검사해야한다.

    상대가 방어력이 몇인지, 보호막의 유무 등등 다양한 조건문이 그 안에 덕지덕지 붙을 것이고, 게임이 업데이트되면서 피해를 경감하는 개념이 생겨날 때 마다 이 함수를 자꾸자꾸 수정해줘야한다.

    그렇게하기보다, 체력을 가진 모든 객체에게 스스로 피해를 받는 함수를 만들어놓고, 이를 공격자가 호출하는것이 코드가 깔끔해지지 않을까?

    • 그렇기 때문에, 모든 플레이어와 몬스터가 모두 활용하는 Stat클래스 내에 스스로 피해를 받는 함수를 만들어 놓자.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      
        /// <summary>
        /// 피해를 받을 때 호출하는 함수
        /// </summary>
        public virtual void OnAttacked(Stat attacker)
        {
            int damage = attacker.Attack - Defense;
            Hp -= (damage) <= 0 ? 1 : damage;    // 데미지가 최소 1은 들어가게끔
              
            if (Hp <= 0)
            {
                Hp = 0;
                OnDead();
            }
        }
              
        /// <summary>
        /// 피해를 받아 체력이 0이하가 되었을 때 호출하는 함수
        /// GameObject 스스로를 파괴시킨다.
        /// </summary>
        protected virtual void OnDead()
        {
            Managers.Game.Despawn(gameObject);
        }
      

      그리고 이를 만들었으면, 이를 PlayerController클래스와 MonsterController클래스에서 활용하도록 수정하자.

      OnDead 함수는 PlayerStat 클래스에서 override하는데, 일단은 아무행동도 하지 않도록 하자.

    테스트 결과, 정상적으로 동작한다.

  • 그러면 이제 몬스터를 잡으면, 플레이어의 경험치가 오르도록 경험치를 구현해보자.
    • 경험치를 주는건, 결국 플레이어에게 죽은 몬스터가 주는 것이기 때문에, Stat 클래스에서 이를 구현하자.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      
        /// <summary>
        /// 피해를 받아 체력이 0이하가 되었을 때 호출하는 함수.
        /// GameObject 스스로를 파괴시킨다.
        /// </summary>
        protected virtual void OnDead(Stat attacker)
        {
            // 공격자의 타입이 Player일 때만 경험치를 주도록.
            PlayerStat playerStat = attacker as PlayerStat;
            if (playerStat != null)
            {
                playerStat.Exp += 5;
            }
                  
            Managers.Game.Despawn(gameObject);
        }
      

      일단은 고정된 수치만큼 경험치 5를 주도록 만들었다.

      그리고 레벨업이 되려면 먼저 레벨업의 기준이 되는 수치가 필요할 것이고, 이를 각 레벨마다 데이터로 저장해야 할 것이다.

      • 그래서 프로젝트에 저장한 데이터파일을 수정하고 (totalExp 추가)

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        
          {
            "stats" : [
              {
                "level" : "1",
                "maxHp" : "200",
                "attack" : "20",
                "totalExp" : "0"
              },
              {
                "level" : "2",
                "maxHp" : "250",
                "attack" : "25",
                "totalExp" : "10"
              },
              {
                "level" : "3",
                "maxHp" : "300",
                "attack" : "30",
                "totalExp" : "20"
              }
            ]
          }
        
      • 이를 읽을 때 데이터 포맷이 되는 클래스도 수정해주자

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        
          namespace Data
          {
              /// <summary>
              /// 캐릭터의 능력치를 정의하는 클래스.
              /// Json파일을 읽고 쓰는 포맷이 되어준다.
              /// </summary>
              [Serializable]
              public class Stat
              {
                  public int level;       // 캐릭터의 레벨
                  public int maxHp;       // 캐릭터의 체력
                  public int attack;      // 캐릭터의 공격력
                  public int totalExp;    // 캐릭터의 레벨업 경험치
              }
              ...
        
      • 그리고 이를 활용해 레벨업 로직을 만들어줘야 한다. 데이터에 맞춰서 스텟들을 초기화하는 로직도 함께 구현해보자

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        
          public class PlayerStat : Stat
          {
              [SerializeField] 
              protected int _exp;
              [SerializeField] 
              protected int _gold;
                    
              public int Exp
              {
                  get => _exp;
                  set
                  {
                      _exp = value;
                                
                      // 레벨업 체크
                      // 경험치를 받았을 때 레벨업이 가능하도록
                      int level = Level;
                      while (true)
                      {
                          if (Managers.Data.StatDict.TryGetValue(level + 1, out var stat) == false)
                              break;
                          if (_exp < stat.totalExp)
                              break;
                          level++;
                      }
                    
                      // 레벨업이 일어났다면,
                      // 그 레벨에 맞춰서 스텟들을 수정
                      if (level != Level)
                      {
                          Level = level;
                          SetStat(Level);
                          Debug.Log(Level);
                      }
                  }
              }
                    
              public int Gold
              {
                  get => _gold;
                  set => _gold = value;
              }
                        
              private void Start()
              {
                  // 초기화
                  _level = 1;
                    
                  SetStat(1);
                  _defense = 5;
                  _moveSpeed = 5.0f;
                  _gold = 0;
              }
                    
              public void SetStat(int level)
              {
                  // ResourceManager를 통해 Stat데이터를 읽어와서 초기화
                  Dictionary<int, Data.Stat> statDict = Managers.Data.StatDict;
                    
                  _hp = statDict[level].maxHp;
                  _maxHp = statDict[level].maxHp;
                  _attack = statDict[level].attack;
              }
                    
              protected override void OnDead(Stat attacker)
              {
                  Debug.Log("PlayerDead");
              }
          }
                    
        
        • _defense, _moveSpeed, _gold 는 저장된 데이터를 참고하지 않는데, 참고하도록 수정할 수도 있겠다.

몬스터 자동 생성

  • 몬스터가 자동으로 리스폰되는 시스템을 만들어보자
  • 특정한 영역내의 몬스터의 갯수를 정해진 갯수만큼 유지시키도록 하여, 플레이어가 하나를 죽이면 잠깐의 대기 시간 후에 몬스터가 리스폰되게하는 시스템을 만들어보자.
  • 해당 시스템을 관리할 SpawningPool 클래스를 만들자
    • 기존에 몬스터를 Spawn시키고 관리하는 클래스는 GameManager 이니까, 여기에 Action<T>을 만들어 이를 활용하여 만들자
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    
      /// <summary>
      /// 몬스터의 리스폰을 관리하는 클래스
      /// </summary>
      public class SpawningPool : MonoBehaviour
      {
          // 현재 존재하는 몬스터의 수
          // GameManager의 OnSpawnEvent를 호출하여 이 숫자를 관리한다.
          [SerializeField] 
          private int _monsterCount = 0;
          private int reserveCount = 0;
            
          // 유지시켜야 하는 몬스터의 수
          [SerializeField] 
          private int _keepMonsterCount = 0;
        
          // 스폰 기준점과 반경
          [SerializeField] 
          private Vector3 spawnPos;
          [SerializeField] 
          private float _spawnRadius = 15;
        
          // 스폰 딜레이
          [SerializeField] 
          private float spawnDelay = 5.0f;
            
          public void AddMonsterCount(int value) { _monsterCount += value; }
          public void SetKeepMonsterCount(int count) { _keepMonsterCount = count; }
            
          void Start()
          {
              Managers.Game.OnSpawnEvent -= AddMonsterCount;
              Managers.Game.OnSpawnEvent += AddMonsterCount;
          }
        
          void Update()
          {
              while (reserveCount + _monsterCount < _keepMonsterCount)
              {
                  StartCoroutine(nameof(ReserveSpawn));
              }
          }
        
          IEnumerator ReserveSpawn()
          {
              reserveCount++;
                
              // 딜레이 적용
              yield return new WaitForSeconds(Random.Range(0, spawnDelay));
                
              GameObject monster = Managers.Game.Spawn(Define.WorldObject.Monster,"DarkKnight");
              var nma = monster.GetOrAddComponent<NavMeshAgent>();
        
              Vector3 randPos;
              while (true)
              {
                  // 정해진 지점으로부터 일정 반경 내의 무작위 장소에 Spawn.
                  Vector3 randDir = Random.insideUnitSphere * Random.Range(0, _spawnRadius);
                  randDir.y = 0;
                  randPos = spawnPos + randDir;
        
                  // 정해진 장소가 존재할 수 있는 곳인지 검사
                  NavMeshPath path = new NavMeshPath();
                  if (nma.CalculatePath(randPos, path))
                      break;
              }
        
              monster.transform.position = randPos;
              reserveCount--;
          }
      }
        
    
  • 이에 맞춰 GameManagerEx 클래스도 수정하자.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    
      /// <summary>
      /// 컨텐츠에서 몬스터와 플레이어를 관리하기 위한 매니저
      /// </summary>
      public class GameManagerEx
      {
          // 플레이어는 하나뿐이니까
          private GameObject _player;
            
          // 중복을 방지하면서 몬스터를 저장하기 위한 HashSet
          private HashSet<GameObject> _monsters = new HashSet<GameObject>();
        
          // 몬스터가 추가되거나 삭제될 때 SpawningPool에게도 전달하기 위한 Action
          // 매개변수는 늘거나, 줄어들은 숫자
          public Action<int> OnSpawnEvent;
        
          /// <summary>
          /// 플레이어를 찾기 위한 함수
          /// </summary>
          /// <returns></returns>
          public GameObject GetPlayer()
          {
              return _player;
          }
            
          /// <summary>
          /// GameObject 생성을 담당하는 함수.
          /// </summary>
          /// <returns></returns>
          public GameObject Spawn(Define.WorldObject type, string path, Transform parent = null)
          {
              GameObject go = Managers.Resource.Instantiate(path, parent);
        
              switch (type)
              {
                  case Define.WorldObject.Monster:
                      _monsters.Add(go);
                      OnSpawnEvent?.Invoke(1);
                      break;
                  case Define.WorldObject.Player:
                      _player = go;
                      break;
              }
              return go;
          }
        
          /// <summary>
          /// 인자로 받은 GameObject의 타입을 반환하는 함수.
          /// </summary>
          /// <param name="go"></param>
          /// <returns></returns>
          public Define.WorldObject GetWorldObjectType(GameObject go)
          {
              BaseController bc = go.GetComponent<BaseController>();
        
              if (bc == null)
                  return Define.WorldObject.Unknown;
                
              return bc.WorldObjectType;
          }
            
          /// <summary>
          /// GameObject를 삭제하는 함수.
          /// 삭제하고자 하는 GameObject를 관리목록에서도 지우고, Destroy한다.
          /// </summary>
          /// <param name="go">삭제하고자 하는 GameObject</param>
          public void Despawn(GameObject go)
          {
              Define.WorldObject type = GetWorldObjectType(go);
        
              switch (type)
              {
                  case Define.WorldObject.Monster:
                      if (_monsters.Contains(go))
                      {
                          _monsters.Remove(go);
                          OnSpawnEvent?.Invoke(-1);
                      }
                      break;
                  case Define.WorldObject.Player:
                      if (_player == go)
                          _player = null;
                      break;
              }
                
              Managers.Resource.Destroy(go);
          }
      }
    
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.