本文基于Starter Assets - Third Person Character Controller v1.0 版本。
转载请注明来源。

希望这篇文章可以帮助中国游戏开发者快速理解 Unity 标准资源包的 ThirdPersonController 脚本。

using Cinemachine;
using UnityEngine;
using UnityEngine.InputSystem;

/* Note: 角色和胶囊的动画都是通过控制器调用的,使用动画师的空检查。*/

[RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(PlayerInput))]
public class ThirdPersonController : MonoBehaviour
{
	[Header("Player")]
	[Tooltip("角色的移动速度,单位为 m/s")]
	public float MoveSpeed = 2.0f;
	[Tooltip("角色的冲刺速度为 m/s")]
	public float SprintSpeed = 5.335f;
	[Tooltip("角色转身面对运动方向的速度")]
	[Range(0.0f, 0.3f)]
	public float RotationSmoothTime = 0.12f;
	[Tooltip("加速和减速的速率")]
	public float SpeedChangeRate = 10.0f;

	[Space(10)]
	[Tooltip("玩家可以跳的高度")]
	public float JumpHeight = 1.2f;
	[Tooltip("角色使用自己的重力值。引擎的默认值是-9.81f")]
	public float Gravity = -15.0f;

	[Space(10)]
	[Tooltip("在能够再次跳跃之前所需的时间。设置为0f可立即再次跳起")]
	public float JumpTimeout = 0.50f;
	[Tooltip("进入下落状态前需要经过的时间。避免下楼梯时出错。")]
	public float FallTimeout = 0.15f;

	[Space(10)] 
	[Tooltip("最高的降落速度(用于限制下落速度过快)")]
	[SerializeField]
	private float _terminalVelocity = 53.0f;
	
	[Header("Player Grounded")]
	[Tooltip("角色是否已经接触地面。不是CharacterController内置的接地检查的一部分。")]
	public bool Grounded = true;
	[Tooltip("着地检测球的球心上下偏移量。0时,球心在脚底,值增大向下偏移。")]
	public float GroundedOffset = -0.14f;
	[Tooltip("接地检查的半径。应该与CharacterController的半径相匹配")]
	public float GroundedRadius = 0.28f;
	[Tooltip("被角色视为地面的 layers")]
	public LayerMask GroundLayers;

	[Header("Cinemachine")]
	[Tooltip("在Cinemachine虚拟摄像机中设置的跟随目标,摄像机将跟随该目标。")]
	public GameObject CinemachineCameraTarget;
	[Tooltip("你可以将摄像机向上移动多少度")]
	public float TopClamp = 70.0f;
	[Tooltip("你可以将摄像机向下移动多少度")]
	public float BottomClamp = -30.0f;
	[Tooltip("此值用于微调摄像机旋转的X轴分量。对锁定时微调相机位置很有用")]
	public float CameraAngleOverride = 0.0f;
	[Tooltip("用于锁定所有轴上的摄像机位置")]
	public bool LockCameraPosition = false;
	[Tooltip("指针运动可以使摄像机旋转的阈值。指针移动距离的平方小于此值时,摄像机不跟随指针")]
	[SerializeField]
	private float _threshold = 0.01f;

	// cinemachine
	/// <summary>
	/// 摄像机当前transform.rotation的Y轴分量
	/// </summary>
	private float _cinemachineTargetYaw;
	/// <summary>
	/// 摄像机当前transform.rotation的X轴分量
	/// </summary>
	private float _cinemachineTargetPitch;
	
	// player
	/// <summary>
	/// Player当前速度。与不同,此值最大速度使用最高设定速度 * 输入量大小,是真实速度
	/// </summary>
	private float _speed;
	/// <summary>
	/// Player动画器使用的速度变量。与_speed不同,此值最大值为移动或冲刺的设定速度。
	/// </summary>
	private float _animationBlend;
	/// <summary>
	/// Player目标旋转角度
	/// </summary>
	private float _targetRotation = 0.0f;
	/// <summary>
	/// 当前Player旋转到目标方向的速度。
	/// </summary>
	private float _rotationVelocity;
	/// <summary>
	///	Player当前纵向速度
	/// </summary>
	private float _verticalVelocity;

	// timeout deltatime
	/// <summary>
	/// 在能够再次跳跃之前的剩余等待时间。初始值来自序列化字段 JumpTimeout。
	/// </summary>
	private float _jumpTimeoutDelta;
	/// <summary>
	/// 进入下落状态前的剩余等待时间。初始值来自序列化字段 FallTimeout。
	/// </summary>
	private float _fallTimeoutDelta;

