반응형

 

유니티 엔진에 대한 본격적인 공부에 들어가기에 앞서 게임 엔진과 유니티 엔진에 대해서 알아봅시다.

반응형
반응형

 

마우스 입력 처리를 응용해서

1. 캐릭터가 마우스 위치를 쳐다보게 하는 기능

2. 클릭으로 투사체 발사 기능

3. 휠으로 줌 인/줌 아웃하는 기능

을 구현하는 방법을 알아봅시다.

 

응용 이전에 마우스 입력 처리에 대한 기본 방법은 링크를 통해서 배우실 수 있습니다.

반응형
반응형

Programming 

모바일 가상 조이스틱 구현하기

 

작성 기준 버전 :: 2019.2 - 2019.4

 

[이 포스트의 내용은 유튜브 영상으로도 시청하실 수 있습니다]

 

 

 

요즘에는 오락실이 별로 많지 않지만 제가 어렸을 때 오락실에서 게임할 때는 오락실 기계에 붙어있는 조이스틱과 버튼을 사용해서 게임을 플레이 했었다. 그리고 본격적으로 PC게임으로 넘어왔을 때는 키보드와 마우스를 통해 게임 속의 캐릭터나 유닛들을 조작했다.

 

 

또 구세대 2G 폰에서는 핸드폰에 달린 숫자 버튼들이 게임을 조작하는 데 쓰였었다. 한참 모바일 기기에서 새로운 시도를 하던 때에는 게임을 위한 전용 휴대폰도 나왔었다.

 

 

하지만 어느 새 스마트폰의 시대가 와버렸는데 스마트폰은 보통 입력을 받아들일 수 있는 부분이 화면의 터치 밖에 없다. 그래서 게임 조작 방식에 많은 제약이 있을 수 밖에 없었다.

 

터치만 가능한 모바일 기기에서 어떻게 하면 좀 더 콘솔 게임과 같은 조작이 가능할까 하며 고안된게 바로 가상 조이스틱이다. 가상 조이스틱은 화면에 조이스틱 UI를 띄우고 그것을 터치하여 조작하는 방식의 컨트롤러다.

 

조이스틱 UI 배치하기

 

 

 

가상 조이스틱을 구현하기 위해서 먼저 필요한 리소스는 조이스틱을 위에서 아래로 쳐다보았을 때의 조이스틱 바닥 부분과 레버 머리부분을 표현한 이미지이다.

 

 

 

이미지 리소스를 준비하고 나면 이미지 게임 오브젝트를 두 개 만들어서 위의 이미지와 같이 넣어준다. 빨간색 레버가 자식 게임 오브젝트가 되도록 만든다.

 

 

그리고 조이스틱 레버 이미지의 위치를 화면 좌측하단에서부터 계산하기 위해서 Joystick 게임 오브젝트의 앵커를 left bottom으로 설정한다.

 

조이스틱 스크립트 작성

 

드래그 이벤트 가져오기

 

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{    
    public void OnBeginDrag(PointerEventData eventData)
    {
        Debug.Log("Begin");
    }
    
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트
    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        Debug.Log("Drag");
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        Debug.Log("End");
    }
}

 

이미지 배치가 끝나면 조이스틱 기능을 만들기 위해서 VirtualJoystick이라는 이름으로 C# 스크립트를 생성한다. 그리고 제일 상단에 EventSystems 네임스페이스를 using 선언해준다. 이 이벤트 시스템 네임스페이스에는 유니티에서 키보드, 마우스, 터치 등의 사용자 입력을 오브젝트에 이벤트로 보낼 수 있는 기능들을 지원한다.

 

그 다음에는 MonoBehaviour 상속 뒤에 IBeginDragHandler, IDragHandler, IEndDragHandler를 추가한다. 여기서 I로 시작하는 이 핸들러들은 인터페이스라는 것으로 클래스와는 다르게 구현해야할 함수만을 지정해주는 것이다. 이 인터페이스를 상속받으면 인터페이스가 가지고 있는 함수들을 강제로 구현하도록 에러가 표시된다.

 

여기서 [F12]를 눌러서 일일이 구현해야할 함수를 보고 작성할 수도 있지만, 에러가 표시되고 있는 인터페이스 위에 커서를 두고 [Ctrl + .] 단축키를 누르면 구현해야할 함수들을 자동으로 생성되게 할 수 있다.

 

참고로 BeginDrag는 드래그를 시작할 때, Drag는 드래그 중일 때, EndDrag는 드래그를 끝냈을 때를 의미한다.

 

먼저 드래그를 체크해보기 위해서 각 드래그 이벤트 함수에 로그 코드를 추가하고 코드를 저장한 뒤 에디터로 돌아간다.

 

 

그리고 생성한 컴포넌트를 Joystic 게임 오브젝트에 부착하고 게임을 플레이시킨다.

 

 

그 다음 조이스틱 이미지 위에 클릭하고 드래그하면 Begin, Drag, End 순서로 로그가 출력된다.

 

레버 이미지가 드래그를 따라서 움직이게 만들기

 

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField]
    private RectTransform lever;    // 추가
    private RectTransform rectTransform;    // 추가

    private void Awake()    // 추가
    {
        rectTransform = GetComponent<RectTransform>();
    }
    
    public void OnBeginDrag(PointerEventData eventData)
    {  
        // Debug.Log("Begin");

        // 추가
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        lever.anchoredPosition = inputDir;
    }
    
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        // Debug.Log("Drag");
        
        // 추가
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        lever.anchoredPosition = inputDir;
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
        // Debug.Log("End");
        
        // 추가
        lever.anchoredPosition = Vector2.zero;
    }
}

 

드래그 이벤트가 제대로 동작하는 것을 확인했으니 터치한 위치를 따라서 레버 이미지가 따라 움직이도록 만들 차례이다.

 

우선 빨간 레버 이미지의 Rect Transform 컴포넌트를 가질 lever 변수와 Joystick의 Rect Transform을 가지고 있을 rectTransform 변수를 선언한다. 그리고 lever 렉트 트랜스폼은 에디터의 인스펙터 뷰에서 넣어주기 위해서 SerializeField 어트리뷰트를 걸어주고 본체의 rectTransform은 Awake 함수에서 GetComponent로 가지고 와서 저장해둔다.

 

그 다음은 터치한 위치를 찾아내서 lever 이미지가 그 위치로 이동하게 만들어 줘야한다.

 

터치한 위치는 이벤트 함수의 매개변수로 받는 eventData.position을 통해서 가져올 수 있다. 이렇게 가져온 eventData.position에서 가상 조이스틱 게임 오브젝트의 위치인 rectTransform.anchoredPosition을 빼주면 lever가 있어야할 위치를 구할 수 있게 된다. 구한 inputDir를 lever.anchoredPosition에 넣어준다. 이 과정은 OnBeginDrag와 OnDrag, 두 이벤트에서 동일하게 처리해준다.

 

그리고 마지막으로 가상 조이스틱에서 손을 뗐을 때의 이벤트인 OnEndDrag에서는 lever.anchoredPosition을 Vector2.zero로 만들어서 조이스틱의 중심으로 다시 돌아가게 만들어준다.

 

 

코드를 저장하고 에디터로 돌아간 다음에는 컴포넌트의 비어있는 lever 프로퍼티에 레버 이미지를 할당해준다.

 

 

게임을 플레이시키고 가상 조이스틱 위에 드래그해보면 빨간색 레버 이미지가 터치한 위치를 잘 따라오는 것을 볼 수 있다. 그리고 터치를 끝내면 자동으로 중심으로 레버가 돌아가기까지 한다.

 

 

하지만 너무 잘 따라와서 문제인데 드래그를 유지한 채로 조이스틱 영역 밖으로 움직이면 빨간 레버 역시 조이스틱 위치를 벗어나 버린다. 레버가 조이스틱 영역을 벗어나지 않도록 수정할 필요가 있어보인다.

 

레버 이동 제한하기

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField]
    private RectTransform lever;
    private RectTransform rectTransform;
    // 추가
    [SerializeField, Range(10f, 150f)]
    private float leverRange;    
    
    private void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
    }
    
    public void OnBeginDrag(PointerEventData eventData)
    {
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        //추가
        var clampedDir = inputDir.magnitude < leverRange ? 
            inputDir : inputDir.normalized * leverRange;

        // lever.anchoredPosition = inputDir;
        lever.anchoredPosition = clampedDir;    // 변경

    }
    
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트
    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        // 추가
        var clampedDir = inputDir.magnitude < leverRange ? inputDir : inputDir.normalized * leverRange;

        // lever.anchoredPosition = inputDir;
        lever.anchoredPosition = clampedDir;    // 변경
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
        lever.anchoredPosition = Vector2.zero;
    }
}

 

먼저 레버가 움직일 수 있는 거리를 조이스틱의 중심부로부터 일정 거리로 제한시키기 위해서 float 타입으로 leverRange라는 변수를 추가한다. private으로 두되 에디터에서 수정할 수 있도록 SerializeField 어트리뷰트와 특정 범위 내에서만 값을 조절할 수 있도록 Range 어트리뷰트를 추가해준다. Range의 범위는 10에서 150 사이로 하자.

 

leverRange 변수를 만들고 나면 이벤트 함수에서 inputPos를 lever의 anchoredPosition에 바로 넣지 말고 inputPos의 길이와 leverRange를 비교한 뒤, leverRange보다 짧으면 inputPos를 바로 주고, 길면 inputPos를 정규화한 다음 leverRange를 곱하는 방식으로 inputPos의 거리를 제한하여 lever의 anchoredPosition에 넣어준다. 이 과정도 OnDrag에서 똑같이 처리해준다.

 

 

 

코드를 저장하고 에디터로 돌아가서 레버 이미지가 조이스틱 영역을 크게 벗어나지 않는 적절한 영역을 확인한다. 지금은 100 정도가 적당해 보인다.

 

 

VirtualJoystick 컴포넌트가 부착되어 있는 Joystick 게임 오브젝트를 선택하고 인스펙터 뷰에서 Lever Range 프로퍼티를 100으로 설정한다.

 

 

게임을 플레이시키고 가상 조이스틱을 움직여보면 드래그를 조이스틱 밖으로 끌어도 조이스틱의 레버가 조이스틱 영역을 벗어나지 않는 것을 볼 수 있다.

 

조작 기능 추가하기

using UnityEngine;
using UnityEngine.EventSystems; // 키보드, 마우스, 터치를 이벤트로 오브젝트에 보낼 수 있는 기능을 지원

