CharacterController
를 이용한 TPS 플레이어를 구현하는 도중 내 플레이어와 유니티가 샘플로 만든 플레이어의 퀄리티 차이를 체감하고, 유니티의 ThirdPersonController는 어떻게 작성되어 있나 확인하기 위한 문서
해당 스크립트는 다음 주소의 에셋에서 확인할 수 있다.
[Header("Player")]
[Tooltip("Move speed of the character in m/s")]
public float MoveSpeed = 2.0f;
[Tooltip("Sprint speed of the character in m/s")]
public float SprintSpeed = 5.335f;
[Tooltip("How fast the character turns to face movement direction")]
[Range(0.0f, 0.3f)]
public float RotationSmoothTime = 0.12f;
[Tooltip("Acceleration and deceleration")]
public float SpeedChangeRate = 10.0f;
// 플레이어 발소리 관련 변수들
public AudioClip LandingAudioClip;
public AudioClip[] FootstepAudioClips;
[Range(0, 1)] public float FootstepAudioVolume = 0.5f;
[Space(10)]
[Tooltip("The height the player can jump")]
public float JumpHeight = 1.2f;
[Tooltip("The character uses its own gravity value. The engine default is -9.81f")]
public float Gravity = -15.0f;
[Space(10)]
[Tooltip("Time required to pass before being able to jump again. Set to 0f to instantly jump again")]
public float JumpTimeout = 0.50f;
[Tooltip("Time required to pass before entering the fall state. Useful for walking down stairs")]
public float FallTimeout = 0.15f;
[Header("Player Grounded")]
[Tooltip("If the character is grounded or not. Not part of the CharacterController built in grounded check")]
public bool Grounded = true;
[Tooltip("Useful for rough ground")]
public float GroundedOffset = -0.14f;
[Tooltip("The radius of the grounded check. Should match the radius of the CharacterController")]
public float GroundedRadius = 0.28f;
[Tooltip("What layers the character uses as ground")]
public LayerMask GroundLayers;
[Header("Cinemachine")]
[Tooltip("The follow target set in the Cinemachine Virtual Camera that the camera will follow")]
public GameObject CinemachineCameraTarget;
[Tooltip("How far in degrees can you move the camera up")]
public float TopClamp = 70.0f;
[Tooltip("How far in degrees can you move the camera down")]
public float BottomClamp = -30.0f;
[Tooltip("Additional degress to override the camera. Useful for fine tuning camera position when locked")]
public float CameraAngleOverride = 0.0f;
[Tooltip("For locking the camera position on all axis")]
public bool LockCameraPosition = false;
// 시네머신 관련 변수
private float _cinemachineTargetYaw;
private float _cinemachineTargetPitch;
// 플레이어 변수
private float _speed;
private float _animationBlend;
private float _targetRotation = 0.0f;
private float _rotationVelocity;
private float _verticalVelocity;
private float _terminalVelocity = 53.0f;
// timeout deltatime
private float _jumpTimeoutDelta;
private float _fallTimeoutDelta;
// 애니메이터의 애니메이션 값 ID들
private int _animIDSpeed;
private int _animIDGrounded;
private int _animIDJump;
private int _animIDFreeFall;
private int _animIDMotionSpeed;
#if ENABLE_INPUT_SYSTEM
private PlayerInput _playerInput;
#endif
private Animator _animator;
private CharacterController _controller;
private StarterAssetsInputs _input;
private GameObject _mainCamera;
private const float _threshold = 0.01f;
private bool _hasAnimator;
private bool IsCurrentDeviceMouse
{
get
{
#if ENABLE_INPUT_SYSTEM
return _playerInput.currentControlScheme == "KeyboardMouse";
#else
return false;
#endif
}
}
private void Awake()
{
// 메인 카메라의 레퍼런스를 가져온다
if (_mainCamera == null)
{
_mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
}
}
private void Start()
{
// 시네머신 카메라의 요 회전값을 가져온다
_cinemachineTargetYaw = CinemachineCameraTarget.transform.rotation.eulerAngles.y;
// 애니메이터, 캐릭터컨트롤러, 인풋시스템 컴포넌트를 가져온다
_hasAnimator = TryGetComponent(out _animator);
_controller = GetComponent<CharacterController>();
_input = GetComponent<StarterAssetsInputs>();
#if ENABLE_INPUT_SYSTEM
_playerInput = GetComponent<PlayerInput>();
#else
Debug.LogError( "Starter Assets package is missing dependencies. Please use Tools/Starter Assets/Reinstall Dependencies to fix it");
#endif
// 애니메이터의 애니메이션 트리거 ID 등록
AssignAnimationIDs();
// 타임아웃 값들 초기화
_jumpTimeoutDelta = JumpTimeout;
_fallTimeoutDelta = FallTimeout;
}
private void Update()
{
_hasAnimator = TryGetComponent(out _animator);
// 점프 기능과 중력 작용 관리
JumpAndGravity();
// 그라운드 체크
GroundedCheck();
// 움직임 관리
Move();
}
private void LateUpdate()
{
// 카메라 회전 관리
CameraRotation();
}
private void AssignAnimationIDs()
{
_animIDSpeed = Animator.StringToHash("Speed");
_animIDGrounded = Animator.StringToHash("Grounded");
_animIDJump = Animator.StringToHash("Jump");
_animIDFreeFall = Animator.StringToHash("FreeFall");
_animIDMotionSpeed = Animator.StringToHash("MotionSpeed");
}
Physics.CheckSphere()
를 이용해 그라운드를 체크한다. private void GroundedCheck()
{
// 오프셋을 적용해 스피어 콜라이더의 위치를 잡는다
Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y - GroundedOffset,
transform.position.z);
// CheckSphere()로 캐릭터의 바닥쪽에 바닥으로 정한 물체 콜라이더가 닿았는지 체크한다.
Grounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers,
QueryTriggerInteraction.Ignore);
// 애니메이션을 가진 아바타가 있으면 애니메이션 프로퍼티를 갱신한다
if (_hasAnimator)
{
_animator.SetBool(_animIDGrounded, Grounded);
}
}
private void CameraRotation()
{
// 카메라 포지션이 고정되지 않았고 카메라 회전 입력이 들어왔으면
if (_input.look.sqrMagnitude >= _threshold && !LockCameraPosition)
{
// 마우스 입력은 DeltaTime을 곱하지 않는다
float deltaTimeMultiplier = IsCurrentDeviceMouse ? 1.0f : Time.deltaTime;
// 각각 x,z축 입력을 할당한다
_cinemachineTargetYaw += _input.look.x * deltaTimeMultiplier;
_cinemachineTargetPitch += _input.look.y * deltaTimeMultiplier;
}
// 회전각이 0에서 360사이에 있도록 클램핑해준다
_cinemachineTargetYaw = ClampAngle(_cinemachineTargetYaw, float.MinValue, float.MaxValue);
_cinemachineTargetPitch = ClampAngle(_cinemachineTargetPitch, BottomClamp, TopClamp);
// 시네머신카메라의 회전을 설정한다
CinemachineCameraTarget.transform.rotation = Quaternion.Euler(_cinemachineTargetPitch + CameraAngleOverride,
_cinemachineTargetYaw, 0.0f);
}
private void Move()
{
// 걷기/달리기를 판단해 이동속도를 설정
float targetSpeed = _input.sprint ? SprintSpeed : MoveSpeed;
// 제거, 교체 또는 반복하기 쉽도록 설계된 단순한 가속 및 감속 기능 구현
// 추신 : 벡터2의 ==는 근사치 계산을 하므로 부동소수점 오류가 발생하지 않으며 magnitude보다 가볍다
// 입력이 없으면 속도를 0으로 설정한다
if (_input.move == Vector2.zero) targetSpeed = 0.0f;
// 현재 플레이어의 수평 속도 계산
float currentHorizontalSpeed = new Vector3(_controller.velocity.x, 0.0f, _controller.velocity.z).magnitude;
float speedOffset = 0.1f;
float inputMagnitude = _input.analogMovement ? _input.move.magnitude : 1f;
// 타겟 스패드로 가속 혹은 감속
if (currentHorizontalSpeed < targetSpeed - speedOffset ||
currentHorizontalSpeed > targetSpeed + speedOffset)
{
// 자연스런 속도변화를 위해 선형이 아닌 곡선형 변화 제공
// 추신 : Lerp내 T는 클램프되므로 따로 클램핑할 필요는 없다
_speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * inputMagnitude,
Time.deltaTime * SpeedChangeRate);
// 소수점 세 자리에서 자르기
_speed = Mathf.Round(_speed * 1000f) / 1000f;
}
else
{
_speed = targetSpeed;
}
// 애니메이터에 제공할 이동속도 값 계산
_animationBlend = Mathf.Lerp(_animationBlend, targetSpeed, Time.deltaTime * SpeedChangeRate);
if (_animationBlend < 0.01f) _animationBlend = 0f;
// 입력값 노멀라이징
Vector3 inputDirection = new Vector3(_input.move.x, 0.0f, _input.move.y).normalized;
// 추신 : 벡터2의 !=는 근사치 계산을 하므로 부동소수점 오류가 발생하지 않으며 magnitude보다 가볍다
// 입력이 들어오면 플레이어를 입력이 들어온 쪽으로 회전시킨다
if (_input.move != 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,
RotationSmoothTime);
// 카메라의 포지션을 참고해 가는 방향으로 회전킨다
transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
}
Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;
// 플레이어를 움직인다
_controller.Move(targetDirection.normalized * (_speed * Time.deltaTime) +
new Vector3(0.0f, _verticalVelocity, 0.0f) * Time.deltaTime);
// 아바타가 있으면 아바타 애니메이터 값을 설정한다
if (_hasAnimator)
{
_animator.SetFloat(_animIDSpeed, _animationBlend);
_animator.SetFloat(_animIDMotionSpeed, inputMagnitude);
}
}
private void JumpAndGravity()
{
// 땅에 있으면
if (Grounded)
{
// 낙하 타임아웃 타이머 초기화
_fallTimeoutDelta = FallTimeout;
// 애니메이터 값 갱신
if (_hasAnimator)
{
_animator.SetBool(_animIDJump, false);
_animator.SetBool(_animIDFreeFall, false);
}
// 땅에 있을 때 낙하가속을 고정시킴
if (_verticalVelocity < 0.0f)
{
_verticalVelocity = -2f;
}
// 점프
if (_input.jump && _jumpTimeoutDelta <= 0.0f)
{
// 높이 * 중력가속도 * -2의 제곱근 = 원하는 높이에 닿기까지 필요한 가속도
_verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);
// 애니메이션 값 갱신
if (_hasAnimator)
{
_animator.SetBool(_animIDJump, true);
}
}
// 점프 타임아웃 타이머
if (_jumpTimeoutDelta >= 0.0f)
{
_jumpTimeoutDelta -= Time.deltaTime;
}
}
else
{
// 점프 타임아웃 타이머 초기화
_jumpTimeoutDelta = JumpTimeout;
// 낙하 타임아웃 타이머
if (_fallTimeoutDelta >= 0.0f)
{
_fallTimeoutDelta -= Time.deltaTime;
}
else
{
// 애니메이션 값 갱신
if (_hasAnimator)
{
_animator.SetBool(_animIDFreeFall, true);
}
}
// 땅에 있지 않으면 점프 비활성화
_input.jump = false;
}
// 점프 중 중력가속도 적용
if (_verticalVelocity < _terminalVelocity)
{
_verticalVelocity += Gravity * Time.deltaTime;
}
}
private static float ClampAngle(float lfAngle, float lfMin, float lfMax)
{
if (lfAngle < -360f) lfAngle += 360f;
if (lfAngle > 360f) lfAngle -= 360f;
return Mathf.Clamp(lfAngle, lfMin, lfMax);
}
private void OnDrawGizmosSelected()
{
Color transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
Color transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);
if (Grounded) Gizmos.color = transparentGreen;
else Gizmos.color = transparentRed;
// 선택되면 바닥 콜라이더에 대한 기즈모 그리기
Gizmos.DrawSphere(
new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z),
GroundedRadius);
}
private void OnFootstep(AnimationEvent animationEvent)
{
if (animationEvent.animatorClipInfo.weight > 0.5f)
{
if (FootstepAudioClips.Length > 0)
{
var index = Random.Range(0, FootstepAudioClips.Length);
AudioSource.PlayClipAtPoint(FootstepAudioClips[index], transform.TransformPoint(_controller.center), FootstepAudioVolume);
}
}
}
private void OnLand(AnimationEvent animationEvent)
{
if (animationEvent.animatorClipInfo.weight > 0.5f)
{
AudioSource.PlayClipAtPoint(LandingAudioClip, transform.TransformPoint(_controller.center), FootstepAudioVolume);
}
}