	// animation IDs
	/// <summary>
	/// 动画变量ID。角色当前速度。
	/// </summary>
	private int _animIDSpeed;
	/// <summary>
	/// 动画变量ID。角色是否着地。
	/// </summary>
	private int _animIDGrounded;
	/// <summary>
	/// 动画变量ID。角色是否在跳跃。
	/// </summary>
	private int _animIDJump;
	/// <summary>
	/// 动画变量ID。角色是否在自由降落。
	/// </summary>
	private int _animIDFreeFall;
	/// <summary>
	/// 动画变量ID。动画播放速度的乘数,用于调节动画播放速度。
	/// </summary>
	private int _animIDMotionSpeed;

	private Animator _animator;
	private CharacterController _controller;
	private InputHandler _input;
	private GameObject _mainCamera;
	private CinemachineVirtualCamera _cinemachineVirtualCamera;

	/// <summary>
	/// 是否使用Animator
	/// </summary>
	private bool _hasAnimator;

	private void Awake()
	{
		// 获取对 main camera 的引用
		if (_mainCamera == null)
		{
			_mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
		}
	}

	private void Start()
	{
		_hasAnimator = TryGetComponent(out _animator);
		_controller = GetComponent<CharacterController>();
		_input = GetComponent<InputHandler>();
		_cinemachineVirtualCamera = GameObject.Find("PlayerFollowCamera").GetComponent<CinemachineVirtualCamera>();
		_cinemachineVirtualCamera.Follow = CinemachineCameraTarget.transform;

		AssignAnimationIDs();

		// 在开始时,重置剩余等待时间
		_jumpTimeoutDelta = JumpTimeout;
		_fallTimeoutDelta = FallTimeout;
	}

	private void Update()
	{
		JumpAndGravity();
		GroundedCheck();
		Move();
	}

	private void LateUpdate()
	{
		CameraRotation();
	}

	/// <summary>
	/// 分配 Animator 变量的 ID(Hash值)。
	/// </summary>
	private void AssignAnimationIDs()
	{
		_animIDSpeed = Animator.StringToHash("Speed");
		_animIDGrounded = Animator.StringToHash("Grounded");
		_animIDJump = Animator.StringToHash("Jump");
		_animIDFreeFall = Animator.StringToHash("FreeFall");
		_animIDMotionSpeed = Animator.StringToHash("MotionSpeed");
	}