public class VirtualJoystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField]
    private RectTransform lever;
    private RectTransform rectTransform;
    [SerializeField, Range(10f, 150f)]
    private float leverRange;
    
    private Vector2 inputVector;    // 추가
    private bool isInput;    // 추가

    private void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
    }
    
    public void OnBeginDrag(PointerEventData eventData)
    {
        // var inputDir = eventData.position - rectTransform.anchoredPosition;
        // var clampedDir = inputDir.magnitude < leverRange ? inputDir 
        //     : inputDir.normalized * leverRange;
        // lever.anchoredPosition = clampedDir;

        ControlJoystickLever(eventData);  // 추가
        isInput = true;    // 추가
    }
        
    // 오브젝트를 클릭해서 드래그 하는 도중에 들어오는 이벤트
    // 하지만 클릭을 유지한 상태로 마우스를 멈추면 이벤트가 들어오지 않음    
    public void OnDrag(PointerEventData eventData)
    {
        // var inputDir = eventData.position - rectTransform.anchoredPosition;
        // var clampedDir = inputDir.magnitude < leverRange ? inputDir 
        //     : inputDir.normalized * leverRange;
        // lever.anchoredPosition = clampedDir;
        
        ControlJoystickLever(eventData);    // 추가
        isInput = false;    // 추가
    }
    
    // 추가
    public void ControlJoystickLever(PointerEventData eventData)
    {
        var inputDir = eventData.position - rectTransform.anchoredPosition;
        var clampedDir = inputDir.magnitude < leverRange ? inputDir 
            : inputDir.normalized * leverRange;
        lever.anchoredPosition = clampedDir;
        inputVector = clampedDir / leverRange;
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
        lever.anchoredPosition = Vector2.zero;
    }
}

 

이제 조작 기능을 추가할 차례이다.

 

이번에는 Vector2로 inputVector 변수와 bool 타입으로 isInput 변수를 추가한다.

 

그리고 OnBeginDrag와 OnDrag 이벤트 함수에서 inputVector 값을 넣어줄 차례인데, 두 이벤트 모두 동작하고 있는 함수의 내용이 완전히 동일하다는 것을 알 수 있다. 그렇기 때문에 ControlJoystickLever라는 함수를 새로 만들고 두 함수의 내용을 복사해서 붙여넣어 준다.

 

그 다음에는 ControlJoystickLever 함수에서 clampedDir를 leverRange로 나누어서 inputVector에 넣어준다. clampedDir를 바로 써도 될 것 같은데 굳이 leverRange로 나누어서 사용하는 이유는 이 clampedDir는 해상도를 기반으로 만들어진 값이라 캐릭터의 이동속도로 쓰기에는 너무 큰 값을 가지고 있기 때문이다.

 

이런 값으로 캐릭터를 움직이면 너무 빠른 속도로 캐릭터가 움직일게 분명하기 때문에 입력 방향의 값을 0-1 사이 값으로 만들어서 정규화된 값으로 캐릭터에 전달하기 위한 것이다.

 

그리고 화면 해상도를 기준으로 값이 정해지기 때문에 해상도가 바뀌면 입력 방향 값의 크기가 바뀌어서 캐릭터의 이동 속도가 바뀌는 문제도 있어서 0-1사이로 정규화된 값을 사용해야 한다. 캐릭터는 이렇게 전달받은 정규화된 이동 벡터에 이동속도를 곱해서 일정한 속도로 이동하게 된다.

 

그 다음엔 OnBeginDrag에서 isInput을 true로 바꿔주고 OnEndDrag에서는 false로 바꿔준다.

 

private void InputControlVector()
{
    Debug.Log(inputDirection.x + " / " + inputDirection.y);
    // 캐릭터에게 입력벡터를 전달
}

 

그 다음에는 구해낸 입력 벡터를 캐릭터에 전달하기 위한 함수로 InputContorlVector를 만든다. 이 함수 안에 캐릭터에게 이동 벡터를 전달하는 기능을 작성하면 된다. 우선 지금은 매개변수로 받은 inputVector의 값을 로그로 출력하는 코드만 작성해두자.

 

void Update()
{
    if(isInput)
    {
        InputControlVector();
    }
}

 

업데이트 함수로 가서 isInput이 true일 때, InputControlVector 함수를 호출해주면 됩니다. 

 

여기에서 한 가지 의문을 가질 수 있다. 이 InputControlVector 함수를 OnBeginDrag와 OnDrag 이벤트 함수에서도 호출할 수 있을 텐데 왜 굳이 isInput변수를 따로 만들어서 Update 함수에서 처리하는가 하는 것이다. 

 

이 부분은 OnDrag 이벤트 함수의 특성과 관련이 있다. 앞에서도 이야기했다시피 OnDrag 이벤트 함수는 오브젝트를 클릭하여 드래그하는 도중에 호출되는 이벤트이다. 하지만 여기에 꽤나 중요한 빈틈이 있다.

 

그것은 바로 클릭해서 드래그하는 도중에 클릭을 떼지 않고 마우스를 멈추면 더 이상 이벤트가 호출되지 않는다는 것이다. 그리고 다시 마우스를 움직이기 시작하면 이벤트가 호출된자. 한 마디로 드래그 도중에 마우스 이동을 멈추고 있는 동안에는 이벤트가 들어오지 않는다는 것이다.

 

그래서 드래그 도중에 터치의 이동이나 마우스 이동이 멈췄을 때도 InputControlVector 함수를 연속적으로 호출하기 위해서 isInput이 활성화된 상태일 때 Update 함수에서 지속적으로 호출하도록 만들어 준 것이다.

 

코드를 저장하고 에디터로 돌아가서 다시 테스트해보면 입력 벡터의 길이가 1이 넘지 않는 선에서 입력 방향이 출력되는 것을 볼 수 있다.

 

캐릭터 컨트롤러와 연결하기

 

이렇게 만들어진 가상 조이스틱을 캐릭터와 연결해서 조작할 수 있도록 만들어주어야 한다.

 

// 가상 조이스틱 클래스에서 컨트롤러를 소유
public CharacterController characterController;

private void InputControlVector()
{
    if (characterController)
    {
        characterController.Move(inputDirection);
    }
}

 

간단하게 예시를 들자면 위의 코드처럼 가상 조이스틱 클래스에서 캐릭터 컨트롤러를 소유하거나 콜백을 등록시켜서 호출하는 방법이 있다.

 

 

 

위의 이미지와 같은 방식으로 두 개의 조이스틱을 배치해서 사용하고 싶을 수도 있다.

 

public enum JoystickType { Move, Rotate }
public JoystickType joystickType;

private void InputControlVector()
{
    // 캐릭터에게 입력벡터를 전달
    switch(joystickType)
    {
        case JoystickType.Move:
            controller.Move(inputDirection);
            break;

        case JoystickType.Rotate:
            controller.LookAround(inputDirection);
            break;
    }
}

 

그럴 때는 가상 조이스틱에 타입을 설정할 수 있는 열거형을 만들어서 조이스틱의 타입에 따라 다르게 동작하게 만들어주면 된다.

반응형
  1. ㅇㅇ 2021.03.04 16:52

    좋은 글 친절한 설명 너무 잘 보고 갑니다!
    다만, isInput = false가 OnDrag가 아닌 OnEndDrag함수에 와야하지 않을까 싶습니다

  2. 나도 2021.07.15 10:02

    친절한 설명 덕분에 이해하면서 쭉쭉 배웠습니다. 다만 ㅇㅇ님 댓글처럼 false 부분은 End 함수쪽으로 가야해요 실수같습니다!!

반응형

Programming 

TPS 캐릭터 조작 기능 구현하기

 

작성 기준 버전 :: 2019.2

 

[이 포스트의 내용은 유튜브 영상으로도 시청하실 수 있습니다]

 

지난 포스트 중 하나에서는 마우스를 이용해서 클릭한 위치로 캐릭터를 이동시키는 RPG나 AOS 게임에서 주로 사용되는 캐릭터 조작 방법을 구현했었다.

 

이번에는 키보드로 이동하고 마우스로 원하는 대상을 바라보게 하는 TPS 게임에서 사용되는 캐릭터 조작 방법을 구현해보도록 하자.

 

리소스 임포트와 기본 애니메이션 세팅

 

본격적인 구현에 들어가기 전에 캐릭터로 사용할 모델링과 애니메이션을 영상 하단의 링크에서 다운로드 받아서 임포트한다.

 

practice-animation.zip
다운로드

 

FBX 파일을 임포트하고 나면 먼저 캐릭터의 이동 애니메이션을 보여주기 위한 애니메이션 세팅을 해보자.

 

 

먼저 임포트된 FBX 파일 중에 Run, Stand의 애니메이션 임포트 세팅 중에서 Loop Time을 체크하고 적용한다.

 

 

그리고 애니메이터 컨트롤러를 생성한 뒤 Bool 타입으로 isMove 파라미터를 추가한다. 

 

 

그 다음에는 Stand 애니메이션을 기본 스테이트로 추가하고 Run 애니메이션을 추가한 뒤 isMove의 상태에 따라서 Stand와 Run을 왔다갔다 하도록 세팅해준다.

 

캐릭터 배치와 카메라 세팅

 

 

이 작업이 끝나면 씬에 캐릭터가 돌아다닐 바닥이 될 Plane을 하나 배치하고 BoxMan@Stand를 배치하고 BoxMan에 붙어있는 Animator 컴포넌트의 비어있는 Controller 프로퍼티에 방금 만든 애니메이터 컨트롤러를 할당해준다.

 

 

애니메이션에 대한 세팅이 끝나면 카메라를 이동시켜서 TPS 게임에서 자주 보이는 카메라 구도를 만들어보자. TPS 시점의 게임에서는 이런 구도의 카메라 시점이 주로 사용된다.

 

일인칭으로 보여지는 FPS 게임은 이것과는 약간 다르게 캐릭터의 몸이 거의 보이지 않고 손 정도만 보이게 시점을 세팅하게 되지만 이후의 기능 구현 자체는 거의 비슷하게 진행하면 된다.

 

본격적인 조작 기능 구현에 들어가기에 앞서 카메라와 캐릭터 구조를 조금 더 살펴보자.

 

지금 시점 자체는 나쁘지 않지만 조작 기능이 들어가기에는 아쉬움이 많다.

 

 

특히 마우스를 상하좌우로 움직이면 카메라 역시 마우스의 움직임에 맞게 회전해야 하는데, 카메라를 잡고 위/아래/좌/우로 회전시켜보면 전혀 적절하지 못하다는 것을 깨달을 수 있을 것이다.

 

해결책 1 : 카메라를 캐릭터의 자식 게임 오브젝트로 만들기

 

 

첫 번째로 생각해볼 만한 방법은 카메라를 캐릭터의 자식 게임 오브젝트로 만드는 것이다.

 

 

카메라를 BoxMan의 자식 게임 오브젝트로 만들면 좌우로 움직이는건 정상적으로 동작한다.

 

 

하지만 위/아래로 움직여보면 캐릭터가 같이 기울어져서 적절하지 못한 것을 알 수 있다. 적절한 카메라 회전을 위해서는 캐릭터를 회전시키지 않으면서도 캐릭터를 중심으로 카메라를 회전시키는 방법을 찾아야 할 것이다.

 

