이동 애니메이션
이전까지 만든 이동 로직에 애니메이션을 추가하고 디테일을 잡아보자.
스프린트
shift에 스프린트를 넣어 이동속도를 빠르게 만들어보자.

PassThrough로 설정해 눌려있는 상태에서만 켜지도록 설정해 준다.
public void OnSprint(InputValue value)
{
sprint = value.isPressed;
}
이후 입력 콜백을 구현하여 sprint라는 변수를 변경하도록 설정한다.
private void Move()
{
_targetSpeed = parkourInputController.sprint ? playerMovementData.sprintSpeed : playerMovementData.moveSpeed;
...
이후 Move에서 sprint 여부에 따라 속력을 다르게 부여하면 된다.
이동 애니메이션
이제 걷기 및 뛰기 애니메이션을 구현해 보자.


이런 방식으로 Speed라는 변수를 통해 상태를 변경하도록 제작했다.
BlendTree를 통해 걷기와 뛰기의 애니메이션이 자연스럽게 변경되도록 제작하였다.
하지만 한 가지 문제가 있다.
현재 갖고 있는 애니메이션이 인플레이스에서 동작하는 애니메이션이 아니기에 다음과 같은 현상이 발생한다.

이를 개선하는 방법으로는 루트 모션이 없는 애니메이션을 사용하여 직접 속도를 제어하거나 루트 모션을 통해 이동을 진행하는 것이다.
현재 제작하려는 시스템은 파쿠르 시스템이기에 위치를 하나하나 계산하여 수동으로 적용하는 것은 쉽지 않다.
따라서 루트모션이 적용된 애니메이션을 통해 이동 및 장애물을 넘는 방식을 선택해야 할 것이다.
그럼 Move함수에서 이동을 제어하는 것을 제거하고 OnAnimatorMove라는 함수에서 이동을 시켜주어야 할 것이다.
OnAnimatorMove에서는 animator의 위치 및 회전 변화량을 감지할 수 있다.
이를 이용해 애니메이션이 이동한만큼 이동시켜 주면 된다.
private void OnAnimatorMove()
{
Vector3 deltaPosition = animator.deltaPosition;
Quaternion deltaRotation = animator.deltaRotation;
// 이동량 감지 및 처리
if (deltaPosition != Vector3.zero)
{
characterController.Move(deltaPosition);
}
if (deltaRotation != Quaternion.identity)
{
transform.rotation *= deltaRotation;
}
}
그리고 기존 Move에서 직접 제어하던 이동 로직을 수정해 단순히 애니메이션 매개변수만 변경하는 정도로 수정해 주자.
private void Move()
{
Vector3 inputDirection = new Vector3(parkourInputController.moveInput.x, 0.0f, parkourInputController.moveInput.y).normalized;
if (parkourInputController.moveInput != Vector2.zero)
{
_targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + mainCamera.transform.eulerAngles.y;
float rotation = Mathf.SmoothDampAngle(transform.eulerAngles.y, _targetRotation, ref _rotationVelocity, playerMovementData.rotationSmoothTime);
transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
_targetSpeed = parkourInputController.sprint ? playerMovementData.sprintSpeed : playerMovementData.moveSpeed;
}
else
{
_targetSpeed = 0f;
}
if (!animator.IsUnityNull())
{
_speed = Mathf.Lerp(_speed, _targetSpeed, Time.deltaTime * playerMovementData.speedChangeRate);
animator.SetFloat(SpeedAnimID, _speed);
}
}
단, 회전에 대한 구현은 그대로 남겨두었다.
만약 루트모션이 적용된 애니메이션이지만 루트가 이동을 하지 않는 경우에는 다음 사진처럼 루트 포지션이 bake 되어 있는지 확인해 보면 된다.

걷기나 뛰기에서는 수평이동이라 XZ만 체크 해제하면 되지만 Y도 같이 해주었다.

애니메이션 속도를 보정해 주기 위해 multiplier를 하나 추가해 속도를 제어하도록 변경해 보자.
이는 이전 Data에 설정했던 값을 통해 곱해지도록 만들고 Move에서 애니메이션 blend 하는 값을 1~2로 설정하자.
private void Move()
{
Vector3 inputDirection = new Vector3(parkourInputController.moveInput.x, 0.0f, parkourInputController.moveInput.y).normalized;
if (parkourInputController.moveInput != Vector2.zero)
{
_targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + mainCamera.transform.eulerAngles.y;
float rotation = Mathf.SmoothDampAngle(transform.eulerAngles.y, _targetRotation, ref _rotationVelocity, playerMovementData.rotationSmoothTime);
transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
_targetSpeed = parkourInputController.sprint ? 2 : 1;
}
else
{
_targetSpeed = 0f;
}
if (!animator.IsUnityNull())
{
_speed = Mathf.Lerp(_speed, _targetSpeed, Time.deltaTime * playerMovementData.speedChangeRate);
animator.SetFloat(SpeedAnimID, _speed);
}
}
private void OnAnimatorMove()
{
Vector3 deltaPosition = animator.deltaPosition;
Quaternion deltaRotation = animator.deltaRotation;
// 이동량 감지 및 처리
if (deltaPosition != Vector3.zero)
{
var speedMult = parkourInputController.sprint ? playerMovementData.sprintSpeed : playerMovementData.moveSpeed;
characterController.Move(deltaPosition * speedMult);
}
if (deltaRotation != Quaternion.identity)
{
transform.rotation *= deltaRotation;
}
}
점프 애니메이션
이동 애니메이션은 완성했지만 문제가 하나 있다.
기존 점프를 표현하던 수직 이동 코드가 빠져버리게 되어서 중력을 적용하는 로직이 사라졌다.
또한, 점프 애니메이션도 만약 루트 모션이 들어가 있다면 이를 또 제어할 수 있게 코드를 변경해야 한다.
우선, 점프는 4단계로 구분할 수 있다.
- 점프 시작
- 점프 중(상승)
- 자유낙하(하강)
- 착지
이를 수직 속도를 통해 구분하여 애니메이션을 제어해 보자.
private void JumpAndGravity()
{
if (_grounded)
{
// 땅에 있을 때 중력 적용
if (_verticalVelocity <= 0)
{
_verticalVelocity = -2f;
}
if (parkourInputController.jump)
{
parkourInputController.jump = false;
_verticalVelocity = _maxJumpSpeed;
}
}
if (!animator.IsUnityNull())
{
var verticalAnimVale = (1 - _verticalVelocity / _maxJumpSpeed) * 0.5f;
if (_grounded && Mathf.Approximately(_verticalVelocity, -2))
{
verticalAnimVale = 1f;
}
animator.SetFloat(VerticalAnimID, verticalAnimVale);
}
// 중력 적용
_verticalVelocity -= playerMovementData.gravity * Time.deltaTime;
}
vertical속력을 통해 점플 애니메이션을 블랜딩 하는 것은 착지 이전 단계들에 대해서만 수행하면 된다.
착지 애니메이션은 무조건 실행되어야 하기 때문에 다른 스테이트로 빼내어 실행 이후 원래 흐름으로 복귀하면 된다.
Jump의 경우에는 _verticalVelocity의 변화를 0~1로 fit 하여 적용하면 된다.
변환 공식은 다음과 같다.
var verticalAnimVale = (1 - _verticalVelocity / _maxJumpSpeed) * 0.5f;
수직 속도는 초기 $\sqrt{2gh}$에서 시작하여 계속하여 감소하다 최고점에서 0으로 변하며 그 이후로는 음수로 적용된다.
즉, 해당 공식을 적용하면 0~1로 변환할 수 있다.
한 가지 주의할 점이 땅에 착지하면 수직 속도를 -2로 일정하게 눌러지는 코드가 있기 때문에 이에 대한 예외 처리를 해주어야 한다.
if (_grounded && Mathf.Approximately(_verticalVelocity, -2))
{
verticalAnimVale = 1f;
}
점프 애니메이션 디테일
점프 애니메이션을 구현했지만 문제가 몇 개 있다.
- 점프 연타
- 이동 점프
점프를 연타하면 위와 같이 점프가 일정하지 않게 처리된다.
이는 약간의 delay를 줘서 해결할 수 있다. 일종의 쿨타임으로 생각하면 된다.
공중에 있을 때 점프 타임아웃을 계속하여 초기화하다 착지하게 되면 이를 줄여나가 0 이하로 떨어졌을 때만 점프가 실행될 수 있도록 하면 된다.
또한 Landing에서 블랜딩 되고 있는 경우가 있으니 현재상태가 Landing이 아닐 때만 점프가 실행되도록 예외를 주자.
private void JumpAndGravity()
{
if (_grounded)
{
// 땅에 있을 때 중력 적용
if (_verticalVelocity <= 0)
{
_verticalVelocity = -2f;
}
if (parkourInputController.jump && _jumpTimeout <= 0)
{
AnimatorStateInfo currentState = animator.GetCurrentAnimatorStateInfo(0);
if (!currentState.IsName("Landing"))
{
parkourInputController.jump = false;
_verticalVelocity = _maxJumpSpeed;
}
}
_jumpTimeout -= Time.deltaTime;
}
else
{
_jumpTimeout = playerMovementData.jumpCoolDown;
}
if (!animator.IsUnityNull())
{
var verticalAnimVale = (1 - _verticalVelocity / _maxJumpSpeed) * 0.5f;
if (_grounded && Mathf.Approximately(_verticalVelocity, -2))
{
verticalAnimVale = 1f;
}
animator.SetFloat(VerticalAnimID, verticalAnimVale);
}
// 중력 적용
_verticalVelocity -= playerMovementData.gravity * Time.deltaTime;
}
이제 이동하면서 점프할 때를 처리해 보자.
지금은 수평 이동이 애니메이션에 의해 처리되고 있다.
따라서 점프의 경우 수평적인 이동속도가 없어 이동하다 점프하게 되면 제자리에 멈추게 된다.
이를 해결하기 위해 점프 중인 상태와 그렇지 않은 상태를 구분하도록 만들고 이동 로직에서 이를 분기로 처리해주면 된다.
점프키를 눌러 점프 상태가 되었다면 점프중인 상태를 변수로 저장한 뒤 이를 통해 이동 로직을 분리해 보자.
if (_grounded)
{
// 땅에 있을 때 중력 적용
if (_verticalVelocity <= 0)
{
_verticalVelocity = -2f;
_isJumping = false;
}
if (parkourInputController.jump && _jumpTimeout <= 0)
{
parkourInputController.jump = false;
AnimatorStateInfo currentState = animator.GetCurrentAnimatorStateInfo(0);
if (!currentState.IsName("Landing") && !currentState.IsName("Jump"))
{
_isJumping = true;
_verticalVelocity = _maxJumpSpeed;
_jumpMomentum = new Vector3(characterController.velocity.x, 0, characterController.velocity.z);
}
}
_jumpTimeout -= Time.deltaTime;
}
else
{
_jumpTimeout = playerMovementData.jumpCoolDown;
}
_isJumping이란 변수를 통해 점프 상태를 나타내고 이를 이동 로직에서 분기로 적용해 보자.
// 점프중에는 입력 방향으로 이동하도록
if (_isJumping)
{
var velocity = _jumpMomentum.magnitude;
Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;
characterController.Move(targetDirection.normalized * (velocity * Time.deltaTime));
}
private void OnAnimatorMove()
{
if (_isJumping) return;
...
애니메이터 무브에서는 점프중일 때는 관여하지 않도록 처리했다.
다음에는 점프 및 낙하에서 오류나 디테일을 잡아보자.
특히 경사로와 낮은 턱을 떨어질 때 Ground체크가 비정상적일 수 있는 부분을 처리해 보자.