<--! 수학 기호 --> [Charon] #4. RigidBody를 이용한 3D 캐릭터 N단 대시(Dash) 구현하기

새소식

반응형
Dev Log/[Unity 3D] 카론

[Charon] #4. RigidBody를 이용한 3D 캐릭터 N단 대시(Dash) 구현하기

  • -

 

 

 

오랜만에 다시 카론(Charon) 게임 프로젝트 글을 올리는 것 같네요. 그동안 이것저것 또 열심히 했습니다.

비록 컴퓨터공학과 4학년이지만 학과 MT를 가보기도 하고, 추석 할인 세일을 하길래 인프런에서 강의 2개를 사서 조금씩 보기도 했습니다. 

 

이제 슬슬 졸업 작품 발표 기한도 다가오는 지라, 개발 계획 및 범위를 다시 재조정할 필요가 있다고 느껴 저번 주에 회의를 진행했었습니다. 이제 한동안은 카론(Charon) 프로젝트와 코딩테스트 게시글이 주를 이루겠네요.

 

이번 주에 제가 맡은 개발은 캐릭터의 대시(Dash) 기능입니다. 사실 저번에 이미 구현했던 기능이지만, 문제점들이 많았던 관계로 수정을 해 나갔지요.

 

 

추가) 2023-11-24

본 글과 같이 만든 대시는 부자연스럽기 때문에, 리팩토링을 진행하였습니다. 해당 내용은 🔗여기에서 확인하실 수 있습니다.

 

 

 

1. 키 바인딩과 이벤트 함수 등록

 

대시는 스페이스 바(Space bar)로 키 입력을 받을 겁니다. ActionInput에서 대시와 관련된 키 바인딩을 해주었습니다.

 

대시 키 바인딩

 

그리고 PlayerController 스크립트에 입력 이벤트를 수신할 함수를 만들어 주었습니다.

public void OnDashInput(InputAction.CallbackContext context)
{
    if (context.performed)
    {
        ...
    }
}

 

그리고 캐릭터에 붙어 있는 Player Input 컴포넌트에서 이벤트 함수를 추가해 주었습니다.

 

 

 

 


2. 데이터 계산과 적용의 분리 (Refactoring)

 

이전에는 Move() 함수에서 경사로인지 아닌지를 고려하여 벡터를 계산하고 그 값을 적용했었습니다. 하지만 경사로에서도 대시(Dash)를 할 수 있어야 하기에, 계산된 벡터가 필요한 상황입니다. 그래서 저는 Move() 함수에 있던 벡터 계산 부분을 떼어와, 새로운 메소드로 만들어 주었습니다.

protected PlayerState playerState;         // 캐릭터의 상태
protected Vector3 inputDirection;          // 키보드 입력으로 들어온 이동 방향
protected Vector3 calculatedDirection;     // 경사 지형 등을 계산한 이동 방향
protected Vector3 gravity;                 // 중력
private bool isOnSlope;
private bool isGrounded;


protected Vector3 GetDirection(float currentMoveSpeed)
{
    isOnSlope = IsOnSlope();
    isGrounded = IsGrounded();
    Vector3 calculatedDirection = 
                           CalculateNextFrameGroundAngle(currentMoveSpeed) < maxSlopeAngle ? 
                           inputDirection : Vector3.zero;

    calculatedDirection = (isGrounded && isOnSlope) ? 
    AdjustDirectionToSlope(calculatedDirection) : calculatedDirection.normalized;
    
    return calculatedDirection;
}


protected void ControlGravity()
{
    gravity = Vector3.down * Mathf.Abs(rigidBody.velocity.y);

    if (isGrounded && isOnSlope)
    {
        gravity = Vector3.zero;
        rigidBody.useGravity = false;
        return;
    }
    
    rigidBody.useGravity = true;
}
void Update()
{
    calculatedDirection = GetDirection(player.MoveSpeed * CONVERT_UNIT_VALUE);
    ControlGravity();
}

void FixedUpdate()
{
    Move(calculatedDirection, player.MoveSpeed * CONVERT_UNIT_VALUE);
}

 

기존에 있던 메소드들의 변경된 버전은 다음과 같습니다.