해결책 2 : 카메라 암(Camera Arm) 만들기

 

 

다른 해결책으로는 카메라 암(Camera Arm) 역할을 할 빈 게임 오브젝트를 하나 만들고 BoxMan의 머리 정도의 위치로 이동시켜 주고 카메라를 이 빈 게임 오브젝트의 자식 게임 오브젝트로 만들어준다. 

 

 

이렇게 세팅해주면 상하좌우 모든 방향의 회전에도 문제없이 카메라가 캐릭터를 중심으로 움직이는 것을 확인할 수 있다.

 

 

이대로 Camera Arm을 BoxMan 밑에 배치해도 되지만 그렇게 하면 BoxMan의 회전에 Camera Arm이 묶이게 되기 때문에 Character라는 이름의 빈 게임 오브젝트를 만들어서 BoxMan과 Camera Arm을 그 아래에 배치하자. 이렇게 해주면 BoxMan의 회전과 Camera Arm의 회전이 분리되서 관리하기가 쉬울 것이다.

 

카메라 회전 기능 구현하기

 

이제 본격적으로 캐릭터 조작 기능을 만들어보자.

 

TPSCharacterController라는 이름으로 C# 스크립트를 생성하고 다음 코드를 따라 작성하자.

 

public class TPSCharacterController : MonoBehaviour
{
    [SerializeField]
    private Transform characterBody;
    [SerializeField]
    private Transform cameraArm;

    Animator animator;

    void Start()
    {
        animator = characterBody.GetComponent<Animator>();
    }
}

 

Transform 타입으로 BoxMan 모델을 관리할 characterBody 변수와 카메라의 회전을 관리할 cameraArm 변수를 선언하고 애니메이션을 관리할 Animator 변수를 선언하고 Start 함수에서 characterBody.GetComponent 함수로 BoxMan에 붙어있는 Animator 컴포넌트를 가져와서 저장해둔자.

 

private void LookAround()
{
    Vector2 mouseDelta = new Vector2(Input.GetAxis("Mouse X"), Input.GetAxis("Mouse Y"));
    Vector3 camAngle = cameraArm.rotation.eulerAngles;

    cameraArm.rotation = Quaternion.Euler(camAngle.x - mouseDelta.y, camAngle.y + mouseDelta.x, camAngle.z);
}

 

먼저 마우스의 움직임에 따라서 카메라를 회전시키는 기능을 구현하기 위해서 LookAround 함수를 만든다. 

 

LookAround 함수에서는 먼저 마우스가 이전 위치에 비해서 얼마나 움직였는지 알아내야 한다. 마우스가 움직인 수치는 Input.GetAxis 함수에 "Mouse X"와 "Mouse Y"를 매개변수로 넣어서 가져올 수 있다. 

 

"Mouse X"로는 마우스를 좌우로 움직인 수치를 가져올 수 있고 "Mouse Y"로는 마우스를 위아래로 움직인 수치를 가져올 수 있다.

 

이 두 값을 Vector2로 묶어서 mouseDelta 변수에 저장한다. 참고로 프로그래밍에서 이전 값과 현재 값의 차이를 주로 delta라는 용어로 표현한다. 

 

그 다음에는 cameraArm의 rotation 값을 오일러 각으로 변환해둔다. 그리고 camAngle과 mouseDelta를 합쳐서 새로운 회전값을 만들어낸 다음 cameraArm.rotation에 넣어줘야 한다.

 

우선 마우스 좌우 움직임으로 카메라 좌우 움직임을 제어해야 하기 때문에 mouseDelta의 x 값을 camAngle의 y에 더해준다. 그리고 마우스 수직 움직임은 카메라 상하 움직임을 제어해야 하기 때문에 mouseDelta의 y 값을 camAngle의 x에 추가해준다.

 

이 마우스 수직 움직임의 경우에는 한국에서는 마우스를 위로 움직이면 위를 쳐다보고 아래로 움직이면 아래를 쳐다보게 하는 방식을 사용하는 유저들이 더 많지만 북미에서는 이 움직임을 반대로 사용하는 경우가 꽤 있다고 한다. 그렇게 때문에 별도의 옵션을 둬서 두 가지 조작 방식 중에 사용자가 원하는 방식을 선택할 수 있도록 하는게 좋다. 

 

우선 한국에서 주로 쓰는 방식으로 구현하자. 마우스 방향과 바라보는 방향을 일치시키려면 mouseDelta.y를 camAngle.x에서 빼주면 된다.

 

void Update()
{
    LookAround();
}

 

그리고 LookAround 함수를 Update 함수에 호출하게 만들어준 뒤 코드를 저장하고 에디터로 돌아가보자.

 

 

그 다음 Character 게임 오브젝트에 생성한 컨트롤러 컴포넌트를 붙이고 프로퍼티들을 할당해준다.

 

 

게임을 플레이시키고 마우스를 움직여보면 우선 카메라는 제대로 움직이는 것 같지만 카메라 상하 회전에 제한이 없어서 그런지 마우스를 움직이다보면 화면이 뒤집어지는 경우가 발생한다.

 

카메라 각도 제한하기

 

이런 문제를 막기 위해서는 카메라의 x 회전을 제한해야 한다.

 

위쪽 회전은 약 70도를 제한하고 아래쪽 회전은 약 25도를 제한하도록 하자. 다만 x 회전 값을 출력해보면 알겠지만 수평을 기준으로 위쪽으로 회전하면 0도에서 90도까지 각도가 나오고 아래쪽으로 회전하면 360도부터 270도까지 각도가 나온다.

 

private void LookAround()
{
    Vector2 mouseDelta = new Vector2(Input.GetAxis("Mouse X"), Input.GetAxis("Mouse Y"));
    Vector3 camAngle = cameraArm.rotation.eulerAngles;
    
    //추가
    float x = camAngle.x - mouseDelta.y;
    if (x < 180f)
    {
        x = Mathf.Clamp(x, -1f, 70f);
    }
    else
    {
        x = Mathf.Clamp(x, 335f, 361f);
    }

    cameraArm.rotation = Quaternion.Euler(x, camAngle.y + mouseDelta.x, camAngle.z);
}

 

우선 camAngle.x - mouseDelta.y로 만들어낸 값을 바로 회전값에 넣지 말고 따로 x에 저장한다. 그리고 회전값에 넣어주기 전에 x값의 상태에 따라서 처리하는 과정을 구현해야 한다.

 

먼저 x가 180도보다 작은 경우는 위쪽으로 회전하는 경우이므로 x의 값을 -1도에서 70도까지 제한시켜둔다. 여기서 0도가 아닌 -1도로 최저점을 잡는 이유는 0도로 잡으면 카메라가 수평면 아래로 내려가지 않는 문제가 발생하기 때문이다.

 

그리고 x각이 180도보다 큰 경우는 아래쪽으로 회전하는 경우로 25도를 제한하기 위해서 335도에서 361도 사이로 값을 제한시킨다.

 

이렇게 구해진 x값을 camAngle의 x값 자리에 넣어준다.

 

 

카메라 회전을 제한시킨 기능을 에디터에서 테스트해보면 위아래 회전에 제한이 걸려서 카메라가 뒤집어지는 문제가 발생하지 않는 것을 확인할 수 있다.

 

이동기능 추가하기

 

이제 여기에 이동 기능을 추가할 차례이다.

 

이동방향 구하기

 

private void Move()
{
    Debug.DrawRay(cameraArm.position, cameraArm.forward, Color.red);
}

 

움직이는 기능을 추가하기 위해서 Move 함수를 만든다. 

 

캐릭터를 이동시키기 위해서는 먼저 캐릭터가 이동할 방향을 찾아야 한다. 이동할 방향으로 가장 적합한 대상은 카메라가 바라보고 있는 방향일 것이다.

 

cameraArm.forward를 사용하면 될 것 같은데 우선 이 값이 적당한 방향인지 확인하기 위해서 Debug.DrawRay로 카메라 암 위치에서 cameraArm.forward 방향으로 에디터에서 선으로 그려보자.

 

void Update()
{
    LookAround();
    Move();
}

 

Update 함수에서 Move 함수를 호출하게 만든다.

 

 

게임을 플레이시키고 마우스를 움직여보면 씬 뷰에서 Camera Arm 위치에 빨간색 선이 카메라 암이 쳐다보는 방향을 표시하는 것을 볼 수 있다.

 

캐릭터의 이동 방향으로 쓰기에 적당해 보이지만 위쪽이나 아래쪽을 쳐다볼 때는 선이 기울어지는 것을 볼 수 있다. 정상적인 이동 방향을 보장하기 위해서는 이 선을 수평으로 변환해야 한다.

 

private void Move()
{
    Debug.DrawRay(cameraArm.position, 
        new Vector3(cameraArm.forward.x, 0f, cameraArm.forward.z).normalized, Color.red);
}

 

새 벡터를 만들면서 cameraArm.forward의 y값을 넣지 않는 것으로 높이를 없애고 normalized 시켜서 길이를 1로 맞춰준다.

 

 

다시 에디터에서 게임을 플레이시키고 마우스를 움직여보면 위나 아래를 쳐다봐도 빨간 선의 방향이 수평을 유지하는 것을 볼 수 있다.

 

이동기능 구현하기

 

private void Move()
{
    Vector2 moveInput = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
    bool isMove = moveInput.magnitude != 0;
    animator.SetBool("isMove", isMove);
    if (isMove)
    {
        Vector3 lookForward = new Vector3(cameraArm.forward.x, 0f, cameraArm.forward.z).normalized;
        Vector3 lookRight = new Vector3(cameraArm.right.x, 0f, cameraArm.right.z).normalized;
        Vector3 moveDir = lookForward * moveInput.y + lookRight * moveInput.x;

        characterBody.forward = lookForward;
        transform.position += moveDir * Time.deltaTime * 5f;
    }
}

 

그럼 다시 스크립트 에디터로 돌아가서 이동 기능을 구현하는 것을 진행해보자.

 

우선 Input.GetAxis 함수로 "Horizontal"과 "Vertical"로 이동 입력 값을 가져와서 moveInput 벡터를 만든다. 그리고 moveInput의 길이로 입력이 발생하고 있는지 판정하여 isMove 변수에 넣어준다.

 

moveInput의 길이가 0이면 이동 입력이 없는 것이고 0이 아니면 이동 입력이 발생하고 있는 것이다.

 

이 isMove를 animator의 "isMove"에 세팅해주면 이동 입력이 발생할 때는 걷는 애니메이션을 재생하고, 이동 입력이 없을 때는 대기 애니메이션을 재생할 것이다.

 

그리고 아까 전에 구해낸 방향을 바라보는 방향으로 저장한다. 거기에 추가로 cameraArm.right 역시 정면과 마찬가지로 평면화시켜서 저장해둔다.

 