	/// <summary>
	/// 着地检测。
	/// </summary>
	private void GroundedCheck()
	{
		Vector3 rootPosition = transform.position;
		// 设置着地检测球的球心位置,使用偏移值 GroundedOffset
		Vector3 spherePosition = new Vector3(rootPosition.x, rootPosition.y - GroundedOffset, rootPosition.z);
		// 进行碰撞检测,忽略触发器。
		Grounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers, QueryTriggerInteraction.Ignore);
		// 如果使用 Animator,则更新_animIDGrounded变量。
		if (_hasAnimator)
		{
			_animator.SetBool(_animIDGrounded, Grounded);
		}
	}

	/// <summary>
	/// 摄像机的鼠标跟随旋转。
	/// </summary>
	private void CameraRotation()
	{
		// 如果有输入,并且摄像机位置不固定的话
		if (_input.look.sqrMagnitude >= _threshold && !LockCameraPosition)
		{
			_cinemachineTargetYaw += _input.look.x * Time.deltaTime;
			_cinemachineTargetPitch += _input.look.y * Time.deltaTime;
		}

		// 钳制我们的旋转,使我们的值被限制在360度以内
		_cinemachineTargetYaw = ClampAngle(_cinemachineTargetYaw, float.MinValue, float.MaxValue);
		_cinemachineTargetPitch = ClampAngle(_cinemachineTargetPitch, BottomClamp, TopClamp);

		// 更改Cinemachine的目标物体旋转,以此改变摄像机的位置和旋转
		CinemachineCameraTarget.transform.rotation = Quaternion.Euler(_cinemachineTargetPitch + CameraAngleOverride, _cinemachineTargetYaw, 0.0f);
	}

	private void Move()
	{
		// 一个简单的加速和减速的设计,易于移除、替换或迭代
		
		// 根据移动速度、冲刺速度和是否按了冲刺键来设置目标速度
		float targetSpeed;

		// 注意:Vector2的==操作符使用了近似值,所以不容易出现浮点错误,而且比幅度更便宜。
		// 如果没有输入,将目标速度设为0
		if (_input.move == Vector2.zero) 
			targetSpeed = 0.0f;
		else
			targetSpeed = _input.sprint ? SprintSpeed : MoveSpeed;

		Vector3 controllerVelocity = _controller.velocity;
		// 实时水平面上速度的大小
		float currentHorizontalSpeed = new Vector3(controllerVelocity.x, 0.0f, controllerVelocity.z).magnitude;

		// 由于使用 Lerp ,将当前速度向目标速度逼近,此值用于避免速度进入没有意义的无限细分。
		float speedOffset = 0.1f;
		// 如果模拟真实运动,则根据输入的Vector2的大小,调整角色跑步、走路等动作的速度和移动速度;否则同一使用原速度播放动画并使用原速度移动。
		float inputMagnitude = _input.analogMovement ? _input.move.magnitude : 1f;

		// 加速或减速至目标速度
		if (currentHorizontalSpeed < targetSpeed - speedOffset || currentHorizontalSpeed > targetSpeed + speedOffset)
		{
			// 使用 Lerp 给人一种更有机的速度变化
			_speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * inputMagnitude, Time.deltaTime * SpeedChangeRate);

			// 将速度四舍五入到小数点后3位
			_speed = Mathf.Round(_speed * 1000f) / 1000f;
		}
		else
		{
			// 使用Lerp将速度调整到目标速度附近后,直接转到目标速度
			_speed = targetSpeed;
		}
		// 获取Animator中速度变量的值
		_animationBlend = Mathf.Lerp(_animationBlend, targetSpeed, Time.deltaTime * SpeedChangeRate);

		// 归一化输入方向
		Vector3 inputDirection = new Vector3(_input.move.x, 0.0f, _input.move.y).normalized;

		// 注意:Vector2的 !=操作符使用了近似值,所以不容易出现浮点错误,而且比幅度更便宜。
		// 移动时,调整身体转身到目标角度
		if (_input.move != Vector2.zero)
		{
			// 目标旋转角度 = 输入的Vector2的弧度 * 弧度转角度常数 + mainCamera的全局角度的水平面分量
			_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;

		// 移动Player(水平面移动量+垂直移动量)
		_controller.Move(targetDirection.normalized * (_speed * Time.deltaTime) + new Vector3(0.0f, _verticalVelocity, 0.0f) * Time.deltaTime);

		// 更新 animator 
		if (_hasAnimator)
		{
			_animator.SetFloat(_animIDSpeed, _animationBlend);
			_animator.SetFloat(_animIDMotionSpeed, inputMagnitude);
		}
	}

	private void JumpAndGravity()
	{
		if (Grounded)
		{
			// 回到地面,重置下落CD
			_fallTimeoutDelta = FallTimeout;

			// 更新 animator 
			if (_hasAnimator)
			{
				_animator.SetBool(_animIDJump, false);
				_animator.SetBool(_animIDFreeFall, false);
			}

			// 阻止我们落地后速度无限下降
			if (_verticalVelocity < 0.0f)
			{
				_verticalVelocity = -2f;
			}

			// 跳跃
			if (_input.jump && _jumpTimeoutDelta <= 0.0f)
			{
				// H*-2*G的平方根=达到理想高度所需的速度
				_verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);

				// update animator
				if (_hasAnimator)
				{
					_animator.SetBool(_animIDJump, true);
				}
			}

			// jump timeout
			if (_jumpTimeoutDelta >= 0.0f)
			{
				_jumpTimeoutDelta -= Time.deltaTime;
			}
		}
		else
		{
			// 减少确认进入下落确认时间
			if (_fallTimeoutDelta >= 0.0f)
			{
				_fallTimeoutDelta -= Time.deltaTime;
			}
			else
			{
				// 确认进入下落状态
				// 离开地面则重置跳跃CD
				_jumpTimeoutDelta = JumpTimeout;
				// 如果到达下落CD,这播放自由下落动画
				if (_hasAnimator)
				{
					_animator.SetBool(_animIDFreeFall, true);
				}
			}

			// 不在地面时不允许跳跃
			_input.jump = false;
		}

		// 如果没有达到最高下落速度,则使用重力加速度增加下落速度
		if (_verticalVelocity < _terminalVelocity)
		{
			_verticalVelocity += Gravity * Time.deltaTime;
		}
	}

	/// <summary>
	/// 限制角度范围。原值可以超过360度,目标不可以。
	/// </summary>
	/// <param name="lfAngle">需要限制范围的角度</param>
	/// <param name="lfMin">最小允许角度</param>
	/// <param name="lfMax">最大允许角度</param>
	/// <returns></returns>
	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);
	}

	/// <summary>
	/// 展示着地检测球。着地为绿色,没有着地为红色。
	/// </summary>
	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。
		Gizmos.DrawSphere(new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z), GroundedRadius);
	}
}