더보기
protected void Move(Vector3 moveDirection, float currentMoveSpeed)
{
    if (playerState != PlayerState.MOVE)
        return;

    float animationPlaySpeed = DEFAULT_ANIMATION_PLAYSPEED +
                               GetAnimationSyncWithMovement(currentMoveSpeed);
    LookAt(inputDirection);
    rigidBody.velocity = moveDirection * currentMoveSpeed + gravity;
    animator.SetFloat("Velocity", animationPlaySpeed);
}


public void OnMoveInput(InputAction.CallbackContext context)
{
    Vector2 input = context.ReadValue<Vector2>();
    inputDirection = new Vector3(input.x, 0f, input.y);
}


protected void LookAt(Vector3 direction)
{
    if (direction != Vector3.zero)
    {
        Quaternion targetAngle = Quaternion.LookRotation(direction);
        rigidBody.rotation = targetAngle;
    }
}

 

위와 같이 계산하는 부분실제로 캐릭터 행동에 적용되는 메소드 부분으로 분리해 주었습니다. 이렇게 분리함으로써, 대시(Dash)에서도 공통적으로 필요한 데이터들을 중복 계산할 필요없이 사용할 수 있게 되었습니다.

 

그리고 입력 벡터가 inputDirectioncalculatedDirection 이렇게 두 개를 가지고 있는 것을 볼 수 있습니다.

calculatedDirection경사로 등에서 투영된 벡터일 수 있기에, 캐릭터가 바라보는 방향을 해당 벡터로 하게 되면 불필요한 회전을 하게 되는 등의 알 수 없는 동작을 할 수 있습니다. 그래서 원본 벡터를 보존해두는 것이 좋겠다고 생각하였습니다.

 

 

 


3. 코루틴(Coroutine)을 이용하여 대시 기능 만들기

 

우선 enum을 이용하여, 캐릭터의 여러 상태들을 정의하였습니다.

public enum PlayerState
{
    MOVE,       // 평상 시 상태(Idle 및 Move가 가능)
    DASH,       // 대시 상태
    NDASH,      // N단 대시가 가능할 경우의 상태
}

protected PlayerState playerState;

 

그리고 대시와 관련된 변수들을 선언하였는데, 꽤나 많더군요..

[Header("대시(Dash) 옵션")]
//[Header("주의) DashForwardRollTime + DashReInputTime = 0.4초를 지켜야 합니다.")]
[SerializeField, Tooltip("대시(Dash)의 힘을 나타내는 값입니다.")]
protected float dashPower;

[SerializeField, Tooltip("대시(Dash) 앞구르기 모션 시간")]
protected float dashForwardRollTime;
    
[SerializeField, Tooltip("대시(Dash) 시작 후, 다시 대시 입력을 받을 수 있는 시간")]
protected float dashReInputTime;
    
[SerializeField, Tooltip("대시(Dash) 후, 경직 시간")]
protected float dashTetanyTime;
    
[SerializeField, Tooltip("대시(Dash) 재사용 대기시간")]
protected float dashCoolTime;

private WaitForSeconds DASH_FORWARD_ROLL_TIME;
private WaitForSeconds DASH_RE_INPUT_TIME;
private WaitForSeconds DASH_TETANY_TIME;
private Coroutine dashCoroutine;
private Coroutine dashCoolTimeCoroutine;
private int currentDashCount;

 

 

 

우선 키 입력을 받는 부분부터 작업을 들어가보도록 하겠습니다.

public void OnDashInput(InputAction.CallbackContext context)
{
    if (context.performed)
    {
        bool isAvailableDash = 
        playerState != PlayerState.DASH && currentDashCount < player.DashCount && isGrounded;

        if (isAvailableDash)
        {
            playerState = PlayerState.DASH;
            currentDashCount++;

            if (dashCoroutine != null && dashCoolTimeCoroutine != null)
            {
                StopCoroutine(dashCoroutine);
                StopCoroutine(dashCoolTimeCoroutine);
            }

            dashCoroutine = StartCoroutine(DashCoroutine());
        }
    }
}

 