이렇게 구해낸 lookForward와 lookRight에 moveInput을 곱해서 더하면 바라보고 있는 방향을 기준으로 이동 방향을 구할 수 있다. 그리고 구해낸 이동 방향과 Time.deltaTime, 이동속도를 모두 곱해서 transform.position에 더해주면 캐릭터를 이동시킬 수 있게 된다.

 

이동시 바라보는 방향 1 : 카메라가 바라보는 방향

 

private void Move()
{
    Vector2 moveInput = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
    bool isMove = moveInput.magnitude != 0;
    animator.SetBool("isMove", isMove);
    if (isMove)
    {
        Vector3 lookForward = new Vector3(cameraArm.forward.x, 0f, cameraArm.forward.z).normalized;
        Vector3 lookRight = new Vector3(cameraArm.right.x, 0f, cameraArm.right.z).normalized;
        Vector3 moveDir = lookForward * moveInput.y + lookRight * moveInput.x;

        characterBody.forward = lookForward;
        transform.position += moveDir * Time.deltaTime * 5f;
    }
}

 

마지막으로 캐릭터가 바라보는 방향을 정하는 방법을 여러 가지로 구현할 수 있다.

 

첫 번째 방법은 캐릭터를 이동시킬 때 시선을 정면에 고정시키는 방법이다.

 

characterBody의 forward를 lookForward로 정해주면 된다.

 

 

캐릭터를 움직여보면 캐릭터를 앞뒤좌우 어느 방향으로 움직이든 카메라가 바라보는 방향을 바라본다.

 

이 기능을 위해서는 캐릭터가 좌우로 움직일 때는 옆걸음을 치는 애니메이션을 넣고, 뒤로 움직일 때는 뒷걸음을 치는 애니메이션을 넣어줘야 할 것이다.

 

이런 식으로 움직이는 방법은 캐릭터가 언제나 정면을 바라보기 때문에 좀 더 직관적인 컨트롤을 할 수 있다는 장점이 있다.

 

이동시 바라보는 방향 2 : 이동방향

 

private void Move()
{
    Vector2 moveInput = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
    bool isMove = moveInput.magnitude != 0;
    animator.SetBool("isMove", isMove);
    if (isMove)
    {
        Vector3 lookForward = new Vector3(cameraArm.forward.x, 0f, cameraArm.forward.z).normalized;
        Vector3 lookRight = new Vector3(cameraArm.right.x, 0f, cameraArm.right.z).normalized;
        Vector3 moveDir = lookForward * moveInput.y + lookRight * moveInput.x;

        characterBody.forward = moveDir;
        transform.position += moveDir * Time.deltaTime * 5f;
    }
}

 

characterBody.forward를 moveDir로 지정해주면 된다.

 

 

캐릭터를 움직여보면 캐릭터는 카메라가 바라보는 방향을 기준으로 앞뒤좌우로 움직이지만 정면을 바라보지 않고 이동해야하는 방향을 바라보는 것을 알 수 있다.

 

이렇게 구현된 컨트롤은 정면만 바라보는 컨트롤에 비해서 복잡하기는 하지만 좀 더 섬세한 방향 컨트롤이 가능해진다.

 

이 두 가지 방식 중에서 게임에서 필요한 방식을 선택해서 구현하면 된다.

반응형
  1. 테샤르 2020.06.08 12:56 신고

    잘보고갑니다!!

  2. 아무거나 2021.03.13 01:00

    혹시 이동기능 코드좀 올려주실수 있나요? 변수 선언 부분에서 cameraarm 선언부랑 characterbody 부분 선언을 어떻게 해야할지 감이 안와서요

반응형

UI 비법서 (7) 

Button 컴포넌트

 

작성 기준 버전 :: 2019.2

 

[이 포스트의 내용은 유튜브 영상으로도 시청하실 수 있습니다.]

 

아마 게임을 하면서 플레이어와 제일 많은 상호작용을 하는 UI 요소는 바로 버튼일 것이다. 버튼은 마우스의 클릭에 반응해서 지정해둔 기능을 동작하게 하는 UI이다. 이렇게 설명만 들어도 간단해보이는 Button 컴포넌트가 이렇게 영상까지 만들어서 이야기할 필요가 있을까 싶을 것이다.

 

버튼 게임 오브젝트 생성하기

 

하지만 이렇게 간단해보이는 Button 컴포넌트에도 유용하게 사용할 수 있는 팁들이 꽤 있다.

 

 

하이어라키 뷰에 우클릭하고 [UI > Button]을 선택하면 버튼 게임 오브젝트를 생성할 수 있다.

 

 

생성된 버튼 게임 오브젝트에는 Image 컴포넌트와 Button 컴포넌트가 부착되어 있는 것을 볼 수 있다. 거기에 기본적으로 버튼 게임 오브젝트 아래에는 버튼에 글자도 표시되게 Text 컴포넌트가 붙어있는 게임 오브젝트도 자식으로 있는 것을 알 수 있다. 이게 버튼의 제일 기본적인 상태이다.

 

Button 컴포넌트의 프로퍼티

 

그럼 Button 컴포넌트의 프로퍼티들을 하나씩 알아보도록 하자.

 

Interactable

 

첫 번째 프로퍼티는 Interactable이다. Interactable은 버튼이 상호작용 가능한지를 정하는 프로퍼티입니다.

 

 

먼저 Interactabla이 체크된 상태에서 클릭해보면 클릭할 때마다 버튼이 깜빡깜빡하며 버튼과 마우스 사이의 상호작용이 일어나고 있는 것을 확인할 수 있다.

 

 

Interactable을 끄면 먼저 버튼의 색깔이 약간 어두워지고 반투명해지는 것을 볼 수 있다. 그 상태에서 버튼을 클릭해보면 아까와는 다르게 상호작용하면서 발생하는 깜빡임이 나타나지 않는 것을 알 수 있다.

 

이 프로퍼티는 특정한 조건에 따라서 버튼을 활성화/비활성화 하는 기능으로 응용할 수 있다.

 

Transition

 

그다음 프로퍼티는 Transition이다. Transition은 버튼이 각 상호작용 상태에서 어떤 모습으로 보일 것인지를 결정한다.

 

 

버튼의 상호작용 상태로는 먼저 아무런 상호작용도 일어나고 있지 않은 Normal, 마우스가 버튼 위에서 호버링되고 있는 상태인 Highlighted, 버튼을 클릭하고 손을 떼지 않은 상태인 Pressed, 버튼에 클릭을 하고 손을 뗀 상태인 Selected, 버튼이 비활성화된 Disabled. 

 

이렇게 총 5가지가 있다.

 

그리고 선택 가능한 Transition의 종류로는 None, Color Tint, Sprite Swap, Animation이 있다.

 

None

 

None 타입의 트랜지션은 버튼과의 상호작용이 일어나도 아무런 표현을 하지 않는 타입이다.

 

Color Tint

 

버튼을 생성하면 기본 트랜지션으로 설정되는 Color Tint는 버튼에 상호작용을 했을 떄 상태변화를 생상으로 표현하는 것이다.

 

위에서 버튼을 클릭할 때마다 버튼이 깜빡깜빡하던 것을 기억할 것이다. 여기 Pressed를 보면 색깔이 약간 어두운 색으로 되어 있다. 이 Pressed에 설정된 색상이 버튼 이미지에 합쳐져서 버튼을 클릭할 때마다 깜빡거렸던 것이다.

 

Sprite Swap

 

Sprite Swap은 버튼의 상호작용 상태에 따라서 버튼 게임 오브젝트의 Image를 완전히 교체하는 방식이다.

 

 

Transition을 Sprite Swap으로 바꾸면 각 버튼 상호작용 상태에 따라서 색깔을 바꾸던 프로퍼티들이 스프라이트를 넣도록 바뀐 것을 볼 수 있다.

 

Sprite Swap 타입에서는 Normal 상태가 없는 것을 볼 수 있는데 Sprite Swap의 기본 상태는 버튼에 붙은 기본 이미지의 Image Source여서 바꿔넣을 필요가 없기 때문이다.

 

  

버튼 이미지에 기본 이미지를 넣고 Pressed에 버튼이 눌린 이미지를 넣으면 위의 그림과 같이 버튼이 눌리는 듯한 연출이 가능하다.

 

Animation

 

Animation 타입은 버튼의 색깔만 바뀌는 Color Tint나 이미지만 바뀌는 Sprite Swap에 비해서 좀 더 복잡하고 다양한 연출이 필요할 때 사용한다.

 

 

Animation 타입에서 Transition의 하위 프로퍼티는 각 상태에 맞는 이름으로 되어있는데 버튼 애니메이션에 추가될 스테이트와 파라미터의 이름을 지정하는 부분입니다. 이 부분은 따로 손댈 필요가 없다.

 

아래에 있는 Auto Generate Animation 버튼을 누르면 버튼에 대한 애니메이터 컨트롤러를 생성하는 대화상자가 뜬다. 여기서 애니메이터를 생성하면 하면 자동으로 버튼 게임 오브젝트에 애니메이터 컴포넌트가 추가되고 애니메이터에서 필요한 스테이트와 파라미터들이 자동으로 생성된다.

 

버튼 게임 오브젝트를 선택한 상태로 [Ctrl +6] 단축키를 누르면 애니메이션 뷰가 열리는데 개발자는 여기서 각 상태의 애니메이션 키만 잡아주면 된다.

 

 

  

이미지 하나를 버튼의 자식 게임 오브젝트로 만들고 버튼 위에 마우스를 올렸을 때, 이미지를 활성화 시키면서 360도 회전시키는 애니메이션을 만들어보자.

 

 

그 다음 플레이시킨 뒤 버튼에 마우스를 올려보면 버튼 앞에서 사각형이 나타나서 회전하는 것을 볼 수 있다.

 

이런 연출은 확실히 Color Tint나 Sprite Swap으로는 할 수 없는 연출이다. 일반적으로는 Color Tint나 Sprite Swap을 사용하지만 버튼과의 상호작용 연출에서 이런 다양한 연출을 사용하려면 Animation 타입의 Transition을 사용하는 것이 좋다.

 

Navigation

 

그 다음 프로퍼티인 Navigation은 모든 UI 컴포넌트에 있는 것으로 키보드나 패드로 UI를 선택할 때 어떤 순서로 포커스가 넘어갈 것인지를 결정하는 옵션dl다.

 

On Click 이벤트

 

 

On Click()은 버튼을 눌렀을 때 실행될 기능을 넣을 수 있는 On Click 이벤트 리스트이다.

 

public class ButtonTester : MonoBehaviour
{
    public void OnClickButton()
    {
        Debug.Log("Button Click!");
    }
}

 

On Click에 실행될 함수를 이벤트로 등록해주기 위해서는 스크립트에서 public으로 된 함수를 만들면 된다.

 

 

그리고 버튼에 스크립트로 작성한 컴포넌트를 추가하고 On Click 항목의 [+] 버튼을 누른 뒤 비어있는 오브젝트 란에 호출할 함수를 가진 컴포넌트를 넣어주고 호출해야할 함수를 선택해주면 된다.

 

