경사 슬라이딩
경사면이 가파르거나 내려오는 속도가 충분하다면 경사면을 슬라이딩하여 내려오도록 하는 시스템을 만들어 보자.
추가로 이전에 높은 경사를 오르는 부분을 막아놨는데 이를 현실처럼 최대한 올라가다가 미끄러져 내려오듯이 만들어 보자.

경사 제한
경사를 올라갈 수 있는지 아닌지 계산하는 두 가지 방법이 떠오른다.
- 현재 속도로 어느 정도 올라갈지 예측하기
- 경사를 오르며 한계에 다다르면 미끄러지게 만들기
우선 첫 번째 방법은 경사의 각도가 변할 수 있으며 이를 모두 예측하는 것은 쉽지 않을 것이다.
따라서 두 번째 방법으로 구현해 보자.
이전에 경사의 기울기를 구하여 이동 벡터를 변경하는 것 까지는 구현해 놨다.
경사의 기울기가 한계를 넘어갈 때 캐릭터를 경사의 내리막 방향으로 회전한 후 애니메이션을 변경해 보자.

현재는 한계에 다다르면 이동을 막아놨다.
이때 중력을 경사의 normal에 project 하여 얻은 벡터 쪽으로 회전시켜 보자.
if (footTracker.isUp && footTracker.groundAngle > playerMovementData.slopeLimit)
{
_slopeMovement = Vector3.ProjectOnPlane(Physics.gravity, footTracker.groundNormal).normalized * deltaPosition.magnitude * speedMult;
Vector3.Normalize(_slopeMovement);
transform.rotation = Quaternion.Euler(_slopeMovement);
}
이렇게 강제로 돌려도 Move함수에서 입력 방향에 맞게 캐릭터를 회전시키는 코드가 있어서 정상적으로 동작하지 않는다.
이를 처리하기 위해 캐릭터에 상태를 부여할 필요가 있다.
즉, 상태 패턴을 적용하여 캐릭터에 현재 상태에 따라 입력값 처리를 변경해 줘야 할 것 같다.
코드가 Controller에 모여있는 것은 유지보수가 어려울 수 있으므로 상태별로 처리할 수 있게 변경해 보자.
캐릭터 상태 패턴
상태를 분리해 보자.
앞으로 여러 파쿠르 시스템을 추가할 것이고 현재는 땅에서 이동, 점프 및 낙하, 경사에서 미끄러짐 정도만 구분 지어 보자.
우선 이를 관리할 enum이 필요하다.
namespace Player.Parkour
{
public enum ParkourState
{
None = 0,
OnGround,
InAir,
SlopeSlip,
}
}
그리고 이를 나타낼 변수를 Controller 쪽에 하나 정의하자.
// Parkour State
private ParkourState _currentParkourState;
이제 Controller의 Update에서 직접 캐릭터를 제어하지 않고 현재 상태에 대해서 상태를 업데이트하도록 변경해 주자.
Controller에서는 이 상태의 전이만 담당하자.
우선 기본 상태를 정의하자.
namespace Player.ParkourState
{
public abstract class BaseParkourState
{
public ParkourCharacterController _characterController;
public BaseParkourState(ParkourCharacterController characterController)
{
_characterController = characterController;
}
public abstract void OnEnter();
public abstract void OnUpdate();
public abstract void OnExit();
}
}
한 가지 고민인 게 현재 구조가 다음과 같은 구조이다.