어려운 코드는 아닐 거라고 생각합니다. 만약 N단 대시가 가능한 상황이라면, 다시 키 재입력을 받았을 때 코루틴을 중지하고 다시 시작하는 것이 핵심이겠네요.

 

이제 실질적인 로직이 들어있는 코루틴 부분을 볼 건데, 그 전에 애니메이션과 관련하여 세팅이 궁금하신 분은 아래에 더보기란에 두었습니다. 제가 사용한 대시 모션은 🔗앞구르기이구요.

 

더보기
  • Dash(Trigger) 변수와 IsDashing(Bool) 매개변수 추가

 

  • FBX 파일 리깅 및 애니메이션 설정

 

  • Move  →  Dash 트랜지션

 

  • Dash  →  Dash 트랜지션 (N단 대시를 위해 필요)

 

  • Dash  →  Move 트랜지션

 

*애니메이터 컴포넌트의 Apply Root Motion 부분을 체크 해제 하셔야 합니다.

 

 

저는 RigidBody.velocity의 속도값을 순간적으로 조정하여 빠르게 이동하는 방법을 선택했습니다.

rigidBody.velocity = dashDirection * dashPower; 부분에 이동속도도 곱해줘야 캐릭터 이동속도에 따라 대시 거리가 유동적으로 변할텐데, 그 부분을 빼먹었네요.

void Start()
{
    DASH_FORWARD_ROLL_TIME = new WaitForSeconds(dashForwardRollTime);
    DASH_RE_INPUT_TIME = new WaitForSeconds(dashReInputTime);
    DASH_TETANY_TIME = new WaitForSeconds(dashTetanyTime);
}

private IEnumerator DashCoroutine()
{
    Vector3 LookAtDirection = (inputDirection == Vector3.zero) ? transform.forward : inputDirection;
    Vector3 dashDirection = (calculatedDirection == Vector3.zero) ? transform.forward : calculatedDirection;

    animator.SetFloat("Velocity", 0f);         // Move 애니메이션이 재생되면 안 되므로 0으로
    animator.SetBool("IsDashing", true);
    animator.SetTrigger("Dash");
    LookAt(LookAtDirection);
    rigidBody.velocity = dashDirection * dashPower;

    yield return DASH_FORWARD_ROLL_TIME;       // 대시 앞구르기 모션 시간
    playerState = (player.DashCount > 1 && currentDashCount < player.DashCount) ? PlayerState.NDASH : PlayerState.DASH;
        
    yield return DASH_RE_INPUT_TIME;           // N단 대시가 가능할 때, 키 입력을 받을 수 있는 시간
    animator.SetBool("IsDashing", false);
    rigidBody.velocity = Vector3.zero;

    yield return DASH_TETANY_TIME;             // 대시 후, 경직 시간
    playerState = PlayerState.MOVE;

    dashCoolTimeCoroutine = StartCoroutine(DashCoolTimeCoroutine());   // 대시 쿨타임 체크 시작
}


private IEnumerator DashCoolTimeCoroutine()
{
    float currentTime = 0f;
    while (true)
    {
        currentTime += Time.deltaTime;
        if (currentTime >= dashCoolTime)
            break;
        yield return null;
    }

    if (currentDashCount == player.DashCount)
        currentDashCount = 0;
}

 

Unity Engine의 애니메이션 30프레임이 1초로 간주됩니다. 대시 애니메이션이 35프레임 쯤 되니까 1초 언저리겠네요. 대시가 조금 더 빨랐으면 하는 맘에 대시 애니메이션의 재생 속도를 2배속으로 해주었는데, 그렇다면 대략 0.4~0.5초가 애니메이션 길이가 될 겁니다.

 

대시 애니메이션 재생을 트리거(Trigger)로 하였는데, 이렇게 하니 기존에 연결되었던 Move 노드로 갔다가 다시 돌아오더라구요. 그래서 N단 대시가 가능할 때는 Dash 노드에서 벗어나지 않도록 IsDashing이라는 애니메이션 Bool 매개변수로 조정해주었습니다.

 

이렇게 하고 나면, 대시 기능은 대강 완성이 됩니다. 테스트를 해보며 조작감에 불편함은 없는지, 이상한 버그는 없는지 체크만 하면 되겠네요.

 

 