이렇게 함수를 선택해주면 이 버튼이 클릭되었을 때, 이 함수가 실행되게 된다.

 

스크립트에서 On Click 이벤트 등록하기

 

버튼의 응용 기술로는 이렇게 에디터에서 고정으로 이벤트를 등록하지 않고 스크립트에서 동적으로 등록해주는 방법이 있다.

 

using UnityEngine.UI;

public class ButtonTester : MonoBehaviour
{
    Button button;

    public void OnClickButton()
    {
        Debug.Log("Button Click!");
    }

    void Start()
    {
        button = GetComponent<Button>();
        button.onClick.AddListener(OnClickButton);
    }
}

 

스크립트에서 Button 컴포넌트와 같은 UI의 기능을 사용하기 위해서는 UI 네임 스페이스를 using 선언해줘야 한다.

 

그 다음에는 Button 변수를 선언하고 Start 함수에서 GetComponent로 게임 오브젝트에 부착된 Button 컴포넌트를 가져온다. 그리고 button.onClick.AddListener에 함수를 넣어주면 이 함수가 이 버튼을 클릭했을 때 호출되는 이벤트로 등록된다.

 

게임을 플레이해보면 Button 컴포넌트의 On Click에서는 아무런 이벤트가 등록되지 않은 것처럼 보이지만 게임 뷰에서 버튼을 클릭해보면 스크립트에서 버튼의 이벤트로 등록해준 ButtonTester의 OnClickButton 함수가 정상적으로 호출되어 로그를 출력하는 것을 볼 수 있다.

 

버튼 터치 영역 키우기

 

모바일 기기에서 작은 버튼은 터치가 잘 안되거나 잘못 터치할 확률이 높다. 이런 문제를 해결하기 위해서 그냥 버튼의 크기를 키워버릴 수도 있지만 그렇게 하면 디자이너가 지정해준 전체적인 UI 레이아웃이 깨지게 될 것이다. 그러니 실제로 보이는 버튼의 크기는 키우지 않고 버튼의 터치 영역만 키워야 한다.

 

 

우선 버튼의 자식 컴포넌트로 이미지를 하나 추가하고 적당히 버튼을 덮도록 세팅한 다음 투명하게 만들어주면 된다.

 

 

그러면 눈에 보이는 버튼 이미지보다 바깥을 클릭해도 버튼이 무사히 동작하는 것을 볼 수 있다.

 

이것을 응용해서 버튼보다 작은 투명 이미지를 만들고 그 투명 이미지를 제외한 나머지의 Raycast Target을 끄는 방법으로 실제 버튼 이미지보다 작은 영역만 클릭 가능하게 만드는 것도 가능하다.

 

버튼 이미지보다 작은 영역에 터치를 받는 테크닉은 잘 사용되지 않을 것 같지만 버튼 이미지 리소스 외곽에 그림자나 글로우가 들어간 경우에 글로우 영역이나 그림자 영역을 제외하고 실제 버튼 영역에만 클릭을 받기 위해서 사용되는 경우가 종종 있다.

 

반응형
  1. 테샤르 2020.05.25 13:07 신고

    잘보고갑니다!

반응형

Programming 

클릭한 위치로 캐릭터를 이동시키는 기능 구현하기

 

작성 기준 버전 :: 2019.2

 

[이 포스트의 내용은 유튜브 영상으로도 시청하실 수 있습니다]

 

게임의 조작 방식에는 굉장히 다양한 방법이 있다. 그 중에서 쿼터 뷰 형식의 RPG 게임이나 AOS 장르에서는 맵의 지형을 클릭하고 그 클릭한 위치로 캐릭터를 이동시키는 조작 방식을 사용한다.

 

쿼터 뷰 RPG나 AOS 장르처럼 클릭한 위치로 캐릭터를 이동시키는 기능을 만드는 방법을 알아보자.

 

클릭한 위치로 캐릭터 이동시키기

 

public class ClickMovement : MonoBehaviour
{
    private Camera camera;

    private bool isMove;
    private Vector3 destination;

    private void Awake()
    {
        camera = Camera.main;
    }
}

 

클릭한 위치로 캐릭터를 이동시키는 기능을 구현하기 위해서 먼저 위와 같은 변수들을 선언해준다.

 

Camera는 스크린 좌표계로 표시되는 마우스의 위치를 월드 좌표계로 표시하기 위해서 선언했다. 그리고 isMove는 캐릭터가 움직이고 있는 중인지 판정하기 위해서, destination은 캐릭터가 목표 위치를 기억하기 위해서 선언했다.

 

그리고 Awake 함수에서 씬에 존재하는 메인 카메라를 가져와서 camera 매개변수에 저장해준다.

 

private void SetDestination(Vector3 dest)
{
    destination = dest;
    isMove = true;
}

 

SetDestination 함수를 만들어서 그 안에 매개변수로 받아온 dest를 destination에 넣어주고 isMove를 true로 만들어 주는 코드를 작성한다. SetDestination 함수는 클릭으로 찾아낸 마우스의 위치를 저장해두고 이동을 시작하게 하는 역할을 한다.

 

private void Move()
{
    if (isMove)
    {
        if (Vector3.Distance(destination, transform.position) <= 0.1f)
        {
            isMove = false;
            return;
        }
        var dir = destination - transform.position;
        transform.position += dir.normalized * Time.deltaTime * 5f;
    }
}

 

Move 함수를 만든다. Move 함수에서는 isMove가 true일 때, 즉 SetDestination에서 목적지를 지정한 뒤에 목적지와 캐릭터의 거리가 0.1 유니티 미터보다 작으면 이동을 중지하고, 아직 도착하지 못한 상태라면 캐릭터의 위치에서 목적지로 향하는 방향을 구해서 캐릭터를 이동시키는 작업을 처리한다.

 

void Update()
{
    if (Input.GetMouseButton(1))
    {
        RaycastHit hit;
        if (Physics.Raycast(camera.ScreenPointToRay(Input.mousePosition), out hit))
        {
            SetDestination(hit.point);
        }
    }

    Move();
}

 

SetDestination 함수와 Move 함수를 모두 작성하면 Update 함수로 가서 마우스 오른쪽 버튼을 클릭했을 때 마우스 커서의 위치를 찾아내는 코드를 작성한다.

 

public class ClickMovement : MonoBehaviour
{
    private Camera camera;

    private bool isMove;
    private Vector3 destination;

    private void Awake()
    {
        camera = Camera.main;
    }

    void Update()
    {
        if (Input.GetMouseButton(1))
        {
            RaycastHit hit;
            if (Physics.Raycast(camera.ScreenPointToRay(Input.mousePosition), out hit))
            {
                SetDestination(hit.point);
            }
        }

        Move();
    }

    private void SetDestination(Vector3 dest)
    {
        destination = dest;
        isMove = true;
    }

    private void Move()
    {
        if (isMove)
        {
            if (Vector3.Distance(destination, transform.position) <= 0.1f)
            {
                isMove = false;
                return;
            }

            var dir = destination - transform.position;
            transform.position += dir.normalized * Time.deltaTime * 5f;
        }
    }
}

 

코드의 전문은 위와 같다.

 

 

이 컴포넌트를 캐릭터에 부착하고 콜라이더가 있는 맵에 우클릭하면 클릭한 위치로 캐릭터가 이동하는 것을 볼 수 있다.

 

기능 개선 1 : 카메라가 캐릭터 따라가게 하기

 

하지만 몇 가지 개선해야할 점이 보인다. 우선 카메라가 캐릭터를 따라가지 않아서 위쪽을 클릭하면 캐릭터가 화면 밖을 벗어나버리는 것을 볼 수 있다.

 

 

카메라가 캐릭터를 따라가게 하기 위해서 카메라를 캐릭터의 자식 오브젝트로 만들어보자.

 

 

다시 게임을 플레이시키고 우클릭으로 캐릭터를 이동시켜보면 카메라가 캐릭터를 따라서 움직이는 것을 확인할 수 있다.

 

기능 개선 2 : 캐릭터가 이동할 방향을 바라보게 하기

 

두 번째로 개선할 점은 캐릭터가 이동할 방향을 바라보게 하는 것이다. 지금은 캐릭터를 이동시켜도 게임을 시작할 때 바라보고 있던 방향만을 바라보면서 이상하게 이동하는 것을 볼 수 있다.

 

private void Move()
{
    if (isMove)
    {
        if (Vector3.Distance(destination, transform.position) <= 0.1f)
        {
            isMove = false;
            return;
        }
        var dir = destination - transform.position;
        transform.foward = dir;	// 추가
        transform.position += dir.normalized * Time.deltaTime * 5f;
    }
}

 

Move 함수를 보면 캐릭터가 이동할 방향을 구해둔 부분이 있다. 이 방향을 캐릭터의 transform.forward에 넣어주면 이동하는 방향을 바라보게 할 수 있다.

 

 

그리고 다시 게임을 플레이시키고 이동할 지점을 우클릭 해보면 우클릭한 지점을 캐릭터가 바라보면서 이동하기는 한다. 하지만 카메라가 캐릭터의 자식 오브젝트로 되어있어서 캐릭터의 방향이 바뀔 때 카메라도 함께 돌아가서 굉장히 어지럽다.

 

 

약간 구조를 바꿔야 될 것 같다. 우선 Character Root라는 이름으로 빈 게임 오브젝트를 만든다. 그리고 BoxMan을 그 아래로 옮기고 BoxMan 밑에 있는 카메라 역시 Charactor Root 아래로 옮겨준다.

 

이렇게 구조를 만들면 캐릭터를 이동시킬 때 카메라가 캐릭터가 따라서 움직이는 것을 유지하면서 BoxMan을 정상적으로 회전시킬 수 있다.

 

구조는 완성되었으니 본격적으로 기능을 수정해보자.

 

 

우선 BoxMan에 붙어있는 ClickMovement를 Character Root로 옮겨서 이동하는 주체를 Character Root로 바꿔준다.

 

[SerializeField]
private Transform character;	// 추가

private void Move()
{
    if (isMove)
    {
        if (Vector3.Distance(destination, transform.position) <= 0.1f)
        {
            isMove = false;
            return;
        }
        var dir = destination - transform.position;
        character.transform.foward = dir;	// 변경
        transform.position += dir.normalized * Time.deltaTime * 5f;
    }
}

 

캐릭터 몸체 만을 회전시키기 위해서 Transform 변수를 선언하고 회전 주체를 transform에서 character.transform으로 바꿔준다.

 

 

코드를 모두 작성하면 유니티 에디터로 돌아와서 비어있는 Character 프로퍼티에 회전해야 하는 캐릭터 게임 오브젝트를 넣어준다.

 

 

그 다음 플레이시켜보면 카메라는 회전하지 않고 BoxMan이 우클릭한 방향을 바라보며 이동하는 것을 볼 수 있다.

 

이제 기능이 완성된 것처럼 보인다.

 

 

하지만 여기에 장애물이 추가되면 어떨까?

 

 

Character Root에는 장애물과 충돌할 수 있게 Capsule Collider와 Rigidbody를 추가한다. 그리고 장애물과 충돌했을 때 캐릭터가 넘어지지 않도록 Constraints의 Freeze Rotation을 전부 체크해준다.

 

 

다시 게임을 플레이시키고 캐릭터를 벽 너머로 이동시키기 위해서 벽 너머를 우클릭하면 돌아서 이동하지 못하고 벽에 계속 부딪히기만 하는 것을 볼 수 있다. 게다가 벽 위를 클릭하면 벽 위에 올라서기 위해서 점프까지 하는 것을 볼 수 있다.

 

기능 개선 3 : 길찾기 기능 추가하기

 

꽤 재밌어보이는 기능이 완성됐지만 우리의 목적은 캐릭터가 장애물을 회피해서 이동하는 것이기 때문에 다시 개선하는 작업에 들어가보자.

 

일단 Character Root에 붙어있는 Capsule Collider의 Is Trigger를 체크해준다.

 

일반적인 RPG 게임이나 AOS 게임에서는 장애물 건너편으로 이동하기 위해서 조작을 하면 캐릭터가 자동으로 이동할 수 있는 길을 찾아서 이동하는 것을 볼 수 있다.

 

캐릭터가 장애물을 돌아서 이동하기 위해서는 길찾기 기능이 필요하다. 길찾기 알고리즘을 직접 구현할 수도 있지만, 유니티 엔진에서 제공하는 내비게이션 기능을 사용하면 훨씬 간단하게 구현할 수 있다.

 

 

우선 상단 메뉴 바에서 [Window > AI > Navigation]을 선택해서 내비게이션 뷰를 연다. 그리고 장애물과 바닥을 선택하고 내비게이션 뷰에서 Navigation Static을 체크하고 내비게이션 메시를 구워준다.

 

 

그러면 캐릭터가 이동할 수 있는 영역이 파란색으로 표시된다.

 

 

그 다음에는 캐릭터를 이 내비게이션 메시 위에서 길을 찾아 움직일 수 있는 에이전트로 만들어줘야 한다. Character Root에 NavMeshAgent 컴포넌트를 붙여준다. 

 

using UnityEngine.AI;  // 추가

public class ClickMovement : MonoBehaviour
{
    private Camera camera;
    private NavMeshAgent agent;  // 추가

    private bool isMove;
    private Vector3 destination;

    private void Awake()
    {
        camera = Camera.main;
        agent = GetComponent<NavMeshAgent>();  // 추가
        agent.updateRotation = false;  // 추가
    }

    void Update()
    {
        if (Input.GetMouseButton(1))
        {
            RaycastHit hit;
            if (Physics.Raycast(camera.ScreenPointToRay(Input.mousePosition), out hit))
            {
                SetDestination(hit.point);
            }
        }

        // Move();
        LookMoveDirection();  // 변경
    }

    private void SetDestination(Vector3 dest)
    {
        agent.SetDestination(dest);  //추가
        destination = dest;
        isMove = true;
    }

    // private void Move()
    private void LookMoveDirection()  // 변경
    {
        if (isMove)
        {
            // if (Vector3.Distance(destination, transform.position) <= 0.1f)
            if (agent.velocity.magnitude == 0.0f)  // 변경
            {
                isMove = false;
                return;
            }

            // var dir = destination - transform.position;
            var dir = new Vector3(agent.steeringTarget.x, transform.position.y, agent.steeringTarget.z) - transform.position;  // 변경
            character.transform.foward = dir;
        }
    }
}

 

그리고 스크립트 에디터로 돌아가서 AI 네임스페이스를 추가해주고 NavMeshAgent 변수를 만든다. 그 다음 Awake 함수에서 게임 오브젝트에 붙어있는 NavMeshAgent 컴포넌트를 가져와서 넣어준다.

 

컴포넌트를 가져온 다음에는 agent가 Character Root를 회전시키지 않도록 updateRotation을 false로 바꿔줍니다. 그리고 SetDestination 함수에서는 매개변수로 받아온 목표 위치를 agent.SetDestination 함수에 넣어준다.

 

이렇게 하면 NavMeshAgent가 자동으로 길을 찾아서 캐릭터를 이동하도록 만들어준다.

 

그러면 Move 함수의 원래 목적은 사라지는데, 대신 Move 함수는 BoxMan이 이동 방향을 쳐다보게 하는 기능을 해야한다.

 

변경되는 기능에 따라 함수의 이름은 LookMoveDirection으로 바꿔주면 좋다. 원래 코드는 BoxMan이 destination을 쳐다보게 되어있는데 이렇게 하면 캐릭터가 길을 돌아서 이동하는 와중에도 목적지만 쳐다봐서 부자연스럽게 움직이게 된다.

 

캐릭터는 목적지 대신에 자신이 이동하는 방향을 쳐다봐야 한다. direction을 구할 때 destination 대신 agent.steeringTarget을 사용해야 한다. 다만 아까 전에 캐릭터와 높이가 다른 목적지를 클릭 했을 때 캐릭터가 기울어졌던 것을 기억할 것이다. 캐릭터가 높이가 다른 곳을 쳐다보면서 기울어지는 것을 막기위해서 캐릭터가 agent의 steeringTarget 방향을 바라보되 높이는 캐릭터 자신의 높이를 바라보게 해야 한다. 그리고 도착 판정을 거리 대신에 agent의 속도로 변경하여 목적지에 도착한 경우면 함수를 종료하도록 만들어 준다.

 

이렇게 하면 캐릭터가 목적지에 도착한 다음 엉뚱한 방향을 바라보지 않게 될 것이다.

 

 

게임을 플레이시키고 우클릭으로 캐릭터를 이동시켜보면 잘 동작하는 것을 볼 수 있다. 벽 건너편을 클릭해도 제대로 돌아서 이동하고 벽 위를 클릭해도 캐릭터가 기울어지며 올라가려는 시도를 하지 않는 것을 알 수 있다.

 

반응형
  1. 테샤르 2020.05.14 10:21 신고

    잘보고갑니다 :D
    저도 지금 회사에서 비슷한 작업하고있는데 생각보다 어렵더라고요 ./

    • wergia 2020.05.14 10:54 신고

      조금만 방심하면 버그같은 기능들이 우수수 쏟아지죠 :<

      진짜 이것저것 다해보면서 꼼꼼하게 테스트해야 버그가 안생기는 것 같아요!

  2. 2020.07.22 21:02

    비밀댓글입니다

    • wergia 2020.07.23 09:13 신고

      음 상황만 들어서는 원인을 파악하기 어렵습니다. 프로젝트 전체를 확인해봐야 파악이 가능할 것 같아요.

      우선 간단하게 짐작해보자면 캐릭터가 뒤로 걷거나 바닥에 파묻히는 문제는 땅 바닥 부분과 캐릭터의 발부분 높이가 맞지 않아서 그런 것으로 생각됩니다. 바닥의 높이를 낮추거나 캐릭터의 높이를 높여보세요.
      장애물을 통과하는 부분은 길찾기 쪽 구현에서 문제가 생긴게 아닌가 싶네요.

  3. 2020.07.23 11:44

    비밀댓글입니다

    • wergia 2020.07.23 12:10 신고

      "SetDestination" can only be called on an active agent that has been placed on a NavMesh.
      라는 에러로그는 NavMeshAgent가 Bake된 NavMesh 위에 있지 않다는 뜻입니다.
      내비게이션 뷰를 활성화시킨 상태에서 NavMeshAgent 컴포넌트를 붙인 게임 오브젝트를 선택하고 에이전트의 초록색 선 원기둥이 파란색으로 표시되는 NavMesh 영역 위에 있는지 확인해보세요.

  4. 2020.07.23 13:37

    비밀댓글입니다

    • wergia 2020.07.23 15:48 신고

      ㅎㅎ 도움이 되었다니 정말 기쁩니다
      공부 열심히 하셔서 나중에 재밌는 게임으로 뵙는 날이 있으면 좋겠습니다 ㅎㅎ

반응형

Animation 

애니메이션 이벤트

 

작성 기준 버전 :: 5.6 - 2019.2

 

[이 포스트의 내용은 유튜브 영상으로도 시청하실 수 있습니다]

 

게임에서는 애니메이션이 실행되는 도중에 적절한 타이밍에 맞춰서 무언가가 실행되어야 하는 경우가 종종 발생한다. 예를 들자면 캐릭터가 이동할 때 발 동작에 맞춰서 발소리가 나거나 먼지가 일어나야 하는 경우, 캐릭터가 무기를 휘두를 때 타이밍에 맞춰서 대미지가 들어가야 하는 경우등이 있을 수 있다.

 

이때 발 움직임과 다르게 소리가 나거나 공격하는 동작과 다르게 대미지가 들어간다면 플레이어는 굉장히 거슬리는 느낌을 받게 될 것이다.

 

이런 애니메이션과 리액션의 타이밍을 맞추기 위해서 존재하는 기능이 바로 유니티의 애니메이션 이벤트이다. 이 기능은 애니메이션 실행 도중에, 원하는 시점에 원하는 함수를 호출할 수 있게 해준다.

 

애니메이션 이벤트를 사용하는 방법은 FBX 모델과 함께 임포트된 애니메이션에서 사용하는 방법과 애니메이션 클립에서 사용하는 방법, 이렇게 2가지가 있다.

 

방법 1 : FBX 파일에서 모델과 함께 임포트된 애니메이션에서 애니메이션 이벤트 사용하기

 

 

프로젝트 뷰에서 임포트한 FBX 파일을 선택하면 인스펙터 뷰에 그 FBX 파일의 정보들이 보인다. 그 중에 상단의 Animations 탭을 선택하면 그 FBX에 포함된 애니메이션의 정보를 볼 수 있다.

 

애니메이션 이벤트 기능은 Events 항목에서 볼 수 있다.

 

 

접혀있는 Events 항목을 열면 이런 타임라인과 비활성화된 필드를 볼 수 있다.

 

 

애니메이션에 이벤트를 추가하기 위해서는 먼저 이벤트를 추가할 지점을 애니메이션에서 선택해야 하는데 인스펙터 뷰의 제일 아래를 보면 이렇게 지금 선택한 FBX 파일에 포함되어 있는 애니메이션을 미리보기로 재생해볼 수 있는 부분이 있다.

 

여기서 재생 버튼 옆의 타임라인을 클릭해보면 애니메이션의 특정 지점을 선택할 수 있게 된다.

 

이 타임라인에서 애니메이션 지점을 선택하면 위쪽의 전체 애니메이션 타임라인은 물론이고 Events의 타임라인도 함께 움직이는 것을 볼 수 있다.

 

 

