在油管上找到一个很详细的Unity FPS Controller Unity Tutorial,决定跟着这个系列重新做了。
第一人称视角移动
按照这个教程,修改了一部分代码。
public class PlayerController : MonoBehaviour
{
// 是否能够移动
public bool CanMove { get; private set; } = true;
// 人物移动相关的参数
[Header("Movement Parameters")]
[SerializeField] private float walkSpeed = 3.0f;
[SerializeField] private float gravity = -9.81f;
[SerializeField] private bool isGrounded;
// 相机视角相关参数
[Header("Look Parameters")]
// 鼠标X轴速度,Y轴速度
[SerializeField, Range(1, 100)] private float lookSpeedX = 2.0f;
[SerializeField, Range(1, 100)] private float lookSpeedY = 2.0f;
[SerializeField, Range(0, 90)] private float upperLookLimit = 89.0f;
[SerializeField, Range(0, 90)] private float lowerLookLimit = 89.0f;
// 相机和角色控制器变量
private Camera playerCamera;
private CharacterController characterController;
// 储存玩家移动速度
private Vector3 moveDirection;
// 储存"Horizontal"和"Vertical"输入
private Vector2 moveInput;
// 鼠标向下看,相机X轴的旋转角度
private float rotationX;
// 存鼠标输入
private float mouseY;
private float mouseX;
private void Awake()
{
// Camera作为player的子物体
playerCamera = GetComponentInChildren<Camera>();
characterController = GetComponent<CharacterController>();
// 锁定电脑光标
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
}
教程分成了移动输入,鼠标观看,还有最终应用移动
void Update()
{
GroundCheck();
if (CanMove)
{
HandleMovementInput();
HandleMouseLook();
ApplyFinalMovement();
}
}
处理移动输入
private void HandleMovementInput()
{
// 获得WASD的输入 * speed
moveInput = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")) * walkSpeed;
// y轴,垂直速度保存下来不动
float moveDirectionY = moveDirection.y;
// 得到三个方向的输入
moveDirection = transform.right * moveInput.x + transform.forward * moveInput.y;
moveDirection.y = moveDirectionY;
}
处理鼠标输入
private void HandleMouseLook()
{
mouseX = Input.GetAxis("Mouse Y") * lookSpeedY * Time.deltaTime;
mouseY = Input.GetAxis("Mouse X") * lookSpeedX * Time.deltaTime;
rotationX -= mouseX;
// 左手握成点赞手势,大拇指指向正方向,手指蜷曲方向为正
// 所以最小值是往上看,负数
rotationX = Mathf.Clamp(rotationX, -upperLookLimit, lowerLookLimit);
playerCamera.transform.localRotation = Quaternion.Euler(rotationX, 0, 0);
// 貌似是因为涉及到坐标变化,所以只能用乘法?
transform.rotation *= Quaternion.Euler(0, mouseY, 0);
// 这个也行
// transform.Rotate(transform.up * mouseY);
}
处理移动,重力实现详见前一篇。
private void ApplyFinalMovement()
{
if (!isGrounded)
{
moveDirection.y += gravity * Time.deltaTime;
}
characterController.Move(moveDirection * Time.deltaTime);
}
private void GroundCheck()
{
isGrounded = Physics.CheckSphere(checkGround.position, groundCheckRadius, groundLayer);
if (isGrounded && moveDirection.y < 0.0f)
{
moveDirection.y = -2.0f;
}
}
冲刺
添加
public class PlayerController : MonoBehaviour
{
public bool CanMove { get; private set; } = true;
// 可以冲刺,且按了冲刺键,则进入冲刺状态
private bool IsSprinting => canSprint && Input.GetKey(sprintKey);
[Header("Functional Options")]
[SerializeField] private bool canSprint = true;
[Header("Controls")]
// 也可以在unity顶部菜单栏Edit -> Project Setting -> Input Manager 添加一个输入
[SerializeField] private KeyCode sprintKey = KeyCode.LeftShift;
[Header("Movement Parameters")]
[SerializeField] private float walkSpeed = 3.0f;
[SerializeField] private float sprintSpeed = 6.0f;
}
float speed = IsSprinting ? sprintSpeed : walkSpeed;
private void HandleMovementInput()
{
float speed = IsSprinting ? sprintSpeed : walkSpeed;
// 获得WASD的输入 * speed
moveInput = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")) * speed;
}
也可以加个Input,然后Input.GetButton(“Run”);
跳跃
private bool ShouldJump => canJump && Input.GetKeyDown(jumpKey); 跳跃功能开启,且按下跳跃键,判断应该执行跳跃
public class PlayerController : MonoBehaviour
{
private bool ShouldJump => canJump && Input.GetKeyDown(jumpKey);
// 功能开关
[Header("Functional Options")]
[SerializeField] private bool canJump = true;
[Header("Controls")]
[SerializeField] private KeyCode jumpKey = KeyCode.Space;
[Header("Jumping Parameters")]
// 最大跳跃次数
[SerializeField] private int maxJumpCount = 3;
// 目前跳跃次数
[SerializeField] private int jumpCount = 0;
// 上次跳跃时间
[SerializeField] private float lastJumpTime;
// 跳跃次数重置冷却时间
[SerializeField] private float jumpCoolDownTime = 0.1f;
// 跳跃高度
[SerializeField] private float jumpHeight = 5.0f;
[SerializeField] private float gravity = -9.81f;
}
void Update()
{
GroundCheck();
if (CanMove)
{
HandleMovementInput();
HandleMouseLook();
// 如果跳跃功能开启
if (canJump)
{
// 执行跳跃
HandleJump();
}
ApplyFinalMovement();
}
}
因为离地有一小段时间,仍会判断在地面上,所以加一小段时间,防止第一次离地跳跃,不计算使用了跳跃
private void HandleJump()
{
if (ShouldJump && jumpCount < maxJumpCount)
{
moveDirection.y = Mathf.Sqrt(-gravity * 2f * jumpHeight);
jumpCount++;
lastJumpTime = Time.time;
}
if (Time.time - lastJumpTime > jumpCoolDownTime && isGrounded) jumpCount = 0;
}
蹲伏
切换下蹲
网上绝大多数的教程,要么直接不教怎么实现蹲,要么就是通过按键,改变「CharacterControllerd」的「Height」,好一点的加个平滑过度,但是很难找到按住下蹲,松开自动站立的教程。这里先写按「C」切换站立和下蹲的状态。
public class PlayerController : MonoBehaviour
{
// 当功能开启,且按下「C」键,且不是已经在进行蹲/起立的状态,且在地面上(这可以根据需求决定)
private bool ShouldCrouch => canSwitchCrouch && Input.GetKeyDown(switchCrouchKey)
&& !duringCrouchAnimation && isGrounded;
[Header("Functional Options")]
// 是否开启切换下蹲功能
[SerializeField] private bool canSwitchCrouch = false;
[Header("Controls")]
// 也可以在unity顶部菜单栏Edit -> Project Setting -> Input Manager 添加一个输入
[SerializeField] private KeyCode switchCrouchKey = KeyCode.C;
[Header("Movement Parameters")]
// 蹲下时的移动速度
[SerializeField] private float crouchSpeed = 1.0f;
[Header("Crouch Parameters")]
// 蹲下时的高度与站立的高度
[SerializeField] private float crouchHeight = 1.35f;
[SerializeField] private float standingHeight = 2f;
[SerializeField] private float timeToCrouch = 0.25f;
[SerializeField] private bool isCrouching;
[SerializeField] private bool duringCrouchAnimation;
void Update()
{
GroundCheck();
if (CanMove)
{
HandleMovementInput();
HandleMouseLook();
if (canJump)
HandleJump();
// 如果开启切换下蹲过程,执行下蹲
if (canSwitchCrouch)
HandleCrouch();
ApplyFinalMovement();
}
}
按下了下蹲,应该下蹲或起立,执行一个协程
private void HandleCrouch()
{
if (ShouldCrouch)
StartCoroutine(CrouchStand());
}
这个协程做了这些事:
①判断如果是在蹲的状态下,那么下面就要执行站立,判断头顶有无障碍物,有障碍物就不执行站立
②根据「isCrouching」判断,如果「isCrouching」为「True」,说明此时是在蹲的状态,那么「targetHeight」则为「standingHeight」站立时的高度
③获取当前的高度,而「timeElapsed」是一个计时器,后面Lerp平滑站立蹲起的时候用
④下面就循环执行改变高度的操作,时间从 0 -> timeToCrouch, 按时间的占比,用Mathf.Lerp算出某时刻的高度
⑤完成高度的改变后,因为会有些误差,所以将高度设定为目标高度
⑥蹲的状态改变,蹲的动画结束
private IEnumerator CrouchStand()
{
if (isCrouching && Physics.Raycast(playerCamera.transform.position, transform.up, 1f)) yield break;
duringCrouchAnimation = true;
float timeElapsed = 0;
float targetHeight = isCrouching ? standingHeight : crouchHeight;
float currentHeight = characterController.height;
while (timeElapsed < timeToCrouch)
{
float chrouchPercentage = timeElapsed / timeToCrouch;
characterController.height = Mathf.Lerp(currentHeight, targetHeight, chrouchPercentage);
timeElapsed += Time.deltaTime;
yield return null;
}
characterController.height = targetHeight;
isCrouching = !isCrouching;
duringCrouchAnimation = false;
}
到此为止,高度可以在设定的「timeToCrouch」时间内平滑匀速过渡。但是摄像头高度不会变化,所以需要改变「Center」的位置,将碰撞体上移。
[Header("Crouch Parameters")]
[SerializeField] private float crouchHeight = 1.35f;
[SerializeField] private float standingHeight = 2f;
// 下蹲时,碰撞体的中心位置变化
[SerializeField] private float timeToCrouch = 0.25f;
[SerializeField] private Vector3 crouchingCenter = Vector3.zero;
[SerializeField] private Vector3 standingCenter = Vector3.zero;
[SerializeField] private bool isCrouching;
[SerializeField] private bool duringCrouchAnimation;
// 地面检测物体和摄像头位置变化
// changedLength = (standingHeight - crouchHeight) / 2
[SerializeField] private float changedDistanceY;
这个crouchingCenter高度,可以实现设定好,我这里在Start()里计算。
void Start()
{
changedDistanceY = (standingHeight - crouchHeight) / 2f;
crouchingCenter.Set(standingCenter.x, standingCenter.y + changedDistanceY, standingCenter.z);
changedDistanceY + crouchingCenter.y, 0);
}
协程里,也和height一样的操作。
private IEnumerator CrouchStand()
{
if (isCrouching && Physics.Raycast(playerCamera.transform.position, transform.up, 1f)) yield break;
duringCrouchAnimation = true;
float timeElapsed = 0;
float targetHeight = isCrouching ? standingHeight : crouchHeight;
float currentHeight = characterController.height;
Vector3 targetCenter = isCrouching ? standingCenter : crouchingCenter;
Vector3 currentCenter = characterController.center;
while (timeElapsed < timeToCrouch)
{
float chrouchPercentage = timeElapsed / timeToCrouch;
characterController.height = Mathf.Lerp(currentHeight, targetHeight, chrouchPercentage);
characterController.center = Vector3.Lerp(currentCenter, targetCenter, chrouchPercentage);
timeElapsed += Time.deltaTime;
yield return null;
}
characterController.height = targetHeight;
characterController.center = targetCenter;
isCrouching = !isCrouching;
duringCrouchAnimation = false;
}
这里测试的时候,如果设置成center下移,可能会在站起时穿墙。
groundCheck的位置跟上移,一样的操作。
[SerializeField] private Vector3 groundCheckStandingPosition;
[SerializeField] private Vector3 groundCheckCrouchPosition;
void Start()
{
// 蹲相关数据
changedDistanceY = (standingHeight - crouchHeight) / 2f;
crouchingCenter.Set(standingCenter.x, standingCenter.y + changedDistanceY, standingCenter.z);
groundCheckStandingPosition = new Vector3(0, groundCheck.localPosition.y, 0);
groundCheckCrouchPosition = new Vector3(0, groundCheck.localPosition.y + changedDistanceY + crouchingCenter.y, 0);
}
private IEnumerator CrouchStand()
{
Vector3 targetGroundCheckPosition = isCrouching ? groundCheckStandingPosition : groundCheckCrouchPosition;
Vector3 currentGroundCheckPosition = groundCheck.localPosition;
while (timeElapsed < timeToCrouch)
{
groundCheck.localPosition = Vector3.Lerp(currentGroundCheckPosition, targetGroundCheckPosition, chrouchPercentage);
timeElapsed += Time.deltaTime;
yield return null;
}
groundCheck.localPosition = targetGroundCheckPosition;
isCrouching = !isCrouching;
duringCrouchAnimation = false;
}
按住「LeftCtrl」下蹲
第二期有更好的解决方法:②第一人称视角控制器——开镜、头部摆动、斜面滑落
这个是真找不到好的教程,感觉多少都有点问题,下面是我自己写的,算是没有太大问题吧。
public class PlayerController : MonoBehaviour
{
// 功能开关
[Header("Functional Options")]
[SerializeField] private bool canSwitchCrouch = false;
// 按住下蹲开关
[SerializeField] private bool canHoldToCrouch = true;
[Header("Controls")]
// 也可以在unity顶部菜单栏Edit -> Project Setting -> Input Manager 添加一个输入
[SerializeField] private KeyCode holdToCrouchKey = KeyCode.LeftControl;
[SerializeField] private KeyCode switchCrouchKey = KeyCode.C;
[Header("Crouch Parameters")]
// 下蹲速度
[SerializeField, Range(50f, 100f)] private float crouchStandSpeed = 50f;
// 蹲姿锁
[SerializeField] private bool crouchLock;
}
我分成了两种开关,一种是上面,按C切换蹲和站立的。 一种是按住下蹲的,也可以同时实现蹲和站立。
void Update()
{
GroundCheck();
if (CanMove)
{
HandleMovementInput();
HandleMouseLook();
if (canJump)
HandleJump();
if (canSwitchCrouch || canHoldToCrouch)
HandleCrouch();
ApplyFinalMovement();
}
}
① 如果在开始蹲姿锁的情况下,按下了「C」,切换蹲姿,则开关蹲姿锁
② 如果蹲姿锁开启,但是按下了「leftcontrol」,则关闭蹲姿锁
③ isCrouching,下蹲状态,由是否按住下蹲,或者 开启了蹲姿锁决定
private void HandleCrouch()
{
// 如果开启按住蹲下,优先按住蹲下的功能
if (canHoldToCrouch)
{
// 如果按下蹲姿切换,则锁开关切换
if (Input.GetKeyDown(switchCrouchKey)) crouchLock = !crouchLock;
// 如果在锁定状态下,按下了保持蹲下键,则锁为关闭状态
if (crouchLock && Input.GetKeyDown(holdToCrouchKey)) crouchLock = false;
// 按住保持蹲下键,或者锁定为蹲下,则显示正在蹲伏状态
isCrouching = Input.GetKey(holdToCrouchKey) || crouchLock;
// 调整身高
AdjustHeight();
}
// 否则,通过协程进行蹲起
else if (ShouldCrouch)
StartCoroutine(CrouchStand());
}
在update里,实时调整身高。
① 如果目标高度已经是当期高度,则直接返回。
② 如果当前身高和目标身高误差很小,则直接设定为目标身高
③ 如果是其它情况,则需要调整身高
因为是在update里实现,所以计时器不是那么好用,并且还有蹲一半松开「leftcontrol」等情况。
我尝试了计时器,或者Mathf.SmoothDamp()等方法,都不尽如人意。
第三个参赛的时刻,使用固定参数或者计时器,或者身高比,都有卡顿或者其他麻烦的情况。
测试下来,目前设定一个蹲起速度,然后乘上Time.deltaTime还算比较良好。
Mathf.Lerp(currentHeight, targetHeight, crouchStandSpeed * Time.deltaTime);
想知道有没有更成熟的实现方法,网上找遍了是在是找不到。
关于Lerp和SmoothDamp相关细节可以看看这个。
简单来说,Lerp就是Mathf.Lerp(a, b, t(0.5)) 假设t取0.5,那么在这一帧,就取(b - a)这段,然后乘以0.5,相当于截取一半,然后新的坐标位置就是 a + 这一半长度,然后,如果拿a + 这一半长度的坐标,作为新的起始点,就会继续取一半,相当于最初的3/4的位置。所以到最后会越来越接近b,但是接近的越来越慢。
但是取时间的话,就能实现协程里,匀速的变化,而不是这样的减速效果。
而SmoothDamp,有点类似汽车加速启动,然后减速,最终停止那种平滑感。
private void AdjustHeight()
{
float targetHeight = isCrouching ? crouchHeight : standingHeight;
float currentHeight = characterController.height;
// 如果已经一致,返回
if (targetHeight == currentHeight) return;
Vector3 targetCenter = isCrouching ? crouchingCenter : standingCenter;
Vector3 currentCenter = characterController.center;
Vector3 targetGroundCheckPosition = isCrouching ? groundCheckCrouchPosition : groundCheckStandingPosition;
Vector3 currentGroundCheckPosition = groundCheck.localPosition;
if (Mathf.Abs(targetHeight - currentHeight) < 0.01f)
{
characterController.height = targetHeight;
characterController.center = targetCenter;
groundCheck.localPosition = targetGroundCheckPosition;
}
else
{
// 蹲 -> 站立 过程,如果头上有阻挡,则不能改变身高
if (Physics.Raycast(playerCamera.transform.position, transform.up, 1f) && !isCrouching) return;
characterController.height = Mathf.Lerp(currentHeight, targetHeight, crouchStandSpeed * Time.deltaTime);
characterController.center = Vector3.Lerp(currentCenter, targetCenter, crouchStandSpeed * Time.deltaTime);
groundCheck.localPosition = Vector3.Lerp(currentGroundCheckPosition, targetGroundCheckPosition, crouchStandSpeed * Time.deltaTime);
}
}
还有蹲下时的移动速度,在这里改动。
private void HandleMovementInput()
{
float speed = isCrouching ? crouchSpeed : IsSprinting ? sprintSpeed : walkSpeed;
// Debug.Log("speed: " + speed);
// 获得WASD的输入 * speed
moveInput = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")) * speed;
// y轴,垂直速度保存下来不动
float moveDirectionY = moveDirection.y;
// 得到三个方向的输入
moveDirection = transform.right * moveInput.x + transform.forward * moveInput.y;
moveDirection.y = moveDirectionY;
}