구현된 모습

 

다음과 같이 옵션들을 세팅하고 테스트를 진행해봤습니다.

 

 

1단 대시

 

제 실력이 뛰어난 것이 아니기에 100% 만족할 수준은 아니지만, 그래도 이 정도면 잘 만들어 진 것 같습니다. 

대시가 너무 부자연스러운 것 같아, 🔗뒷 글에서 리팩토링을 진행하였습니다.

 

 


늘 나를 반겨주는 버그들

 

이 쯤 되면 버그와 결혼해야 할 것 같습니다. 경사 지형 이동 부분에서 발생했던 양만큼은 아니지만, 애니메이션과 관련된 버그는 항상 머리가 아프네요. 어떤 것들이 있었는지 하나씩 알아보도록 하겠습니다.

 

첫 번째 버그  |  경사로에서 대시를 하면 튀어오르는 버그

 

첫 번째 버그

 

처음에는 벡터가 이상하게 계산되었나 생각이 들어서, 기즈모로 표시해봤지만 그닥 이상한 점은 없었습니다.

그렇다면 어딘가에 부딪혀서 튕겨져 나간다는 의미가 되는데, 왜 이럴까 한참동안 생각하다가 원인을 겨우 발견했습니다.

 

원인 발견

 

계단 오브젝트들을 서로 이어준 것이라, 제대로 잘 이어지지 않아 위와 같이 콜라이더가 어긋나 있었습니다.

어긋나지 않도록 잘 이어주던가, 계단 배치를 다 하고 하나의 콜라이더로 그냥 다 채우던가 하면 될 것 같습니다.

 

첫 번째 버그 해결 완료

 

두 번째 버그  |  대시를 여러 번 발동 시, 버벅이는 버그

 

 

이 부분은 대시 애니메이션의 Has Exit Time을 체크 해제 해주지 않아서 계속 애니메이션 싱크가 뒤로 밀리는 겁니다.

Has Exit Time을 체크 해제해버리면, 애니메이션이 다 재생되기도 전에 새로 재생되어 버리니 어느 정도 재생이 된 후에 대시 입력을 받을 수 있도록 하면 될 것 같습니다. 저 같은 경우에는 위에서 코루틴을 이용하여 적용해주었습니다.

 

 

세 번째 버그  |  물리와 애니메이션 간의 싱크가 안 맞는 부분

 

이동 애니메이션에서도 그랬지만, 물리 부분와 애니메이션의 싱크를 맞춰주는 것이 중요하죠. 이 부분도 이동 애니메이션 싱크 부분에서 사용했던 그리드 텍스쳐 방법을 이용하면 싱크가 맞는지 안 맞는지 쉽게 알아차릴 수 있습니다.

다음을 보면 애니메이션 재생 시간보다 대시의 물리 지속시간이 더 길어, 미끄러지는 모습을 볼 수 있습니다.

 

싱크 맞추기 전

 

다음은 싱크를 맞춘 부분인데, 사실 조금 더 길게 잡아야 알맞은 모습이긴 합니다.

하지만 더 길게 잡으면, N단 대시를 할 때 조작감이 불편해지는 부분이 있어 이 부분은 이 정도로 타협하였습니다.

 

싱크 맞춘 후

 

 

네 번째 버그  |  대시 이동 방향과 바라보는 방향이 다른 버그

 

이 버그는 이동 키와 스페이스 바를 동시에 눌렀다가 떼면 아주 가끔 발생합니다. 키 입력들이 꼬여서 발생하는 거죠.

 

 

하지만 이동 키와 스페이스 바를 동시에 눌렀다 떼면서 게임 플레이 하는 사람을 없을 것이기에 이 부분은 내버려 두기로 했습니다.

 

 

 

마무리

 

글이 어쩌다보니 길어졌네요. 간단하다면 간단한 기능일 수 있겠지만, 최대한 버그없이 구현하려고 열심히 생각을 했던 것 같습니다. 다음 글에서 찾아뵙도록 하겠습니다! 긴 글 읽어주셔서 감사해요!

 

 

 

728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.