애니메이션 중에 팔을 적절한 지점을 선택하고 Events 타임라인 앞에 있는 버튼을 클릭하면 그러면 Events 타임라인에 작은 표식이 생기며 아래 쪽에 있는 입력 가능한 필드들이 활성화되는 것을 볼 수 있다.

 

참고로 파란색 표식을 잡고 드래그하면 언제든지 이벤트가 실행될 시점을 조절할 수 있다.

 

 

 필드 이름 내용 
Function  이벤트 시점에 호출할 함수의 이름 
Float  함수에 float형 매개변수가 있을 때 들어갈 값 
Int  함수에 int형 매개변수가 있을 때 들어갈 값 
String  함수에 string형 매개변수가 있을 때 들어갈 값 
Object  함수에 Object형 매개변수가 있을 때 들어갈 값 
 
필드에 입력되는 내용은 위와 같다.
 
참고로 애니메이션 이벤트가 함수를 호출하는 방식은 여기에 넣어준 함수 이름으로 애니메이터 컴포넌트가 붙어있는 게임 오브젝트에 부착되어 있는 모든 컴포넌트에서 같은 이름을 가진 함수를 찾아서 실행하는 것이다.

 

만약 게임 오브젝트에 붙어있는 A라는 컴포넌트와 B라는 컴포넌트가 둘 다 Attack이라는 함수를 가지고 있다면 이 두 함수가 모두 호출된다.

 

 

Attack 애니메이션이니 이벤트에서 호출할 함수의 이름은 Attack으로 설정하고 매개변수에는 스크립트에서 여기에 입력된 값들이 넘어오는 것을 체크하기 위해서 Float에는 3.14, Int에는 10, String에는 hello, Object에는 씬에서 큐브 게임 오브젝트를 하나 생성한 뒤 프리팹으로 만들어서 넣어주자.

 

그리고 Apply 버튼을 누르면 추가한 애니메이션 이벤트가 완전히 적용된다.

 

 

그리고 Attack 애니메이션을 재생할 애니메이터 컨트롤러를 만들어서 애니메이터 뷰를 열고 Attack 트리거를 만들어준 다음 Stand와 Attack 애니메이션을 스테이트로 넣어주고, Attack 트리거가 들어오면 Attack 애니메이션이 재생된 다음 Stand로 돌아가게 구성해준다.

 

그리고 BoxMan을 씬에 배치하여 애니메이터에 애니메이터 컨트롤러를 넣어준다.

 

using UnityEngine;

public class AttackComponent : MonoBehaviour
{
    private void Attack()
    {
        Debug.Log("Damage");
    }
}

 

애니메이션 이벤트가 실행할 함수를 만들어주기 위해서 AttackComponent C# 스크립트를 생성하고 코드를 작성한다.

 

 

애니메이션 이벤트는 앞에서 이야기한 것과 같이 애니메이션 이벤트가 포함된 애니메이터 컨트롤러를 가진 컴포넌트과 같은 게임 오브젝트에 부착된 컴포넌트 중에서 이벤트의 Function에 넣어준 이름과 같은 함수를 찾아서 실행해준다.

 

애니메이션 이벤트의 매개변수

 

private void Attack(float f)
{
    Debug.Log("Damage");
}

private void Attack(int i)
{
    Debug.Log("Damage");
}

private void Attack(string str)
{
    Debug.Log("Damage");
}

private void Attack(Object obj)
{
    Debug.Log("Damage");
}

 

애니메이션 이벤트를 정의할 때 넣어준 매개변수를 받아오기 위해서는 이벤트로 만든 함수의 비어있는 괄호 안에 받아올 매개변수 타입과 이름을 넣어주면 된다. 

 

애니메이션 이벤트의 매개변수를 사용할 때 주의할 점이 있는데, 애니메이션 이벤트 함수에서 받아올 수 있는 매개변수의 종류는 하나 뿐이라는 것이다.

 

private void Attack(float f, int i)
{
    Debug.Log("Damage");
}

 

그래서 이렇게 하나보다 많은 매개변수를 가져오려고 하면 애니메이션 이벤트가 실패하게되서 제대로 동작하지 않게 되므로 애니메이션 이벤트의 매개변수를 사용할 때는 하나의 매개변수 타입만을 받아와서 사용해야 한다.

 

그리고 한 컴포넌트에 같은 이름의 함수를 여러 개 만들어두면 스크립트 파일에서 제일 상단에 만들어둔 함수 하나만 이벤트로 호출된다.

 

방법 2 : 애니메이션 클립에서 애니메이션 이벤트 사용하기

 

애니메이션 클립은 유니티 엔진에서 3D 모델없이 애니메이션에 대한 정보만을 가지고 있는 것을 이야기한다. 애니메이션 클립을 만드는 방법은 유니티 엔진에서 직접 만드는 방법도 있고 FBX 파일에서 애니메이션 클립 만을 추출해서 사용할 수도 있다.

 

 

FBX 파일에서 애니메이션 클립 만을 추출하기 위해서는 FBX 파일의 접힌 부분을 확장해보면 재생 버튼 모양의 애니메이션 클립이 보이는데 이것을 선택하고 [Ctrl + D] 단축키를 누르면 애니메이션 클립이 복사되어 추출된다.

 

애니메이션의 최적화를 위해서 이렇게 모델링 정보를 제외하고 애니메이션 클립만 추출해서 사용하기도 한다.

 

애니메이션 클립을 선택하고 [Ctrl + 6] 단축키를 누르면 애니메이션 뷰가 열리고 현재 선택한 애니메이션의 키 값 등의 정보가 표시된다.

 

 

애니메이션 뷰에서도 FBX 파일의 애니메이션 이벤트에서 봤던 것과 같은 모양의 AddEvent 버튼을 볼수 있는데 이것을 누르면 새로운 이벤트를 생성할 수 있다.

 

 

이렇게 추가한 이벤트를 선택하면 인스펙터 뷰에서 FBX 파일에서 이벤트를 만들 때와 같이 호출할 함수의 이름과 매개변수를 넣을 수 있게 되어있다.

 

이 후에는 앞에서와 같이 여기서 추가한 이벤트의 이름과 같은 함수를 스크립트에서 만들어주면 애니메이션 이벤트 기능이 정상적으로 동작하는 것을 확인할 수 있다.

 

애니메이션 이벤트의 단점

 

이 애니메이션 이벤트는 굉장히 편리해보이지만 몇 가지 단점이 있어서 신중하게 사용해야 한다.

 

첫 번째 단점은 레거시(Legacy) 애니메이션을 사용할 때 프레임이 심하게 낮아지거나, 메카님 애니메이션에서 트랜지션을 설정할 때 블랜드되는 과정을 잘못 설정하면 애니메이션 이벤트가 실행되지 않는 경우가 발생할 수 있다.

 

이동할 때 먼지가 발생하는 수준의 기능이면 큰 문제가 되지 않겠지만, 앞에서 구현한 예시처럼 타이밍에 맞춰서 대미지를 넣는 기능을 애니메이션 이벤트로 만들었는데 이게 스킵되는 문제가 발생하면 게임에 심각한 영향을 미치게 된다.

 

두 번째 단점으로는 함수의 호출 구조를 파악하는게 어려워진다는 것이다.

 

 

스크립트 에디터에서 애니메이션 이벤트로 만든 함수를 보면 위쪽에 참조 0개라는 글자가 보인다. 이것은 이 함수를 전체 스크립트에서 몇 군데의 위치에서 호출하고 있는지를 알려준다.

 

 

예를 들어서 Start 함수에서 이 Attack 함수를 호출하고 있다면 참조 0개가 참조 1개로 바뀌는 것을 볼 수 있다.

 

 

이 글자를 클릭해보면 어디서 호출되고 있는지 볼 수 있고 호출되고 있는 위치로도 바로 이동할 수도 있다.

 

게임에 버그가 발생했을 때는 이런 방식으로 호출 구조를 파악해서 흐름을 확인해야 하는 경우가 많은데 애니메이션 이벤트로 호출된 함수는 이 참조에 표시되지 않기 때문에 문제가 발생했을 때 문제가 발생한 지점을 찾기가 어려워 진다.

 

세 번째 단점은 네트워크 게임을 만들 때 애니메이션 이벤트를 사용하면 굉장히 곤란한 문제가 발생할 확률이 높다는 것이다.

 

공격과 대미지 타이밍 같은 처리는 굉장히 중요한 판정으로 분류되기 때문에 네트워크 게임에서는 주로 서버가 처리해야되는 작업인데 서버는 최적화가 굉장히 중요하기 때문에서 애니메이션 재생 같은 부분은 처리하지 않도록 하는 경우가 많다.

 

그렇게 최적화를 진행한 서버에서는 애니메이션 재생이 진행되지 않으니 애니메이션 이벤트 역시 실행되지 않을 것이다. 그렇기 때문에 네트워크 게임에서는 공격 타이밍과 관련된 방법을 애니메이션 이벤트가 아닌 다른 방법으로 구현해야 한다.

 

 

반응형
  1. 테샤르 2020.05.11 13:06 신고

    잘보고갑니다~

반응형

내비게이션 시스템 (2) 

NavMeshAgent와 NavMeshObstacle

 

작성 기준 버전 :: 2019.2

 

[이 포스트는 유튜브 영상으로도 시청하실 수 있습니다]

 

이전 포스트에서는 유니티의 내비게이션 시스템 중에서 길찾기 영역을 설정하는 NavMesh에 대해서 알아보았다.

 

이번 포스트에서는 이 NavMesh 위에서 길을 찾아서 움직일 NavMeshAgent와 NavMeshAgent의 길을 방해하는 NavMeshObstacle에 대해서 알아보도록 하자.

 

NavMeshAgent

 

Agent는 내비게이션 메시 위에서 길을 찾아서 움직일 오브젝트를 의미한다.

 

 

우선 캡슐 게임 오브젝트를 하나 만들어서 내비게이션 메시 위에 배치해보자. 그리고 캡슐의 자식 게임 오브젝트로 큐브 하나를 배치하고 캡슐의 정면이 어디를 가리키고 있는지 알기 쉽게 만든다. 거기에 더해 Rigidbody 컴포넌트를 붙이고 Constraints를 전부 체크해준다.

 

그 다음에는 NavMeshAgent 컴포넌트를 부착한다.

 

게임 오브젝트 준비가 끝나면 이 NavMeshAgent를 움직이기 위해서 Moveable라는 이름으로 C# 스크립트를 생성한다.

 

using UnityEngine;
using UnityEngine.AI;   // 스크립트에서 내비게이션 시스템 기능을 사용하려면 AI 네임스페이스를 using 선언해야함

public class Moveable : MonoBehaviour
{
    // 길을 찾아서 이동할 에이전트
    NavMeshAgent agent;

    // 에이전트의 목적지
    [SerializeField]
    Transform target;

    private void Awake()
    {
        // 게임이 시작되면 게임 오브젝트에 부착된 NavMeshAgent 컴포넌트를 가져와서 저장
        agent = GetComponent<NavMeshAgent>();
    }