즉, ParkourCharacterController에서 상태 객체들을 생성하며 자신을 전달해 참조를 유지하게 되니 상호 참조가 발생한다.
하지만 이 구조는 불가피하다.
Controller에서는 각 State를 전이하고 명령해야 하며 각 State에서는 Controller에게 무언가를 요청하거나 변수를 참조하여 사용할 경우도 있을 것이다.
다행히 C#에서는 순환 참조를 알아서 해결해 주기 때문에 크게 신경 쓰지 않아도 된다.
만약 순환 참조가 신경 쓰인다면 WeakReference를 사용하면 되지만 이는 GC에 의해 언제든지 해제될 가능성이 생기기 때문에 게임의 핵심로직에는 사용하지 않는 것이 좋다.
이제 각 상태를 Controller에서 생성하고 상태를 업데이트하도록 변경해 보자.
private void Start()
{
_grounded = true;
_maxJumpSpeed = Mathf.Sqrt(2.0f * playerMovementData.gravity * playerMovementData.jumpForce);
// 상태 생성
_parkourStates[ParkourState.OnGround] = new OnGroundParkourState(this, characterController);
_parkourStates[ParkourState.InAir] = new InAirParkourState(this, characterController);
_parkourStates[ParkourState.SlopeSlip] = new SlopeSlippingParkourState(this, characterController);
_currentParkourState = ParkourState.OnGround;
}
Update에서 현재 상태를 계속 Update 시키자.
private void Update()
{
...
_parkourStates[_currentParkourState].OnUpdate();
}
이제 Move 같은 Update에서 처리하던 로직을 옮겨보자.
public override void OnUpdate()
{
Vector3 inputDirection = new Vector3(parkourInputController.moveInput.x, 0.0f, parkourInputController.moveInput.y).normalized;
float inputMagnitude = 1f;
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
{
inputMagnitude = playerMovementData.decelerateRate;
_targetSpeed = 0f;
}
// 점프중에는 입력 방향으로 이동하도록
if (_isJumping)
{
var velocity = _jumpMomentum.magnitude;
Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;
characterController.Move(targetDirection.normalized * (velocity * Time.deltaTime));
}
if (!animator.IsUnityNull())
{
_animSpeed = Mathf.Lerp(_animSpeed, _targetSpeed, Time.deltaTime * playerMovementData.speedChangeRate * inputMagnitude);
animator.SetFloat(SpeedAnimID, _animSpeed);
}
}
문제가 되는 것이 기존 변수는 옮기면 그만인데 CharacterController, Input, Camera, Animator 같은 객체를 참조해야 하는데 이를 하나의 구조체로 포함시켜 전달하도록 변경하는 것이 좋아 보인다.
public struct ParkourContext
{
public Vector2 moveInput;
public bool isSprinting;
public bool isJumping;
public Transform cameraTransform;
public Animator animator;
public CharacterController characterController;
public PlayerMovementDataSo movementSettingData;
public float jumpMomentum;
}
추후 필요한 데이터가 더 추가될 수도 있다.
그리고 이제 Move 함수를 State 쪽으로 옮긴 코드를 context를 사용하도록 수정해 보자.
public override void OnUpdate(ParkourContext context)
{
Vector3 inputDirection = new Vector3(context.moveInput.x, 0.0f, context.moveInput.y).normalized;
if (context.moveInput != Vector2.zero)
{
_targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + context.cameraTransform.eulerAngles.y;
float rotation = Mathf.SmoothDampAngle(_parkourCharacterController.transform.eulerAngles.y, _targetRotation, ref _rotationVelocity, context.movementSettingData.rotationSmoothTime);
_parkourCharacterController.transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
_targetSpeed = context.isSprinting ? 2 : 1;
}
else
{
_targetSpeed = 0f;
}
if (!context.animator.IsUnityNull())
{
float alpha = Time.deltaTime * context.movementSettingData.speedChangeRate;
alpha *= context.moveInput != Vector2.zero ? 1 : context.movementSettingData.decelerateRate;
_animSpeed = Mathf.Lerp(_animSpeed, _targetSpeed, alpha);
context.animator.SetFloat(SpeedAnimID, _animSpeed);
}
}
상태를 종료할 때는 변수들을 초기화하여 오류를 방지하자.
public override void OnExit()
{
_targetSpeed = 0f;
_targetRotation = 0f;
_rotationVelocity = 0f;
_animSpeed = 0f;
}
마찬가지로 점프도 state로 분리해 보자.
중력은 항상 작용하기 때문에 모든 State에서 각각 처리하기보다는 Controller에서 적용하는 게 좋을 것 같다.
단, 특정 순간 중력이 작용되지 않게 막을 경우가 있기 때문에 변수를 통해 제어할 수 있게 문을 열어두자.
private void ApplyGravity()
{
if (!applyGravity) return;
// 중력 적용
verticalVelocity -= playerMovementData.gravity * Time.deltaTime;
characterController.Move(new Vector3(0, verticalVelocity, 0) * Time.deltaTime);
}
우선 점프 상태로의 진입점은 jump input이 들어오고 쿨타임이 다 돌았을 때이다.
이는 어떤 상태에서든 실행이 되어야 할 수도 있고 만약 점프가 불가능한 상태가 있다면 이를 전이할 수 없게 관리하는 로직이 필요한데 이걸 모든 상태에 정의하면 중복이 많이 발생할 수 있으므로 이는 Controller에서 처리하자.
private void Update()
{
...
if (parkourInputController.jump && jumpTimeout <= 0)
{
parkourInputController.jump = false;
ChangeParkourState(ParkourState.InAir);
}
...
}
점프 상태로 진입하면 수직 속도를 미리 계산된 값으로 설정해 점프가 실행되도록 한다.
이 로직은 크게 다르지 않다.
한 가지 다른 부분은 점프 중 이동을 점프 상태에서 처리하게 변경한 부분이다.
...
else
{
// 점프중에는 입력 방향으로 이동하도록
Vector3 inputDirection = new Vector3(context.moveInput.x, 0.0f, context.moveInput.y).normalized;
var targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + context.cameraTransform.eulerAngles.y;
var velocity = _parkourCharacterController.horizontalVelocity + inputDirection.magnitude * Time.deltaTime;
Vector3 targetDirection = Quaternion.Euler(0.0f, targetRotation, 0.0f) * Vector3.forward;
context.characterController.Move(targetDirection.normalized * velocity);
_parkourCharacterController.jumpTimeout = context.movementSettingData.jumpCoolDown;
}
입력 방향을 받고 카메라 회전을 적용하여 이동할 방향을 결정하고 속도는 이전 프레임의 수평 속도와 입력값에 의해 결정된다.
수평 속도는 OnAnimatorMove에서 캐릭터를 실제로 이동시킬 때 계산한다.
private void OnAnimatorMove()
{
if (_currentParkourState == ParkourState.InAir) return;
...
// 이동량 감지 및 처리
if (deltaPosition != Vector3.zero)
{
var speedMult = parkourInputController.sprint ? playerMovementData.sprintSpeed : playerMovementData.moveSpeed;
if (footTracker.isOnSlope)
{
...
horizontalVelocity = (new Vector3(_slopeMovement.x, 0, _slopeMovement.z)).magnitude;
}
else
{
...
horizontalVelocity = (deltaPosition * speedMult).magnitude;
}
}
else
{
horizontalVelocity = 0f;
}
...
}

