笙芝 发表于 2025-6-2 23:41:11

类刺客信条跑酷系统开发日志

Parkour Climbing System

——类刺客信条跑酷系统
摘要:这个项目不会用到除模型动画之外的任何资产,完全从0开始构建
写在前面:
GitHub仓库:https://github.com/EanoJiang/Parkour-Climbing-System
第一部分(Day1~4):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section1
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518195144738-171736347.gif
第二部分(Day5~8):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section2
视频太长,为了好上传这里我就上传了10帧率的GIF
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518195205865-581530018.gif
第三部分(Day9~还没写完):
Day1 摄像头脚本

在unity中,xyz轴是右手坐标系,即x水平向右,y垂直向上,z水平向前
public class CameraController : MonoBehaviour{    //摄像机跟随的目标    Transform followTarget;    // Update is called once per frame    void Update()    {      //摄像机放在目标后面5个单位的位置      transform.position = followTarget.position - new Vector3(0, 0, 5);    }}怎么旋转这个相机呢?

https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510155121511-1253896171.png
摄像机向后移动的参量乘一个水平旋转角度
所以,引入四元数点欧拉Quaternion.Euler
这个水平视角旋转角度需要绕y轴的旋转角度,还需要鼠标控制这个角度
并且,当摄像头旋转的时候,摄像头始终对着player
public class CameraController : MonoBehaviour{    //摄像机跟随的目标    Transform followTarget;    //距离    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;    }}https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510155122136-1896591609.gif
完成了水平视角的旋转
让相机垂直旋转

https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510195919587-612139514.png
还需要在垂直视角旋转的时候合理的限幅:让视角最高不超过45°,最低到人物的胸部位置
public class CameraController : MonoBehaviour{    //摄像机跟随的目标    Transform followTarget;    float rotationSpeed = 1.5f;    //距离    float distance;    //绕y轴的旋转角度——水平视角旋转    float rotationY;    //绕x轴的旋转角度——垂直视角旋转    float rotationX;    //限制rotationX幅度    float minVerticalAngle = -20;    float maxVerticalAngle = 45;    //框架偏移向量——摄像机位置视差偏移    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;    }}https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510195920140-2071032161.gif
大致实现了摄像机跟随人物进行旋转
还需要一些细节调整:
    private void Start()    {      //隐藏光标      Cursor.lockState = CursorLockMode.Locked;      Cursor.visible = false;    }考虑到存在大多数角色控制器都有控制反转的选项
using System.Collections;using System.Collections.Generic;using UnityEngine;public class CameraController : MonoBehaviour{    //摄像机跟随的目标    Transform followTarget;    float rotationSpeed = 1.5f;    //距离    float distance;    //绕y轴的旋转角度——水平视角旋转    float rotationY;    //绕x轴的旋转角度——垂直视角旋转    float rotationX;    //限制rotationX幅度    float minVerticalAngle = -20;    float maxVerticalAngle = 45;    //框架偏移向量——摄像机位置视差偏移    Vector2 frameOffset;    //视角控制反转        bool invertX;    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;    }}我通常喜欢这样选择,垂直反转(鼠标向上就看上面),水平不反转(鼠标向左就看左边)
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510195920606-896264919.png
就是让摄像机视角和鼠标移动方向对我来说是同步的,相当于第一人称视角控制的习惯
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510195921001-725020793.gif
Day2 第三人称人物控制脚本

前序准备

先创建个人物模型( 从Mixamo下载的)
导入unity中,选择模型后点开inspector-Materials-Textures,选一个文件夹存放纹理
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510195922501-389206287.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510195922760-513104637.png
OK,下面就开始为这个角色写控制脚本吧!
最简化的第三人称角色控制

public class PlayerController : MonoBehaviour{    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{    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)只有输入的时候才会更新人物朝向
[*]确保模型始终朝向移动方向。
但是还有一个问题:
人物朝向切换太快了,需要设置一个转向速度,让人物从当前朝向到目标朝向慢慢转向
    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);实现效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510200038664-3269519.gif
该部分完整代码:
using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{        float moveSpeed = 5f;    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动画

前序准备

有一个待解决的问题:如何让动画匹配任意人物模型?

[*]找到人物模型,进行如下设置:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222505655-571431839.png
应用后点configure查看骨骼映射情况,如果有没匹配上的需要手动调整
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222506224-452212624.png
最后Done完成

[*]找到要用到的动画,如下设置:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222506597-488703973.png
注意Avatar Definition要选择 从其他avatar复制,然后在source里面选择要应用的avatar

[*]然后每个动画都进行如下设置:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222506830-2052389821.png
注意要选择Loop Pose,如果loop match 的话可以不勾选Bake Into Pose
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222507082-922495023.png
而且几个动画的Length值最好要尽可能接近,以免后面切换的时候出现问题

[*]新建一个角色控制器的动画脚本
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222507298-822416061.png

[*]记得在player的Animator属性里添加这个脚本
万事俱备,下面开始编写动画相关脚本!
Animator组件——动画蓝图

新建一个Blend Tree
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222507497-1393225660.png
拖入对应动画
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222507696-202185890.png
在PlayerController.cs里写动画播放逻辑

    Animator animator;    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);      #endregionSetFloat()有四个参数的重载,第三个参数是要平滑到达的值
基本的第三人称角色控制器效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250510222609680-1683621838.gif
修改后的PlayerController.cs完整代码:
using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{        float moveSpeed = 5f;    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
玩家:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250511104307507-577552798.png
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组件的特性。
效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250511104309005-1999282689.gif
碰撞检测

PlayerController.cs
        float groundCheckRadius = 0.5f;    //检测射线偏移量    Vector3 groundCheckOffset;    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);    }回到控制台进行如下设置:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005544751-1683794088.png
调整到球体覆盖住角色的脚
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005545788-61715709.png
为plane和其他障碍物添加Layer为Obstacles
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005546395-960240975.png
重力设置

https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005547128-1781678204.png
    float ySpeed;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);      #endregiony轴方向的位置移动实时更新,即使没有输入也要更新。
效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005550085-1236961592.gif
下面设置skin width:
官方手册描述:
Character Controller——Skin width
两个碰撞体可以穿透彼此且穿透深度最多为皮肤宽度 (Skin Width)。较大的皮肤宽度可减少抖动。较小的皮肤宽度可能导致角色卡住。合理设置是将此值设为半径的 10%。
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005556747-853551439.png
Center的更准确的设置:
Center.Y = Height /2 + Skin Width
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005557422-912849733.png
改之前:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005558422-484337171.png
改之后:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005559691-435834677.png
可以看到,脚部完全贴合地面,that's good.
有个问题仍然存在:当我们在下落过程中,角色仍在播放走路动画
因此我们需要编写相应的动画逻辑,还可以加下落动画,这个放在后面再写,坑+1
手柄适配

下面改一下对手柄的适配,因为在写摄像机输入控制的时候用的是Mouse:
在Project Settings - Input Manager里面找到Mouse X和Y,分别复制两个副本,重命名为Camera X/Y
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005600797-599966198.png
对第二个Camera进行如下设置
注意死区和灵敏度最好设置成和下面的Horizontal一样的值
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005601490-748525834.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005602222-1310314210.png
回到CameraController.cs脚本,修改如下即可
      //水平视角控制——鼠标(手柄)x轴控制rotationY      rotationY += Input.GetAxis("Camera X") * rotationSpeed * invertYValue;      //垂直视角控制——鼠标(手柄)y轴控制rotationX      rotationX += Input.GetAxis("Camera Y") * rotationSpeed * invertXValue;有个小问题:键鼠控制的话就勾上X轴反转,手柄控制就不要勾了
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005603018-567701880.png
效果就是手柄右摇杆也能控制相机视角了,演示就不放了,没啥区别
以上部分代码我放到了GitHub仓库:
https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section1
ok,可以开始编写最有意思的跑酷系统脚本了!
Day 5 跑酷系统——StepUp&JumpUp

跑酷系统构成:跑酷控制器+环境扫描
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005603752-2086435973.png
环境扫描

Environment Scanner
The environment scanner will scan for obstacles in front of the player by using multiple RayCasts.
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005604666-1641575180.png
放置不同高度的Cube,设置Position.y = Scale.y / 2
EnvironmentScanner.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class EnvironmentScanner : MonoBehaviour{        //y轴(竖直方向)偏移量    Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);    //长度    float forwardRayLength = 0.8f;    //障碍物层    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();    }}效果:射线会从膝盖左右的位置射出
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005605832-1794889268.gif
下面把检测信息hitData抽象成一个结构体,
EnvironmentScanner.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class EnvironmentScanner : MonoBehaviour{        //y轴(竖直方向)偏移量    Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);    //长度    float forwardRayLength = 0.8f;    //障碍物层    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);      }    }}效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250512005609922-422944060.gif
障碍高度检测

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}效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014221596-297823847.gif
添加跑酷动作——翻越障碍StepUp

加一个动画StepUp
带位移动作的动画需要在角色的Animator组件里面勾选 Apply Root Motion
而且在这个动画实际作用的时候,角色的位置是向上移动的,所以不要勾选Bake Into Pose
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014222953-1268828629.png
在Animator面板里面,只需StepUp->Locomotion,因为StepUp是条件触发,触发结束回到Locomotion,但是Locomotion不需要回到StepUp状态。
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014223915-742169984.png
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;    }效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014233166-1894919278.gif
该部分完整修改代码:
PlayerController.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{        float moveSpeed = 5f;    float rotationSpeed = 10f;        float groundCheckRadius = 0.5f;    //检测射线偏移量    Vector3 groundCheckOffset;    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;public class ParkourAction : ScriptableObject{    string animName;    float minHeigth;    float maxHeigth;}然后在unity的右键菜单就可以看到
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014253170-2123775980.png
新建两个跑酷动作
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014253960-988805152.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014254691-1935003517.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014255344-425436498.png
然后剪辑动画,让起始帧和结束帧刚好时想要的状态
起始帧:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014256214-78679693.png
结束帧:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014257106-1641244514.png
接下来的任务就是把之前代码中关于动画的硬编码部分解耦
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014257800-994195039.png
ParkourAction.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourAction : ScriptableObject{    string animName;    float minHeigth;    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面板
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014258426-621454800.png
效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513014312351-1707703773.gif
修改后的完整代码:
ParkourAction.cs上面放了
ParkourController.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourController : MonoBehaviour{    //定义一个面板可见的跑酷动作属性列表    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
        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 (timermaxHeigth)      {            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{        float moveSpeed = 5f;    float rotationSpeed = 500f;        float groundCheckRadius = 0.5f;    //检测射线偏移量    Vector3 groundCheckOffset;    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{    //定义一个面板可见的跑酷动作属性列表    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 (timeranimName;    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);    }这里我们匹配角色右脚(先抬起)就行,
首先,在动画剪辑中找到角色右脚开始离地的位置
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224833462-1038041008.png
再找到即将落地的位置
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224833739-307554271.png
把标准化结果填入action里
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224834001-327502153.png
另一个动画同样如此操作:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224834289-1751773000.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224834598-244659324.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224834907-1028182543.png
其实只要结束的时间MatchTargetTime弄准确了就好,开始时间大概就行
有个问题需要特别注意的:
如果出现了动画播放后角色的脚位置莫名浮动一下,需要在动画里面勾选如下选项
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224835137-1845102767.png
效果如下,除了穿模现象不会出现动画播放过程中脚部浮空的现象:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250513224836898-467950488.gif
完整的修改代码如下:
ParkourAction.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourAction : ScriptableObject{    //动画名称    string animName;    //高度区间    float minHeigth;    float maxHeigth;        bool rotateToObstacle;        bool enableTargetMatching;    AvatarTarget matchBodyPart;    float matchStartTime;    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{    //定义一个面板可见的跑酷动作属性列表    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 (timermatchPositionXYZWeight;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;      }效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250514102856748-849758445.gif
该部分修改的完整代码
ParkourAction.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class ParkourAction : ScriptableObject{    //动画名称    string animName;    //高度区间    float minHeigth;    float maxHeigth;        bool rotateToObstacle;        bool enableTargetMatching;    AvatarTarget matchBodyPart;    float matchStartTime;    float matchTargetTime;    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{    //定义一个面板可见的跑酷动作属性列表    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 (timermaxHeigth)      {            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右手
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034723379-666449773.png
如果从障碍物的右边沿开始撑手翻越,动画则不镜像翻转并target matching左手
fine,开始修改相应脚本:
新建一个脚本VaultAction.cs继承ParkourAction
在ParkourAction中将CheckIfPossible()设置为虚函数,并在VaultAction中重写
ParkourAction.cs
    public virtual bool CheckIfPossible(ObstacleHitData hitData, Transform player)    {      ...    }可以对带有Fence标签的障碍物进行边沿判断,以水平方向中心位置为0,小于0就是左边沿,大于0就是右边沿
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034724132-58776994.png
注意Fence的坐标系应该是这样的:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034725086-2081865699.png
在局部空间坐标系中,
如果击中点的
z0 就镜像,跟踪右手
反之就不镜像,跟踪左手
所以还需要一个标志位 Mirror
并且在VaultFence的动画里,Mirror是否镜像动画可以由一个自定义参数决定,参数名这里设置为 mirrorAction
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034725811-2001198689.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034726372-1319546811.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034727087-1246066408.png
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能够访问
    protected AvatarTarget matchBodyPart;//在内部和子类可访问ParkourController.cs里赋值给动画组件animator
    //跑酷动作    IEnumerator DoParkourAction(ParkourAction action)    {      ...      //设置动画是否镜像      animator.SetBool("mirrorAction", action.Mirror);        ...    }然后需要新建一个VaultAction脚本的对象,
VaultAction.cs
// 和ParkourAction一样,可以通过右键菜单新建脚本相应的对象https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034727888-1918245339.png
参数和VaultFence一样,但是是VaultAction类的对象
记得更新Player的面板参数:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034728900-222791910.png
大功告成!效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250515034731038-1404672546.gif
完整修改代码如下:
ParkourController.cs
using System.Collections;using System.Collections.Generic;using System.Threading;using UnityEngine;public class ParkourController : MonoBehaviour{    //定义一个面板可见的跑酷动作属性列表    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 (timer0.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;public class ParkourAction : ScriptableObject{    //动画名称    string animName;    //对应的障碍物Tag    string obstacleTag;        float minHeigth;    float maxHeigth;        bool rotateToObstacle;        float actionDelay;        bool enableTargetMatching = true;    protected AvatarTarget matchBodyPart;//在内部和子类可访问    float matchStartTime;    float matchTargetTime;    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一样,可以通过右键菜单新建脚本相应的对象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
        //向下发射的射线的长度    float ledgeRayLength = 10f;    //悬崖的高度阈值    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      }效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516031823296-538493401.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516031824085-1646894341.png
该部分完整修改代码:
EnvironmentScanner.cs
using System.Collections;using System.Collections.Generic;using UnityEngine;public class EnvironmentScanner : MonoBehaviour{        //y轴(竖直方向)偏移量    Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);    //长度    float forwardRayLength = 0.8f;    //从击中点向上发射的射线的高度    float heightRayLength = 5f;        //向下发射的射线的长度    float ledgeRayLength = 10f;    //悬崖的高度阈值    float ledgeHeightThreshold = 0.75f;        //障碍物层    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{        float moveSpeed = 5f;    float rotationSpeed = 500f;        float groundCheckRadius = 0.5f;    //检测射线偏移量    Vector3 groundCheckOffset;    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一样需要组合动画来实现:
起跳 + 空中下落 + 着陆
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516031824697-1989316798.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516031825379-127962469.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516031826038-581715027.png
注意:
Falling 要勾选LoopPose,动画循环
起跳和着陆动画选择Y轴变化跟踪Feet,这样可以避免起跳和着陆时脚部的y轴位置浮动
Animator如图设置
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516031826753-1353990720.png
加一个isGrounded标志位用来在Falling->Standing时条件判断,只有在isGrounded == true 的时候才会播放着陆动画。
isGrounded需要在PlayerController.cs中地面检测后赋值
PlayerController.cs
      #region 地面检测      GroundCheck();      animator.SetBool("isGrounded", isGrounded);ParkourController.cs
        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
这就需要得到一个如图所示的转向角:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516032029271-1121914188.png
怎么得到转向角?
Vector3.Angle(transform.forward, ledgeHitData.hitSurface.normal)
怎么找到hitSurface
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250516032030297-1713765924.png
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");            }            #endregionParkourController.cs
      #region 悬崖跳下动作      //在悬崖边沿且不在播放动作中      if(playerController.IsOnLedge && !inAction)      {            //偏差角度小于50度,才会播放JumpDown动画            if(playerController.LedgeHitData.angleledgeHeightThreshold)                {                  //计算当前位置与悬崖表面法线的夹角                  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{        float moveSpeed = 5f;    float rotationSpeed = 500f;        float groundCheckRadius = 0.5f;    //检测射线偏移量    Vector3 groundCheckOffset;    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{    //定义一个面板可见的跑酷动作属性列表        List parkourActions;        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.angle0.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.angle0.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{    //定义一个面板可见的跑酷动作属性列表        List parkourActions;        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);效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250517043136143-377163049.gif
该部分修改的完整代码如下:
PlayerController.cs
using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerController : MonoBehaviour{        float moveSpeed = 5f;    float rotationSpeed = 500f;        float groundCheckRadius = 0.5f;    //检测射线偏移量    Vector3 groundCheckOffset;    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检测射线是以人物中轴为起点的
所以可以用三条射线来同步检查悬崖边沿。
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518032236268-1365474671.png
这个代码在大多数U3D游戏都可以复用,所以我放在Util文件夹下
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518032236999-330535244.png
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.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);https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518032237847-121105483.png
存在问题如下:
悬崖边沿竖直表面检测射线hitSurface还是和原来一样与角色当前位置关联,而不是与这三条hitGround关联,所以实际的边沿限制机制会出问题。
解决方案:
当hitsGround列表里只有一条线击中地面,悬崖边沿竖直表面检测射线hitSurface(也就是从外向里发送的射线)也只能以这个hitGround射线为起点,也就是以validHits的位置为surfaceRay的基准点(也就是surfaceRayOrigin),surfaceRayOrigin的y坐标和之前一样再以角色当前位置的y坐标向下偏移一点即可
修改后:
                // 射线起始位置:脚底向前moveDir再向下偏移一些                var surfaceRayOrigin = validHits.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 为基准点。
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518032239078-1545728449.png
如果validHits列表有两个三个,那也没事,因为这时三个点的位置以哪个为准都行,都可以用来给限制边沿移动机制用。
如果想让边沿检测更严格,也就是离边沿更远,可以增大 originOffset
Bug——人物着陆后的滑步问题

状态机行为的引入

需要在着陆时短暂禁用角色输入控制
在原有的动画中,人物JumpDown是由起跳+下落+着陆 组成的,所以在代码中我们找不到控制着陆的参数
ok,我们可以把现有动画用状态机控制,加一个 ControlStoppingAction脚本
让进入该动画的时候禁用控制,结束该动画恢复控制
这就需要两个接口,一个进入状态一个退出状态时调用
实际上所有动画都可以这样做,方便我们实现自己想要的效果
ControlStoppingAction脚本继承于状态机行为
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518174023943-527458095.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518174025678-1107137317.png
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面板
给着陆动画加入上面状态机行为脚本
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518174027323-49941056.png
原理:
进入该动画的时候调用OnStateEnter()禁用控制,
结束该动画调用ExitStateEnter()恢复控制。
这就实现了着陆动画完全播放完才会启用玩家输入控制
(其他动画都可以添加这个脚本,可以很好解决滑步问题)
效果如下:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518174034093-752042387.gif
如果在这个状态向后转然后跳下的动画,出现跳两次才下落的话,可以进行如下设置:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518174040883-1826761808.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250518174043362-1086736388.png
增加起跳的退出时间,缩短过渡时间即可
体验优化——当落差不大的时候可以自动 JumpDown 而不是手动按下Jump键

ParkourController.cs
        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.angle1){    //自动选择高度最高的点作为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 代码整理

在开始实现后面的爬墙系统之前,对已有代码进行整理和去耦合
一个小问题

检查的时候发现的一个小问题:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001608397-453653726.png
这个参数其实只适合解决非组合动画的滑步问题
而组合动画,最好要用这个状态机行为脚本才能控制最后一个动画
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001608836-1191987570.png
解耦一些可复用的代码

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 (timer0.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()

实现思路:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001609552-1150317355.png
在人物朝向上循环发射多条平行检测射线,
EnvironmentScanner.cs
    //悬崖攀岩石层    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

这里由于新的动画与原有模型不匹配,可以下载新模型,然后继承模型选择新的模型(与新动画骨骼名字是匹配的)
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001610151-1393587183.png
原因:新的动画以及模型的骨骼名字加了个前缀mixamorig:
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001610602-1569068494.png
清理Animator界面——加入Sub-State Machine,收纳同一类的动作

https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001611193-1172375209.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001611754-403989410.png
https://img2023.cnblogs.com/blog/3614909/202505/3614909-20250520001612325-1592774027.png
为新动作Idle To Braced Hang & Hanging Idle编写控制脚本


来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: 类刺客信条跑酷系统开发日志