    void Update()
    {
        // 스페이스 키를 누르면 Target의 위치까지 이동하는 경로를 계산해서 이동
        if(Input.GetKeyDown(KeyCode.Space))
        {
            // 에이전트에게 목적지를 알려주는 함수
            agent.SetDestination(target.position);
        }
    }
}

 

유니티의 내비게이션 메시와 관련된 기능을 사용하기 위해서는 우선 AI 네임스페이스를 using 선언해줘야 한다.

 

먼저 NavMeshAgent 변수를 선언하고 Awake 함수에서 게임 오브젝트에 부착되어 있는 NavMeshAgent 컴포넌트를 가져와서 저장해준다. 그리고 agent가 목표로 잡고 찾아갈 target 변수를 Transform 타입으로 만든다.

 

그 다음에는 Update 함수에서 스페이스 키 입력을 감지하는 코드를 작성한다. 그 안에서 agent에게 목적지를 설정해주는 코드를 작성해야 한다. agent에게 가야할 목적지를 알려주는 함수는 agent.SetDestination이다.

 

매개변수로 target.position을 넣어주면 스페이스를 누를 때마다 agent가 타깃의 위치를 찾아서 경로를 계산하고 이동할 것이다.

 

 

그리고 하이어라키 뷰에서 빈 게임 오브젝트를 하나 생성하고 Target으로 이름 지은 뒤 좀 더 보기 쉽게 아이콘을 설정해준다. 다음에는 캡슐 게임 오브젝트에 Moveable 컴포넌트를 부착하고 Target 게임 오브젝트를 Target 프로퍼티에 할당해준다.

 

 

  

게임을 플레이 시켜보자. 게임 플레이가 시작되고 Target 게임 오브젝트를 적당한 위치로 가져다 놓은 다음 스페이스 키를 누르면 캡슐이 Target을 향해서 장애물과 부딪히지 않고 이동하는 것을 볼 수 있다.

 

NavMeshAgent의 프로퍼티

 

Base Offset

 

 

우선 첫 번째 프로퍼티인 Base Offset은 길을 찾을 때 사용되는 충돌 실린더의 위치이다. 이 값을 조절해보면 캡슐 게임 오브젝트가 바닥에 파묻히거나 공중에 뜨는 것을 볼 수 있다.

 

캐릭터와 에이전트의 위치를 맞추기 위해서 사용되는 프로퍼티이다.

 

Steering

 

Speed

 

Speed는 단어 그대로 에이전트가 움직이는 속도이다. 

 

Angular Speed

 

Angular Speed는 에이전트가 회전하는 속도이다.

 

Angular Speed 프로퍼티의 값은 degree/sec로 회전 속도를 결정한다.

 

Acceleration

 

Acceleration은 가속도이다. 

 

기본 값이 8에서는 에이전트가 최대 속도까지 도달하는데 어느 정도 시간이 걸리지만 20 정도로 수정해주면 빠르게 최대 속도에 도달하는 것을 볼 수 있다.

 

Stopping Distance

 

Stopping Distance는 목표와 얼마만큼의 거리를 두고 멈출 지를 결정한다.

 

이 기능은 원거리 유닛이 공격할 대상을 향해서 이동하다가 공격 가능한 사정거리에 도달하면 이동을 멈추고 공격하게 하려고 할 때 사용할 수 있다.

 

Auto Breaking

 

Auto Breaking은 에이전트가 목적지에 도착하기 직전에 감속을 시작할 것인가를 결정하는 프로퍼티이다.

 

Auto Breaking이 켜져있을 때는 도착하기 직전에 감속을 시작해서 목적지에 제대로 멈추지만 Auto Breaking을 끄고 이동을 시키면 목적지에 도착하고나서 감속을 시작하기 때문에 속도를 주체하지 못하고 목적지를 넘어간 뒤 목적지에 도착하기 위해서 계속 왔다갔다하는 모습을 볼 수 있다.

 

Obstacle Avoidance 

 

Obstacle Avoidance 파트는 다른 에이전트와 나중에 설명할 NevMeshObstacle을 어떻게 회피할 것인지를 결정하는 내용의 프로퍼티들을 가지고 있다.

 

먼저 기본 상황에서 이동하는 에이전트는 길을 막고 있는 에이전트를 향해서 이동하다가 돌아갈 길이 있으면 돌아가고 길이 없다면 살짝 밀어내고 길을 찾아간다.

 

Radius

 

Radius를 변경하면 초록색 원기둥의 두께가 두꺼워지는 것을 볼 수 있다.

 

하지만 지형인 벽과는 관계없이 다른 에이전트나 NavMeshObstacle과만 충돌하는 영역의 두께만 조절된다.

 

Height

 

Height는 에이전트끼리의 높이 충돌을 조절하는 프로퍼티이다. 

 

Quality

 

Quality는 장애물 회피 품질을 뜻한다. 

 

HighQuality로 설정하면 다른 에이전트를 회피하기 위해서 최대한 정밀한 움직임을 보이게 되고 Low Quality로 설정하면 피하는 움직임이 간소화된다. 그리고 양 쪽 다 None으로 설정하면 서로 완전히 무시하고 지나간다.

 

Priority

 

Priority는 에이전트 간의 우선 순위이다. 우선 순위는 0부터 99까지 있는데 0이 가장 높고 99가 가장 낮다.

 

우선 순위가 높은 에이전트는 길을 찾을 때 우선 순위가 낮은 에이전트를 고려하지 않고 그냥 밀고 지나가버린다. 그리고 우선 순위가 같으면 회피하려는 노력은 하지만 여의치 않을 때는 그냥 밀고 지나가게 된다. 마지막으로 낮은 우선 순위의 에이전트는 높은 우선 순위의 에이전트를 밀어내지 못한다.

 

Path Finding

 

Auto Repath

 

Auto Repath는 내비게이션 메시에 변동이 생겼을 때 자동으로 길을 다시 찾을 것인가를 설정하는 프로퍼티이다. 목적지로 가는 최단 경로의 중간이 막히면 자동으로 경로를 다시 계산해서 이동한다.

 

하지만 아주 먼거리를 이동할 때 아직 시야에 보이지 않는 중간이 막혀도 경로를 바로 재계산하기 때문에 경로가 막힌 구역까지 도착한 다음 경로를 새로 계산하기를 원한다면 체크를 해제하고 경로가 막힌 구역까지 도착한 다음 경로를 새로 계산하는 기능을 직접 구현해야 한다.

 

Area Mask

 

Area Mask에서는 이 에이전트가 지나갈 수 있는 영역과 지나가지 못하는 영역을 설정할 수 있다. 

 

특정 영역을 꺼주면 이 캐릭터는 아무리 가까운 거리라도 그 영역을 건너지 못하게 된다.

 

 

NavMeshObstacle

 

 

컴포넌트의 이름에서도 알 수 있지만 이 컴포넌트는 장애물 역할을 한다. 기본 상태에서는 작은 장애물에만 자연스러운 움직임을 보이고 큰 장애물은 부자연스럽게 회피하는 동작을 보이게 된다.

 

작은 장애물만 회피가 가능하면 그냥 Navigation Static으로 설정한 벽을 배치하는게 더 나을 것이라고 생각할 수 있다. 하지만 Navigation Static이 적용된 지형과 NavMeshObstacle은 큰 차이점이 있는데 Navigation Static으로 설정된 벽은 움직일 수 없다는 것이다. 그와 반대로 NavMeshObstacle을 사용하는 장애물은 게임이 플레이되는 도중에 언제든지 움직일 수 있다.

 

그리고 실시간으로 에이전트를 밀어내는 동작도 가능하며 장애물에 밀려난 목적지가 있는 에이전트는 다시 원래 자리로 돌아오려는 움직임으로 보이게 된다.

 

Shape

 

먼저 Shape 프로퍼티는 장애물의 형태를 결정하는 옵션으로 Box, Capsule, 두 가지 형태를 지원한다.

 

Carve를 제외한 프로퍼티는 이 Box나 Capsule의 크기를 설정하는데 쓰인다.

 

Carve

 

 

Carve는 "파내다"라는 뜻으로 내비게이션 메시 영역을 새로 굽지 않아도 실시간으로 "파내서" 에이전트가 지나갈 수 없는 영역으로 만드는 것이다.

 

Carve를 켜서 실시간으로 내비게이션 메시를 파내게 하면 큰 장애물도 자연스럽게 회피하게 할 수 있다.

 

Move Threshold

 

Carve의 하위 프로퍼티인 Move Threshold는 최소 이동 거리를 뜻한다. Move Threshold보다 조금 움직인 것은 오브젝트가 움직이지 않은 것으로 간주하여 Carve를 새로 계산하지 않는다.

 

큐브 게임 오브젝트를 미세하게 움직일 때는 내비게이션 메시의 파인 부분이 그대로 유지되지만 Move Threshold 보다 크게 움직이면 파인 부분이 사라진다. 그리고 움직임을 정지하면 내비게이션 메시에 파인 부분이 새로 생겨난다.

 

Time To Stationary

 

Time To Stationary는 게임 오브젝트가 얼마나 정지해있으면 완전히 멈춘 것으로 판정하고 Carve를 새로 계산해서 내비게이션 메시를 파낼지를 결정하는 값이다.

 

기본 값은 0.5초 후에 새로 계산하게 되어있다. 이것을 2로 변경하고 내비게이션 탭을 활성화해서 영역이 보이게 만든 다음 장애물을 움직여보자.

 

장애물이 이동을 멈추고나면 아까보다 훨씬 시간이 많이 지난 다음에 내비게이션 메시가 파지는 것을 확인할 수 있다.

 

Carve Only Stationary

 

Carve Only Stationary는 정지된 상태에서만 내비게이션 메시를 파내도록 할 것인지를 결정하는 프로퍼티이다. 

 

기본 값은 true로 체크되어 있다. 그래서 장애물이 움직이면 파내진 구멍이 사라졌다가 정지하면 내비게이션 메시가 다시 파내진다. 하지만 Carve Only Stationary를 끄면 장애물이 움직일 때 내비게이션 메시의 파인 구멍이 실시간으로 장애물을 따라 움직인다.

 

자연스러운 움직임을 위해서는 Carve Only Stationary를 끄는게 좋지만, 물체가 움직이는 상황에서 실시간으로 내비게이션 메시에 구멍을 파내는 계산을 계속하는 것은 게임의 성능에 좋지않은 영향을 끼칠 수 있기 때문에 반드시 필요한 경우에만 이 옵션을 끄고 그 외에는 이 옵션을 사용하는게 좋다.

 

이 NavMeshObstacle은 여러가지 용도로 사용할 수 있습니다.

 

유니티 공식 문서에서 언급되듯이 천천히 움직이는 탱크 같은 곳에 사용해도 되고 특정한 트리거를 발동하면 떨어져서 길을 막는 돌무더기 같은 것에도 사용해도 됩니다.

반응형

+ Recent posts