SlopeSlip
드디어 이번 주제인 경사에서 미끄러지는 상태를 만들어보자.
현재 경사처리는 다음과 같다.
if (footTracker.isUp && footTracker.groundAngle > playerMovementData.slopeLimit)
{
_slopeMovement = Vector3.ProjectOnPlane(Physics.gravity, footTracker.groundNormal).normalized * deltaPosition.magnitude * speedMult;
Vector3.Normalize(_slopeMovement);
transform.rotation = Quaternion.Euler(_slopeMovement);
}
이렇게 처리했더니 경사 자체를 올라가지 못한다.
따라서 if조건에 들어가면 상태를 변경하여 경사를 미끄러지게 만들어야 할 것 같다.
미끄러지면서 좌우로 움직여 캐릭터를 조정할 수 있게 할 것이며 점프를 할 수 없게 막을 것이다.
우선 상태 전이부터 해보자.
생각하고 있는 게 경사를 오르다 속도가 점점 줄어 더 이상 올라갈 수 없게 되면 상태를 전이시켜 애니메이션을 변경하면 될 것 같다.
즉 오를 수 없는 경사에 진입할 때 속도를 기록하고 이를 점차 줄여나가다 올라갈 수 있는 힘이 부족해지면 상태를 전이하는 것이다.
private bool _isClimbingSlope = false;
private Vector3 _slopeVelocity = Vector3.zero;
// 올라가지 못하는 경사 진입 시
if (footTracker.isUp && footTracker.groundAngle > playerMovementData.slopeLimit)
{
if (!_isClimbingSlope)
{
_slopeVelocity = Vector3.ProjectOnPlane(deltaPosition, footTracker.groundNormal).normalized * deltaPosition.magnitude * speedMult;
_isClimbingSlope = true;
}
// 중력에 의한 저항 적용
var gravitySlope = Vector3.ProjectOnPlane(Physics.gravity, footTracker.groundNormal);
float resistanceStrength = Mathf.SmoothStep(0f, 1f, (footTracker.groundAngle - playerMovementData.slopeLimit) / (90f - playerMovementData.slopeLimit));
_slopeVelocity += gravitySlope * resistanceStrength * playerMovementData.slopeResistanceMultiplier * Time.deltaTime;
// 속도가 0 이하가 되면 미끄러짐 상태로 전환
float upwardSpeed = Vector3.Dot(_slopeVelocity, -gravitySlope.normalized);
if (upwardSpeed <= 0)
{
// 상태 전환
}
characterController.Move(_slopeVelocity);
horizontalVelocity = (new Vector3(_slopeVelocity.x, 0, _slopeVelocity.z)).magnitude;
}
중력에 저항을 받는 힘은 중력을 경사 normal에 project 하면 된다.
그리고 경사 각도의 따라 저항 값이 달라지도록 변수를 설정해 주었다.
이는 SmoothStep을 이용하였는데 지금 실행되는 로직은 slopeLimit을 넘은 각도이기 때문에 최대 90도라 가정하고 그 정도를 0~1로 변환하는 로직을 사용한 것이다.
이를 slopeVelocity에 더해 실제 이동할 벡터를 계산해 준다.
그리고 이를 경사에서 받는 중력의 반대 방향, 즉 이동하려는 방향과 Dot을 통해 값을 구해주면 slopeVelocity이 어떠한 방향을 향하고 있는지 알 수 있다.
Dot의 결과가 0보다 크다면 -90~90 사이가 되고 이동하려는 방향으로 조그만 힘이라고 존재하는 것이다.
하지만 0보다 작으면 이동 방향의 반대 방향으로 힘을 받는다는 뜻이다.
따라서 더 이상 올라가지 못한다는 것이다.
// 속도가 0 이하가 되면 미끄러짐 상태로 전환
float upwardSpeed = Vector3.Dot(_slopeVelocity, -gravitySlope.normalized);
if (upwardSpeed <= 0)
{
ChangeParkourState(ParkourState.SlopeSlip);
return;
}
근데 이 부분에서 한 가지 고민이 되는 게 상태가 변경되면서 애니메이션을 바로 바꾸고 싶은데 Update에서 context를 통해 animation을 설정할 수밖에 없다.
Enter에서 애님을 설정해 주려면 생성자에서 이를 전달받거나 Update에서 최초 1회만 애님을 설정하도록 하면 되는데 전자가 더 확실한 흐름이라고 생각한다.
따라서 이를 개선하기 위해 참조가 간편하도록 BlackBoard를 만들어 Controller에 붙이자.
참조가 한 단계 더 생기는 것이긴 하지만 BlackBoard만 전달하면 되므로 각 상태에서는 접근이 편할 것 같다.
using Cinemachine;
using UnityEngine;
namespace Player
{
public class PlayerBlackBoard: MonoBehaviour
{
[Header("Components")]
[SerializeField] private CharacterController _characterController;
[SerializeField] private ParkourInputController _parkourInputController;
[SerializeField] private CinemachineBrain _mainCamera;
[SerializeField] private Animator _animator;
[SerializeField] private FootTracker _footTracker;
[SerializeField] private PlayerMovementDataSo _playerMovementData;
public CharacterController characterController { get; private set; }
public ParkourInputController parkourInputController { get; private set; }
public CinemachineBrain mainCamera { get; private set; }
public Animator animator { get; private set; }
public FootTracker footTracker { get; private set; }
public PlayerMovementDataSo playerMovementData { get; private set; }
public void Initialize()
{
characterController = _characterController;
parkourInputController = _parkourInputController;
mainCamera = _mainCamera;
animator = _animator;
footTracker = _footTracker;
playerMovementData = _playerMovementData;
}
}
}
// 상태 생성
_parkourStates[ParkourState.OnGround] = new OnGroundParkourState(this, blackBoard);
_parkourStates[ParkourState.InAir] = new InAirParkourState(this, blackBoard);
_parkourStates[ParkourState.SlopeSlip] = new SlopeSlippingParkourState(this, blackBoard);
그리고 AnimatorMove부분이 너무 복잡해서져서 이동 또한 각 상태에서 처리하도록 변경해 보자.
private void OnAnimatorMove()
{
_parkourStates[_currentParkourState].HandleAnimation();
}
기존 코드는 대부분 이동과 관련된 코드이기 때문에 이동 스테이트로 옮기고 참조만 맞춰 주었다.
이로 인해 각 상태에서 애니메이션이 실행될 때 어떻게 동작할지 정의할 수 있게 되었다.
사실 상태를 나누면서 AnimatorMove에서 캐릭터 위치를 실질적으로 제어하기 때문에 상태를 나누는 것이 더 번거롭게 느껴졌는데 이렇게 분리하니까 충분히 개선된 것 같다.
OnAnimatorMove의 경우 Animator를 갖고 있는 객체에 있는 스크립트(MnonoBehaviour를 상속한)라면 모두 실행되기 때문에 위처럼 직접 호출하지 않고 각 상태에 정의해도 된다.
하지만 나는 각 상태가 인스펙터로 표시되는 것과 객체에 컴포넌트로 붙는 것을 선호하지 않아 직접 호출하는 방식으로 구현하였다.
정리하자면 OnGround 상태에서 오를 수 없는 경사를 만나면 초기 속도에서 중력에 의한 저항을 계속 받아 점점 속도가 느려지고 임계점에 다다르면 미끄러지는 상태로 변경한다.
미끄러지는 상태에서는 애니메이션을 실행하여 아래로 미끄러지다 오를 수 있는 경사를 만나거나 평지를 만났을 때 OnGround상태로 되돌아간다.
public override void OnUpdate(ParkourContext context)
{
_targetRotation = Mathf.Atan2(_gravitySlope.x, _gravitySlope.z) * Mathf.Rad2Deg;
_parkourCharacterController.transform.rotation = Quaternion.Euler(0.0f, _targetRotation, 0.0f);
if (_playerBlackBoard.footTracker.groundAngle < _playerBlackBoard.playerMovementData.slopeLimit)
{
_playerBlackBoard.animator.SetTrigger(AnimationHash.SlopeSlipEndAnimID);
_parkourCharacterController.ChangeParkourState(Parkour.ParkourState.OnGround);
}
}
