Parkour Climbing System
——类刺客信条跑酷系统
摘要:这个项目不会用到除模型动画之外的任何资产,完全从0开始构建
写在前面:
GitHub仓库:https://github.com/EanoJiang/Parkour-Climbing-System
第一部分(Day1~4):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section1
第二部分(Day5~8):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section2
视频太长,为了好上传这里我就上传了10帧率的GIF
第三部分(Day9~还没写完):
Day1 摄像头脚本
在unity中,xyz轴是右手坐标系,即x水平向右,y垂直向上,z水平向前- public class CameraController : MonoBehaviour{ //摄像机跟随的目标 [SerializeField] Transform followTarget; // Update is called once per frame void Update() { //摄像机放在目标后面5个单位的位置 transform.position = followTarget.position - new Vector3(0, 0, 5); }}
复制代码 怎么旋转这个相机呢?
摄像机向后移动的参量乘一个水平旋转角度
所以,引入四元数点欧拉Quaternion.Euler
这个水平视角旋转角度需要绕y轴的旋转角度,还需要鼠标控制这个角度
并且,当摄像头旋转的时候,摄像头始终对着player- public class CameraController : MonoBehaviour{ //摄像机跟随的目标 [SerializeField] Transform followTarget; //距离 [SerializeField] float distance; //绕y轴的旋转角度 float rotationY; private void Update() { //鼠标x轴控制rotationY rotationY += Input.GetAxis("Mouse X"); //水平视角旋转参量 //想要水平旋转视角,所以需要的参量为绕y轴旋转角度 var horizontalRotation = Quaternion.Euler(0, rotationY, 0); //摄像机放在目标后面5个单位的位置 transform.position = followTarget.position - horizontalRotation * new Vector3(0, 0, distance); //摄像机始终朝向目标 transform.rotation = horizontalRotation; }}
复制代码
完成了水平视角的旋转
让相机垂直旋转

还需要在垂直视角旋转的时候合理的限幅:让视角最高不超过45°,最低到人物的胸部位置- public class CameraController : MonoBehaviour{ //摄像机跟随的目标 [SerializeField] Transform followTarget; [SerializeField] float rotationSpeed = 1.5f; //距离 [SerializeField] float distance; //绕y轴的旋转角度——水平视角旋转 float rotationY; //绕x轴的旋转角度——垂直视角旋转 float rotationX; //限制rotationX幅度 [SerializeField] float minVerticalAngle = -20; [SerializeField] float maxVerticalAngle = 45; //框架偏移向量——摄像机位置视差偏移 [SerializeField] Vector2 frameOffset; private void Update() { //鼠标x轴控制rotationY rotationY += Input.GetAxis("Mouse X") * rotationSpeed; //鼠标y轴控制rotationX rotationX += Input.GetAxis("Mouse Y") * rotationSpeed; //限制rotationX幅度 rotationX = Mathf.Clamp(rotationX, minVerticalAngle, maxVerticalAngle); //视角旋转参量 //想要水平旋转视角,所以需要的参量为绕y轴旋转角度 var targetRotation = Quaternion.Euler(rotationX, rotationY, 0); //摄像机的焦点位置 var focusPosition = followTarget.position + new Vector3(frameOffset.x, frameOffset.y, 0); //摄像机放在目标后面5个单位的位置 transform.position = focusPosition - targetRotation * new Vector3(0, 0, distance); //摄像机始终朝向目标 transform.rotation = targetRotation; }}
复制代码
大致实现了摄像机跟随人物进行旋转
还需要一些细节调整:- private void Start() { //隐藏光标 Cursor.lockState = CursorLockMode.Locked; Cursor.visible = false; }
复制代码 考虑到存在大多数角色控制器都有控制反转的选项- using System.Collections;using System.Collections.Generic;using UnityEngine;public class CameraController : MonoBehaviour{ //摄像机跟随的目标 [SerializeField] Transform followTarget; [SerializeField] float rotationSpeed = 1.5f; //距离 [SerializeField] float distance; //绕y轴的旋转角度——水平视角旋转 float rotationY; //绕x轴的旋转角度——垂直视角旋转 float rotationX; //限制rotationX幅度 [SerializeField] float minVerticalAngle = -20; [SerializeField] float maxVerticalAngle = 45; //框架偏移向量——摄像机位置视差偏移 [SerializeField] Vector2 frameOffset; //视角控制反转 [Header("视角控制反转:invertX是否反转垂直视角,invertY是否反转水平视角")] [SerializeField] bool invertX; [SerializeField] bool invertY; float invertXValue; float invertYValue; private void Start() { //隐藏光标 Cursor.lockState = CursorLockMode.Locked; Cursor.visible = false; } private void Update() { //视角控制反转参数 invertXValue = (invertX)? -1 : 1; invertYValue = (invertY)? -1 : 1; //水平视角控制——鼠标x轴控制rotationY rotationY += Input.GetAxis("Mouse X") * rotationSpeed * invertYValue; //垂直视角控制——鼠标y轴控制rotationX rotationX += Input.GetAxis("Mouse Y") * rotationSpeed * invertXValue; //限制rotationX幅度 rotationX = Mathf.Clamp(rotationX, minVerticalAngle, maxVerticalAngle); //视角旋转参量 //想要水平旋转视角,所以需要的参量为绕y轴旋转角度 var targetRotation = Quaternion.Euler(rotationX, rotationY, 0); //摄像机的焦点位置 var focusPosition = followTarget.position + new Vector3(frameOffset.x, frameOffset.y, 0); //摄像机放在目标后面5个单位的位置 transform.position = focusPosition - targetRotation * new Vector3(0, 0, distance); //摄像机始终朝向目标 transform.rotation = targetRotation; }}
复制代码我通常喜欢这样选择,垂直反转(鼠标向上就看上面),水平不反转(鼠标向左就看左边)
就是让摄像机视角和鼠标移动方向对我来说是同步的,相当于第一人称视角控制的习惯
Day2 第三人称人物控制脚本
前序准备
先创建个人物模型( 从Mixamo下载的)
导入unity中,选择模型后点开inspector-Materials-Textures,选一个文件夹存放纹理
OK,下面就开始为这个角色写控制脚本吧!
最简化的第三人称角色控制
- public class PlayerController : MonoBehaviour{ [SerializeField]float moveSpeed = 5f; private void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; transform.position += moveInput * moveSpeed * Time.deltaTime; }}
复制代码 需要注意的:
- .normalized :
如果不进行标准化,moveInput 向量的长度会变得大于1(具体来说,比如h,v长度都为1,\(\sqrt{h^2 + 0^2 + v^2} = \sqrt{1^2 + 0^2 + 1^2} = \sqrt{2} \approx 1.414\))。这意味着在对角线方向上移动时,玩家的移动速度会比只在一个方向上移动时快。为了确保玩家在所有方向上移动时速度一致,需要对向量进行标准化。
- Time.deltaTime :
Time.deltaTime 是Unity引擎提供的一个浮点数,表示从上一帧到当前帧所用的时间(以秒为单位)。使用 Time.deltaTime 可以确保玩家的移动速度在不同帧率下保持一致。如果不使用 Time.deltaTime,在高帧率下玩家会移动得更快,在低帧率下玩家会移动得更慢。
改进
上面这样显然不能满足角色控制,因为当我们按下前进方向键的时候,人物并没有根据当前摄像机显示的方向移动
还需要进行如下改进:
在CameraController.cs里面加入- //水平方向的旋转,返回摄像机的水平旋转四元数。 public Quaternion PlanarRotation => Quaternion.Euler(0, rotationY, 0);
复制代码这里提一句C#中的特性:
大多数语言中想要获取一个返回值,需要定义一个函数,然后返回- public Quaternion GetPlanarRotation() { return Quaternion.Euler(0, rotationY, 0); }
复制代码 但是C#可以优雅的利用表达式主体定义的属性,直接获取这个属性
然后在PlayerController.cs里调用这个返回值- public class PlayerController : MonoBehaviour{ [SerializeField]float moveSpeed = 5f; CameraController cameraController; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); } private void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); float moveAmount = Mathf.Abs(h) + Mathf.Abs(v); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物移动方向关联相机的水平旋转朝向 // 这样角色就只能在水平方向移动,而不是相机在竖直方向的旋转量也会改变角色的移动方向 var moveDir = cameraController.PlanarRotation * moveInput; //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新移动+转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //帧同步移动 transform.position += moveDir * moveSpeed * Time.deltaTime; //人物模型转起来:让人物朝向与移动方向一致 transform.rotation = Quaternion.LookRotation(moveDir); } }}
复制代码这里解决了一个问题:
当方向键输入结束,人物模型朝向又回到了初始状态朝向
所以需要实时响应输入
- if (moveAmount > 0)只有输入的时候才会更新人物朝向
- 确保模型始终朝向移动方向。
但是还有一个问题:
人物朝向切换太快了,需要设置一个转向速度,让人物从当前朝向到目标朝向慢慢转向- [SerializeField]float rotationSpeed = 10f; Quaternion targetRotation;
复制代码- //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新移动+转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //帧同步移动 transform.position += moveDir * moveSpeed * Time.deltaTime; //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
复制代码 实现效果如下:

该部分完整代码:- using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{ [Header("玩家属性")] [SerializeField]float moveSpeed = 5f; [SerializeField]float rotationSpeed = 10f; Quaternion targetRotation; CameraController cameraController; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); } private void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); float moveAmount = Mathf.Abs(h) + Mathf.Abs(v); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物移动方向关联相机的朝向 var moveDir = cameraController.PlanarRotation * moveInput; //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新移动+转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //帧同步移动 transform.position += moveDir * moveSpeed * Time.deltaTime; //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); }}
复制代码 Day3 Animation动画
前序准备
有一个待解决的问题:如何让动画匹配任意人物模型?
应用后点configure查看骨骼映射情况,如果有没匹配上的需要手动调整
最后Done完成
注意Avatar Definition要选择 从其他avatar复制,然后在source里面选择要应用的avatar
注意要选择Loop Pose,如果loop match 的话可以不勾选Bake Into Pose
而且几个动画的Length值最好要尽可能接近,以免后面切换的时候出现问题
- 记得在player的Animator属性里添加这个脚本
万事俱备,下面开始编写动画相关脚本!
Animator组件——动画蓝图
新建一个Blend Tree
拖入对应动画
在PlayerController.cs里写动画播放逻辑
- private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); }
复制代码 Update()方法:- //把moveAmount限制在0-1之间(混合树的区间) float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v));
复制代码 Blender Tree里moveMount的区间是(0,1)- #region 角色动画控制 //角色动画播放 animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime); #endregion
复制代码 SetFloat()有四个参数的重载,第三个参数是要平滑到达的值
基本的第三人称角色控制器效果如下:

修改后的PlayerController.cs完整代码:- using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{ [Header("玩家属性")] [SerializeField]float moveSpeed = 5f; [SerializeField]float rotationSpeed = 10f; Quaternion targetRotation; CameraController cameraController; Animator animator; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); } private void Update() { #region 角色输入控制 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); //把moveAmount限制在0-1之间(混合树的区间) float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v)); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物移动方向关联相机的朝向 var moveDir = cameraController.PlanarRotation * moveInput; //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新移动+转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //帧同步移动 transform.position += moveDir * moveSpeed * Time.deltaTime; //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion #region 角色动画控制 //角色动画播放 animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime); #endregion }}
复制代码 Day4 物理引擎——碰撞体和重力
Character Controller
官网描述:
Character Controller
控制器本身不会对力作出反应,也不会自动推开刚体。
如果要通过角色控制器来推动刚体或对象,可以编写脚本通过 OnControllerColliderHit() 函数对与控制器碰撞的任何对象施力。
另一方面,如果希望玩家角色受到物理组件的影响,那么可能更适合使用刚体,而不是角色控制器。
“本身不会对力作出反应,也不会自动推开刚体。”
所以,对于墙体这种不希望被碰到就移动位置的组件,更适合用Character Controller,而不是刚体rigid body
为玩家和碰撞墙体添加Character Controller组件
别忘了给Plane加一个collision
玩家:
center、radius、height设置碰撞胶囊体的三维
center通常设置height的一半略多一些
然后center.Z最好向前偏移一个小值,因为大多数能被玩家直观感受到的碰撞发生在角色面前
回到PlayerController.cs脚本,- private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); //角色控制器 charactercontroller = GetComponent(); }
复制代码 Update()方法:- //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(moveDir * moveSpeed * Time.deltaTime); //transform.position += moveDir * moveSpeed * Time.deltaTime;
复制代码 原来的直接用 transform.position +=改变位置,换成用 CharacterController.Move()来控制角色的移动,这会使用CharacterController组件的特性。
效果如下:
碰撞检测
PlayerController.cs- [Header("ground check")] [SerializeField]float groundCheckRadius = 0.5f; //检测射线偏移量 [SerializeField]Vector3 groundCheckOffset; [SerializeField]LayerMask groundLayer; bool isGrounded;
复制代码 Update()- #region 碰撞检测 GroundCheck(); Debug.Log("isGrounded: "+ isGrounded); #endregion
复制代码 检测函数和画线函数- private void GroundCheck() { // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true // 位置偏移用来在unity控制台里面调整 isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer); } //画检测射线 private void OnDrawGizmosSelected() { //射线颜色,最后一个参数是透明度 Gizmos.color = new Color(0, 1, 0, 0.5f); Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius); }
复制代码 回到控制台进行如下设置:
调整到球体覆盖住角色的脚
为plane和其他障碍物添加Layer为Obstacles
重力设置
Update()- #region 碰撞检测 GroundCheck(); #endregion if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; } else { //在空中时,角色的速度由ySpeed决定 ySpeed += Physics.gravity.y * Time.deltaTime; } var velocity = moveDir * moveSpeed; velocity.y = ySpeed; //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(velocity * Time.deltaTime); //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion
复制代码 y轴方向的位置移动实时更新,即使没有输入也要更新。
效果如下:
下面设置skin width:
官方手册描述:
Character Controller——Skin width
两个碰撞体可以穿透彼此且穿透深度最多为皮肤宽度 (Skin Width)。较大的皮肤宽度可减少抖动。较小的皮肤宽度可能导致角色卡住。合理设置是将此值设为半径的 10%。
Center的更准确的设置:
Center.Y = Height /2 + Skin Width
改之前:
改之后:
可以看到,脚部完全贴合地面,that's good.
有个问题仍然存在:当我们在下落过程中,角色仍在播放走路动画
因此我们需要编写相应的动画逻辑,还可以加下落动画,这个放在后面再写,坑+1
手柄适配
下面改一下对手柄的适配,因为在写摄像机输入控制的时候用的是Mouse:
在Project Settings - Input Manager里面找到Mouse X和Y,分别复制两个副本,重命名为Camera X/Y
对第二个Camera进行如下设置
注意死区和灵敏度最好设置成和下面的Horizontal一样的值
回到CameraController.cs脚本,修改如下即可- //水平视角控制——鼠标(手柄)x轴控制rotationY rotationY += Input.GetAxis("Camera X") * rotationSpeed * invertYValue; //垂直视角控制——鼠标(手柄)y轴控制rotationX rotationX += Input.GetAxis("Camera Y") * rotationSpeed * invertXValue;
复制代码有个小问题:键鼠控制的话就勾上X轴反转,手柄控制就不要勾了
效果就是手柄右摇杆也能控制相机视角了,演示就不放了,没啥区别
以上部分代码我放到了GitHub仓库:
https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section1
ok,可以开始编写最有意思的跑酷系统脚本了!
Day 5 跑酷系统——StepUp&JumpUp
跑酷系统构成:跑酷控制器+环境扫描
环境扫描
Environment Scanner
The environment scanner will scan for obstacles in front of the player by using multiple RayCasts.
放置不同高度的Cube,设置Position.y = Scale.y / 2
EnvironmentScanner.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class EnvironmentScanner : MonoBehaviour{ [Header ("向前发送的射线相关参数")] //y轴(竖直方向)偏移量 [SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0); //长度 [SerializeField] float forwardRayLength = 0.8f; //障碍物层 [SerializeField] LayerMask obstacleLayer; public void ObstacleCheck() { //让射线从膝盖位置开始发送 //射线的起始位置 = 角色位置 + 一个偏移量 var forwardOrigin = transform.position + forwardRayOffset; //用来存射线检测的信息 RaycastHit hitInfo; //是否击中障碍物 bool hitFound = Physics.Raycast(forwardOrigin, transform.forward, out hitInfo,forwardRayLength, obstacleLayer); //调试用的射线 //第二个参数dir:Direction and length of the ray. Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength, (hitFound) ?Color.red:Color.white); }}
复制代码 然后在ParkourController.cs里调用ObstacleCheck()方法- using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourController : MonoBehaviour{ EnvironmentScanner environmentScanner; private void Awake() { environmentScanner = GetComponent(); } // Update is called once per frame void Update() { environmentScanner.ObstacleCheck(); }}
复制代码 效果:射线会从膝盖左右的位置射出

下面把检测信息hitData抽象成一个结构体,
EnvironmentScanner.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class EnvironmentScanner : MonoBehaviour{ [Header ("向前发送的射线相关参数")] //y轴(竖直方向)偏移量 [SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0); //长度 [SerializeField] float forwardRayLength = 0.8f; //障碍物层 [SerializeField] LayerMask obstacleLayer; public ObstacleHitData ObstacleCheck() { var hitData = new ObstacleHitData(); //让射线从膝盖位置开始发送 //射线的起始位置 = 角色位置 + 一个偏移量 var forwardOrigin = transform.position + forwardRayOffset; //是否击中障碍物 hitData.forwardHitFound = Physics.Raycast(forwardOrigin, transform.forward, out hitData.forwardHitInfo, forwardRayLength, obstacleLayer); //调试用的射线 //第二个参数dir:Direction and length of the ray. Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength, (hitData.forwardHitFound)? Color.red : Color.white); return hitData; }}public struct ObstacleHitData{ //是否击中障碍物 public bool forwardHitFound; //用来存射线检测的信息 public RaycastHit forwardHitInfo;}
复制代码 ParkourController.cs打印检测信息:击中的障碍物名字- using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourController : MonoBehaviour{ EnvironmentScanner environmentScanner; private void Awake() { environmentScanner = GetComponent(); } // Update is called once per frame private void Update() { var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //调试用:打印障碍物名称 Debug.Log("找到障碍:"+ hitData.forwardHitInfo.transform.name); } }}
复制代码 效果如下:
障碍高度检测
EnvironmentScanner.cs
ObstacleCheck()- //如果击中,则从击中点上方高度heightRayLength向下发射的射线 if(hitData.forwardHitFound){ var heightOrigin = hitData.forwardHitInfo.point + Vector3.up * heightRayLength; hitData.heightHitFound = Physics.Raycast(heightOrigin,Vector3.down, out hitData.heightHitInfo, heightRayLength, obstacleLayer); //调试用的射线 //第二个参数dir:Direction and length of the ray. Debug.DrawRay(heightOrigin, Vector3.down * heightRayLength, (hitData.heightHitFound)? Color.red : Color.white); }
复制代码 结构体属性更新:- public struct ObstacleHitData{ #region 从角色膝盖出发的向前射线检测相关 //是否击中障碍物 public bool forwardHitFound; //用来存射线检测的信息 public RaycastHit forwardHitInfo; #endregion #region 从击中点垂直方向发射的射线检测相关 public bool heightHitFound; public RaycastHit heightHitInfo; #endregion}
复制代码 效果如下:
添加跑酷动作——翻越障碍StepUp
加一个动画StepUp
带位移动作的动画需要在角色的Animator组件里面勾选 Apply Root Motion
而且在这个动画实际作用的时候,角色的位置是向上移动的,所以不要勾选Bake Into Pose
在Animator面板里面,只需StepUp->Locomotion,因为StepUp是条件触发,触发结束回到Locomotion,但是Locomotion不需要回到StepUp状态。
ParkourController.cs- EnvironmentScanner environmentScanner; Animator animator; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); }
复制代码 Update()- private void Update() { if (Input.GetButton("Jump") && !inAction) { //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //调试用:打印障碍物名称 Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); //播放动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放攀爬动画 StartCoroutine(DoParkourAction()); } } }
复制代码- //攀爬动作 IEnumerator DoParkourAction(){ inAction = true; //从当前动画到StepUp动画,平滑过渡0.2s //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画 animator.CrossFade("StepUp", 0.2f); //暂停协程,直到下一帧继续执行,确保动画过渡已经开始。 yield return null; //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); //暂停协程,直到 "StepUp" 动画播放完毕。 yield return new WaitForSeconds(animStateInfo.length); inAction = false; }
复制代码一些特性:
StartCoroutine()方法:开启一个协程
CrossFade()方法:平滑地从当前动画过渡到指定的目标动画
yield return null; // 暂停协程,直到下一帧继续执行,确保动画过渡已经开始
animator.GetCurrentAnimatorStateInfo( layerIndex ):获取目标动画层索引的动画对象,可以用来调用其属性
yieldreturn new WaitForSeconds( ); // 暂停协程,等待 XX秒 结束。
存在一些问题:跑酷动画在播放的时候,角色仍然受控制,这会出错
fine,下面解决这个问题:
PlayerController.cs- //是否拥有控制权:默认拥有控制权,否则角色初始就不受控 bool hasControl = true;
复制代码- private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); //角色控制器 charactercontroller = GetComponent(); }
复制代码 Update():- //如果没有控制权,后面的碰撞检测就不执行了 if(!hasControl){ return; } #region 碰撞检测 ... #endregion
复制代码- //角色控制 public void SetControl(bool hasControl){ //传参给 hasControl 私有变量 this.hasControl = hasControl; //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件 //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动 charactercontroller.enabled = hasControl; //如果角色控制权被禁用,则更新动画参数和朝向 if (!hasControl) { //更新动画参数 animator.SetFloat("moveAmount", 0); //更新朝向 targetRotation = transform.rotation; } }
复制代码 ParkourController.cs- PlayerController playerController;
复制代码- private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); }
复制代码- IEnumerator DoParkourAction() { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //从当前动画到StepUp动画,平滑过渡0.2s //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画 animator.CrossFade("StepUp", 0.2f); //暂停协程,直到下一帧继续执行,确保动画过渡已经开始。 yield return null; //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); //暂停协程,直到 "StepUp" 动画播放完毕。 yield return new WaitForSeconds(animStateInfo.length); //启用玩家控制 playerController.SetControl(true); //跑酷动作结束 inAction = false; }
复制代码 效果如下:

该部分完整修改代码:
PlayerController.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{ [Header("玩家属性")] [SerializeField]float moveSpeed = 5f; [SerializeField]float rotationSpeed = 10f; [Header("Ground Check")] [SerializeField]float groundCheckRadius = 0.5f; //检测射线偏移量 [SerializeField]Vector3 groundCheckOffset; [SerializeField]LayerMask groundLayer; //是否在地面 bool isGrounded; //是否拥有控制权:默认拥有控制权,否则角色初始就不受控 bool hasControl = true; float ySpeed; Quaternion targetRotation; CameraController cameraController; Animator animator; CharacterController charactercontroller; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); //角色控制器 charactercontroller = GetComponent(); } private void Update() { #region 角色输入控制 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); //把moveAmount限制在0-1之间(混合树的区间) float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v)); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物移动方向关联相机的朝向 var moveDir = cameraController.PlanarRotation * moveInput; //如果没有控制权,后面的碰撞检测就不执行了 if(!hasControl){ return; } #region 碰撞检测 GroundCheck(); #endregion if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; } else { //在空中时,角色的速度由ySpeed决定 ySpeed += Physics.gravity.y * Time.deltaTime; } var velocity = moveDir * moveSpeed; velocity.y = ySpeed; //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(velocity * Time.deltaTime); //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion #region 角色动画控制 //设置人物动画参数moveAmount animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime); #endregion } //地面检测 private void GroundCheck() { // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true // 位置偏移用来在unity控制台里面调整 isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer); } //角色控制 public void SetControl(bool hasControl){ //传参给 hasControl 私有变量 this.hasControl = hasControl; //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件 //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动 charactercontroller.enabled = hasControl; //如果角色控制权被禁用,则更新动画参数和朝向 if (!hasControl) { //更新动画参数 animator.SetFloat("moveAmount", 0); //更新朝向 targetRotation = transform.rotation; } } //画检测射线 private void OnDrawGizmosSelected() { //射线颜色,最后一个参数是透明度 Gizmos.color = new Color(0, 1, 0, 0.5f); Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius); }}
复制代码 ParkourController.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourController : MonoBehaviour{ EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { if (Input.GetButton("Jump") && !inAction) { //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //调试用:打印障碍物名称 Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); //播放动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction()); } } } //攀爬动作 IEnumerator DoParkourAction() { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //从当前动画到StepUp动画,平滑过渡0.2s //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画 animator.CrossFade("StepUp", 0.2f); //暂停协程,直到下一帧继续执行,确保动画过渡已经开始。 yield return null; //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); //暂停协程,直到 "StepUp" 动画播放完毕。 yield return new WaitForSeconds(animStateInfo.length); //启用玩家控制 playerController.SetControl(true); //跑酷动作结束 inAction = false; }}
复制代码 下面解决该部分穿模现象,在障碍物前面预留一点空间,在遇到更高的障碍物时,取用更匹配的动画
基于障碍物高度自动选用不同的动作——StepUp和JumpUp
(Selecting Parkour Actions Based on Obstacle Height)
新建ParkourAction.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]public class ParkourAction : ScriptableObject{ [SerializeField] string animName; [SerializeField] float minHeigth; [SerializeField] float maxHeigth;}
复制代码 然后在unity的右键菜单就可以看到
新建两个跑酷动作
然后剪辑动画,让起始帧和结束帧刚好时想要的状态
起始帧:
结束帧:
接下来的任务就是把之前代码中关于动画的硬编码部分解耦
ParkourAction.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]public class ParkourAction : ScriptableObject{ [SerializeField] string animName; [SerializeField] float minHeigth; [SerializeField] float maxHeigth; public bool CheckIfPossible(ObstacleHitData hitData, Transform player) { //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标 float height = hitData.heightHitInfo.point.y - player.position.y; //只有在这个区间内才会返回true if(height < minHeigth || height > maxHeigth) { return false; } else { return true; } } //外部可访问的动画名称 public string AnimName => animName;}
复制代码 ParkourController.cs- private void Update() { if (Input.GetButton("Jump") && !inAction) { //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); } } //调试用:打印障碍物名称 Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } }
复制代码- //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //从当前动画到指定的目标动画,平滑过渡0.2s //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画 animator.CrossFade(action.AnimName, 0.2f); //暂停协程,直到下一帧继续执行,确保动画过渡已经开始。 yield return null; //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); #region 调试用 if (!animStateInfo.IsName(action.AnimName)) { Debug.LogError("动画名称不匹配!"); } #endregion //暂停协程,直到 "StepUp" 动画播放完毕。 yield return new WaitForSeconds(animStateInfo.length); //启用玩家控制 playerController.SetControl(true); //跑酷动作结束 inAction = false; }
复制代码 回到unity面板

效果如下:

修改后的完整代码:
ParkourAction.cs上面放了
ParkourController.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourController : MonoBehaviour{ //定义一个面板可见的跑酷动作属性列表 [SerializeField] List parkourActions; EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { if (Input.GetButton("Jump") && !inAction) { //调试用的射线也只会在if满足的时候触发 //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); //跳出循环 break; } } //调试用:打印障碍物名称 //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } } //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //从当前动画到指定的目标动画,平滑过渡0.2s //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画 animator.CrossFade(action.AnimName, 0.2f); ////暂停协程,直到下一帧继续执行,确保动画过渡已经开始。 //多等一会儿? yield return new WaitForSeconds(0.2f); //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); #region 调试用 if (!animStateInfo.IsName(action.AnimName)) { Debug.LogError("动画名称不匹配!"); } #endregion //暂停协程,直到 "StepUp" 动画播放完毕。 yield return new WaitForSeconds(animStateInfo.length); //启用玩家控制 playerController.SetControl(true); //跑酷动作结束 inAction = false; }}
复制代码 下面解决当角色侧身对着障碍物时,让角色平滑旋转以朝向障碍物
需要平滑旋转以朝向障碍物 的动作
ParkourAction.cs- [Header ("自主勾选该动作是否需要转向障碍物")] [SerializeField] bool rotateToObstacle; //目标旋转量 public Quaternion TargetRotation { get; set; }
复制代码 CheckIfPossible()- //如果需要转向障碍物,才会计算目标旋转量 if (rotateToObstacle) { //目标旋转量 = 障碍物法线的反方向normal TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal); }
复制代码- public bool RotateToObstacle => rotateToObstacle;
复制代码 PlayerController.cs- //让rotationSpeed可以被外部访问 public float RotationSpeed => rotationSpeed;
复制代码 ParkourController.cs- ////暂停协程,直到 "StepUp" 动画播放完毕。 //yield return new WaitForSeconds(animStateInfo.length); //动画播放期间,暂停协程,并让角色平滑旋转向障碍物 float timer = 0f; while (timer maxHeigth) { return false; } //如果需要转向障碍物,才会计算目标旋转量 if (rotateToObstacle) { //目标旋转量 = 障碍物法线的反方向normal TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal); } return true; } //外部可访问的动画名称 public string AnimName => animName; public bool RotateToObstacle => rotateToObstacle;}
复制代码 PlayerController.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{ [Header("玩家属性")] [SerializeField]float moveSpeed = 5f; [SerializeField]float rotationSpeed = 500f; [Header("Ground Check")] [SerializeField]float groundCheckRadius = 0.5f; //检测射线偏移量 [SerializeField]Vector3 groundCheckOffset; [SerializeField]LayerMask groundLayer; //是否在地面 bool isGrounded; //是否拥有控制权:默认拥有控制权,否则角色初始就不受控 bool hasControl = true; float ySpeed; Quaternion targetRotation; CameraController cameraController; Animator animator; CharacterController charactercontroller; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); //角色控制器 charactercontroller = GetComponent(); } private void Update() { #region 角色输入控制 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); //把moveAmount限制在0-1之间(混合树的区间) float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v)); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物移动方向关联相机的朝向 var moveDir = cameraController.PlanarRotation * moveInput; //如果没有控制权,后面的碰撞检测就不执行了 if(!hasControl){ return; } #region 地面检测 GroundCheck(); #endregion if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; } else { //在空中时,角色的速度由ySpeed决定 ySpeed += Physics.gravity.y * Time.deltaTime; } var velocity = moveDir * moveSpeed; velocity.y = ySpeed; //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(velocity * Time.deltaTime); //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion #region 角色动画控制 //设置人物动画参数moveAmount animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime); #endregion } //地面检测 private void GroundCheck() { // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true // 位置偏移用来在unity控制台里面调整 isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer); } //角色控制 public void SetControl(bool hasControl){ //传参给 hasControl 私有变量 this.hasControl = hasControl; //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件 //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动 charactercontroller.enabled = hasControl; //如果角色控制权被禁用,则更新动画参数和朝向 if (!hasControl) { //更新动画参数 animator.SetFloat("moveAmount", 0f); //更新朝向 targetRotation = transform.rotation; } } //画检测射线 private void OnDrawGizmosSelected() { //射线颜色,最后一个参数是透明度 Gizmos.color = new Color(0, 1, 0, 0.5f); Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius); } //让rotationSpeed可以被外部访问 public float RotationSpeed => rotationSpeed;}
复制代码 ParkourController.cs- using System.Collections;using System.Collections.Generic;using System.Threading;using UnityEngine;public class ParkourController : MonoBehaviour{ //定义一个面板可见的跑酷动作属性列表 [SerializeField] List parkourActions; EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { if (Input.GetButton("Jump") && !inAction) { //调试用的射线也只会在if满足的时候触发 //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); //跳出循环 break; } } //调试用:打印障碍物名称 //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } } //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //从当前动画到指定的目标动画,平滑过渡0.2s //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画 animator.CrossFade(action.AnimName, 0.2f); ////暂停协程,直到下一帧继续执行,确保动画过渡已经开始。 //多等一会儿? yield return new WaitForSeconds(0.2f); //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); #region 调试用 if (!animStateInfo.IsName(action.AnimName)) { Debug.LogError("动画名称不匹配!"); } #endregion ////暂停协程,直到 "StepUp" 动画播放完毕。 //yield return new WaitForSeconds(animStateInfo.length); //动画播放期间,暂停协程,并让角色平滑旋转向障碍物 float timer = 0f; while (timer animName; public bool RotateToObstacle => rotateToObstacle; public bool EnableTargetMatching => enableTargetMatching; public AvatarTarget MatchBodyPart => matchBodyPart; public float MatchStartTime => matchStartTime; public float MatchTargetTime => matchTargetTime;
复制代码 ParkourController.cs
DoParkourAction()方法- //如果勾选目标匹配EnableTargetMatching if (action.EnableTargetMatching) { MatchTarget(action); }
复制代码 新加一个函数,调用Unity自带的Animator.MatchTarget 函数- void MatchTarget(ParkourAction action) { //只有在不匹配的时候才会调用 if (animator.isMatchingTarget) { return; } //调用unity自带的MatchTarget方法 animator.MatchTarget(action.MatchPosition, transform.rotation, action.MatchBodyPart, new MatchTargetWeightMask(new Vector3(0, 1, 0), 0), action.MatchStartTime, action.MatchTargetTime); }
复制代码 这里我们匹配角色右脚(先抬起)就行,
首先,在动画剪辑中找到角色右脚开始离地的位置
再找到即将落地的位置
把标准化结果填入action里
另一个动画同样如此操作:
其实只要结束的时间MatchTargetTime弄准确了就好,开始时间大概就行
有个问题需要特别注意的:
如果出现了动画播放后角色的脚位置莫名浮动一下,需要在动画里面勾选如下选项

效果如下,除了穿模现象不会出现动画播放过程中脚部浮空的现象:

完整的修改代码如下:
ParkourAction.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]public class ParkourAction : ScriptableObject{ //动画名称 [SerializeField] string animName; //高度区间 [SerializeField] float minHeigth; [SerializeField] float maxHeigth; [Header ("自主勾选该动作是否需要转向障碍物")] [SerializeField] bool rotateToObstacle; [Header("Target Matching")] [SerializeField] bool enableTargetMatching; [SerializeField] AvatarTarget matchBodyPart; [SerializeField] float matchStartTime; [SerializeField] float matchTargetTime; //目标旋转量 public Quaternion TargetRotation { get; set; } //匹配的位置 public Vector3 MatchPosition { get; set; } public bool CheckIfPossible(ObstacleHitData hitData, Transform player) { //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标 float height = hitData.heightHitInfo.point.y - player.position.y; //只有在这个区间内才会返回true if(height < minHeigth || height > maxHeigth) { return false; } //如果需要转向障碍物,才会计算目标旋转量 if (rotateToObstacle) { //目标旋转量 = 障碍物法线的反方向normal TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal); } //如果需要匹配位置,才会计算匹配的位置 if (enableTargetMatching) { //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息 MatchPosition = hitData.heightHitInfo.point; } Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y); return true; } //外部可访问的属性 public string AnimName => animName; public bool RotateToObstacle => rotateToObstacle; public bool EnableTargetMatching => enableTargetMatching; public AvatarTarget MatchBodyPart => matchBodyPart; public float MatchStartTime => matchStartTime; public float MatchTargetTime => matchTargetTime;}
复制代码 ParkourController.cs- using System.Collections;using System.Collections.Generic;using System.Threading;using UnityEngine;public class ParkourController : MonoBehaviour{ //定义一个面板可见的跑酷动作属性列表 [SerializeField] List parkourActions; EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { if (Input.GetButton("Jump") && !inAction) { //调试用的射线也只会在if满足的时候触发 //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); //跳出循环 break; } } //调试用:打印障碍物名称 //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } } //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //从当前动画到指定的目标动画,平滑过渡0.2s //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画 animator.CrossFade(action.AnimName, 0.2f); ////暂停协程,直到下一帧继续执行,确保动画过渡已经开始。 yield return null; //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); //#region 调试用 //if (!animStateInfo.IsName(action.AnimName)) //{ // Debug.LogError("动画名称不匹配!"); //} //#endregion ////暂停协程,直到 "StepUp" 动画播放完毕。 //yield return new WaitForSeconds(animStateInfo.length); //动画播放期间,暂停协程,并让角色平滑旋转向障碍物 float timer = 0f; while (timer matchPositionXYZWeight;
复制代码 ParkourController.cs
MatchTarget()- animator.MatchTarget(action.MatchPosition, transform.rotation, action.MatchBodyPart, new MatchTargetWeightMask(action.MatchPositionXYZWeight, 0), action.MatchStartTime, action.MatchTargetTime);
复制代码 这里会报一些目标匹配的warning,可以加一个判断:动画过渡是否结束- //只有当不在过渡状态时才执行目标匹配 if (action.EnableTargetMatching && !animator.IsInTransition(0)) { MatchTarget(action); }
复制代码- //只有在不匹配和不在过渡状态的时候才会调用 if (animator.isMatchingTarget || animator.IsInTransition(0)) { return; }
复制代码 效果如下:

该部分修改的完整代码
ParkourAction.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]public class ParkourAction : ScriptableObject{ //动画名称 [SerializeField] string animName; //高度区间 [SerializeField] float minHeigth; [SerializeField] float maxHeigth; [Header ("自主勾选该动作是否需要转向障碍物")] [SerializeField] bool rotateToObstacle; [Header("Target Matching")] [SerializeField] bool enableTargetMatching; [SerializeField] AvatarTarget matchBodyPart; [SerializeField] float matchStartTime; [SerializeField] float matchTargetTime; [SerializeField] Vector3 matchPositionXYZWeight = new Vector3(0, 1, 0); //目标旋转量 public Quaternion TargetRotation { get; set; } //匹配的位置 public Vector3 MatchPosition { get; set; } public bool CheckIfPossible(ObstacleHitData hitData, Transform player) { //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标 float height = hitData.heightHitInfo.point.y - player.position.y; //只有在这个区间内才会返回true if(height < minHeigth || height > maxHeigth) { return false; } //如果需要转向障碍物,才会计算目标旋转量 if (rotateToObstacle) { //目标旋转量 = 障碍物法线的反方向normal TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal); } //如果需要匹配位置,才会计算匹配的位置 if (enableTargetMatching) { //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息 MatchPosition = hitData.heightHitInfo.point; } Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y); return true; } //外部可访问的属性 public string AnimName => animName; public bool RotateToObstacle => rotateToObstacle; public bool EnableTargetMatching => enableTargetMatching; public AvatarTarget MatchBodyPart => matchBodyPart; public float MatchStartTime => matchStartTime; public float MatchTargetTime => matchTargetTime; public Vector3 MatchPositionXYZWeight => matchPositionXYZWeight;}
复制代码 ParkourController.cs- using System.Collections;using System.Collections.Generic;using System.Threading;using UnityEngine;public class ParkourController : MonoBehaviour{ //定义一个面板可见的跑酷动作属性列表 [SerializeField] List parkourActions; EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { if (Input.GetButton("Jump") && !inAction) { //调试用的射线也只会在if满足的时候触发 //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); //跳出循环 break; } } //调试用:打印障碍物名称 //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } } //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //从当前动画到指定的目标动画,平滑过渡0.2s animator.CrossFade(action.AnimName, 0.2f); // 等待过渡完成 yield return new WaitForSeconds(0.3f); // 给足够时间让过渡完成,稍微大于CrossFade的过渡时间 // 现在获取动画状态信息 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); //#region 调试用 //if (!animStateInfo.IsName(action.AnimName)) //{ // Debug.LogError("动画名称不匹配!"); //} //#endregion ////暂停协程,直到 "StepUp" 动画播放完毕。 //yield return new WaitForSeconds(animStateInfo.length); //动画播放期间,暂停协程,并让角色平滑旋转向障碍物 float timer = 0f; while (timer maxHeigth) { return false; } //如果需要转向障碍物,才会计算目标旋转量 if (rotateToObstacle) { //目标旋转量 = 障碍物法线的反方向normal TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal); } //如果需要匹配位置,才会计算匹配的位置 if (enableTargetMatching) { //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息 MatchPosition = hitData.heightHitInfo.point; } Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y); return true; } //外部可访问的属性 public string AnimName => animName; public bool RotateToObstacle => rotateToObstacle; public bool EnableTargetMatching => enableTargetMatching; public AvatarTarget MatchBodyPart => matchBodyPart; public float MatchStartTime => matchStartTime; public float MatchTargetTime => matchTargetTime; public Vector3 MatchPositionXYZWeight => matchPositionXYZWeight; public float ActionDelay => actionDelay; }
复制代码 VaultFence的优化——镜像动画
更加符合现实的情况是:
如果从障碍物的左边沿开始撑手翻越,动画应该镜像翻转并target matching右手
如果从障碍物的右边沿开始撑手翻越,动画则不镜像翻转并target matching左手
fine,开始修改相应脚本:
新建一个脚本VaultAction.cs继承ParkourAction
在ParkourAction中将CheckIfPossible()设置为虚函数,并在VaultAction中重写
ParkourAction.cs- public virtual bool CheckIfPossible(ObstacleHitData hitData, Transform player) { ... }
复制代码 可以对带有Fence标签的障碍物进行边沿判断,以水平方向中心位置为0,小于0就是左边沿,大于0就是右边沿
注意Fence的坐标系应该是这样的:
在局部空间坐标系中,
如果击中点的
z0 就镜像,跟踪右手
反之就不镜像,跟踪左手
所以还需要一个标志位 Mirror
并且在VaultFence的动画里,Mirror是否镜像动画可以由一个自定义参数决定,参数名这里设置为 mirrorAction
ParkourAction.cs里设置标志位参量 Mirror,在VaultAction里赋值 Mirror,再去ParkourController里SetBool赋值给animator动画组件的参量 mirrorAction
ParkourAction.cs- //动作镜像 public bool Mirror { get; set; }
复制代码 VaultAction.cs里编写 Mirror和 matchBodyPart的赋值逻辑- public class VaultAction : ParkourAction{ public override bool CheckIfPossible(ObstacleHitData hitData, Transform player) { // 虚函数原有逻辑不变 if(!base.CheckIfPossible(hitData, player)){ return false; } // 增加额外的检查条件 //击中点从全局坐标空间转到Fence上的局部坐标空间 var hitPointFence = hitData.forwardHitInfo.transform.InverseTransformPoint(hitData.forwardHitInfo.point); if( (hitPointFence.z < 0 && hitPointFence.x < 0) || (hitPointFence.z > 0 && hitPointFence.x > 0) ){ //左边沿,镜像,跟踪右手 Mirror = true; matchBodyPart = AvatarTarget.RightHand; } else{ //右边沿,不镜像,跟踪左手 Mirror = false; matchBodyPart = AvatarTarget.LeftHand; } return true; }}
复制代码 注意:matchBodyPart需要在ParkourAction.cs里面加上访问修饰符 protected,让子类VaultAction.cs能够访问- [SerializeField] protected AvatarTarget matchBodyPart; //在内部和子类可访问
复制代码 ParkourController.cs里赋值给动画组件animator- //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { ... //设置动画是否镜像 animator.SetBool("mirrorAction", action.Mirror); ... }
复制代码 然后需要新建一个VaultAction脚本的对象,
VaultAction.cs- // 和ParkourAction一样,可以通过右键菜单新建脚本相应的对象[CreateAssetMenu(menuName = "Parkour System/Custom Actions/New Vault Action")]
复制代码
参数和VaultFence一样,但是是VaultAction类的对象
记得更新Player的面板参数:

大功告成!效果如下:

完整修改代码如下:
ParkourController.cs- using System.Collections;using System.Collections.Generic;using System.Threading;using UnityEngine;public class ParkourController : MonoBehaviour{ //定义一个面板可见的跑酷动作属性列表 [SerializeField] List parkourActions; EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { if (Input.GetButton("Jump") && !inAction && playerController.isGrounded) { //调试用的射线也只会在if满足的时候触发 //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); //跳出循环 break; } } //调试用:打印障碍物名称 //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } } //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false); //设置动画是否镜像 animator.SetBool("mirrorAction", action.Mirror); //从当前动画到指定的目标动画,平滑过渡0.2s animator.CrossFade(action.AnimName, 0.2f); // 等待过渡完成 yield return new WaitForSeconds(0.3f); // 给足够时间让过渡完成,稍微大于CrossFade的过渡时间 // 现在获取动画状态信息 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); //#region 调试用 //if (!animStateInfo.IsName(action.AnimName)) //{ // Debug.LogError("动画名称不匹配!"); //} //#endregion ////暂停协程,直到 "StepUp" 动画播放完毕。 //yield return new WaitForSeconds(animStateInfo.length); //动画播放期间,暂停协程,并让角色平滑旋转向障碍物 float timer = 0f; while (timer 0.5f){ break; } yield return null; } //对于一些组合动作,第一阶段播放完后就会被输入控制打断,这时候给一个延迟,让第二阶段的动画也播放完 //对于ClimbUp动作,第二阶段就是CrouchToStand yield return new WaitForSeconds(action.ActionDelay); //延迟结束后才启用玩家控制 playerController.SetControl(true); //跑酷动作结束 inAction = false; } //目标匹配 void MatchTarget(ParkourAction action) { //只有在不匹配和不在过渡状态的时候才会调用 if (animator.isMatchingTarget || animator.IsInTransition(0)) { return; } //调用unity自带的MatchTarget方法 animator.MatchTarget(action.MatchPosition, transform.rotation, action.MatchBodyPart, new MatchTargetWeightMask(action.MatchPositionXYZWeight, 0), action.MatchStartTime, action.MatchTargetTime); }}
复制代码 ParkourAction.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]public class ParkourAction : ScriptableObject{ //动画名称 [SerializeField] string animName; //对应的障碍物Tag [SerializeField] string obstacleTag; [Header("高度区间")] [SerializeField] float minHeigth; [SerializeField] float maxHeigth; [Header ("自主勾选该动作是否需要转向障碍物")] [SerializeField] bool rotateToObstacle; [Header("动作播放后的延迟")] [SerializeField] float actionDelay; [Header("Target Matching")] [SerializeField] bool enableTargetMatching = true; [SerializeField] protected AvatarTarget matchBodyPart; //在内部和子类可访问 [SerializeField] float matchStartTime; [SerializeField] float matchTargetTime; [SerializeField] Vector3 matchPositionXYZWeight = new Vector3(0, 1, 0); //目标旋转量 public Quaternion TargetRotation { get; set; } //匹配的位置 public Vector3 MatchPosition { get; set; } //动作镜像 public bool Mirror { get; set; } //动作执行前的检查————这是一个虚函数,在子类中可覆盖 //主要是找false public virtual bool CheckIfPossible(ObstacleHitData hitData, Transform player) { //障碍物Tag //如果Tag填写了字段且不匹配,false if(!string.IsNullOrEmpty(obstacleTag) && hitData.forwardHitInfo.collider.tag != obstacleTag){ return false; } //高度Tag //如果高度不在区间内,false //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标 float height = hitData.heightHitInfo.point.y - player.position.y; if(height < minHeigth || height > maxHeigth) { return false; } //如果需要转向障碍物,才会计算目标旋转量 if (rotateToObstacle) { //目标旋转量 = 障碍物法线的反方向normal TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal); } //如果需要匹配位置,才会计算匹配的位置 if (enableTargetMatching) { //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息 MatchPosition = hitData.heightHitInfo.point; } Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y); return true; } //外部可访问的属性 public string AnimName => animName; public bool RotateToObstacle => rotateToObstacle; public bool EnableTargetMatching => enableTargetMatching; public AvatarTarget MatchBodyPart => matchBodyPart; public float MatchStartTime => matchStartTime; public float MatchTargetTime => matchTargetTime; public Vector3 MatchPositionXYZWeight => matchPositionXYZWeight; public float ActionDelay => actionDelay; }
复制代码 VaultAction.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;// 和ParkourAction一样,可以通过右键菜单新建脚本相应的对象[CreateAssetMenu(menuName = "Parkour System/Custom Actions/New Vault Action")]public class VaultAction : ParkourAction{ public override bool CheckIfPossible(ObstacleHitData hitData, Transform player) { // 虚函数原有逻辑不变 if(!base.CheckIfPossible(hitData, player)){ return false; } // 增加额外的检查条件 //击中点从全局坐标空间转到Fence上的局部坐标空间 var hitPointFence = hitData.forwardHitInfo.transform.InverseTransformPoint(hitData.forwardHitInfo.point); if( (hitPointFence.z < 0 && hitPointFence.x < 0) || (hitPointFence.z > 0 && hitPointFence.x > 0) ){ //左边沿,镜像,跟踪右手 Mirror = true; matchBodyPart = AvatarTarget.RightHand; } else{ //右边沿,不镜像,跟踪左手 Mirror = false; matchBodyPart = AvatarTarget.LeftHand; } return true; }}
复制代码 下面开始实现沿着地面外沿行走时的防跌落机制、从高处跳下时的动画
Day7 从悬崖跳落——JumpDown From The Ledges
悬崖边沿Ledge检测
EnvironmentScanner.cs- [Header("悬崖Ledge检测——向下发送的射线相关参数")] //向下发射的射线的长度 [SerializeField] float ledgeRayLength = 10f; //悬崖的高度阈值 [SerializeField] float ledgeHeightThreshold = 0.75f;
复制代码- //检测是否在悬崖边缘 public bool LedgeCheck(Vector3 moveDir) { //只有移动才会检测Ledge if (moveDir == Vector3.zero) return false; //起始位置向前偏移量 var originOffset = moveDir * 0.5f; //检测射线的起始位置 var origin = transform.position + originOffset + Vector3.up; //起始位置不要在脚底,悬崖和和脚在同一高度,可能会检测不到,向上偏移一些 //射线向下发射是否击中:击中点在地面位置,赋值给hitGround if (Physics.Raycast(origin, Vector3.down, out RaycastHit hitGround, ledgeRayLength, obstacleLayer)) { //调试用的射线 Debug.DrawRay(origin, Vector3.down * ledgeRayLength, Color.green); //计算当前位置高度 = 角色位置高度 - 击中点高度 float height = transform.position.y - hitGround.point.y; //超过这个悬崖高度阈值,才会认为是悬崖边缘 if (height > ledgeHeightThreshold) { return true; } } return false; }
复制代码 回到PlayerController.cs调用这个检测- EnvironmentScanner environmentScanner;
复制代码- //环境扫描器 environmentScanner = GetComponent();
复制代码- //是否在悬崖边沿上 public bool IsOnLedge { get; set; }
复制代码- if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; //在地上的时候进行悬崖检测,传给isOnLedge变量 IsOnLedge = environmentScanner.LedgeCheck(moveDir); #region 调试用 if (IsOnLedge) { Debug.Log("On Ledge"); } #endregion }
复制代码 效果如下:


该部分完整修改代码:
EnvironmentScanner.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class EnvironmentScanner : MonoBehaviour{ [Header ("障碍物检测——向前发送的射线相关参数")] //y轴(竖直方向)偏移量 [SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0); //长度 [SerializeField] float forwardRayLength = 0.8f; //从击中点向上发射的射线的高度 [SerializeField] float heightRayLength = 5f; [Header("悬崖Ledge检测——向下发送的射线相关参数")] //向下发射的射线的长度 [SerializeField] float ledgeRayLength = 10f; //悬崖的高度阈值 [SerializeField] float ledgeHeightThreshold = 0.75f; [Header("LayerMask")] //障碍物层 [SerializeField] LayerMask obstacleLayer; public ObstacleHitData ObstacleCheck() { var hitData = new ObstacleHitData(); //让射线从膝盖位置开始发送 //射线的起始位置 = 角色位置 + 一个偏移量 var forwardOrigin = transform.position + forwardRayOffset; //射线向前发送是否击中障碍物:击中点在障碍物上,赋值给hitData.forwardHitInfo hitData.forwardHitFound = Physics.Raycast(forwardOrigin, transform.forward, out hitData.forwardHitInfo, forwardRayLength, obstacleLayer); //调试用的射线 //第二个参数dir:Direction and length of the ray. Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength, (hitData.forwardHitFound)? Color.red : Color.white); //如果击中,则从击中点上方高度heightRayLength向下发射的射线 if(hitData.forwardHitFound){ var heightOrigin = hitData.forwardHitInfo.point + Vector3.up * heightRayLength; hitData.heightHitFound = Physics.Raycast(heightOrigin,Vector3.down, out hitData.heightHitInfo, heightRayLength, obstacleLayer); //调试用的射线 //第二个参数dir:Direction and length of the ray. Debug.DrawRay(heightOrigin, Vector3.down * heightRayLength, (hitData.heightHitFound)? Color.red : Color.white); } return hitData; } //检测是否在悬崖边缘 public bool LedgeCheck(Vector3 moveDir) { //只有移动才会检测Ledge if (moveDir == Vector3.zero) return false; //起始位置向前偏移量 var originOffset = moveDir * 0.5f; //检测射线的起始位置 var origin = transform.position + originOffset + Vector3.up; //起始位置不要在脚底,悬崖和和脚在同一高度,可能会检测不到,向上偏移一些 //射线向下发射是否击中:击中点在地面位置,赋值给hitGround if (Physics.Raycast(origin, Vector3.down, out RaycastHit hitGround, ledgeRayLength, obstacleLayer)) { //调试用的射线 Debug.DrawRay(origin, Vector3.down * ledgeRayLength, Color.green); //计算当前位置高度 = 角色位置高度 - 击中点高度 float height = transform.position.y - hitGround.point.y; //超过这个悬崖高度阈值,才会认为是悬崖边缘 if (height > ledgeHeightThreshold) { return true; } } return false; }}public struct ObstacleHitData{ #region 从角色膝盖出发的向前射线检测相关 //是否击中障碍物 public bool forwardHitFound; //用来存射线检测的信息 public RaycastHit forwardHitInfo; #endregion #region 从击中点垂直方向发射的射线检测相关 public bool heightHitFound; //用来存射该射线向下击中障碍物的检测信息 public RaycastHit heightHitInfo; #endregion}
复制代码 PlayerController.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{ [Header("玩家属性")] [SerializeField]float moveSpeed = 5f; [SerializeField]float rotationSpeed = 500f; [Header("Ground Check")] [SerializeField]float groundCheckRadius = 0.5f; //检测射线偏移量 [SerializeField]Vector3 groundCheckOffset; [SerializeField]LayerMask groundLayer; //是否在地面 public bool isGrounded; //是否拥有控制权:默认拥有控制权,否则角色初始就不受控 bool hasControl = true; //是否在悬崖边沿上 public bool IsOnLedge { get; set; } float ySpeed; Quaternion targetRotation; CameraController cameraController; Animator animator; CharacterController charactercontroller; EnvironmentScanner environmentScanner; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); //角色控制器 charactercontroller = GetComponent(); //环境扫描器 environmentScanner = GetComponent(); } private void Update() { #region 角色输入控制 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); //把moveAmount限制在0-1之间(混合树的区间) float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v)); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物移动方向关联相机的水平旋转朝向 var moveDir = cameraController.PlanarRotation * moveInput; //如果没有控制权,后面的碰撞检测就不执行了 if(!hasControl){ return; } #region 地面检测 GroundCheck(); if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; //在地上的时候进行悬崖检测,传给isOnLedge变量 IsOnLedge = environmentScanner.LedgeCheck(moveDir); #region 调试用 if (IsOnLedge) { Debug.Log("On Ledge"); } #endregion } else { //在空中时,角色的速度由ySpeed决定 ySpeed += Physics.gravity.y * Time.deltaTime; } #endregion var velocity = moveDir * moveSpeed; velocity.y = ySpeed; //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(velocity * Time.deltaTime); //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion #region 角色动画控制 //设置人物动画参数moveAmount animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime); #endregion } //地面检测 private void GroundCheck() { // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true // 位置偏移用来在unity控制台里面调整 isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer); } //角色控制 public void SetControl(bool hasControl){ //传参给 hasControl 私有变量 this.hasControl = hasControl; //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件 //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动 charactercontroller.enabled = hasControl; //如果角色控制权被禁用,则更新动画参数和朝向 if (!hasControl) { //更新动画参数 animator.SetFloat("moveAmount", 0f); //更新朝向 targetRotation = transform.rotation; } } //画检测射线 private void OnDrawGizmosSelected() { //射线颜色,最后一个参数是透明度 Gizmos.color = new Color(0, 1, 0, 0.5f); Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius); } //让rotationSpeed可以被外部访问 public float RotationSpeed => rotationSpeed;}
复制代码 从悬崖跳落
这个动画跟VaultFence一样需要组合动画来实现:
起跳 + 空中下落 + 着陆
注意:
Falling 要勾选LoopPose,动画循环
起跳和着陆动画选择Y轴变化跟踪Feet,这样可以避免起跳和着陆时脚部的y轴位置浮动
Animator如图设置
加一个isGrounded标志位用来在Falling->Standing时条件判断,只有在isGrounded == true 的时候才会播放着陆动画。
isGrounded需要在PlayerController.cs中地面检测后赋值
PlayerController.cs- #region 地面检测 GroundCheck(); animator.SetBool("isGrounded", isGrounded);
复制代码 ParkourController.cs- [Header("跳下悬崖动画")] [SerializeField] ParkourAction JumpDownAction;
复制代码 Update():- #region 悬崖跳下动作 //在悬崖边沿且不在播放动作中 if(playerController.IsOnLedge && !inAction) { playerController.IsOnLedge = false; StartCoroutine(DoParkourAction(JumpDownAction)); } #endregion
复制代码 一个Bug:如果跳落的时候不按方向键,就会出现Falling动画播放异常
Bug修复——Falling动画异常
这里有个问题折磨我一个小时才想明白:
因为GroundCheck()方法在Update()调用,但是每次进入动画的时候,角色控制禁用,PlayerdController是被禁用的。
禁用角色控制后,PlayerController中的Update方法会提前返回(因为!hasControl为true时会执行return),导致GroundCheck()不会被调用,isGrounded也就不会更新。
所以如果不按方向键,这个isGrounded就一直是true,所以Falling动画只播放一遍就切换到了Standing动画,没有循环。
ParkourController.cs 的 DoParkourAction()方法:- IEnumerator DoParkourAction(ParkourAction action) { //跑酷动作开始 inAction = true; //禁用玩家控制 playerController.SetControl(false);
复制代码 这个问题应该这么被解决:
只有在地上的时候,速度才会被更新,这样就不会在空中也能控制角色,也解决了Falling播放异常的问题。
PlayerController.cs- var velocity = Vector3.zero; #region 地面检测 GroundCheck(); animator.SetBool("isGrounded", isGrounded); if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; velocity = moveDir * moveSpeed; //在地上的时候进行悬崖检测,传给isOnLedge变量 IsOnLedge = environmentScanner.LedgeCheck(moveDir); #region 调试用 if (IsOnLedge) { Debug.Log("On Ledge"); } #endregion } else { //在空中时,ySpeed受重力控制 ySpeed += Physics.gravity.y * Time.deltaTime; //简单模拟有空气阻力的平抛运动:空中时的速度设置为角色朝向速度的一半 velocity = transform.forward * moveSpeed / 2; } #endregion //更新y轴方向的速度 velocity.y = ySpeed;
复制代码 还有bug:当角色到达悬崖边沿的时候按下反方向键,角色动作会出现异常
Bug修复——转向过大动画异常
这是因为要转向的角度过大,所以要限制当转向角过大时不允许播放JumpDown
这就需要得到一个如图所示的转向角:
怎么得到转向角?
Vector3.Angle(transform.forward, ledgeHitData.hitSurface.normal)
怎么找到hitSurface
EnvironmentScanner.cs- /// /// 检测是否在悬崖边缘 /// /// /// /// /// out关键字需要在方法内部初始化 public bool LedgeCheck(Vector3 moveDir, out LedgeHitData ledgeHitData) { //...原有代码不变... //射线向下发射是否击中:击中点在地面位置,赋值给hitGround if (Physics.Raycast(origin, Vector3.down, out RaycastHit hitGround, ledgeRayLength, obstacleLayer)) { //调试用的向下发射的射线 Debug.DrawRay(origin, Vector3.down * ledgeRayLength, Color.green); //检测射线起始位置:脚底向前moveDir再向下偏移一些 var surfaceRayOrigin = transform.position + moveDir - Vector3.up * 0.1f; //悬崖竖直表面射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface if (Physics.Raycast(surfaceRayOrigin, -moveDir, out ledgeHitData.hitSurface, 2f, obstacleLayer)) { //计算当前位置高度 = 角色位置高度 - 击中点高度 float height = transform.position.y - hitGround.point.y; //超过这个悬崖高度阈值,才会认为是悬崖边缘 if (height > ledgeHeightThreshold) { //计算当前位置与悬崖表面法线的夹角 ledgeHitData.angle = Vector3.Angle(transform.forward, ledgeHitData.hitSurface.normal); ledgeHitData.height = height; return true; } } } return false; }
复制代码 PlayerController.cs- //悬崖边沿击中相关数据 public LedgeHitData LedgeHitData { get; set; }
复制代码 传参- #region 悬崖检测 //在地上的时候进行悬崖检测,传给isOnLedge变量 IsOnLedge = environmentScanner.LedgeCheck(moveDir,out LedgeHitData ledgeHitData); //如果在悬崖边沿,就把击中数据传给LedgeHitData变量,用来在ParkourController里面调用 if (IsOnLedge) { LedgeHitData = ledgeHitData; Debug.Log("On Ledge"); } #endregion
复制代码 ParkourController.cs- #region 悬崖跳下动作 //在悬崖边沿且不在播放动作中 if(playerController.IsOnLedge && !inAction) { //偏差角度小于50度,才会播放JumpDown动画 if(playerController.LedgeHitData.angle ledgeHeightThreshold) { //计算当前位置与悬崖表面法线的夹角 ledgeHitData.angle = Vector3.Angle(transform.forward, ledgeHitData.hitSurface.normal); ledgeHitData.height = height; return true; } } } return false; }}public struct ObstacleHitData{ #region 从角色膝盖出发的向前射线检测相关 //是否击中障碍物 public bool forwardHitFound; //用来存射线检测的信息 public RaycastHit forwardHitInfo; #endregion #region 从击中点垂直方向发射的射线检测相关 public bool heightHitFound; //用来存射该射线向下击中障碍物的检测信息 public RaycastHit heightHitInfo; #endregion}public struct LedgeHitData{ public float angle; public float height; public RaycastHit hitSurface;}
复制代码 PlayerController.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{ [Header("玩家属性")] [SerializeField]float moveSpeed = 5f; [SerializeField]float rotationSpeed = 500f; [Header("Ground Check")] [SerializeField]float groundCheckRadius = 0.5f; //检测射线偏移量 [SerializeField]Vector3 groundCheckOffset; [SerializeField]LayerMask groundLayer; //是否在地面 bool isGrounded; //是否拥有控制权:默认拥有控制权,否则角色初始就不受控 bool hasControl = true; //是否在悬崖边沿上 public bool IsOnLedge { get; set; } //悬崖边沿击中相关数据 public LedgeHitData LedgeHitData { get; set; } float ySpeed; Quaternion targetRotation; CameraController cameraController; Animator animator; CharacterController charactercontroller; EnvironmentScanner environmentScanner; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); //角色控制器 charactercontroller = GetComponent(); //环境扫描器 environmentScanner = GetComponent(); } private void Update() { #region 角色输入控制 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); //把moveAmount限制在0-1之间(混合树的区间) float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v)); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物移动方向关联相机的水平旋转朝向 var moveDir = cameraController.PlanarRotation * moveInput; //如果没有控制权,后面的就不执行了 if(!hasControl){ return; } var velocity = Vector3.zero; #region 地面检测 GroundCheck(); animator.SetBool("isGrounded", isGrounded); if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; velocity = moveDir * moveSpeed; #region 悬崖检测 //在地上的时候进行悬崖检测,传给isOnLedge变量 IsOnLedge = environmentScanner.LedgeCheck(moveDir,out LedgeHitData ledgeHitData); //如果在悬崖边沿,就把击中数据传给LedgeHitData变量,用来在ParkourController里面调用 if (IsOnLedge) { LedgeHitData = ledgeHitData; Debug.Log("On Ledge"); } #endregion } else { //在空中时,ySpeed受重力控制 ySpeed += Physics.gravity.y * Time.deltaTime; //简单模拟有空气阻力的平抛运动:空中时的速度设置为角色朝向速度的一半 velocity = transform.forward * moveSpeed / 2; } #endregion //更新y轴方向的速度 velocity.y = ySpeed; //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(velocity * Time.deltaTime); //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向 //没有输入就不更新转向,也就不会回到初始朝向 if (moveAmount > 0) { //人物模型转起来:让人物朝向与移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion #region 角色动画控制 //设置人物动画参数moveAmount animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime); #endregion } //地面检测 private void GroundCheck() { // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true // 位置偏移用来在unity控制台里面调整 isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer); } //角色控制 public void SetControl(bool hasControl){ //传参给 hasControl 私有变量 this.hasControl = hasControl; //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件 //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动 charactercontroller.enabled = hasControl; //如果角色控制权被禁用,则更新动画参数和朝向 if (!hasControl) { //更新动画参数 animator.SetFloat("moveAmount", 0f); //更新朝向 targetRotation = transform.rotation; } } //画检测射线 private void OnDrawGizmosSelected() { //射线颜色,最后一个参数是透明度 Gizmos.color = new Color(0, 1, 0, 0.5f); Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius); } //让rotationSpeed可以被外部访问 public float RotationSpeed => rotationSpeed;}
复制代码 ParkourController.cs- using System.Collections;using System.Collections.Generic;using System.Threading;using UnityEngine;public class ParkourController : MonoBehaviour{ //定义一个面板可见的跑酷动作属性列表 [Header("跑酷动作列表")] [SerializeField] List parkourActions; [Header("跳下悬崖动画")] [SerializeField] ParkourAction jumpDownAction; EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); #region 各种跑酷动作 if (Input.GetButton("Jump") && !inAction) { if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); //跳出循环 break; } } //调试用:打印障碍物名称 //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } #endregion #region 悬崖跳下动作 //在悬崖边沿且不在播放动作中且前方没有障碍物 if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound) { //偏差角度小于50度,才会播放JumpDown动画 if(playerController.LedgeHitData.angle 0.2f 避免了太小的旋转角度也会更新 if (moveAmount > 0 && moveDir.magnitude > 0.2f) { //人物模型转起来:让目标朝向与当前移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); }
复制代码- if (IsOnLedge) { LedgeHitData = ledgeHitData; //调用悬崖边沿移动限制 LedgeMovement(); // Debug.Log("On Ledge"); }
复制代码- //悬崖边沿移动限制机制 private void LedgeMovement(){ //计算玩家期望移动方向与悬崖边沿法线的夹角 float angle = Vector3.Angle(LedgeHitData.hitSurface.normal, desireMoveDir); //这个夹角是锐角说明玩家将要走过悬崖边沿,限制不让走 Debug.Log("angle: " + angle); if(angle < 90){ velocity = Vector3.zero; //让当前方向为0,也就是不让玩家旋转方向,但是期望方向还是与相机转动方向一致,仍然可以转回去 moveDir = Vector3.zero; } }
复制代码 将JumpDown动画播放判定条件加一个“按下Jump键"
ParkourController.cs- #region 悬崖跳下动作 //在悬崖边沿且不在播放动作中且前方没有障碍物 if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound) { bool shouldJump = true; if(!Input.GetButtonDown("Jump")){ shouldJump = false; } //偏差角度小于50度,才会播放JumpDown动画 if(playerController.LedgeHitData.angle 0.2f 避免了太小的旋转角度也会更新 if (moveAmount > 0 && moveDir.magnitude > 0.2f) { //人物模型转起来:让目标朝向与当前移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion } //地面检测 private void GroundCheck() { // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true // 位置偏移用来在unity控制台里面调整 isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer); } //悬崖边沿移动限制机制 private void LedgeMovement() { //计算玩家期望移动方向与悬崖边沿法线的夹角 float angle = Vector3.Angle(LedgeHitData.hitSurface.normal, desireMoveDir); //这个夹角是锐角说明玩家将要走过悬崖边沿,限制不让走 Debug.Log("angle: " + angle); if (angle < 90) { velocity = Vector3.zero; //让当前方向为0,也就是不让玩家旋转方向,但是期望方向还是与相机转动方向一致,仍然可以转回去 moveDir = Vector3.zero; } } //角色控制 public void SetControl(bool hasControl) { //传参给 hasControl 私有变量 this.hasControl = hasControl; //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件 //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动 charactercontroller.enabled = hasControl; //如果角色控制权被禁用,moveAmount也应该设置为0,目标朝向设置为当前朝向也就是不允许通过输入转动方向 if (!hasControl) { //更新动画参数 animator.SetFloat("moveAmount", 0f); //更新朝向 targetRotation = transform.rotation; } } //画检测射线 private void OnDrawGizmosSelected() { //射线颜色,最后一个参数是透明度 Gizmos.color = new Color(0, 1, 0, 0.5f); Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius); } //让rotationSpeed可以被外部访问 public float RotationSpeed => rotationSpeed;}
复制代码 ParkourController.cs- using System.Collections;using System.Collections.Generic;using System.Threading;using UnityEngine;public class ParkourController : MonoBehaviour{ //定义一个面板可见的跑酷动作属性列表 [Header("跑酷动作列表")] [SerializeField] List parkourActions; [Header("跳下悬崖动画")] [SerializeField] ParkourAction jumpDownAction; EnvironmentScanner environmentScanner; Animator animator; PlayerController playerController; //是否在动作中 bool inAction; private void Awake() { environmentScanner = GetComponent(); animator = GetComponent(); playerController = GetComponent(); } // Update is called once per frame private void Update() { //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体 var hitData = environmentScanner.ObstacleCheck(); #region 各种跑酷动作 if (Input.GetButton("Jump") && !inAction) { if (hitData.forwardHitFound) { //对于每一个在跑酷动作列表中的跑酷动作 foreach (var action in parkourActions) { //如果动作可行 if(action.CheckIfPossible(hitData, transform)) { //播放对应动画 //StartCoroutine()方法:开启一个协程 //启动 DoParkourAction 协程,播放跑酷动画 StartCoroutine(DoParkourAction(action)); //跳出循环 break; } } //调试用:打印障碍物名称 //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name); } } #endregion #region 悬崖跳下动作 //在悬崖边沿且不在播放动作中且前方没有障碍物 if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound) { bool shouldJump = true; if(!Input.GetButtonDown("Jump")){ shouldJump = false; } //偏差角度小于50度,才会播放JumpDown动画 if(playerController.LedgeHitData.angle 80){ //当前朝向与期望移动方向的夹角超过80度 //转向悬崖边沿也就是期望方向,但是不移动 velocity = Vector3.zero; //这里不能写moveDir = desireMoveDir;直接return就很好 //这样直接返回就不会执行后面的代码了,人物转向直接由前面Update()里的代码控制 return; }
复制代码不能写 moveDir = desireMoveDir而直接返回的原因
直接返回就不会再执行后面的if,代码跳转回到Update(),人物转向由如下代码控制:- if (moveAmount > 0 && moveDir.magnitude > 0.2f) { //人物模型转起来:让目标朝向与当前移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
复制代码 效果如下:

该部分修改的完整代码如下:
PlayerController.cs- using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{ [Header("玩家属性")] [SerializeField] float moveSpeed = 5f; [SerializeField] float rotationSpeed = 500f; [Header("Ground Check")] [SerializeField] float groundCheckRadius = 0.5f; //检测射线偏移量 [SerializeField] Vector3 groundCheckOffset; [SerializeField] LayerMask groundLayer; //是否在地面 bool isGrounded; //是否拥有控制权:默认拥有控制权,否则角色初始就不受控 bool hasControl = true; //moveDir、velocity改成全局变量 //当前角色的移动方向,这是实时移动方向,只要输入方向键就会更新 Vector3 moveDir; //角色期望的移动方向,这个期望方向是和相机水平转动方向挂钩的,与鼠标或者手柄右摇杆一致 Vector3 desireMoveDir; Vector3 velocity; //是否在悬崖边沿上 public bool IsOnLedge { get; set; } //悬崖边沿击中相关数据 public LedgeHitData LedgeHitData { get; set; } float ySpeed; Quaternion targetRotation; CameraController cameraController; Animator animator; CharacterController charactercontroller; EnvironmentScanner environmentScanner; private void Awake() { //相机控制器设置为main camera cameraController = Camera.main.GetComponent(); //角色动画 animator = GetComponent(); //角色控制器 charactercontroller = GetComponent(); //环境扫描器 environmentScanner = GetComponent(); } private void Update() { #region 角色输入控制 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); //把moveAmount限制在0-1之间(混合树的区间) float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v)); //标准化 moveInput 向量 var moveInput = new Vector3(h, 0, v).normalized; //让人物期望移动方向关联相机的水平旋转朝向 // 这样角色就只能在水平方向移动,而不是相机在竖直方向的旋转量也会改变角色的移动方向 desireMoveDir = cameraController.PlanarRotation * moveInput; //让当前角色的移动方向等于期望方向 moveDir = desireMoveDir; //如果没有控制权,后面的就不执行了 if (!hasControl) { return; } velocity = Vector3.zero; #region 地面检测 GroundCheck(); animator.SetBool("isGrounded", isGrounded); if (isGrounded) { //设置一个较小的负值,让角色在地上的时候被地面吸住 ySpeed = -0.5f; //在地上的速度只需要初始化角色期望方向的速度就行,只有水平分量 velocity = desireMoveDir * moveSpeed; #region 悬崖检测 //在地上的时候进行悬崖检测,传给isOnLedge变量 IsOnLedge = environmentScanner.LedgeCheck(desireMoveDir, out LedgeHitData ledgeHitData); //如果在悬崖边沿,就把击中数据传给LedgeHitData变量,用来在ParkourController里面调用 if (IsOnLedge) { LedgeHitData = ledgeHitData; //调用悬崖边沿移动限制 LedgeMovement(); // Debug.Log("On Ledge"); } #endregion //在地面上,速度只有水平分量 #region 角色动画控制 // dampTime是阻尼系数,用来平滑动画 //这里不应该根据输入值赋值给BlendTree动画用的moveAmount参数 //因为动画用的moveAmount参数只需要水平方向的移动量就行了,不需要考虑y轴 //那么也就不需要方向,只需要值 //所以传入归一化的 velocity.magnitude / moveSpeed就行了 animator.SetFloat("moveAmount", velocity.magnitude / moveSpeed, 0.2f, Time.deltaTime); #endregion } else { //在空中时,ySpeed受重力控制 ySpeed += Physics.gravity.y * Time.deltaTime; //简单模拟有空气阻力的平抛运动:空中时的速度设置为角色朝向速度的一半 velocity = transform.forward * moveSpeed / 2; } #endregion //更新y轴方向的速度 velocity.y = ySpeed; //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(velocity * Time.deltaTime); //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向 //没有输入并且移动方向角度小于0.2度就不更新转向,也就不会回到初始朝向 //moveDir.magnitude > 0.2f 避免了太小的旋转角度也会更新 if (moveAmount > 0 && moveDir.magnitude > 0.2f) { //人物模型转起来:让目标朝向与当前移动方向一致 targetRotation = Quaternion.LookRotation(moveDir); } //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向 transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); #endregion } //地面检测 private void GroundCheck() { // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true // 位置偏移用来在unity控制台里面调整 isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer); } //悬崖边沿移动限制机制 private void LedgeMovement() { //计算玩家期望移动方向与悬崖边沿法线的有向夹角 //所以这里的方向是左前是正,右前是负 float signedAngle = Vector3.SignedAngle(LedgeHitData.hitSurface.normal, desireMoveDir, Vector3.up); //无向夹角 float angle = Math.Abs(signedAngle); //这个夹角是锐角说明玩家将要走过悬崖边沿,限制不让走 Debug.Log("angle: " + angle); if(Vector3.Angle(transform.forward, desireMoveDir) >80){ //当前朝向与期望移动方向的夹角超过80度 //转向悬崖边沿也就是期望方向,但是不移动 velocity = Vector3.zero; //这里不能写moveDir = desireMoveDir;直接return就很好 //这样直接返回就不会执行后面的代码了,人物转向直接由前面Update()里的代码控制 return; } if(angle < 60){ //速度设置为0,让玩家停止移动 velocity = Vector3.zero; //让当前方向为0,也就是不让玩家旋转方向,但是期望方向还是与相机转动方向一致,仍然可以转回去 moveDir = Vector3.zero; } else if (angle < 90) { //60度到90度,玩家直接90度转向与悬崖边沿平行的方向 //只保留与 悬崖法线和竖直方向构成平面 的垂直方向速度 //叉乘遵循右手法则:a x b = c,手指从a弯曲向b,拇指方向是c,所以这里是left方向 var parallerDir_left = Vector3.Cross(Vector3.up, LedgeHitData.hitSurface.normal); //具体的左还是右,取决于玩家期望输入方向与悬崖边沿法线的有向夹角signedAngle的正负 // (刚好也是左正右负,逻辑不变,直接乘就行) var dir = parallerDir_left * Math.Sign(signedAngle); //只保留与悬崖边沿平行的方向的速度 velocity = velocity.magnitude * dir; //更新角色当前方向 moveDir = dir; } } //角色控制 public void SetControl(bool hasControl) { //传参给 hasControl 私有变量 this.hasControl = hasControl; //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件 //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动 charactercontroller.enabled = hasControl; //如果角色控制权被禁用,moveAmount也应该设置为0,目标朝向设置为当前朝向也就是不允许通过输入转动方向 if (!hasControl) { //更新动画参数 animator.SetFloat("moveAmount", 0f); //更新朝向 targetRotation = transform.rotation; } } //画检测射线 private void OnDrawGizmosSelected() { //射线颜色,最后一个参数是透明度 Gizmos.color = new Color(0, 1, 0, 0.5f); Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius); } //让rotationSpeed可以被外部访问 public float RotationSpeed => rotationSpeed;}
复制代码 Bug——障碍物边沿脚步浮空问题
原因:向下发射的悬崖边沿Ledge检测射线是以人物中轴为起点的
所以可以用三条射线来同步检查悬崖边沿。

这个代码在大多数U3D游戏都可以复用,所以我放在Util文件夹下

PhysicsUtil.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class PhysicsUtil{ /// /// 三条射线同步检测 /// /// /// /// 间距 /// 方向 /// /// /// /// public static bool ThreeRaycast(Vector3 origin, Vector3 direction, float spacing, Transform transform, out List hits, float distance, LayerMask layer, bool debugDraw = false) { bool centerHitFound = Physics.Raycast(origin, direction, out RaycastHit centerHit, distance, layer); bool leftHitFound = Physics.Raycast(origin - transform.right * spacing, direction, out RaycastHit leftHit, distance, layer); bool rightHitFound = Physics.Raycast(origin + transform.right * spacing, direction, out RaycastHit rightHit, distance, layer); //击中对象列表 hits = new List() { centerHit, leftHit, rightHit }; //只要一条射线命中,就认为命中 bool hitFound = centerHitFound || leftHitFound || rightHitFound; //如果要显示调试射线 if (debugDraw) { Debug.DrawLine(origin, centerHit.point, Color.red); Debug.DrawLine(origin - transform.right * spacing, leftHit.point, Color.red); Debug.DrawLine(origin + transform.right * spacing, rightHit.point, Color.red); } return hitFound; }}
复制代码 注意:transform只有右上前方向,没有left!!!需要取反实现
EnvironmentScanner.cs
LedgeCheck()- //射线向下发射是否击中:击中点在地面位置,赋值给hitGround if (PhysicsUtil.ThreeRaycast(origin, Vector3.down, 0.2f, transform, out List hitsGround, ledgeRayLength, obstacleLayer, true)) { //有效击中返回值列表:检查hitsGround里的所有击中信息hit //height:计算当前位置高度 = 角色位置高度 - 击中点高度 //超过这个悬崖高度阈值ledgeHeightThreshold,才会认为是悬崖边缘 var validHits = hitsGround.Where(hit => transform.position.y - hit.point.y > ledgeHeightThreshold).ToList(); //只要有一个有效击中,就认为是悬崖边缘 if (validHits.Count > 0) { #region 悬崖边沿竖直表面检测——悬崖边沿移动限制机制需要用到ledgeHitData.hitSurface的属性,播放JumpDown动画时判定需要用到ledgeHitData.angle和ledgeHitData.height // 射线起始位置:脚底向前moveDir再向下偏移一些 var surfaceRayOrigin = transform.position + moveDir - Vector3.up * 0.1f; // 射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface if (Physics.Raycast(surfaceRayOrigin, -moveDir, out RaycastHit hitSurface, 2f, obstacleLayer)) { //计算当前位置高度 = 角色位置高度 - 任一击中点高度(这三个击中点高度都是一样的) float height = transform.position.y - validHits[0].point.y; //计算当前位置与悬崖表面法线的夹角 ledgeHitData.angle = Vector3.Angle(transform.forward, hitSurface.normal); ledgeHitData.height = height; ledgeHitData.hitSurface = hitSurface; return true; } #endregion } }
复制代码 这行代码相当于把列表里的每个hit都判定了一遍,满足括号里的大于悬崖高度阈值才会返回该hit,最终返回一个总的有效击中列表- //有效击中返回值列表:检查hitsGround里的所有击中信息hit //height:计算当前位置高度 = 角色位置高度 - 击中点高度 //超过这个悬崖高度阈值ledgeHeightThreshold,才会认为是悬崖边缘 var validHits = hitsGround.Where(hit => transform.position.y - hit.point.y > ledgeHeightThreshold).ToList();
复制代码 后面只需要validHits.Count>0就能实现:只要一条射线击中那就是在悬崖边沿。
加个调试射线就很容易看出问题- // 射线起始位置:脚底向前moveDir再向下偏移一些 var surfaceRayOrigin = transform.position + moveDir + Vector3.down * 0.1f; // 射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface if (Physics.Raycast(surfaceRayOrigin, -moveDir, out RaycastHit hitSurface, 2f, obstacleLayer)) { Debug.DrawLine(surfaceRayOrigin, transform.position, Color.cyan);
复制代码
存在问题如下:
悬崖边沿竖直表面检测射线hitSurface还是和原来一样与角色当前位置关联,而不是与这三条hitGround关联,所以实际的边沿限制机制会出问题。
解决方案:
当hitsGround列表里只有一条线击中地面,悬崖边沿竖直表面检测射线hitSurface(也就是从外向里发送的射线)也只能以这个hitGround射线为起点,也就是以validHits[0]的位置为surfaceRay的基准点(也就是surfaceRayOrigin),surfaceRayOrigin的y坐标和之前一样再以角色当前位置的y坐标向下偏移一点即可
修改后:- // 射线起始位置:脚底向前moveDir再向下偏移一些 var surfaceRayOrigin = validHits[0].point; surfaceRayOrigin.y = transform.position.y - 0.1f; // 射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface if (Physics.Raycast(surfaceRayOrigin, transform.position - surfaceRayOrigin, out RaycastHit hitSurface, 2f, obstacleLayer)) { Debug.DrawLine(surfaceRayOrigin, transform.position, Color.cyan);...
复制代码 也就是修改后的悬崖边沿竖直表面检测射线hitSurface 以 validhits[0] 为基准点。
如果validHits列表有两个三个,那也没事,因为这时三个点的位置以哪个为准都行,都可以用来给限制边沿移动机制用。
如果想让边沿检测更严格,也就是离边沿更远,可以增大 originOffset
Bug——人物着陆后的滑步问题
状态机行为的引入
需要在着陆时短暂禁用角色输入控制
在原有的动画中,人物JumpDown是由起跳+下落+着陆 组成的,所以在代码中我们找不到控制着陆的参数
ok,我们可以把现有动画用状态机控制,加一个 ControlStoppingAction脚本
让进入该动画的时候禁用控制,结束该动画恢复控制
这就需要两个接口,一个进入状态一个退出状态时调用
实际上所有动画都可以这样做,方便我们实现自己想要的效果
ControlStoppingAction脚本继承于状态机行为
PlayerController.cs加一个公开的可以外部传参的角色控制权属性:- //角色控制权属性,可以外部传参 public bool HasControl{ get => hasControl; set => hasControl = value; }
复制代码 ControlStoppingAction.cs- using System.Buffers.Text;using System.Collections;using System.Collections.Generic;using UnityEngine;public class ControlStoppingAction : StateMachineBehaviour{ PlayerController player; //进入该状态时调用 public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { //如果player还没有被赋值,则获取player组件 if (player == null) { player = animator.GetComponent(); } //禁用玩家的控制 player.HasControl = false; } //退出该状态时调用 public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { //恢复玩家的控制 animator.GetComponent().HasControl = true; }}
复制代码 回到Unity-Animator面板
给着陆动画加入上面状态机行为脚本
原理:
进入该动画的时候调用OnStateEnter()禁用控制,
结束该动画调用ExitStateEnter()恢复控制。
这就实现了着陆动画完全播放完才会启用玩家输入控制
(其他动画都可以添加这个脚本,可以很好解决滑步问题)
效果如下:
如果在这个状态向后转然后跳下的动画,出现跳两次才下落的话,可以进行如下设置:
增加起跳的退出时间,缩短过渡时间即可
体验优化——当落差不大的时候可以自动 JumpDown 而不是手动按下Jump键
ParkourController.cs- [Header("自动跳下高度")] [SerializeField] float autoJumpDownHeight = 1f;
复制代码- #region 悬崖跳下动作 //在悬崖边沿且不在播放动作中且前方没有障碍物 if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound) { //低矮的落差shouldJump == true,直接播放JumpDown动画 bool shouldJump = true; //只有高度大于autoJumpDownHeight 且 玩家按下跳跃键才会跳下悬崖 if(playerController.LedgeHitData.height > autoJumpDownHeight && !Input.GetButtonDown("Jump")){ shouldJump = false; } //偏差角度小于50度,才会播放JumpDown动画 if(playerController.LedgeHitData.angle 1){ //自动选择高度最高的点作为height height = validHits.Max(validHit => transform.position.y - validHit.point.y);}
复制代码 如果情况多,查找效率不高的话可以自定义一个查找方法,比如二分查找、快速查找等等
奇奇怪怪的命名问题——单词拼错了
ParkourController.cs中的minHeight和maxHeight打错了
改之前记得备份一下ParkourAction组件的面板参数(拍个照),改完把这些参数写回去。
Bug——还是存在的滑步问题
warning: CharacterController.Move called on inactive
PlayerController.cs- private void Update() { //如果没有控制权,后面的就不执行了 if (!hasControl) { return; }
复制代码- //先检查角色控制器是否激活 if(charactercontroller.gameObject.activeSelf && charactercontroller.enabled && hasControl){ //帧同步移动 //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动 charactercontroller.Move(velocity * Time.deltaTime); }
复制代码 问题解决
以上部分代码我放到了GitHub仓库:
https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section2
ok,可以开始编写后面的爬墙系统了!
Day9 代码整理
在开始实现后面的爬墙系统之前,对已有代码进行整理和去耦合
一个小问题
检查的时候发现的一个小问题:
这个参数其实只适合解决非组合动画的滑步问题
而组合动画,最好要用这个状态机行为脚本才能控制最后一个动画
解耦一些可复用的代码
ParkourController.csl里的DoParkourAction()抽象为通用动作播放方法,放到PlayerController.cs中:- //是否在动作中 public bool InAction {get;private set;}
复制代码- /// /// 通用动作播放 /// /// /// /// /// /// /// /// public IEnumerator DoAction(string animName, MatchTargetParams matchParams, Quaternion targetRotation, float actionDelay = 0f, bool needRotate = false, bool mirrorAction = false) { //跑酷动作开始 InAction = true; //不是所有动作都需要,具体动作自行写上 // //禁用玩家控制 // playerController.SetControl(false); //设置动画是否镜像 animator.SetBool("mirrorAction", mirrorAction); //从当前动画到指定的目标动画,平滑过渡0.2s animator.CrossFade(animName, 0.2f); // 等待过渡完成 //yield return new WaitForSeconds(0.3f); // 给足够时间让过渡完成,稍微大于CrossFade的过渡时间 yield return null; // 现在获取动画状态信息 var animStateInfo = animator.GetCurrentAnimatorStateInfo(0); //#region 调试用 //if (!animStateInfo.IsName(animName)) //{ // Debug.LogError("动画名称不匹配!"); //} //#endregion ////暂停协程,直到 "StepUp" 动画播放完毕。 //yield return new WaitForSeconds(animStateInfo.length); //动画播放期间,暂停协程,并让角色平滑旋转向障碍物 float timer = 0f; while (timer 0.5f){ break; } yield return null; } //对于一些组合动作,第一阶段播放完后就会被输入控制打断,这时候给一个延迟,让第二阶段的动画也播放完 //对于ClimbUp动作,第二阶段就是CrouchToStand yield return new WaitForSeconds(actionDelay); //不是所有动作都需要,具体动作自行写上 // //延迟结束后才启用玩家控制 // playerController.SetControl(true); //跑酷动作结束 InAction = false; }
复制代码 MatchTarget()也放进来- //目标匹配 void MatchTarget(MatchTargetParams mp) { //只有在不匹配和不在过渡状态的时候才会调用 if (animator.isMatchingTarget || animator.IsInTransition(0)) { return; } //调用unity自带的MatchTarget方法 animator.MatchTarget(mp.matchPosition, transform.rotation, mp.matchBodyPart, new MatchTargetWeightMask(mp.matchPositionXYZWeight, 0), mp.matchStartTime, mp.matchTargetTime); }
复制代码- //目标匹配TargetMatching用到的参数public class MatchTargetParams{ public Vector3 matchPosition; public AvatarTarget matchBodyPart; public Vector3 matchPositionXYZWeight; public float matchStartTime; public float matchTargetTime;}
复制代码 在ParkourController.cs的DoParkourAction()里调用DoAction():- //跑酷动作 IEnumerator DoParkourAction(ParkourAction action) { //禁用玩家控制 playerController.SetControl(false); MatchTargetParams matchParams = null; if(action.EnableTargetMatching){ if(matchParams == null){ matchParams = new MatchTargetParams(){ matchPosition = action.MatchPosition, matchBodyPart = action.MatchBodyPart, matchPositionXYZWeight = action.MatchPositionXYZWeight, matchStartTime = action.MatchStartTime, matchTargetTime = action.MatchTargetTime }; } } yield return playerController.DoAction(action.AnimName, matchParams, transform.rotation, action.ActionDelay, action.RotateToObstacle, action.Mirror); //延迟结束后才启用玩家控制 playerController.SetControl(true); }
复制代码 开始编写爬墙系统
Day10 爬墙系统Climbing System
攀岩石检测——ClimbLedgeCheck()
实现思路:
在人物朝向上循环发射多条平行检测射线,
EnvironmentScanner.cs- //悬崖攀岩石层 [SerializeField] LayerMask climbLedgeLayer;
复制代码- /// /// 攀崖石检测 /// /// 角色朝向 /// 检测信息 /// public bool ClimbLedgeCheck(Vector3 dir,out RaycastHit ledgeHit){ ledgeHit = new RaycastHit(); if(dir == Vector3.zero){ return false; } Vector3 origin = transform.position + Vector3.up * 1.5f; Vector3 offset = Vector3.up * 0.15f; //在人物朝向上循环发射多条平行检测射线 foreach(int i in Enumerable.Range(0, 10)){ Debug.DrawRay(origin + offset * i, dir, Color.white); if(Physics.Raycast(origin + offset * i, dir, out RaycastHit hit, 0.5f, climbLedgeLayer)){ ledgeHit = hit; return true; } } return false; }
复制代码 ClimbController.cs- using System.Collections;using System.Collections.Generic;using UnityEngine;public class ClimbController : MonoBehaviour{ EnvironmentScanner envScanner; public bool IsOnClimbLedge{ get; private set; } void Awake() { envScanner = GetComponent(); } void Update() { if(Input.GetButton("Jump")){ IsOnClimbLedge = envScanner.ClimbLedgeCheck(transform.forward, out RaycastHit ledgeHit); if(IsOnClimbLedge){ Debug.Log("Climbing on ledge"); } } }}
复制代码 加入动作——Idle To Braced Hang & Hanging Idle
这里由于新的动画与原有模型不匹配,可以下载新模型,然后继承模型选择新的模型(与新动画骨骼名字是匹配的)
原因:新的动画以及模型的骨骼名字加了个前缀mixamorig:
清理Animator界面——加入Sub-State Machine,收纳同一类的动作
为新动作Idle To Braced Hang & Hanging Idle编写控制脚本
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |