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

Parkour Climbing System

——类刺客信条跑酷系统

摘要:这个项目不会用到除模型动画之外的任何资产,完全从0开始构建

写在前面
点个Star吧
GitHub仓库:https://github.com/EanoJiang/Parkour-Climbing-System

第一部分(Day1~4):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section1

1747569051696

第二部分(Day5~8):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section2

视频太长,为了好上传这里我就上传了10帧率的GIF

1747568594221

完整部分,也就是第三部分(Day9~Day15):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section3

视频太长这里放不了,这里给一个b站的链接:【类刺客信条跑酷系统Demo】


Day1 摄像头脚本

在unity中,xyz轴是右手坐标系,即x水平向右,y垂直向上,z水平向前

public class CameraController : MonoBehaviour
{
    //摄像机跟随的目标
    [SerializeField] Transform followTarget;

    // Update is called once per frame
    void Update()
    {
        //摄像机放在目标后面5个单位的位置
        transform.position = followTarget.position - new Vector3(0, 0, 5);
    }
}

怎么旋转这个相机呢?

1746860633930

摄像机向后移动的参量乘一个水平旋转角度

所以,引入四元数点欧拉Quaternion.Euler

这个水平视角旋转角度需要绕y轴的旋转角度,还需要鼠标控制这个角度

并且,当摄像头旋转的时候,摄像头始终对着player

public class CameraController : MonoBehaviour
{
    //摄像机跟随的目标
    [SerializeField] Transform followTarget;
    //距离
    [SerializeField] float distance;

    //绕y轴的旋转角度
    float rotationY;

    private void Update()
    {
        //鼠标x轴控制rotationY
        rotationY += Input.GetAxis("Mouse X");
        //水平视角旋转参量
        //想要水平旋转视角,所以需要的参量为绕y轴旋转角度
        var horizontalRotation = Quaternion.Euler(0, rotationY, 0);

        //摄像机放在目标后面5个单位的位置
        transform.position = followTarget.position - horizontalRotation * new Vector3(0, 0, distance);
        //摄像机始终朝向目标
        transform.rotation = horizontalRotation;
    }
}

1746863453449

完成了水平视角的旋转

让相机垂直旋转

1746863550195

还需要在垂直视角旋转的时候合理的限幅:让视角最高不超过45°,最低到人物的胸部位置

public class CameraController : MonoBehaviour
{
    //摄像机跟随的目标
    [SerializeField] Transform followTarget;
    [SerializeField] float rotationSpeed = 1.5f;
    //距离
    [SerializeField] float distance;

    //绕y轴的旋转角度——水平视角旋转
    float rotationY;
    //绕x轴的旋转角度——垂直视角旋转
    float rotationX;
    //限制rotationX幅度
    [SerializeField] float minVerticalAngle = -20;
    [SerializeField] float maxVerticalAngle = 45;
    //框架偏移向量——摄像机位置视差偏移
    [SerializeField] Vector2 frameOffset;

    private void Update()
    {
        //鼠标x轴控制rotationY
        rotationY += Input.GetAxis("Mouse X") * rotationSpeed;
        //鼠标y轴控制rotationX
        rotationX += Input.GetAxis("Mouse Y") * rotationSpeed;
        //限制rotationX幅度
        rotationX = Mathf.Clamp(rotationX, minVerticalAngle, maxVerticalAngle);

        //视角旋转参量
        //想要水平旋转视角,所以需要的参量为绕y轴旋转角度
        var targetRotation = Quaternion.Euler(rotationX, rotationY, 0);

        //摄像机的焦点位置
        var focusPosition = followTarget.position + new Vector3(frameOffset.x, frameOffset.y, 0);
        //摄像机放在目标后面5个单位的位置
        transform.position = focusPosition - targetRotation * new Vector3(0, 0, distance);
        //摄像机始终朝向目标
        transform.rotation = targetRotation;
    }
}

1746864912217

大致实现了摄像机跟随人物进行旋转

还需要一些细节调整:

    private void Start()
    {
        //隐藏光标
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;

    }

考虑到存在大多数角色控制器都有控制反转的选项

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CameraController : MonoBehaviour
{
    //摄像机跟随的目标
    [SerializeField] Transform followTarget;
    [SerializeField] float rotationSpeed = 1.5f;
    //距离
    [SerializeField] float distance;

    //绕y轴的旋转角度——水平视角旋转
    float rotationY;
    //绕x轴的旋转角度——垂直视角旋转
    float rotationX;
    //限制rotationX幅度
    [SerializeField] float minVerticalAngle = -20;
    [SerializeField] float maxVerticalAngle = 45;
    //框架偏移向量——摄像机位置视差偏移
    [SerializeField] Vector2 frameOffset;

    //视角控制反转
    [Header("视角控制反转:invertX是否反转垂直视角,invertY是否反转水平视角")]
    [SerializeField] bool invertX;
    [SerializeField] bool invertY;

    float invertXValue;
    float invertYValue;

    private void Start()
    {
        //隐藏光标
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;

    }

    private void Update()
    {
        //视角控制反转参数
        invertXValue = (invertX)? -1 : 1;
        invertYValue = (invertY)? -1 : 1;

        //水平视角控制——鼠标x轴控制rotationY
        rotationY += Input.GetAxis("Mouse X") * rotationSpeed * invertYValue;
        //垂直视角控制——鼠标y轴控制rotationX
        rotationX += Input.GetAxis("Mouse Y") * rotationSpeed * invertXValue;
        //限制rotationX幅度
        rotationX = Mathf.Clamp(rotationX, minVerticalAngle, maxVerticalAngle);

        //视角旋转参量
        //想要水平旋转视角,所以需要的参量为绕y轴旋转角度
        var targetRotation = Quaternion.Euler(rotationX, rotationY, 0);

        //摄像机的焦点位置
        var focusPosition = followTarget.position + new Vector3(frameOffset.x, frameOffset.y, 0);
        //摄像机放在目标后面5个单位的位置
        transform.position = focusPosition - targetRotation * new Vector3(0, 0, distance);
        //摄像机始终朝向目标
        transform.rotation = targetRotation;
    }
}

我通常喜欢这样选择,垂直反转(鼠标向上就看上面),水平不反转(鼠标向左就看左边)

1746872861555

就是让摄像机视角和鼠标移动方向对我来说是同步的,相当于第一人称视角控制的习惯

1746866907598

Day2 第三人称人物控制脚本

前序准备

先创建个人物模型( 从Mixamo下载的)

导入unity中,选择模型后点开inspector-Materials-Textures,选一个文件夹存放纹理

1746867236092

1746867473999

OK,下面就开始为这个角色写控制脚本吧!

最简化的第三人称角色控制

public class PlayerController : MonoBehaviour
{
    [SerializeField]float moveSpeed = 5f;
    private void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        //标准化 moveInput 向量
        var moveInput = new Vector3(h, 0, v).normalized;

        transform.position += moveInput * moveSpeed * Time.deltaTime;

    }

}

需要注意的:

  1. .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\))。这意味着在对角线方向上移动时,玩家的移动速度会比只在一个方向上移动时快。为了确保玩家在所有方向上移动时速度一致,需要对向量进行标准化。

  2. Time.deltaTime

    Time.deltaTime 是Unity引擎提供的一个浮点数,表示从上一帧到当前帧所用的时间(以秒为单位)。使用 Time.deltaTime 可以确保玩家的移动速度在不同帧率下保持一致。如果不使用 Time.deltaTime,在高帧率下玩家会移动得更快,在低帧率下玩家会移动得更慢。

改进

上面这样显然不能满足角色控制,因为当我们按下前进方向键的时候,人物并没有根据当前摄像机显示的方向移动

还需要进行如下改进:

在CameraController.cs里面加入

    //水平方向的旋转,返回摄像机的水平旋转四元数。
    public Quaternion PlanarRotation => Quaternion.Euler(0, rotationY, 0);

这里提一句C#中的特性:

大多数语言中想要获取一个返回值,需要定义一个函数,然后返回

    public Quaternion GetPlanarRotation()
    {
        return Quaternion.Euler(0, rotationY, 0);
    }

但是C#可以优雅的利用表达式主体定义的属性,直接获取这个属性

然后在PlayerController.cs里调用这个返回值

public class PlayerController : MonoBehaviour
{
    [SerializeField]float moveSpeed = 5f;

    CameraController cameraController;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
    }
    private void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        float moveAmount = Mathf.Abs(h) + Mathf.Abs(v);

        //标准化 moveInput 向量
        var moveInput = new Vector3(h, 0, v).normalized;

        //让人物移动方向关联相机的水平旋转朝向
        //  这样角色就只能在水平方向移动,而不是相机在竖直方向的旋转量也会改变角色的移动方向
        var moveDir = cameraController.PlanarRotation * moveInput;

        //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新移动+转向
	//没有输入就不更新转向,也就不会回到初始朝向
        if (moveAmount > 0)
        {
            //帧同步移动
            transform.position += moveDir * moveSpeed * Time.deltaTime;
            //人物模型转起来:让人物朝向与移动方向一致
            transform.rotation = Quaternion.LookRotation(moveDir);
        }

    }

}

这里解决了一个问题:

当方向键输入结束,人物模型朝向又回到了初始状态朝向

所以需要实时响应输入

  • if (moveAmount > 0)只有输入的时候才会更新人物朝向
  • 确保模型始终朝向移动方向。

但是还有一个问题:

人物朝向切换太快了,需要设置一个转向速度,让人物从当前朝向到目标朝向慢慢转向

    [SerializeField]float rotationSpeed = 10f;

    Quaternion targetRotation;
        //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新移动+转向
        //没有输入就不更新转向,也就不会回到初始朝向
        if (moveAmount > 0)
        {
            //帧同步移动
            transform.position += moveDir * moveSpeed * Time.deltaTime;
            //人物模型转起来:让人物朝向与移动方向一致
            targetRotation = Quaternion.LookRotation(moveDir);
        }
        //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向
        transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation,
                         rotationSpeed * Time.deltaTime);

实现效果如下:

1746878411104

该部分完整代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField]float moveSpeed = 5f;
    [SerializeField]float rotationSpeed = 10f;

    Quaternion targetRotation;

    CameraController cameraController;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
    }
    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动画

前序准备

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

  1. 找到人物模型,进行如下设置:

1746881639467

应用后点configure查看骨骼映射情况,如果有没匹配上的需要手动调整

1746882155347

最后Done完成

  1. 找到要用到的动画,如下设置:

1746881909962

注意Avatar Definition要选择 从其他avatar复制,然后在source里面选择要应用的avatar

  1. 然后每个动画都进行如下设置:

1746886211629

注意要选择Loop Pose,如果loop match 的话可以不勾选Bake Into Pose

1746886650167

而且几个动画的Length值最好要尽可能接近,以免后面切换的时候出现问题

  1. 新建一个角色控制器的动画脚本

1746883164590

  1. 记得在player的Animator属性里添加这个脚本

万事俱备,下面开始编写动画相关脚本!

Animator组件——动画蓝图

新建一个Blend Tree

1746884538405

拖入对应动画

1746884573800

在PlayerController.cs里写动画播放逻辑

    Animator animator;
    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
    }

Update()方法:

        //把moveAmount限制在0-1之间(混合树的区间)
        float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v));

Blender Tree里moveMount的区间是(0,1)

        #region 角色动画控制
        //角色动画播放
        animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime);

        #endregion

SetFloat()有四个参数的重载,第三个参数是要平滑到达的值

基本的第三人称角色控制器效果如下:

1746887121425

修改后的PlayerController.cs完整代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField]float moveSpeed = 5f;
    [SerializeField]float rotationSpeed = 10f;

    Quaternion targetRotation;

    CameraController cameraController;
    Animator animator;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
    }
    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

玩家:

1746888951094

center、radius、height设置碰撞胶囊体的三维

center通常设置height的一半略多一些

然后center.Z最好向前偏移一个小值,因为大多数能被玩家直观感受到的碰撞发生在角色面前

回到PlayerController.cs脚本,

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
    }

Update()方法:

            //帧同步移动
            //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动
            charactercontroller.Move(moveDir * moveSpeed * Time.deltaTime);
            //transform.position += moveDir * moveSpeed * Time.deltaTime;

原来的直接用 transform.position +=改变位置,换成用 CharacterController.Move()来控制角色的移动,这会使用CharacterController组件的特性。

效果如下:

1746931354281

碰撞检测

PlayerController.cs

    [Header("ground check")]
    [SerializeField]float groundCheckRadius = 0.5f;
    //检测射线偏移量
    [SerializeField]Vector3 groundCheckOffset;
    [SerializeField]LayerMask groundLayer;

    bool isGrounded;

Update()

        #region 碰撞检测
        GroundCheck();
        Debug.Log("isGrounded: "+ isGrounded);

        #endregion

检测函数和画线函数

    private void GroundCheck()
    {
        // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true
        // 位置偏移用来在unity控制台里面调整
        isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer);
    }

    //画检测射线
    private void OnDrawGizmosSelected()
    {
        //射线颜色,最后一个参数是透明度
        Gizmos.color = new Color(0, 1, 0, 0.5f);
        Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius);
    }

回到控制台进行如下设置:

1746934260351

调整到球体覆盖住角色的脚

1746934409546

为plane和其他障碍物添加Layer为Obstacles

1746934423427

重力设置

1746935473123

    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);
        #endregion


y轴方向的位置移动实时更新,即使没有输入也要更新。

效果如下:

1746936573614

下面设置skin width:

官方手册描述:

Character Controller——Skin width

两个碰撞体可以穿透彼此且穿透深度最多为皮肤宽度 (Skin Width)。较大的皮肤宽度可减少抖动。较小的皮肤宽度可能导致角色卡住。合理设置是将此值设为半径的 10%。

1746936785928

Center的更准确的设置:

Center.Y = Height /2 + Skin Width

1746936929220

改之前:

1746937090741

改之后:

1746937118338

可以看到,脚部完全贴合地面,that's good.

有个问题仍然存在:当我们在下落过程中,角色仍在播放走路动画

因此我们需要编写相应的动画逻辑,还可以加下落动画,这个放在后面再写,坑+1

手柄适配

下面改一下对手柄的适配,因为在写摄像机输入控制的时候用的是Mouse:

在Project Settings - Input Manager里面找到Mouse X和Y,分别复制两个副本,重命名为Camera X/Y

1746937520888

对第二个Camera进行如下设置

注意死区和灵敏度最好设置成和下面的Horizontal一样的值

1746937691558

1746937820120

回到CameraController.cs脚本,修改如下即可

        //水平视角控制——鼠标(手柄)x轴控制rotationY
        rotationY += Input.GetAxis("Camera X") * rotationSpeed * invertYValue;
        //垂直视角控制——鼠标(手柄)y轴控制rotationX
        rotationX += Input.GetAxis("Camera Y") * rotationSpeed * invertXValue;

有个小问题:键鼠控制的话就勾上X轴反转,手柄控制就不要勾了

1746938242143

效果就是手柄右摇杆也能控制相机视角了,演示就不放了,没啥区别

以上部分代码我放到了GitHub仓库:

https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section1

ok,可以开始编写最有意思的跑酷系统脚本了!

Day 5 跑酷系统——StepUp&JumpUp

跑酷系统构成:跑酷控制器+环境扫描

1746942439234

环境扫描

Environment Scanner

The environment scanner will scan for obstacles in front of the player by using multiple RayCasts.

1746941442394

放置不同高度的Cube,设置Position.y = Scale.y / 2

EnvironmentScanner.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnvironmentScanner : MonoBehaviour
{
    [Header ("向前发送的射线相关参数")]
    //y轴(竖直方向)偏移量
    [SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);
    //长度
    [SerializeField] float forwardRayLength = 0.8f;
    //障碍物层
    [SerializeField] LayerMask obstacleLayer;
    public void ObstacleCheck()
    {
        //让射线从膝盖位置开始发送
        //射线的起始位置 = 角色位置 + 一个偏移量
        var forwardOrigin = transform.position + forwardRayOffset;
        //用来存射线检测的信息
        RaycastHit hitInfo;
        //是否击中障碍物
        bool hitFound = Physics.Raycast(forwardOrigin, transform.forward, 
                                    out hitInfo,forwardRayLength, obstacleLayer);
        //调试用的射线
        //第二个参数dir:Direction and length of the ray.
        Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength,
                (hitFound) ?Color.red:Color.white);
    }
}

然后在ParkourController.cs里调用ObstacleCheck()方法

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    EnvironmentScanner environmentScanner;

    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
    }
    // Update is called once per frame
    void Update()
    {
        environmentScanner.ObstacleCheck();
    }
}

效果:射线会从膝盖左右的位置射出

1746971480663

下面把检测信息hitData抽象成一个结构体,

EnvironmentScanner.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnvironmentScanner : MonoBehaviour
{
    [Header ("向前发送的射线相关参数")]
    //y轴(竖直方向)偏移量
    [SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);
    //长度
    [SerializeField] float forwardRayLength = 0.8f;
    //障碍物层
    [SerializeField] LayerMask obstacleLayer;
    public ObstacleHitData ObstacleCheck()
    {
        var hitData = new ObstacleHitData();
        //让射线从膝盖位置开始发送
        //射线的起始位置 = 角色位置 + 一个偏移量
        var forwardOrigin = transform.position + forwardRayOffset;
        //是否击中障碍物
        hitData.forwardHitFound = Physics.Raycast(forwardOrigin, transform.forward,
                                    out hitData.forwardHitInfo, forwardRayLength, obstacleLayer);
        //调试用的射线
        //第二个参数dir:Direction and length of the ray.
        Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength,
                (hitData.forwardHitFound)? Color.red : Color.white);
        return hitData;
    }
}

public struct ObstacleHitData
{
    //是否击中障碍物
    public bool forwardHitFound;
    //用来存射线检测的信息
    public RaycastHit forwardHitInfo;
}

ParkourController.cs打印检测信息:击中的障碍物名字

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    EnvironmentScanner environmentScanner;

    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
    }
    // Update is called once per frame
    private void Update()
    {
        var hitData = environmentScanner.ObstacleCheck();
        if (hitData.forwardHitFound)
        {
            //调试用:打印障碍物名称
            Debug.Log("找到障碍:"+ hitData.forwardHitInfo.transform.name);
        }
    }
}

效果如下:

1746972866843

障碍高度检测

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

}

效果如下:

1747010848782

添加跑酷动作——翻越障碍StepUp

加一个动画StepUp

带位移动作的动画需要在角色的Animator组件里面勾选 Apply Root Motion

而且在这个动画实际作用的时候,角色的位置是向上移动的,所以不要勾选Bake Into Pose

1747017405655

在Animator面板里面,只需StepUp->Locomotion,因为StepUp是条件触发,触发结束回到Locomotion,但是Locomotion不需要回到StepUp状态。

1747017659323

ParkourController.cs

    EnvironmentScanner environmentScanner;
    Animator animator;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
    }

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<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
    }

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<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    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;
    }

效果如下:

1747057989364

该部分完整修改代码:

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField]float moveSpeed = 5f;
    [SerializeField]float rotationSpeed = 10f;

    [Header("Ground Check")]
    [SerializeField]float groundCheckRadius = 0.5f;
    //检测射线偏移量
    [SerializeField]Vector3 groundCheckOffset;
    [SerializeField]LayerMask groundLayer;

    //是否在地面
    bool isGrounded;
    //是否拥有控制权:默认拥有控制权,否则角色初始就不受控
    bool hasControl = true;

    float ySpeed;

    Quaternion targetRotation;

    CameraController cameraController;
    Animator animator;
    CharacterController charactercontroller;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
    }
    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<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // Update is called once per frame
    private void Update()
    {
        if (Input.GetButton("Jump") && !inAction)
        {
            //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
            var hitData = environmentScanner.ObstacleCheck();
            if (hitData.forwardHitFound)
            {
                //调试用:打印障碍物名称
                Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);
                //播放动画
                //StartCoroutine()方法:开启一个协程
                //启动 DoParkourAction 协程,播放跑酷动画
                StartCoroutine(DoParkourAction());

            }
        }
    }
    //攀爬动作
    IEnumerator DoParkourAction()
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

        //从当前动画到StepUp动画,平滑过渡0.2s
        //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画
        animator.CrossFade("StepUp", 0.2f);
        //暂停协程,直到下一帧继续执行,确保动画过渡已经开始。
        yield return null;

        //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);
        //暂停协程,直到 "StepUp" 动画播放完毕。
        yield return new WaitForSeconds(animStateInfo.length);

        //启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

}

下面解决该部分穿模现象,在障碍物前面预留一点空间,在遇到更高的障碍物时,取用更匹配的动画

基于障碍物高度自动选用不同的动作——StepUp和JumpUp

(Selecting Parkour Actions Based on Obstacle Height)

新建ParkourAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
    [SerializeField] string animName;
    [SerializeField] float minHeigth;
    [SerializeField] float maxHeigth;
}

然后在unity的右键菜单就可以看到

1747058984694

新建两个跑酷动作

1747064092998

1747064081055

1747059510840

然后剪辑动画,让起始帧和结束帧刚好时想要的状态

起始帧:

1747059445268

结束帧:

1747059468190

接下来的任务就是把之前代码中关于动画的硬编码部分解耦

1747061728967

ParkourAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
    [SerializeField] string animName;
    [SerializeField] float minHeigth;
    [SerializeField] float maxHeigth;

    public bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标
        float height = hitData.heightHitInfo.point.y - player.position.y;
        //只有在这个区间内才会返回true
        if(height < minHeigth || height > maxHeigth)
        {
            return false;
        }
        else
        {
            return true;
        }
    }
    //外部可访问的动画名称
    public string AnimName => animName;
}

ParkourController.cs

    private void Update()
    {
        if (Input.GetButton("Jump") && !inAction)
        {
            //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
            var hitData = environmentScanner.ObstacleCheck();
            if (hitData.forwardHitFound)
            {
                //对于每一个在跑酷动作列表中的跑酷动作
                foreach (var action in parkourActions)
                {
                    //如果动作可行
                    if(action.CheckIfPossible(hitData, transform))
                    {
                        //播放对应动画
                        //StartCoroutine()方法:开启一个协程
                        //启动 DoParkourAction 协程,播放跑酷动画
                        StartCoroutine(DoParkourAction(action));
                    }
                }
                //调试用:打印障碍物名称
                Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);

            }
        }
    }
    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

        //从当前动画到指定的目标动画,平滑过渡0.2s
        //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画
        animator.CrossFade(action.AnimName, 0.2f);
        //暂停协程,直到下一帧继续执行,确保动画过渡已经开始。
        yield return null;

        //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        #region 调试用
        if (!animStateInfo.IsName(action.AnimName))
        {
            Debug.LogError("动画名称不匹配!");
        }
        #endregion

        //暂停协程,直到 "StepUp" 动画播放完毕。
        yield return new WaitForSeconds(animStateInfo.length);

        //启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

回到unity面板

1747063999106

效果如下:

1747071382741

修改后的完整代码:

ParkourAction.cs上面放了

ParkourController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    //定义一个面板可见的跑酷动作属性列表
    [SerializeField] List<ParkourAction> parkourActions;

    EnvironmentScanner environmentScanner;
    Animator animator;
    PlayerController playerController;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // Update is called once per frame
    private void Update()
    {
        if (Input.GetButton("Jump") && !inAction)
        {
            //调试用的射线也只会在if满足的时候触发
            //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
            var hitData = environmentScanner.ObstacleCheck();
            if (hitData.forwardHitFound)
            {
                //对于每一个在跑酷动作列表中的跑酷动作
                foreach (var action in parkourActions)
                {
                    //如果动作可行
                    if(action.CheckIfPossible(hitData, transform))
                    {
                        //播放对应动画
                        //StartCoroutine()方法:开启一个协程
                        //启动 DoParkourAction 协程,播放跑酷动画
                        StartCoroutine(DoParkourAction(action));
                        //跳出循环
                        break;
                    }
                }
                //调试用:打印障碍物名称
                //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);

            }
        }
    }
    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

        //从当前动画到指定的目标动画,平滑过渡0.2s
        //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画
        animator.CrossFade(action.AnimName, 0.2f);
        ////暂停协程,直到下一帧继续执行,确保动画过渡已经开始。
        //多等一会儿?
        yield return new WaitForSeconds(0.2f);

        //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        #region 调试用
        if (!animStateInfo.IsName(action.AnimName))
        {
            Debug.LogError("动画名称不匹配!");
        }
        #endregion

        //暂停协程,直到 "StepUp" 动画播放完毕。
        yield return new WaitForSeconds(animStateInfo.length);

        //启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

}

下面解决当角色侧身对着障碍物时,让角色平滑旋转以朝向障碍物

需要平滑旋转以朝向障碍物 的动作

ParkourAction.cs

    [Header ("自主勾选该动作是否需要转向障碍物")]
    [SerializeField] bool rotateToObstacle;

    //目标旋转量
    public Quaternion TargetRotation { get; set; }

CheckIfPossible()

//如果需要转向障碍物,才会计算目标旋转量
        if (rotateToObstacle)
        {
            //目标旋转量 = 障碍物法线的反方向normal
            TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal);
        }
    public bool RotateToObstacle => rotateToObstacle;

PlayerController.cs

    //让rotationSpeed可以被外部访问
    public float RotationSpeed => rotationSpeed;

ParkourController.cs

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物
            if (action.RotateToObstacle)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,action.TargetRotation, playerController.RotationSpeed * Time.deltaTime);
            }

            yield return null;
        }

action组件JumpUp勾选该动作播放期间需要转向障碍物

1747134080273

效果如下:

1747133704929

该部分修改的完整代码:

ParkourAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
    //动画名称
    [SerializeField] string animName;
    //高度区间
    [SerializeField] float minHeigth;
    [SerializeField] float maxHeigth;

    [Header ("自主勾选该动作是否需要转向障碍物")]
    [SerializeField] bool rotateToObstacle;

    //目标旋转量
    public Quaternion TargetRotation { 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);
        }

        return true;

    }
    //外部可访问的动画名称
    public string AnimName => animName;
  
    public bool RotateToObstacle => rotateToObstacle;
}

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField]float moveSpeed = 5f;
    [SerializeField]float rotationSpeed = 500f;

    [Header("Ground Check")]
    [SerializeField]float groundCheckRadius = 0.5f;
    //检测射线偏移量
    [SerializeField]Vector3 groundCheckOffset;
    [SerializeField]LayerMask groundLayer;

    //是否在地面
    bool isGrounded;
    //是否拥有控制权:默认拥有控制权,否则角色初始就不受控
    bool hasControl = true;

    float ySpeed;

    Quaternion targetRotation;

    CameraController cameraController;
    Animator animator;
    CharacterController charactercontroller;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
    }
    private void Update()
    {
        #region 角色输入控制
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        //把moveAmount限制在0-1之间(混合树的区间)
        float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v));

        //标准化 moveInput 向量
        var moveInput = new Vector3(h, 0, v).normalized;

        //让人物移动方向关联相机的朝向
        var moveDir = cameraController.PlanarRotation * moveInput;

        //如果没有控制权,后面的碰撞检测就不执行了
        if(!hasControl){
            return;
        }

        #region 地面检测
        GroundCheck();

        #endregion

        if (isGrounded)
        {
            //设置一个较小的负值,让角色在地上的时候被地面吸住
            ySpeed = -0.5f;
        }
        else
        {
            //在空中时,角色的速度由ySpeed决定
            ySpeed += Physics.gravity.y * Time.deltaTime;
        }

        var velocity = moveDir * moveSpeed;
        velocity.y = ySpeed;
        //帧同步移动
        //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动
        charactercontroller.Move(velocity * Time.deltaTime);

        //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向
        //没有输入就不更新转向,也就不会回到初始朝向
        if (moveAmount > 0)
        {
            //人物模型转起来:让人物朝向与移动方向一致
            targetRotation = Quaternion.LookRotation(moveDir);
        }
        //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向
        transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation,
                         rotationSpeed * Time.deltaTime);
        #endregion

        #region 角色动画控制
        //设置人物动画参数moveAmount
        animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime);

        #endregion


    }

    //地面检测
    private void GroundCheck()
    {
        // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true
        // 位置偏移用来在unity控制台里面调整
        isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer);
    }

    //角色控制
    public void SetControl(bool hasControl){
        //传参给 hasControl 私有变量
        this.hasControl = hasControl;
        //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件
        //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动
        charactercontroller.enabled = hasControl;

        //如果角色控制权被禁用,则更新动画参数和朝向
        if (!hasControl)
        {
            //更新动画参数
            animator.SetFloat("moveAmount", 0f);
            //更新朝向
            targetRotation = transform.rotation;

        }
    }

    //画检测射线
    private void OnDrawGizmosSelected()
    {
        //射线颜色,最后一个参数是透明度
        Gizmos.color = new Color(0, 1, 0, 0.5f);
        Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius);
    }

    //让rotationSpeed可以被外部访问
    public float RotationSpeed => rotationSpeed;

}

ParkourController.cs

using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    //定义一个面板可见的跑酷动作属性列表
    [SerializeField] List<ParkourAction> parkourActions;

    EnvironmentScanner environmentScanner;
    Animator animator;
    PlayerController playerController;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // Update is called once per frame
    private void Update()
    {
        if (Input.GetButton("Jump") && !inAction)
        {
            //调试用的射线也只会在if满足的时候触发
            //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
            var hitData = environmentScanner.ObstacleCheck();
            if (hitData.forwardHitFound)
            {
                //对于每一个在跑酷动作列表中的跑酷动作
                foreach (var action in parkourActions)
                {
                    //如果动作可行
                    if(action.CheckIfPossible(hitData, transform))
                    {
                        //播放对应动画
                        //StartCoroutine()方法:开启一个协程
                        //启动 DoParkourAction 协程,播放跑酷动画
                        StartCoroutine(DoParkourAction(action));
                        //跳出循环
                        break;
                    }
                }
                //调试用:打印障碍物名称
                //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);

            }
        }
    }
    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

        //从当前动画到指定的目标动画,平滑过渡0.2s
        //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画
        animator.CrossFade(action.AnimName, 0.2f);
        ////暂停协程,直到下一帧继续执行,确保动画过渡已经开始。
        //多等一会儿?
        yield return new WaitForSeconds(0.2f);

        //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        #region 调试用
        if (!animStateInfo.IsName(action.AnimName))
        {
            Debug.LogError("动画名称不匹配!");
        }
        #endregion

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物
            if (action.RotateToObstacle)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,action.TargetRotation, playerController.RotationSpeed * Time.deltaTime);
            }

            yield return null;
        }


        //启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

}

动作目标匹配技术

target matching

通常在游戏中可能出现以下情况:角色必须以某种方式移动,使得手或脚在某个时间落在某个地方。例如,角色可能需要跳过踏脚石或跳跃并抓住顶梁。

您可以使用 Animator.MatchTarget 函数来处理此类情况。

在ParkourAction脚本里面加Animator.MatchTarget 函数需要的参数,用于面板传参

ParkourAction.cs

    [Header("Target Matching")]
    [SerializeField] bool enableTargetMatching;
    [SerializeField] AvatarTarget matchBodyPart;
    [SerializeField] float matchStartTime;
    [SerializeField] float matchTargetTime;
    //匹配的位置
    public Vector3 MatchPosition { get; set; }
        //如果需要匹配位置,才会计算匹配的位置
        if (enableTargetMatching)
        {
            //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息
            MatchPosition = hitData.heightHitInfo.point;
        }
        Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y);        //如果需要匹配位置,才会计算匹配的位置
        if (enableTargetMatching)
        {
            //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息
            MatchPosition = hitData.heightHitInfo.point;
        }
        Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y);
    //外部可访问的属性
    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

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);
    }

这里我们匹配角色右脚(先抬起)就行,

首先,在动画剪辑中找到角色右脚开始离地的位置

1747145691011

再找到即将落地的位置

1747145598801

把标准化结果填入action里

1747145732055

另一个动画同样如此操作:

1747145926797

1747145885774

1747146117954

其实只要结束的时间MatchTargetTime弄准确了就好,开始时间大概就行

有个问题需要特别注意的:

如果出现了动画播放后角色的脚位置莫名浮动一下,需要在动画里面勾选如下选项

1747147073150

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

1747146800615

完整的修改代码如下:

ParkourAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
    //动画名称
    [SerializeField] string animName;
    //高度区间
    [SerializeField] float minHeigth;
    [SerializeField] float maxHeigth;

    [Header ("自主勾选该动作是否需要转向障碍物")]
    [SerializeField] bool rotateToObstacle;

    [Header("Target Matching")]
    [SerializeField] bool enableTargetMatching;
    [SerializeField] AvatarTarget matchBodyPart;
    [SerializeField] float matchStartTime;
    [SerializeField] float matchTargetTime;


    //目标旋转量
    public Quaternion TargetRotation { get; set; }
    //匹配的位置
    public Vector3 MatchPosition { get; set; }

    public bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标
        float height = hitData.heightHitInfo.point.y - player.position.y;
        //只有在这个区间内才会返回true
        if(height < minHeigth || height > maxHeigth)
        {
            return false;
        }

        //如果需要转向障碍物,才会计算目标旋转量
        if (rotateToObstacle)
        {
            //目标旋转量 = 障碍物法线的反方向normal
            TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal);
        }

        //如果需要匹配位置,才会计算匹配的位置
        if (enableTargetMatching)
        {
            //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息
            MatchPosition = hitData.heightHitInfo.point;
        }
        Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y);
        return true;

    }
    //外部可访问的属性
    public string AnimName => animName;
    public bool RotateToObstacle => rotateToObstacle;
    public bool EnableTargetMatching => enableTargetMatching;
    public AvatarTarget MatchBodyPart => matchBodyPart;
    public float MatchStartTime => matchStartTime;
    public float MatchTargetTime => matchTargetTime;


}

ParkourController.cs

using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    //定义一个面板可见的跑酷动作属性列表
    [SerializeField] List<ParkourAction> parkourActions;

    EnvironmentScanner environmentScanner;
    Animator animator;
    PlayerController playerController;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // Update is called once per frame
    private void Update()
    {
        if (Input.GetButton("Jump") && !inAction)
        {
            //调试用的射线也只会在if满足的时候触发
            //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
            var hitData = environmentScanner.ObstacleCheck();
            if (hitData.forwardHitFound)
            {
                //对于每一个在跑酷动作列表中的跑酷动作
                foreach (var action in parkourActions)
                {
                    //如果动作可行
                    if(action.CheckIfPossible(hitData, transform))
                    {
                        //播放对应动画
                        //StartCoroutine()方法:开启一个协程
                        //启动 DoParkourAction 协程,播放跑酷动画
                        StartCoroutine(DoParkourAction(action));
                        //跳出循环
                        break;
                    }
                }
                //调试用:打印障碍物名称
                //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);

            }
        }
    }
    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

        //从当前动画到指定的目标动画,平滑过渡0.2s
        //CrossFade()方法:平滑地从当前动画过渡到指定的目标动画
        animator.CrossFade(action.AnimName, 0.2f);
        ////暂停协程,直到下一帧继续执行,确保动画过渡已经开始。
        yield return null;

        //第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        //#region 调试用
        //if (!animStateInfo.IsName(action.AnimName))
        //{
        //    Debug.LogError("动画名称不匹配!");
        //}
        //#endregion

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物RotateToObstacle
            if (action.RotateToObstacle)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,action.TargetRotation, 
                                                        playerController.RotationSpeed * Time.deltaTime);
            }
            //如果勾选目标匹配EnableTargetMatching
            if (action.EnableTargetMatching)
            {
                MatchTarget(action);
            }

            yield return null;
        }


        //启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

    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);
    }

}

至此,可以用翻越动作越过的低矮障碍物的前两项跑酷已基本完成:StepUp和JumpUp

下面开始实现攀爬翻越,也就是蹬墙然后翻越

Day6 跑酷系统——ClimbUp & VaultFence

这两个动作都添加到ParkourActions动作列表

攀爬翻越ClimbUp

miamo上面没有找到完整的ClimbUp,所以用WallClimb + Crouched To Standing组合来完成这个动画,并且剪辑动画只保留必要的部分

1747182118311

1747182254052

1747188708888

Action脚本设置如下:

1747188654998

1747183175349

注意这个动作选右手。

Animator

1747183248346

注意名字。

而且由于脚本里,MatchTarget方法的 MatchTargetWeightMask(new Vector3(0, 1, 0), 0)只匹配了Y轴,所以人物并不会匹配到障碍物的边缘,但如果写成new Vector3(0, 1, 1), 0,虽然能够匹配Z轴,但是又出现了新的问题,当进行前面的StepUp和JumpUp动作的时候,明显不匹配Z轴更好,因为人物翻跃后不应该只落在障碍物边缘。

因此,这里需要加一个单独的MatchTargetWeightMask参数用来让不同的动画选用不同的匹配方案。

ParkourAction.cs

    [SerializeField] Vector3 matchPositionXYZWeight = new Vector3(0, 1, 0);

    public Vector3 MatchPositionXYZWeight => matchPositionXYZWeight;

ParkourController.cs

MatchTarget()

        animator.MatchTarget(action.MatchPosition, transform.rotation, action.MatchBodyPart, 
                        new MatchTargetWeightMask(action.MatchPositionXYZWeight, 0), action.MatchStartTime, action.MatchTargetTime);

这里会报一些目标匹配的warning,可以加一个判断:动画过渡是否结束

            //只有当不在过渡状态时才执行目标匹配
            if (action.EnableTargetMatching && !animator.IsInTransition(0))
            {
                MatchTarget(action);
            }
        //只有在不匹配和不在过渡状态的时候才会调用
        if (animator.isMatchingTarget || animator.IsInTransition(0))
        {
            return;
        }

效果如下:

1747189579277

该部分修改的完整代码

ParkourAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
    //动画名称
    [SerializeField] string animName;
    //高度区间
    [SerializeField] float minHeigth;
    [SerializeField] float maxHeigth;

    [Header ("自主勾选该动作是否需要转向障碍物")]
    [SerializeField] bool rotateToObstacle;

    [Header("Target Matching")]
    [SerializeField] bool enableTargetMatching;
    [SerializeField] AvatarTarget matchBodyPart;
    [SerializeField] float matchStartTime;
    [SerializeField] float matchTargetTime;
    [SerializeField] Vector3 matchPositionXYZWeight = new Vector3(0, 1, 0);


    //目标旋转量
    public Quaternion TargetRotation { get; set; }
    //匹配的位置
    public Vector3 MatchPosition { get; set; }

    public bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标
        float height = hitData.heightHitInfo.point.y - player.position.y;
        //只有在这个区间内才会返回true
        if(height < minHeigth || height > maxHeigth)
        {
            return false;
        }

        //如果需要转向障碍物,才会计算目标旋转量
        if (rotateToObstacle)
        {
            //目标旋转量 = 障碍物法线的反方向normal
            TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal);
        }

        //如果需要匹配位置,才会计算匹配的位置
        if (enableTargetMatching)
        {
            //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息
            MatchPosition = hitData.heightHitInfo.point;
        }
        Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y);
        return true;

    }
    //外部可访问的属性
    public string AnimName => animName;
    public bool RotateToObstacle => rotateToObstacle;
    public bool EnableTargetMatching => enableTargetMatching;
    public AvatarTarget MatchBodyPart => matchBodyPart;
    public float MatchStartTime => matchStartTime;
    public float MatchTargetTime => matchTargetTime;
    public Vector3 MatchPositionXYZWeight => matchPositionXYZWeight;


}

ParkourController.cs

using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    //定义一个面板可见的跑酷动作属性列表
    [SerializeField] List<ParkourAction> parkourActions;

    EnvironmentScanner environmentScanner;
    Animator animator;
    PlayerController playerController;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // Update is called once per frame
    private void Update()
    {
        if (Input.GetButton("Jump") && !inAction)
        {
            //调试用的射线也只会在if满足的时候触发
            //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
            var hitData = environmentScanner.ObstacleCheck();
            if (hitData.forwardHitFound)
            {
                //对于每一个在跑酷动作列表中的跑酷动作
                foreach (var action in parkourActions)
                {
                    //如果动作可行
                    if(action.CheckIfPossible(hitData, transform))
                    {
                        //播放对应动画
                        //StartCoroutine()方法:开启一个协程
                        //启动 DoParkourAction 协程,播放跑酷动画
                        StartCoroutine(DoParkourAction(action));
                        //跳出循环
                        break;
                    }
                }
                //调试用:打印障碍物名称
                //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);

            }
        }
    }
    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

        //从当前动画到指定的目标动画,平滑过渡0.2s
        animator.CrossFade(action.AnimName, 0.2f);

        // 等待过渡完成
        yield return new WaitForSeconds(0.3f); // 给足够时间让过渡完成,稍微大于CrossFade的过渡时间

        // 现在获取动画状态信息
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        //#region 调试用
        //if (!animStateInfo.IsName(action.AnimName))
        //{
        //    Debug.LogError("动画名称不匹配!");
        //}
        //#endregion

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物RotateToObstacle
            if (action.RotateToObstacle)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,action.TargetRotation, 
                                                        playerController.RotationSpeed * Time.deltaTime);
            }
            //如果勾选目标匹配EnableTargetMatching
            //只有当不在过渡状态时才执行目标匹配
            if (action.EnableTargetMatching && !animator.IsInTransition(0))
            {
                MatchTarget(action);
            }

            yield return null;
        }


        //启用玩家控制
        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);
    }

}

撑物翻跃VaultFence

只需给障碍物加一个Fence的tag的判断

1747190548922

1747190468513

1747190514266

1747190934664

VaultFence的Actions优先级要高于JumpUp,不然就直接进入JumpUp了

1747190751791

ParkourAction.cs

    //对应的障碍物Tag
    [SerializeField] string obstacleTag;
    public bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        //障碍物Tag
        //如果Tag填写了字段且不匹配,false
        if(!string.IsNullOrEmpty(obstacleTag) && hitData.forwardHitInfo.collider.tag != obstacleTag){
            return false;
        }
	...
    }

滑步现象

滑步现象的解决方案

对于一些动作,过渡阶段会被输入控制打断,会产生滑步

这时候给一个延迟,让过渡阶段的动画也播放完。

对于ClimbUp动作,第二阶段就是CrouchToStand

所以在启用角色控制前,加一个延迟函数:

        yield return new WaitForSeconds(action.ActionDelay);
        //延迟结束后才启用玩家控制
        playerController.SetControl(true);

其他的动作都可以设定一个小的ActionDelay值用来解决滑步

这个方案的缺点:会造成输入延迟,也就是动作粘滞感

该部分修改的完整代码:

ParkourAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
    //动画名称
    [SerializeField] string animName;
    //对应的障碍物Tag
    [SerializeField] string obstacleTag;

    [Header("高度区间")]
    [SerializeField] float minHeigth;
    [SerializeField] float maxHeigth;

    [Header ("自主勾选该动作是否需要转向障碍物")]
    [SerializeField] bool rotateToObstacle;
    [Header("动作播放后的延迟")]
    [SerializeField] float actionDelay;
    [Header("Target Matching")]
    [SerializeField] bool enableTargetMatching;
    [SerializeField] AvatarTarget matchBodyPart;
    [SerializeField] float matchStartTime;
    [SerializeField] float matchTargetTime;
    [SerializeField] Vector3 matchPositionXYZWeight = new Vector3(0, 1, 0);


    //目标旋转量
    public Quaternion TargetRotation { get; set; }
    //匹配的位置
    public Vector3 MatchPosition { get; set; }

    //动作执行前的检查
    //主要是找false
    public 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;  
}

VaultFence的优化——镜像动画

更加符合现实的情况是:
如果从障碍物的左边沿开始撑手翻越,动画应该镜像翻转并target matching右手
1747215623422
如果从障碍物的右边沿开始撑手翻越,动画则不镜像翻转并target matching左手

fine,开始修改相应脚本:

新建一个脚本VaultAction.cs继承ParkourAction
在ParkourAction中将CheckIfPossible()设置为虚函数,并在VaultAction中重写

ParkourAction.cs

    public virtual bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        ...
    }

可以对带有Fence标签的障碍物进行边沿判断,以水平方向中心位置为0,小于0就是左边沿,大于0就是右边沿
1747218986408

注意Fence的坐标系应该是这样的:

1747246708332

在局部空间坐标系中,

如果击中点的

z<0 && x<0 或者 z>0 && x>0 就镜像,跟踪右手

反之就不镜像,跟踪左手

所以还需要一个标志位 Mirror

并且在VaultFence的动画里,Mirror是否镜像动画可以由一个自定义参数决定,参数名这里设置为 mirrorAction

1747247444478

1747249889889

1747249882216

ParkourAction.cs里设置标志位参量 Mirror,在VaultAction里赋值 Mirror,再去ParkourController里SetBool赋值给animator动画组件的参量 mirrorAction

ParkourAction.cs

    //动作镜像
    public bool Mirror { get; set; }

VaultAction.cs里编写 MirrormatchBodyPart的赋值逻辑

public class VaultAction : ParkourAction
{
    public override bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        // 虚函数原有逻辑不变
        if(!base.CheckIfPossible(hitData, player)){
            return false;
        }
        // 增加额外的检查条件
        //击中点从全局坐标空间转到Fence上的局部坐标空间
        var hitPointFence = hitData.forwardHitInfo.transform.InverseTransformPoint(hitData.forwardHitInfo.point);
        if( (hitPointFence.z < 0 && hitPointFence.x < 0) || (hitPointFence.z > 0 && hitPointFence.x > 0) ){
            //左边沿,镜像,跟踪右手
            Mirror = true;
            matchBodyPart = AvatarTarget.RightHand;

        }
        else{
            //右边沿,不镜像,跟踪左手
            Mirror = false;
            matchBodyPart = AvatarTarget.LeftHand;
        }
        return true;
    }
}

注意:matchBodyPart需要在ParkourAction.cs里面加上访问修饰符 protected,让子类VaultAction.cs能够访问

    [SerializeField] protected AvatarTarget matchBodyPart;  //在内部和子类可访问

ParkourController.cs里赋值给动画组件animator

    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        ...

        //设置动画是否镜像
        animator.SetBool("mirrorAction", action.Mirror);
	...
    }

然后需要新建一个VaultAction脚本的对象,

VaultAction.cs

// 和ParkourAction一样,可以通过右键菜单新建脚本相应的对象
[CreateAssetMenu(menuName = "Parkour System/Custom Actions/New Vault Action")]

1747251537260

参数和VaultFence一样,但是是VaultAction类的对象

记得更新Player的面板参数:

1747251252157

大功告成!效果如下:

1747251888930

完整修改代码如下:

ParkourController.cs

using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    //定义一个面板可见的跑酷动作属性列表
    [SerializeField] List<ParkourAction> parkourActions;

    EnvironmentScanner environmentScanner;
    Animator animator;
    PlayerController playerController;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // Update is called once per frame
    private void Update()
    {
        if (Input.GetButton("Jump") && !inAction && playerController.isGrounded)
        {
            //调试用的射线也只会在if满足的时候触发
            //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
            var hitData = environmentScanner.ObstacleCheck();
            if (hitData.forwardHitFound)
            {
                //对于每一个在跑酷动作列表中的跑酷动作
                foreach (var action in parkourActions)
                {
                    //如果动作可行
                    if(action.CheckIfPossible(hitData, transform))
                    {
                        //播放对应动画
                        //StartCoroutine()方法:开启一个协程
                        //启动 DoParkourAction 协程,播放跑酷动画
                        StartCoroutine(DoParkourAction(action));
                        //跳出循环
                        break;
                    }
                }
                //调试用:打印障碍物名称
                //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);

            }
        }
    }
    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

        //设置动画是否镜像
        animator.SetBool("mirrorAction", action.Mirror);

        //从当前动画到指定的目标动画,平滑过渡0.2s
        animator.CrossFade(action.AnimName, 0.2f);

        // 等待过渡完成
        yield return new WaitForSeconds(0.3f); // 给足够时间让过渡完成,稍微大于CrossFade的过渡时间

        // 现在获取动画状态信息
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        //#region 调试用
        //if (!animStateInfo.IsName(action.AnimName))
        //{
        //    Debug.LogError("动画名称不匹配!");
        //}
        //#endregion

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物RotateToObstacle
            if (action.RotateToObstacle)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,action.TargetRotation, 
                                                        playerController.RotationSpeed * Time.deltaTime);
            }
            //如果勾选目标匹配EnableTargetMatching
            //只有当不在过渡状态时才执行目标匹配
            if (action.EnableTargetMatching && !animator.IsInTransition(0))
            {
                MatchTarget(action);
            }

            //过渡动画完全播完就停止该动作播放
            if(animator.IsInTransition(0) && timer > 0.5f){
                break;
            }

            yield return null;
        }
        //对于一些组合动作,第一阶段播放完后就会被输入控制打断,这时候给一个延迟,让第二阶段的动画也播放完
        //对于ClimbUp动作,第二阶段就是CrouchToStand
        yield return new WaitForSeconds(action.ActionDelay);
        //延迟结束后才启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

    //目标匹配
    void MatchTarget(ParkourAction action)
    {
        //只有在不匹配和不在过渡状态的时候才会调用
        if (animator.isMatchingTarget || animator.IsInTransition(0))
        {
            return;
        }
        //调用unity自带的MatchTarget方法
        animator.MatchTarget(action.MatchPosition, transform.rotation, action.MatchBodyPart, 
                        new MatchTargetWeightMask(action.MatchPositionXYZWeight, 0), action.MatchStartTime, action.MatchTargetTime);
    }

}

ParkourAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
    //动画名称
    [SerializeField] string animName;
    //对应的障碍物Tag
    [SerializeField] string obstacleTag;

    [Header("高度区间")]
    [SerializeField] float minHeigth;
    [SerializeField] float maxHeigth;

    [Header ("自主勾选该动作是否需要转向障碍物")]
    [SerializeField] bool rotateToObstacle;
    [Header("动作播放后的延迟")]
    [SerializeField] float actionDelay;
    [Header("Target Matching")]
    [SerializeField] bool enableTargetMatching = true;
    [SerializeField] protected AvatarTarget matchBodyPart;  //在内部和子类可访问
    [SerializeField] float matchStartTime;
    [SerializeField] float matchTargetTime;
    [SerializeField] Vector3 matchPositionXYZWeight = new Vector3(0, 1, 0);


    //目标旋转量
    public Quaternion TargetRotation { get; set; }
    //匹配的位置
    public Vector3 MatchPosition { get; set; }
    //动作镜像
    public bool Mirror { get; set; }

    //动作执行前的检查————这是一个虚函数,在子类中可覆盖
    //主要是找false
    public virtual bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        //障碍物Tag
        //如果Tag填写了字段且不匹配,false
        if(!string.IsNullOrEmpty(obstacleTag) && hitData.forwardHitInfo.collider.tag != obstacleTag){
            return false;
        }
        //高度Tag
        //如果高度不在区间内,false
        //获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标
        float height = hitData.heightHitInfo.point.y - player.position.y;
        if(height < minHeigth || height > maxHeigth)
        {
            return false;
        }

        //如果需要转向障碍物,才会计算目标旋转量
        if (rotateToObstacle)
        {
            //目标旋转量 = 障碍物法线的反方向normal
            TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal);
        }

        //如果需要匹配位置,才会计算匹配的位置
        if (enableTargetMatching)
        {
            //heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息
            MatchPosition = hitData.heightHitInfo.point;
        }
        Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y);
        return true;

    }
    //外部可访问的属性
    public string AnimName => animName;
    public bool RotateToObstacle => rotateToObstacle;
    public bool EnableTargetMatching => enableTargetMatching;
    public AvatarTarget MatchBodyPart => matchBodyPart;
    public float MatchStartTime => matchStartTime;
    public float MatchTargetTime => matchTargetTime;
    public Vector3 MatchPositionXYZWeight => matchPositionXYZWeight;
    public float ActionDelay => actionDelay;  
}

VaultAction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


// 和ParkourAction一样,可以通过右键菜单新建脚本相应的对象
[CreateAssetMenu(menuName = "Parkour System/Custom Actions/New Vault Action")]
public class VaultAction : ParkourAction
{
    public override bool CheckIfPossible(ObstacleHitData hitData, Transform player)
    {
        // 虚函数原有逻辑不变
        if(!base.CheckIfPossible(hitData, player)){
            return false;
        }
        // 增加额外的检查条件
        //击中点从全局坐标空间转到Fence上的局部坐标空间
        var hitPointFence = hitData.forwardHitInfo.transform.InverseTransformPoint(hitData.forwardHitInfo.point);
        if( (hitPointFence.z < 0 && hitPointFence.x < 0) || (hitPointFence.z > 0 && hitPointFence.x > 0) ){
            //左边沿,镜像,跟踪右手
            Mirror = true;
            matchBodyPart = AvatarTarget.RightHand;

        }
        else{
            //右边沿,不镜像,跟踪左手
            Mirror = false;
            matchBodyPart = AvatarTarget.LeftHand;
        }
        return true;
    }
}

下面开始实现沿着地面外沿行走时的防跌落机制、从高处跳下时的动画

Day7 从悬崖跳落——JumpDown From The Ledges

悬崖边沿Ledge检测

EnvironmentScanner.cs

    [Header("悬崖Ledge检测——向下发送的射线相关参数")]
    //向下发射的射线的长度
    [SerializeField] float ledgeRayLength = 10f;
    //悬崖的高度阈值
    [SerializeField] float ledgeHeightThreshold = 0.75f;
    //检测是否在悬崖边缘
    public bool LedgeCheck(Vector3 moveDir)
    {
        //只有移动才会检测Ledge
        if (moveDir == Vector3.zero)
            return false;

        //起始位置向前偏移量
        var originOffset = moveDir * 0.5f;
        //检测射线的起始位置
        var origin = transform.position + originOffset + Vector3.up;    //起始位置不要在脚底,悬崖和和脚在同一高度,可能会检测不到,向上偏移一些
        //射线向下发射是否击中:击中点在地面位置,赋值给hitGround
        if (Physics.Raycast(origin, Vector3.down, out RaycastHit hitGround, ledgeRayLength, obstacleLayer))
        {
            //调试用的射线
            Debug.DrawRay(origin, Vector3.down * ledgeRayLength, Color.green);
            //计算当前位置高度 = 角色位置高度 - 击中点高度
            float height = transform.position.y - hitGround.point.y;
            //超过这个悬崖高度阈值,才会认为是悬崖边缘
            if (height > ledgeHeightThreshold)
            {
                return true;
            }
        }
        return false;
    }

回到PlayerController.cs调用这个检测

    EnvironmentScanner environmentScanner;
        //环境扫描器
        environmentScanner = GetComponent<EnvironmentScanner>();
    //是否在悬崖边沿上
    public bool IsOnLedge { get; set; }
        if (isGrounded)
        {
            //设置一个较小的负值,让角色在地上的时候被地面吸住
            ySpeed = -0.5f;
            //在地上的时候进行悬崖检测,传给isOnLedge变量
            IsOnLedge = environmentScanner.LedgeCheck(moveDir);
            #region 调试用
            if (IsOnLedge)
            {
                Debug.Log("On Ledge");
            }
            #endregion
        }

效果如下:

1747308683266

1747308693704

该部分完整修改代码:

EnvironmentScanner.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnvironmentScanner : MonoBehaviour
{
    [Header ("障碍物检测——向前发送的射线相关参数")]
    //y轴(竖直方向)偏移量
    [SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);
    //长度
    [SerializeField] float forwardRayLength = 0.8f;
    //从击中点向上发射的射线的高度
    [SerializeField] float heightRayLength = 5f;

    [Header("悬崖Ledge检测——向下发送的射线相关参数")]
    //向下发射的射线的长度
    [SerializeField] float ledgeRayLength = 10f;
    //悬崖的高度阈值
    [SerializeField] float ledgeHeightThreshold = 0.75f;

    [Header("LayerMask")]
    //障碍物层
    [SerializeField] LayerMask obstacleLayer;

    public ObstacleHitData ObstacleCheck()
    {
        var hitData = new ObstacleHitData();
        //让射线从膝盖位置开始发送
        //射线的起始位置 = 角色位置 + 一个偏移量
        var forwardOrigin = transform.position + forwardRayOffset;
        //射线向前发送是否击中障碍物:击中点在障碍物上,赋值给hitData.forwardHitInfo
        hitData.forwardHitFound = Physics.Raycast(forwardOrigin, transform.forward,
                                    out hitData.forwardHitInfo, forwardRayLength, obstacleLayer);
        //调试用的射线
        //第二个参数dir:Direction and length of the ray.
        Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength,
                (hitData.forwardHitFound)? Color.red : Color.white);
  
        //如果击中,则从击中点上方高度heightRayLength向下发射的射线
        if(hitData.forwardHitFound){
            var heightOrigin = hitData.forwardHitInfo.point + Vector3.up * heightRayLength;
            hitData.heightHitFound = Physics.Raycast(heightOrigin,Vector3.down, 
                                    out hitData.heightHitInfo, heightRayLength, obstacleLayer);
            //调试用的射线
            //第二个参数dir:Direction and length of the ray.
            Debug.DrawRay(heightOrigin, Vector3.down * heightRayLength,
                    (hitData.heightHitFound)? Color.red : Color.white);
        }
  
        return hitData;
    }

    //检测是否在悬崖边缘
    public bool LedgeCheck(Vector3 moveDir)
    {
        //只有移动才会检测Ledge
        if (moveDir == Vector3.zero)
            return false;

        //起始位置向前偏移量
        var originOffset = moveDir * 0.5f;
        //检测射线的起始位置
        var origin = transform.position + originOffset + Vector3.up;    //起始位置不要在脚底,悬崖和和脚在同一高度,可能会检测不到,向上偏移一些
        //射线向下发射是否击中:击中点在地面位置,赋值给hitGround
        if (Physics.Raycast(origin, Vector3.down, out RaycastHit hitGround, ledgeRayLength, obstacleLayer))
        {
            //调试用的射线
            Debug.DrawRay(origin, Vector3.down * ledgeRayLength, Color.green);
            //计算当前位置高度 = 角色位置高度 - 击中点高度
            float height = transform.position.y - hitGround.point.y;
            //超过这个悬崖高度阈值,才会认为是悬崖边缘
            if (height > ledgeHeightThreshold)
            {
                return true;
            }
        }
        return false;
    }
}

public struct ObstacleHitData
{
    #region 从角色膝盖出发的向前射线检测相关
    //是否击中障碍物
    public bool forwardHitFound;
    //用来存射线检测的信息
    public RaycastHit forwardHitInfo;
    #endregion
    #region 从击中点垂直方向发射的射线检测相关
    public bool heightHitFound;
    //用来存射该射线向下击中障碍物的检测信息
    public RaycastHit heightHitInfo;

    #endregion

}

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField]float moveSpeed = 5f;
    [SerializeField]float rotationSpeed = 500f;

    [Header("Ground Check")]
    [SerializeField]float groundCheckRadius = 0.5f;
    //检测射线偏移量
    [SerializeField]Vector3 groundCheckOffset;
    [SerializeField]LayerMask groundLayer;

    //是否在地面
    public bool isGrounded;
    //是否拥有控制权:默认拥有控制权,否则角色初始就不受控
    bool hasControl = true;
    //是否在悬崖边沿上
    public bool IsOnLedge { get; set; }

    float ySpeed;

    Quaternion targetRotation;

    CameraController cameraController;
    Animator animator;
    CharacterController charactercontroller;
    EnvironmentScanner environmentScanner;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
        //环境扫描器
        environmentScanner = GetComponent<EnvironmentScanner>();
    }
    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一样需要组合动画来实现:

起跳 + 空中下落 + 着陆

1747315753730

1747319762515

1747319704021

注意:

Falling 要勾选LoopPose,动画循环

起跳和着陆动画选择Y轴变化跟踪Feet,这样可以避免起跳和着陆时脚部的y轴位置浮动

Animator如图设置

1747318308728

加一个isGrounded标志位用来在Falling->Standing时条件判断,只有在isGrounded == true 的时候才会播放着陆动画。

isGrounded需要在PlayerController.cs中地面检测后赋值

PlayerController.cs

        #region 地面检测
        GroundCheck();

        animator.SetBool("isGrounded", isGrounded);

ParkourController.cs

    [Header("跳下悬崖动画")]
    [SerializeField] ParkourAction JumpDownAction;

Update():

        #region 悬崖跳下动作
        //在悬崖边沿且不在播放动作中
        if(playerController.IsOnLedge && !inAction)
        {
            playerController.IsOnLedge = false;

            StartCoroutine(DoParkourAction(JumpDownAction));

        }
        #endregion

一个Bug:如果跳落的时候不按方向键,就会出现Falling动画播放异常

Bug修复——Falling动画异常

这里有个问题折磨我一个小时才想明白:

因为GroundCheck()方法在Update()调用,但是每次进入动画的时候,角色控制禁用,PlayerdController是被禁用的。

禁用角色控制后,PlayerController中的Update方法会提前返回(因为!hasControl为true时会执行return),导致GroundCheck()不会被调用,isGrounded也就不会更新。

所以如果不按方向键,这个isGrounded就一直是true,所以Falling动画只播放一遍就切换到了Standing动画,没有循环。

ParkourController.cs 的 DoParkourAction()方法:

    IEnumerator DoParkourAction(ParkourAction action)
    {
        //跑酷动作开始
        inAction = true;
        //禁用玩家控制
        playerController.SetControl(false);

这个问题应该这么被解决:

只有在地上的时候,速度才会被更新,这样就不会在空中也能控制角色,也解决了Falling播放异常的问题。

PlayerController.cs

        var velocity = Vector3.zero;

        #region 地面检测
        GroundCheck();
        animator.SetBool("isGrounded", isGrounded);
        if (isGrounded)
        {
            //设置一个较小的负值,让角色在地上的时候被地面吸住
            ySpeed = -0.5f;
            velocity = moveDir * moveSpeed;
            //在地上的时候进行悬崖检测,传给isOnLedge变量
            IsOnLedge = environmentScanner.LedgeCheck(moveDir);
            #region 调试用
            if (IsOnLedge)
            {
                Debug.Log("On Ledge");
            }
            #endregion
        }
        else
        {
            //在空中时,ySpeed受重力控制
            ySpeed += Physics.gravity.y * Time.deltaTime;
            //简单模拟有空气阻力的平抛运动:空中时的速度设置为角色朝向速度的一半
            velocity = transform.forward * moveSpeed / 2;
        }
        #endregion
        //更新y轴方向的速度
        velocity.y = ySpeed;

还有bug:当角色到达悬崖边沿的时候按下反方向键,角色动作会出现异常

Bug修复——转向过大动画异常

这是因为要转向的角度过大,所以要限制当转向角过大时不允许播放JumpDown

这就需要得到一个如图所示的转向角:

1747336803854

怎么得到转向角?

Vector3.Angle(transform.forward, ledgeHitData.hitSurface.normal)

怎么找到hitSurface

1747336818880

EnvironmentScanner.cs

    /// <summary>
    /// 检测是否在悬崖边缘
    /// </summary>
    /// <param name="moveDir"></param>
    /// <param name="ledgeHitData"></param>
    /// <returns></returns>
    /// out关键字需要在方法内部初始化
    public bool LedgeCheck(Vector3 moveDir, out LedgeHitData ledgeHitData)
    {
        //...原有代码不变...

        //射线向下发射是否击中:击中点在地面位置,赋值给hitGround
        if (Physics.Raycast(origin, Vector3.down, out RaycastHit hitGround, ledgeRayLength, obstacleLayer))
        {
            //调试用的向下发射的射线
            Debug.DrawRay(origin, Vector3.down * ledgeRayLength, Color.green);

            //检测射线起始位置:脚底向前moveDir再向下偏移一些
            var surfaceRayOrigin = transform.position + moveDir - Vector3.up * 0.1f;
            //悬崖竖直表面射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface
            if (Physics.Raycast(surfaceRayOrigin, -moveDir, out ledgeHitData.hitSurface, 2f, obstacleLayer))
            {
                //计算当前位置高度 = 角色位置高度 - 击中点高度
                float height = transform.position.y - hitGround.point.y;
                //超过这个悬崖高度阈值,才会认为是悬崖边缘
                if (height > ledgeHeightThreshold)
                {
                    //计算当前位置与悬崖表面法线的夹角
                    ledgeHitData.angle = Vector3.Angle(transform.forward, ledgeHitData.hitSurface.normal);
                    ledgeHitData.height = height;
  
                    return true;
                }
            }
        }
        return false;
    }

PlayerController.cs

    //悬崖边沿击中相关数据
    public LedgeHitData LedgeHitData { get; set; }

传参

            #region 悬崖检测
            //在地上的时候进行悬崖检测,传给isOnLedge变量
            IsOnLedge = environmentScanner.LedgeCheck(moveDir,out LedgeHitData ledgeHitData);
            //如果在悬崖边沿,就把击中数据传给LedgeHitData变量,用来在ParkourController里面调用
            if (IsOnLedge)
            {
                LedgeHitData = ledgeHitData;
                Debug.Log("On Ledge");
            }
            #endregion

ParkourController.cs

        #region 悬崖跳下动作
        //在悬崖边沿且不在播放动作中
        if(playerController.IsOnLedge && !inAction)
        {
            //偏差角度小于50度,才会播放JumpDown动画
            if(playerController.LedgeHitData.angle <= 50f){
                playerController.IsOnLedge = false;
                StartCoroutine(DoParkourAction(jumpDownAction));
            }

        }
        #endregion

还有一个问题:当面前有一个障碍,高度差也超过了阈值,也会被认定为悬崖,这时需要额外的判定

解决方案就是调用前面的ParkourController里的hitData中向前发送的射线检测信息heightHitInfo,在播放跳下动画时多加一个判定即可:检测前方是否有障碍,有障碍就不能跳

    private void Update()
    {
        //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
        var hitData = environmentScanner.ObstacleCheck();
        #region 各种跑酷动作
        if (Input.GetButton("Jump") && !inAction)
        {
	    //原来hitData的申明位置
            //...原有代码...
        }
        #endregion

        #region 悬崖跳下动作
        //在悬崖边沿且不在播放动作中且前方没有障碍物
        if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound)
        {
            //原有代码不变
        }
        #endregion
    }

效果如下:

1747335612353

该部分修改的完整代码:

EnvironmentScanner.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnvironmentScanner : MonoBehaviour
{
    [Header("障碍物检测——向前发送的射线相关参数")]
    //y轴(竖直方向)偏移量
    [SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);
    //长度
    [SerializeField] float forwardRayLength = 0.8f;
    //从击中点向上发射的射线的高度
    [SerializeField] float heightRayLength = 5f;

    [Header("悬崖Ledge检测——向下发送的射线相关参数")]
    //向下发射的射线的长度
    [SerializeField] float ledgeRayLength = 10f;
    //悬崖的高度阈值
    [SerializeField] float ledgeHeightThreshold = 0.75f;

    [Header("LayerMask")]
    //障碍物层
    [SerializeField] LayerMask obstacleLayer;

    public ObstacleHitData ObstacleCheck()
    {
        var hitData = new ObstacleHitData();
        //让射线从膝盖位置开始发送
        //射线的起始位置 = 角色位置 + 一个偏移量
        var forwardOrigin = transform.position + forwardRayOffset;
        //射线向前发送是否击中障碍物:击中点在障碍物上,赋值给hitData.forwardHitInfo
        hitData.forwardHitFound = Physics.Raycast(forwardOrigin, transform.forward,
                                    out hitData.forwardHitInfo, forwardRayLength, obstacleLayer);
        //调试用的射线
        //第二个参数dir:Direction and length of the ray.
        Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength,
                (hitData.forwardHitFound) ? Color.red : Color.white);

        //如果击中,则从击中点上方高度heightRayLength向下发射的射线
        if (hitData.forwardHitFound)
        {
            var heightOrigin = hitData.forwardHitInfo.point + Vector3.up * heightRayLength;
            hitData.heightHitFound = Physics.Raycast(heightOrigin, Vector3.down,
                                    out hitData.heightHitInfo, heightRayLength, obstacleLayer);
            //调试用的射线
            //第二个参数dir:Direction and length of the ray.
            Debug.DrawRay(heightOrigin, Vector3.down * heightRayLength,
                    (hitData.heightHitFound) ? Color.red : Color.white);
        }

        return hitData;
    }

    /// <summary>
    /// 检测是否在悬崖边缘
    /// </summary>
    /// <param name="moveDir"></param>
    /// <param name="ledgeHitData"></param>
    /// <returns></returns>
    /// out关键字需要在方法内部初始化
    public bool LedgeCheck(Vector3 moveDir, out LedgeHitData ledgeHitData)
    {
        //用来存悬崖边缘检测相关的信息
        ledgeHitData = new LedgeHitData();

        //只有移动才会检测Ledge
        if (moveDir == Vector3.zero)
            return false;

        //起始位置向前偏移量
        float originOffset = 0.5f;
        //检测射线的起始位置
        var origin = transform.position + moveDir * originOffset + Vector3.up;    //起始位置不要在脚底,悬崖和和脚在同一高度,可能会检测不到,向上偏移一些
        //射线向下发射是否击中:击中点在地面位置,赋值给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;
    }
}
public struct ObstacleHitData
{
    #region 从角色膝盖出发的向前射线检测相关
    //是否击中障碍物
    public bool forwardHitFound;
    //用来存射线检测的信息
    public RaycastHit forwardHitInfo;
    #endregion
    #region 从击中点垂直方向发射的射线检测相关
    public bool heightHitFound;
    //用来存射该射线向下击中障碍物的检测信息
    public RaycastHit heightHitInfo;

    #endregion
}

public struct LedgeHitData{
    public float angle;
    public float height;
    public RaycastHit hitSurface;

}

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField]float moveSpeed = 5f;
    [SerializeField]float rotationSpeed = 500f;

    [Header("Ground Check")]
    [SerializeField]float groundCheckRadius = 0.5f;
    //检测射线偏移量
    [SerializeField]Vector3 groundCheckOffset;
    [SerializeField]LayerMask groundLayer;

    //是否在地面
    bool isGrounded;
    //是否拥有控制权:默认拥有控制权,否则角色初始就不受控
    bool hasControl = true;
    //是否在悬崖边沿上
    public bool IsOnLedge { get; set; }
    //悬崖边沿击中相关数据
    public LedgeHitData LedgeHitData { get; set; }

    float ySpeed;

    Quaternion targetRotation;

    CameraController cameraController;
    Animator animator;
    CharacterController charactercontroller;
    EnvironmentScanner environmentScanner;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
        //环境扫描器
        environmentScanner = GetComponent<EnvironmentScanner>();
    }
    private void Update()
    {
        #region 角色输入控制
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        //把moveAmount限制在0-1之间(混合树的区间)
        float moveAmount = Mathf.Clamp01(Mathf.Abs(h) + Mathf.Abs(v));

        //标准化 moveInput 向量
        var moveInput = new Vector3(h, 0, v).normalized;

        //让人物移动方向关联相机的水平旋转朝向
        var moveDir = cameraController.PlanarRotation * moveInput;

        //如果没有控制权,后面的就不执行了
        if(!hasControl){
            return;
        }

        var velocity = Vector3.zero;

        #region 地面检测
        GroundCheck();
        animator.SetBool("isGrounded", isGrounded);
        if (isGrounded)
        {
            //设置一个较小的负值,让角色在地上的时候被地面吸住
            ySpeed = -0.5f;
            velocity = moveDir * moveSpeed;
            #region 悬崖检测
            //在地上的时候进行悬崖检测,传给isOnLedge变量
            IsOnLedge = environmentScanner.LedgeCheck(moveDir,out LedgeHitData ledgeHitData);
            //如果在悬崖边沿,就把击中数据传给LedgeHitData变量,用来在ParkourController里面调用
            if (IsOnLedge)
            {
                LedgeHitData = ledgeHitData;
                Debug.Log("On Ledge");
            }
            #endregion
        }
        else
        {
            //在空中时,ySpeed受重力控制
            ySpeed += Physics.gravity.y * Time.deltaTime;
            //简单模拟有空气阻力的平抛运动:空中时的速度设置为角色朝向速度的一半
            velocity = transform.forward * moveSpeed / 2;
        }
        #endregion
        //更新y轴方向的速度
        velocity.y = ySpeed;
        //帧同步移动
        //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动
        charactercontroller.Move(velocity * Time.deltaTime);

        //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向
        //没有输入就不更新转向,也就不会回到初始朝向
        if (moveAmount > 0)
        {
            //人物模型转起来:让人物朝向与移动方向一致
            targetRotation = Quaternion.LookRotation(moveDir);
        }
        //更新transform.rotation:让人物从当前朝向到目标朝向慢慢转向
        transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation,
                         rotationSpeed * Time.deltaTime);
        #endregion

        #region 角色动画控制
        //设置人物动画参数moveAmount
        animator.SetFloat("moveAmount", moveAmount,0.2f,Time.deltaTime);

        #endregion


    }

    //地面检测
    private void GroundCheck()
    {
        // Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true
        // 位置偏移用来在unity控制台里面调整
        isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer);
    }

    //角色控制
    public void SetControl(bool hasControl){
        //传参给 hasControl 私有变量
        this.hasControl = hasControl;
        //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件
        //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动
        charactercontroller.enabled = hasControl;

        //如果角色控制权被禁用,则更新动画参数和朝向
        if (!hasControl)
        {
            //更新动画参数
            animator.SetFloat("moveAmount", 0f);
            //更新朝向
            targetRotation = transform.rotation;

        }
    }

    //画检测射线
    private void OnDrawGizmosSelected()
    {
        //射线颜色,最后一个参数是透明度
        Gizmos.color = new Color(0, 1, 0, 0.5f);
        Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius);
    }

    //让rotationSpeed可以被外部访问
    public float RotationSpeed => rotationSpeed;

}

ParkourController.cs

using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    //定义一个面板可见的跑酷动作属性列表
    [Header("跑酷动作列表")]
    [SerializeField] List<ParkourAction> parkourActions;
    [Header("跳下悬崖动画")]
    [SerializeField] ParkourAction jumpDownAction;

    EnvironmentScanner environmentScanner;
    Animator animator;
    PlayerController playerController;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // Update is called once per frame
    private void Update()
    {
        //调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
        var hitData = environmentScanner.ObstacleCheck();
        #region 各种跑酷动作
        if (Input.GetButton("Jump") && !inAction)
        {
            if (hitData.forwardHitFound)
            {
                //对于每一个在跑酷动作列表中的跑酷动作
                foreach (var action in parkourActions)
                {
                    //如果动作可行
                    if(action.CheckIfPossible(hitData, transform))
                    {
                        //播放对应动画
                        //StartCoroutine()方法:开启一个协程
                        //启动 DoParkourAction 协程,播放跑酷动画
                        StartCoroutine(DoParkourAction(action));
                        //跳出循环
                        break;
                    }
                }
                //调试用:打印障碍物名称
                //Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);

            }
        }
        #endregion

        #region 悬崖跳下动作
        //在悬崖边沿且不在播放动作中且前方没有障碍物
        if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound)
        {
            //偏差角度小于50度,才会播放JumpDown动画
            if(playerController.LedgeHitData.angle <= 50f){
                playerController.IsOnLedge = false;
                StartCoroutine(DoParkourAction(jumpDownAction));
            }

        }
        #endregion
    }
    //跑酷动作
    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的过渡时间
        yield return null;
  
        // 现在获取动画状态信息
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        //#region 调试用
        //if (!animStateInfo.IsName(action.AnimName))
        //{
        //    Debug.LogError("动画名称不匹配!");
        //}
        //#endregion

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物RotateToObstacle
            if (action.RotateToObstacle)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,action.TargetRotation, 
                                                        playerController.RotationSpeed * Time.deltaTime);
            }
            //如果勾选目标匹配EnableTargetMatching
            //只有当不在过渡状态时才执行目标匹配
            if (action.EnableTargetMatching && !animator.IsInTransition(0))
            {
                MatchTarget(action);
            }

            //过渡动画完全播完就停止该动作播放
            if(animator.IsInTransition(0) && timer > 0.5f){
                break;
            }

            yield return null;
        }
        //对于一些组合动作,第一阶段播放完后就会被输入控制打断,这时候给一个延迟,让第二阶段的动画也播放完
        //对于ClimbUp动作,第二阶段就是CrouchToStand
        yield return new WaitForSeconds(action.ActionDelay);
        //延迟结束后才启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

    //目标匹配
    void MatchTarget(ParkourAction action)
    {
        //只有在不匹配和不在过渡状态的时候才会调用
        if (animator.isMatchingTarget || animator.IsInTransition(0))
        {
            return;
        }
        //调用unity自带的MatchTarget方法
        animator.MatchTarget(action.MatchPosition, transform.rotation, action.MatchBodyPart, 
                        new MatchTargetWeightMask(action.MatchPositionXYZWeight, 0), action.MatchStartTime, action.MatchTargetTime);
    }

}

其实这里还有问题:前面我设置了当转向角过大的时候不播放JumpDown动画,那应该怎么解决呢?

这就引出了一个机制——防跌落

Day8 防跌落机制Ledge Movement

这里cue一下游科,为什么黑神话刚发售的时候浮屠界设计的那么恶心,你知道放空气墙,但是一个简单的防跌落机制却不做

这个机制和空气墙很不一样,玩家可能无心来到地形边缘,你放空气墙虽然能解决这个问题,但是当玩家有心想要过去,这时空气墙的弊端就出现了,它会极大挫败玩家的心流,也就是出戏,沉浸感大打折扣。

如何实现?

1747409076398

法线方向(蓝色)与人物将要移动的方向如果小于90度(红色),那就禁止移动,如果大于等于90度(绿色),那就允许移动。

PlayerController.cs

    //moveDir、velocity改成全局变量
    //当前角色的移动方向,这是实时移动方向,只要输入方向键就会更新
    Vector3 moveDir;
    //角色期望的移动方向,这个期望方向是和相机水平转动方向挂钩的,与鼠标或者手柄右摇杆一致
    Vector3 desireMoveDir;
    Vector3 velocity;

moveDir全部换成desireMoveDir,

targetRotation = Quaternion.LookRotation(moveDir);

这句换回moveDir,目标朝向还是实时的人物移动方向

其他改动点:

        //每次判断moveAmount的时候,确保只有在玩家实际移动时才会更新转向
        //没有输入并且移动方向角度小于0.2度就不更新转向,也就不会回到初始朝向
        //moveDir.magnitude > 0.2f 避免了太小的旋转角度也会更新
        if (moveAmount > 0 && moveDir.magnitude > 0.2f)
        {
            //人物模型转起来:让目标朝向与当前移动方向一致
            targetRotation = Quaternion.LookRotation(moveDir);
        }
            if (IsOnLedge)
            {
                LedgeHitData = ledgeHitData;
                //调用悬崖边沿移动限制
                LedgeMovement();
                // Debug.Log("On Ledge");
            }
    //悬崖边沿移动限制机制 
    private void LedgeMovement(){
        //计算玩家期望移动方向与悬崖边沿法线的夹角
        float angle = Vector3.Angle(LedgeHitData.hitSurface.normal, desireMoveDir);
        //这个夹角是锐角说明玩家将要走过悬崖边沿,限制不让走
        Debug.Log("angle: " + angle);
        if(angle < 90){
            velocity = Vector3.zero;
            //让当前方向为0,也就是不让玩家旋转方向,但是期望方向还是与相机转动方向一致,仍然可以转回去
            moveDir = Vector3.zero;
        }
    }

将JumpDown动画播放判定条件加一个“按下Jump键"

ParkourController.cs

        #region 悬崖跳下动作
        //在悬崖边沿且不在播放动作中且前方没有障碍物
        if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound)
        {
            bool shouldJump = true;
            if(!Input.GetButtonDown("Jump")){
                shouldJump = false;
            }
            //偏差角度小于50度,才会播放JumpDown动画
            if(playerController.LedgeHitData.angle <= 50f && shouldJump){
                playerController.IsOnLedge = false;
                StartCoroutine(DoParkourAction(jumpDownAction));
            }
        }
        #endregion

这样就实现了只有按下跳键才会播放跳下动画,不然就在悬崖边沿一直不会掉落

还有问题,当在悬崖边缘继续走,走路动画仍在播放,和现实不符,极为出戏。

所以控制走路动画的参量moveAmount不应该由代码里的moveAmount来赋值传参,应该改成归一化的速度 velocity.magnitude / moveSpeed,因为前面设置了 velocity = Vector3.zero;没有速度也就不会再播放走路动画了,问题解决!

PlayerController.cs

            //在地面上,速度只有水平分量
            #region 角色动画控制
            //  dampTime是阻尼系数,用来平滑动画
            //这里不应该根据输入值赋值给BlendTree动画用的moveAmount参数
            //因为动画用的moveAmount参数只需要水平方向的移动量就行了,不需要考虑y轴
            //那么也就不需要方向,只需要值
            //所以传入归一化的 velocity.magnitude / moveSpeed就行了
            animator.SetFloat("moveAmount", velocity.magnitude / moveSpeed, 0.2f, Time.deltaTime);
            #endregion

效果如下:

1747421872953

该部分完整修改代码如下:

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField] float moveSpeed = 5f;
    [SerializeField] float rotationSpeed = 500f;

    [Header("Ground Check")]
    [SerializeField] float groundCheckRadius = 0.5f;
    //检测射线偏移量
    [SerializeField] Vector3 groundCheckOffset;
    [SerializeField] LayerMask groundLayer;

    //是否在地面
    bool isGrounded;
    //是否拥有控制权:默认拥有控制权,否则角色初始就不受控
    bool hasControl = true;

    //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<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
        //环境扫描器
        environmentScanner = GetComponent<EnvironmentScanner>();
    }
    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 angle = Vector3.Angle(LedgeHitData.hitSurface.normal, desireMoveDir);
        //这个夹角是锐角说明玩家将要走过悬崖边沿,限制不让走
        Debug.Log("angle: " + angle);
        if (angle < 90)
        {
            velocity = Vector3.zero;
            //让当前方向为0,也就是不让玩家旋转方向,但是期望方向还是与相机转动方向一致,仍然可以转回去
            moveDir = Vector3.zero;
        }
    }

    //角色控制
    public void SetControl(bool hasControl)
    {
        //传参给 hasControl 私有变量
        this.hasControl = hasControl;
        //根据 hasControl 变量的值来启用或禁用 charactercontroller 组件
        //如果角色没有控制权,则禁用角色控制器,hasControl = false,让角色静止不动
        charactercontroller.enabled = hasControl;

        //如果角色控制权被禁用,moveAmount也应该设置为0,目标朝向设置为当前朝向也就是不允许通过输入转动方向
        if (!hasControl)
        {
            //更新动画参数
            animator.SetFloat("moveAmount", 0f);
            //更新朝向
            targetRotation = transform.rotation;

        }
    }

    //画检测射线
    private void OnDrawGizmosSelected()
    {
        //射线颜色,最后一个参数是透明度
        Gizmos.color = new Color(0, 1, 0, 0.5f);
        Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius);
    }

    //让rotationSpeed可以被外部访问
    public float RotationSpeed => rotationSpeed;

}

ParkourController.cs

using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

public class ParkourController : MonoBehaviour
{
    //定义一个面板可见的跑酷动作属性列表
    [Header("跑酷动作列表")]
    [SerializeField] List<ParkourAction> parkourActions;
    [Header("跳下悬崖动画")]
    [SerializeField] ParkourAction jumpDownAction;

    EnvironmentScanner environmentScanner;
    Animator animator;
    PlayerController playerController;
    //是否在动作中
    bool inAction;


    private void Awake()
    {
        environmentScanner = GetComponent<EnvironmentScanner>();
        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
    }
    // 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 <= 50f && shouldJump){
                playerController.IsOnLedge = false;
                StartCoroutine(DoParkourAction(jumpDownAction));
            }
        }
        #endregion
    }
    //跑酷动作
    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的过渡时间
        yield return null;
  
        // 现在获取动画状态信息
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        //#region 调试用
        //if (!animStateInfo.IsName(action.AnimName))
        //{
        //    Debug.LogError("动画名称不匹配!");
        //}
        //#endregion

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物RotateToObstacle
            if (action.RotateToObstacle)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,action.TargetRotation, 
                                                        playerController.RotationSpeed * Time.deltaTime);
            }
            //如果勾选目标匹配EnableTargetMatching
            //只有当不在过渡状态时才执行目标匹配
            if (action.EnableTargetMatching && !animator.IsInTransition(0))
            {
                MatchTarget(action);
            }

            //过渡动画完全播完就停止该动作播放
            if(animator.IsInTransition(0) && timer > 0.5f){
                break;
            }

            yield return null;
        }
        //对于一些组合动作,第一阶段播放完后就会被输入控制打断,这时候给一个延迟,让第二阶段的动画也播放完
        //对于ClimbUp动作,第二阶段就是CrouchToStand
        yield return new WaitForSeconds(action.ActionDelay);
        //延迟结束后才启用玩家控制
        playerController.SetControl(true);
        //跑酷动作结束
        inAction = false;
    }

    //目标匹配
    void MatchTarget(ParkourAction action)
    {
        //只有在不匹配和不在过渡状态的时候才会调用
        if (animator.isMatchingTarget || animator.IsInTransition(0))
        {
            return;
        }
        //调用unity自带的MatchTarget方法
        animator.MatchTarget(action.MatchPosition, transform.rotation, action.MatchBodyPart, 
                        new MatchTargetWeightMask(action.MatchPositionXYZWeight, 0), action.MatchStartTime, action.MatchTargetTime);
    }

}

在使用手柄操控的时候出现了问题:当我控制移动方向的左摇杆指向一个接近90度的值,比如80度左右,人物并不会完全转向侧边,也就是和边沿平行的方向,这时候在向前推摇杆前进就会出现明显的撞空气墙感觉。

下面来解决这个问题

Bug——摇杆移动过于精确导致的撞墙问题

1747423493342

设定一个区间[60,90],只保留

1747423955943

利用叉乘找到垂直于法线和y轴平面的方向,也就是平行于悬崖边沿的方向:

1747424063475

PlayerController.cs

    //悬崖边沿移动限制机制 
    private void LedgeMovement()
    {
        //计算玩家期望移动方向与悬崖边沿法线的有向夹角
        //所以这里的方向是左前是正,右前是负
        float signedAngle = Vector3.SignedAngle(LedgeHitData.hitSurface.normal, desireMoveDir, Vector3.up);
        //无向夹角
        float angle = Math.Abs(signedAngle);
        //这个夹角是锐角说明玩家将要走过悬崖边沿,限制不让走
        Debug.Log("angle: " + angle);
        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;
        }
    }

另一个问题存在:

1747425699405

如果这样站在悬崖边沿,此时按下前进键,角色不会有任何变化

所以我想让他这样之后转向朝向悬崖边沿,但是只是转向不会移动

Bug——边沿检测导致的转向问题

只需要检测玩家当前朝向与期望移动方向的夹角(无向),如果大于80度这个阈值,就转向悬崖边沿但是不移动

PlayerController.cs

        if(Vector3.Angle(transform.forward, desireMoveDir) >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);

效果如下:

1747427341221

该部分修改的完整代码如下:

PlayerController.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("玩家属性")]
    [SerializeField] float moveSpeed = 5f;
    [SerializeField] float rotationSpeed = 500f;

    [Header("Ground Check")]
    [SerializeField] float groundCheckRadius = 0.5f;
    //检测射线偏移量
    [SerializeField] Vector3 groundCheckOffset;
    [SerializeField] LayerMask groundLayer;

    //是否在地面
    bool isGrounded;
    //是否拥有控制权:默认拥有控制权,否则角色初始就不受控
    bool hasControl = true;

    //moveDir、velocity改成全局变量
    //当前角色的移动方向,这是实时移动方向,只要输入方向键就会更新
    Vector3 moveDir;
    //角色期望的移动方向,这个期望方向是和相机水平转动方向挂钩的,与鼠标或者手柄右摇杆一致
    Vector3 desireMoveDir;
    Vector3 velocity;

    //是否在悬崖边沿上
    public bool IsOnLedge { get; set; }
    //悬崖边沿击中相关数据
    public LedgeHitData LedgeHitData { get; set; }

    float ySpeed;

    Quaternion targetRotation;

    CameraController cameraController;
    Animator animator;
    CharacterController charactercontroller;
    EnvironmentScanner environmentScanner;

    private void Awake()
    {
        //相机控制器设置为main camera
        cameraController = Camera.main.GetComponent<CameraController>();
        //角色动画
        animator = GetComponent<Animator>();
        //角色控制器
        charactercontroller = GetComponent<CharacterController>();
        //环境扫描器
        environmentScanner = GetComponent<EnvironmentScanner>();
    }
    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检测射线是以人物中轴为起点的

所以可以用三条射线来同步检查悬崖边沿。

1747499348950

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

1747499107688

PhysicsUtil.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PhysicsUtil
{
    /// <summary>
    /// 三条射线同步检测
    /// </summary>
    /// <param name="origin"></param>
    /// <param name="direction"></param>
    /// <param name="spacing"></param>间距
    /// <param name="transform"></param>方向
    /// <param name="hits"></param>
    /// <param name="distance"></param>
    /// <param name="layer"></param>
    /// <returns></returns>
    public static bool ThreeRaycast(Vector3 origin, Vector3 direction,
                                float spacing, Transform transform,
                                out List<RaycastHit> 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<RaycastHit>() { 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<RaycastHit> hitsGround, ledgeRayLength, obstacleLayer, true))
        {
            //有效击中返回值列表:检查hitsGround里的所有击中信息hit
            //height:计算当前位置高度 = 角色位置高度 - 击中点高度
            //超过这个悬崖高度阈值ledgeHeightThreshold,才会认为是悬崖边缘
            var validHits = hitsGround.Where(hit => transform.position.y - hit.point.y > ledgeHeightThreshold).ToList();
            //只要有一个有效击中,就认为是悬崖边缘
            if (validHits.Count > 0)
            {
                #region 悬崖边沿竖直表面检测——悬崖边沿移动限制机制需要用到ledgeHitData.hitSurface的属性,播放JumpDown动画时判定需要用到ledgeHitData.angle和ledgeHitData.height
                // 射线起始位置:脚底向前moveDir再向下偏移一些
                var surfaceRayOrigin = transform.position + moveDir - Vector3.up * 0.1f;
                // 射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface
                if (Physics.Raycast(surfaceRayOrigin, -moveDir, out RaycastHit hitSurface, 2f, obstacleLayer))
                {
                    //计算当前位置高度 = 角色位置高度 - 任一击中点高度(这三个击中点高度都是一样的)
                    float height = transform.position.y - validHits[0].point.y;

                    //计算当前位置与悬崖表面法线的夹角
                    ledgeHitData.angle = Vector3.Angle(transform.forward, hitSurface.normal);
                    ledgeHitData.height = height;
                    ledgeHitData.hitSurface = hitSurface;

                    return true;
                }
                #endregion
            }
        }

这行代码相当于把列表里的每个hit都判定了一遍,满足括号里的大于悬崖高度阈值才会返回该hit,最终返回一个总的有效击中列表

            //有效击中返回值列表:检查hitsGround里的所有击中信息hit
            //height:计算当前位置高度 = 角色位置高度 - 击中点高度
            //超过这个悬崖高度阈值ledgeHeightThreshold,才会认为是悬崖边缘
            var validHits = hitsGround.Where(hit => transform.position.y - hit.point.y > ledgeHeightThreshold).ToList();

后面只需要validHits.Count>0就能实现:只要一条射线击中那就是在悬崖边沿。

加个调试射线就很容易看出问题

                // 射线起始位置:脚底向前moveDir再向下偏移一些
                var surfaceRayOrigin = transform.position + moveDir + Vector3.down * 0.1f;
                // 射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface
                if (Physics.Raycast(surfaceRayOrigin, -moveDir, out RaycastHit hitSurface, 2f, obstacleLayer))
                {   
                    Debug.DrawLine(surfaceRayOrigin, transform.position, Color.cyan);

1747508475689

存在问题如下:

悬崖边沿竖直表面检测射线hitSurface还是和原来一样与角色当前位置关联,而不是与这三条hitGround关联,所以实际的边沿限制机制会出问题。

解决方案:

当hitsGround列表里只有一条线击中地面,悬崖边沿竖直表面检测射线hitSurface(也就是从外向里发送的射线)也只能以这个hitGround射线为起点,也就是以validHits[0]的位置为surfaceRay的基准点(也就是surfaceRayOrigin),surfaceRayOrigin的y坐标和之前一样再以角色当前位置的y坐标向下偏移一点即可

修改后:

                // 射线起始位置:脚底向前moveDir再向下偏移一些
                var surfaceRayOrigin = validHits[0].point;
                surfaceRayOrigin.y = transform.position.y - 0.1f;
                // 射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface
                if (Physics.Raycast(surfaceRayOrigin, transform.position - surfaceRayOrigin, out RaycastHit hitSurface, 2f, obstacleLayer))
                {   
                    Debug.DrawLine(surfaceRayOrigin, transform.position, Color.cyan);
...

也就是修改后的悬崖边沿竖直表面检测射线hitSurface 以 validhits[0] 为基准点。

1747508264342

如果validHits列表有两个三个,那也没事,因为这时三个点的位置以哪个为准都行,都可以用来给限制边沿移动机制用。

如果想让边沿检测更严格,也就是离边沿更远,可以增大 originOffset

Bug——人物着陆后的滑步问题

状态机行为的引入

需要在着陆时短暂禁用角色输入控制

在原有的动画中,人物JumpDown是由起跳+下落+着陆 组成的,所以在代码中我们找不到控制着陆的参数

ok,我们可以把现有动画用状态机控制,加一个 ControlStoppingAction脚本

让进入该动画的时候禁用控制,结束该动画恢复控制

这就需要两个接口,一个进入状态一个退出状态时调用

实际上所有动画都可以这样做,方便我们实现自己想要的效果

ControlStoppingAction脚本继承于状态机行为

1747554909531

1747554920893

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<PlayerController>();
        }
        //禁用玩家的控制
        player.HasControl = false;

    }
    //退出该状态时调用
    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        //恢复玩家的控制
        animator.GetComponent<PlayerController>().HasControl = true;
    }
}

回到Unity-Animator面板

给着陆动画加入上面状态机行为脚本

1747555715424

原理:

进入该动画的时候调用OnStateEnter()禁用控制,

结束该动画调用ExitStateEnter()恢复控制。

这就实现了着陆动画完全播放完才会启用玩家输入控制

(其他动画都可以添加这个脚本,可以很好解决滑步问题)

效果如下:

1747556414704

如果在这个状态向后转然后跳下的动画,出现跳两次才下落的话,可以进行如下设置:

1747557147806

1747557168468

增加起跳的退出时间,缩短过渡时间即可

体验优化——当落差不大的时候可以自动 JumpDown 而不是手动按下Jump键

ParkourController.cs

    [Header("自动跳下高度")]
    [SerializeField] float autoJumpDownHeight = 1f;
        #region 悬崖跳下动作
        //在悬崖边沿且不在播放动作中且前方没有障碍物
        if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound)
        {
            //低矮的落差shouldJump == true,直接播放JumpDown动画
            bool shouldJump = true;
            //只有高度大于autoJumpDownHeight 且 玩家按下跳跃键才会跳下悬崖
            if(playerController.LedgeHitData.height > autoJumpDownHeight && !Input.GetButtonDown("Jump")){
                shouldJump = false;
            }
            //偏差角度小于50度,才会播放JumpDown动画
            if(playerController.LedgeHitData.angle <= 50 && shouldJump){
                playerController.IsOnLedge = false;
                StartCoroutine(DoParkourAction(jumpDownAction));
            }
        }
        #endregion

Bug——多个边沿向下检测射线带来的自动跳跃判定问题

问题分析:

因为有多条射线且相互之间有一定间隔,判定是否自动跳跃时用到的这个高度playerController.LedgeHitData.height就非常不精确。

所以我们需要在多个击中点的时候,取高度最高的点作为height

EnvironmentScanner.cs

                    //计算当前位置高度 = 角色位置高度 - 任一击中点高度(这三个击中点高度都是一样的)
                    float height = transform.position.y - validHits[0].point.y;
                    //多个击中点,取高度最高的点作为height
                    if (validHits.Count == 2)
                    {
                        height = Max(transform.position.y - validHits[0].point.y, transform.position.y - validHits[1].point.y);
                    }
                    else if (validHits.Count == 3)
                    {
                        height = Max(transform.position.y - validHits[0].point.y, transform.position.y - validHits[1].point.y, transform.position.y - validHits[2].point.y);
                    }
    #region 最大值函数重载
    private float Max(float num1, float num2){
        return Math.Max(num1, num2);
    }
    private float Max(float num1, float num2, float num3){
        return Math.Max(Math.Max(num1, num2), num3);
    }
    #endregion

当然这个写法十分不优雅

我们可以用System.Linq里的 List列表类型的Max方法,枚举列表里的每一个元素,找出最大值

//计算当前位置高度 = 角色位置高度 - 任一击中点高度(这三个击中点高度都是一样的)
float height = transform.position.y - validHits[0].point.y;
//多个击中点,取高度最高的点作为height
if(validHits.Count > 1){
    //自动选择高度最高的点作为height
    height = validHits.Max(validHit => transform.position.y - validHit.point.y);
}

如果情况多,查找效率不高的话可以自定义一个查找方法,比如二分查找、快速查找等等

奇奇怪怪的命名问题——单词拼错了

ParkourController.cs中的minHeight和maxHeight打错了

改之前记得备份一下ParkourAction组件的面板参数(拍个照),改完把这些参数写回去。

Bug——还是存在的滑步问题

warning: CharacterController.Move called on inactive

PlayerController.cs

    private void Update()
    {
        //如果没有控制权,后面的就不执行了
        if (!hasControl)
        {
            return;
        }
        //先检查角色控制器是否激活
        if(charactercontroller.gameObject.activeSelf && charactercontroller.enabled && hasControl){
            //帧同步移动
            //通过CharacterController.Move()来控制角色的移动,通过碰撞限制运动
            charactercontroller.Move(velocity * Time.deltaTime);
        }

问题解决

以上部分代码我放到了GitHub仓库:

https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section2

ok,可以开始编写后面的爬墙系统了!

Day9 代码整理

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

一个小问题

检查的时候发现的一个小问题:

1747586924053

这个参数其实只适合解决非组合动画的滑步问题

而组合动画,最好要用这个状态机行为脚本才能控制最后一个动画

1747586984462

解耦一些可复用的代码

ParkourController.csl里的DoParkourAction()抽象为通用动作播放方法,放到PlayerController.cs中:

    //是否在动作中
    public bool InAction {get;private set;}
    /// <summary>
    /// 通用动作播放
    /// </summary>
    /// <param name="animName"></param>
    /// <param name="matchParams"></param>
    /// <param name="targetRotation"></param>
    /// <param name="actionDelay"></param>
    /// <param name="needRotate"></param>
    /// <param name="mirrorAction"></param>
    /// <returns></returns>
    public IEnumerator DoAction(string animName, MatchTargetParams matchParams, Quaternion targetRotation,
                    float actionDelay = 0f, bool needRotate = false, bool mirrorAction = false)
    {
        //跑酷动作开始
        InAction = true;
  
        //不是所有动作都需要,具体动作自行写上
        // //禁用玩家控制
        // playerController.SetControl(false);

        //设置动画是否镜像
        animator.SetBool("mirrorAction", mirrorAction);

        //从当前动画到指定的目标动画,平滑过渡0.2s
        animator.CrossFade(animName, 0.2f);

        // 等待过渡完成
        //yield return new WaitForSeconds(0.3f); // 给足够时间让过渡完成,稍微大于CrossFade的过渡时间
        yield return null;
  
        // 现在获取动画状态信息
        var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);

        //#region 调试用
        //if (!animStateInfo.IsName(animName))
        //{
        //    Debug.LogError("动画名称不匹配!");
        //}
        //#endregion

        ////暂停协程,直到 "StepUp" 动画播放完毕。
        //yield return new WaitForSeconds(animStateInfo.length);

        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            //如果勾选该动作需要旋转向障碍物RotateToObstacle
            if (needRotate)
            {
                //让角色平滑旋转向障碍物
                transform.rotation = Quaternion.RotateTowards(transform.rotation,targetRotation, 
                                                        RotationSpeed * Time.deltaTime);
            }
            //如果勾选目标匹配EnableTargetMatching
            //只有当不在过渡状态时才执行目标匹配
            if (matchParams != null && !animator.IsInTransition(0))
            {
                MatchTarget(matchParams);
            }

            //过渡动画完全播完就停止该动作播放
            if(animator.IsInTransition(0) && timer > 0.5f){
                break;
            }

            yield return null;
        }
        //对于一些组合动作,第一阶段播放完后就会被输入控制打断,这时候给一个延迟,让第二阶段的动画也播放完
        //对于ClimbUp动作,第二阶段就是CrouchToStand
        yield return new WaitForSeconds(actionDelay);
  
        //不是所有动作都需要,具体动作自行写上
        // //延迟结束后才启用玩家控制
        // playerController.SetControl(true);

        //跑酷动作结束
        InAction = false;
    }

MatchTarget()也放进来

    //目标匹配
    void MatchTarget(MatchTargetParams mp)
    {
        //只有在不匹配和不在过渡状态的时候才会调用
        if (animator.isMatchingTarget || animator.IsInTransition(0))
        {
            return;
        }
        //调用unity自带的MatchTarget方法
        animator.MatchTarget(mp.matchPosition, transform.rotation, mp.matchBodyPart, 
                        new MatchTargetWeightMask(mp.matchPositionXYZWeight, 0), mp.matchStartTime, mp.matchTargetTime);
    }
//目标匹配TargetMatching用到的参数
public class MatchTargetParams{
    public Vector3 matchPosition;
    public AvatarTarget matchBodyPart;
    public Vector3 matchPositionXYZWeight;
    public float matchStartTime;
    public float matchTargetTime;
}

在ParkourController.cs的DoParkourAction()里调用DoAction():

    //跑酷动作
    IEnumerator DoParkourAction(ParkourAction action)
    {
        //禁用玩家控制
        playerController.SetControl(false);

        MatchTargetParams matchParams = null;
        if(action.EnableTargetMatching){
            if(matchParams == null){
                matchParams = new MatchTargetParams(){
                    matchPosition = action.MatchPosition,
                    matchBodyPart = action.MatchBodyPart,
                    matchPositionXYZWeight = action.MatchPositionXYZWeight,
                    matchStartTime = action.MatchStartTime,
                    matchTargetTime = action.MatchTargetTime
                };
            }
        }

        yield return playerController.DoAction(action.AnimName, matchParams, transform.rotation, 
                                        action.ActionDelay, action.RotateToObstacle, action.Mirror);
  
        //延迟结束后才启用玩家控制
        playerController.SetControl(true);   
    }

开始编写爬墙系统

Day10 爬墙系统Climbing System

攀岩石检测——ClimbLedgeCheck()

实现思路:

1747617788358

在人物朝向上循环发射多条平行检测射线,

EnvironmentScanner.cs

    //悬崖攀岩石层
    [SerializeField] LayerMask climbLedgeLayer;
    /// <summary>
    /// 攀崖石检测
    /// </summary>
    /// <param name="dir"></param>角色朝向
    /// <param name="ledgeHit"></param>检测信息
    /// <returns></returns>
    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<EnvironmentScanner>();  
    }

    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

这里由于新的动画与原有模型不匹配,可以下载新模型,然后继承模型选择新的模型(与新动画骨骼名字是匹配的)

1747644610904

原因:新的动画以及模型的骨骼名字加了个前缀mixamorig:

1747644744288

清理Animator界面——加入Sub-State Machine,收纳同一类的动作

1747645338195

1747645353160

1747645363495

为新动作Idle To Braced Hang & Hanging Idle编写控制脚本

注意:换人物模型的时候需要给Player的Animator组件进行对应设置:Prefab和Avatar都需要选择对应模型映射到unity的Avatar文件

1748951475207

HangingIdle

1750031267883

匹配时间:

1750032357016

1750032380897

ClimbController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ClimbController : MonoBehaviour
{
    [SerializeField] public float matchStartTime;
    [SerializeField] public float matchTargetTime;
    EnvironmentScanner envScanner;
    PlayerController playerController;
    public bool IsOnClimbLedge { get; private set; }
    void Awake()
    {
        envScanner = GetComponent<EnvironmentScanner>();
        playerController = GetComponent<PlayerController>();
    }

    void Update()
    {
        if (!playerController.IsHanging)
        {
            if (Input.GetButton("Jump") && !playerController.InAction)  //其他动作不在播放时
            {
                IsOnClimbLedge = envScanner.ClimbLedgeCheck(transform.forward, out RaycastHit ledgeHit);
                if (IsOnClimbLedge)
                {
                    playerController.SetControl(false);
                    StartCoroutine(JumpToLedge("IdleToHang", ledgeHit.transform, matchStartTime, matchTargetTime));
                }
            }
        }
        else
        {
            //Jump to another Ledge
       
            }
    }

    IEnumerator JumpToLedge(string anim, Transform ledge, float matchStartTime, float matchTargetTime)
    {
        var matchParams = new MatchTargetParams()
        {
            matchPosition = ledge.position,
            matchBodyPart = AvatarTarget.RightHand,
            matchPositionXYZWeight = new Vector3(1, 1, 1),
            matchStartTime = matchStartTime,
            matchTargetTime = matchTargetTime
        };
        var targetRotation = Quaternion.LookRotation(-ledge.forward);
        yield return playerController.DoAction(anim, matchParams, targetRotation, true);
        playerController.IsHanging = true;
    }
}

PlayerController.cs

    //是否在攀岩中
    public bool IsHanging{get;set;}
    private void Update()
    {
        //如果没有控制权,后面的就不执行了
        if (!hasControl)
        {
            return;
        }
        //如果在攀岩就不执行后面的运动逻辑
        if (IsHanging)
        {
            return;
        }

1750034272193

解决Bug——Idle To Hang 和HangingIlde之间高度差导致的位置偏移问题

1750034660334

调整HangingIdle的动画Y轴偏移量后

1750034513504

1750034608935

解决Bug——匹配右手时应该匹配的是ledge的上边沿

            matchPosition = getHandPos(ledge),

    private Vector3 getHandPos(Transform ledge) {
        return ledge.position + Vector3.up * 0.3f / 2 + ledge.forward * 0.2f / 2 - ledge.right * 0.25f; //Ledge的左边也就是人物的右边
    }
}

1750038084466

至此,只有当攀岩架的z轴指向外侧才能正确播放动画,后面来解决这个问题

Climbing Network——可视化各个攀岩架之间的连接关系

这将决定攀岩是如何从一个跳向另一个

创建一个预制体

1750056119102

1750056207497

ClimbPoint.cs

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class ClimbPoint : MonoBehaviour
{
    [SerializeField] List<Neighbour> neighbours;

    //只要攀岩架之间是邻居,那就自动创建双向关系
    public void Awake()
    {
        var twoWayNeighbours = neighbours.Where(n => n.isTwoWay);
        foreach (var neighbour in twoWayNeighbours)
        {
            neighbour.point?.CreateConnection(this, -neighbour.direction, neighbour.connectionType, neighbour.isTwoWay);
        }
    }

    public void CreateConnection(ClimbPoint point, Vector2 direction, ConnectionType connectionType,
                            bool isTwoWay = true)
    {
        var neighbour = new Neighbour()
        {
            point = point,
            direction = direction,
            isTwoWay = isTwoWay,
            connectionType = connectionType
        };
        neighbours.Add(neighbour);
    }
}

//序列化字段可见
[System.Serializable]

public class Neighbour
{
    public ClimbPoint point;
    public Vector2 direction;
    public bool isTwoWay;
    public ConnectionType connectionType;

}
public enum ConnectionType {
    Jump,
    Move,
}

下面绘制线条连接两个邻居

    private void OnDrawGizmos()
    {
        foreach (var neighbour in neighbours) {
            if (neighbour.point != null) {
                Debug.DrawLine(transform.position, neighbour.point.transform.position, (neighbour.isTwoWay) ? Color.blue : Color.gray);  
            }
      
        }
    }

可视化每个攀岩架的z轴向外射线

确保每个攀岩架不会被放置错误

    private void OnDrawGizmos()
    {
        Debug.DrawRay(transform.position, transform.forward, Color.blue);
        foreach (var neighbour in neighbours)
        {
            if (neighbour.point != null)
            {
                Debug.DrawLine(transform.position, neighbour.point.transform.position,
                         (neighbour.isTwoWay) ? Color.green : Color.gray);
            }

        }
    }

效果:

1750058877854

Ledge To Ledge——跳到另一个攀岩架

加入动画,注意高度是否统一,用y轴偏移量让他们统一

1750060920952

1750061247323

1750061353215

1750069203237

ClimbPoint.cs

    //获取邻居攀岩架
    public Neighbour GetNeighbour(Vector2 direction)
    {
        Neighbour neighbour = null;
        if (direction.y != 0)
            //找到第一个y方向匹配的neighbour
            neighbour = neighbours.FirstOrDefault(n => n.direction.y == direction.y);
        //如果在y轴没找到匹配的neighbour
        if (neighbour == null && direction.x != 0)
            //找到第一个x方向匹配的neighbour
            neighbour = neighbours.FirstOrDefault(n => n.direction.x == direction.x);
        return neighbour;
    }

ClimbController.cs

    [SerializeField] public MatchTimeParams idleToHang;
    [SerializeField] public MatchTimeParams HangHopUp;
    void Update()
    {
        if (!playerController.IsHanging)
        {
            #region IdleToHang
            if (Input.GetButton("Jump") && !playerController.InAction)  //其他动作不在播放时
            {
                IsOnClimbLedge = envScanner.ClimbLedgeCheck(transform.forward, out RaycastHit ledgeHit);
                if (IsOnClimbLedge)
                {
                    //currentPoint = 击中点对象的组件ClimbPoint
                    currentPoint = ledgeHit.transform.GetComponent<ClimbPoint>();
                    playerController.SetControl(false);
                    StartCoroutine(JumpToLedge("IdleToHang", ledgeHit.transform, idleToHang.matchStartTime, idleToHang.matchTargetTime));
                }
            }
            #endregion
        }
        else
        {
            #region Ledge To Ledge
            //Mathf.Round(...):对输入值四舍五入,确保结果为 +-1 / 0。
            float h = Mathf.Round(Input.GetAxisRaw("Horizontal"));
            float v = Mathf.Round(Input.GetAxisRaw("Vertical"));
            var inputDir = new Vector2(h, v);
            var neighbour = currentPoint.GetNeighbour(inputDir);

            if (neighbour == null)
                return;
            if (neighbour.connectionType == ConnectionType.Jump && Input.GetButton("Jump"))
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.y == 1)
                    StartCoroutine(JumpToLedge("HangHopUp", currentPoint.transform, HangHopUp.matchStartTime, HangHopUp.matchTargetTime));
                if(neighbour.direction.y == -1)
                    StartCoroutine(JumpToLedge("HangHopDown", currentPoint.transform,HangHopDown.matchStartTime,HangHopDown.matchTargetTime));
            }

            #endregion

        }
    }

[System.Serializable]
public struct MatchTimeParams 
{
    public float matchStartTime;
    public float matchTargetTime;
}

1750068157244

1750068086218

1750070086586

1750068492321

1750068515069

1750070189868

仍有问题,每个动画匹配位置参数中的匹配位置getHandPos应当自适应

这个后面会解决

HangHopRight/Left——左右跳

1750070466344

1750070476142

1750070525625

ClimbController.cs

            if (playerController.InAction || inputDir == Vector2.zero)
                return;

            var neighbour = currentPoint.GetNeighbour(inputDir);

            if (neighbour == null)  
                return;
            if (neighbour.connectionType == ConnectionType.Jump && Input.GetButton("Jump"))
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.y == 1)
                    StartCoroutine(JumpToLedge("HangHopUp", currentPoint.transform, HangHopUp.matchStartTime, HangHopUp.matchTargetTime));
                else if (neighbour.direction.y == -1)
                    StartCoroutine(JumpToLedge("HangHopDown", currentPoint.transform, HangHopDown.matchStartTime, HangHopDown.matchTargetTime));
                else if (neighbour.direction.x == 1)
                    StartCoroutine(JumpToLedge("HangHopRight", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime));
                else if (neighbour.direction.x == -1)
                    StartCoroutine(JumpToLedge("HangHopLeft", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime));
            }

ParkourController.cs

攀岩的时候不FQClimbUp

        if (Input.GetButton("Jump") && !playerController.InAction && !playerController.IsHanging)

最终效果:

1750078079908

该部分完整代码如下:

ClimbPoint.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class ClimbPoint : MonoBehaviour
{
    [SerializeField] List<Neighbour> neighbours;

    //只要攀岩架之间是邻居,那就自动创建双向关系
    public void Awake()
    {
        //只对标记为双向连接的邻居创建双向连接
        var twoWayNeighbours = neighbours.Where(n => n.isTwoWay);
        foreach (var neighbour in twoWayNeighbours)
        {
            neighbour.point?.CreateConnection(this, -neighbour.direction, neighbour.connectionType, neighbour.isTwoWay);
        }
    }

    public void CreateConnection(ClimbPoint point, Vector2 direction, ConnectionType connectionType,
                            bool isTwoWay = true)
    {
        var neighbour = new Neighbour()
        {
            point = point,
            direction = direction,
            isTwoWay = isTwoWay,
            connectionType = connectionType
        };
        neighbours.Add(neighbour);
    }

    //获取邻居攀岩架
    public Neighbour GetNeighbour(Vector2 direction)
    {
        Neighbour neighbour = null;
        if (direction.y != 0)
            //找到第一个y方向匹配的neighbour
            neighbour = neighbours.FirstOrDefault(n => n.direction.y == direction.y);
        //如果在y轴没找到匹配的neighbour
        if (neighbour == null && direction.x != 0)
            //找到第一个x方向匹配的neighbour
            neighbour = neighbours.FirstOrDefault(n => n.direction.x == direction.x);
        return neighbour;
    }

    private void OnDrawGizmos()
    {
        Debug.DrawRay(transform.position, transform.forward, Color.blue);
        foreach (var neighbour in neighbours)
        {
            if (neighbour.point != null)
            {
                Debug.DrawLine(transform.position, neighbour.point.transform.position,
                         (neighbour.isTwoWay) ? Color.green : Color.gray);
            }

        }
    }
}

//下面的序列化字段可见
[System.Serializable]
public class Neighbour
{
    public ClimbPoint point;
    public Vector2 direction;
    public bool isTwoWay;
    public ConnectionType connectionType;

}
public enum ConnectionType {
    Jump,
    Move,
}

ClimbController.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ClimbController : MonoBehaviour
{
    [SerializeField] public MatchTimeParams idleToHang;
    [SerializeField] public MatchTimeParams HangHopUp;
    [SerializeField] public MatchTimeParams HangHopDown;
    [SerializeField] public MatchTimeParams HangHopRight;

    ClimbPoint currentPoint;
    EnvironmentScanner envScanner;
    PlayerController playerController;
    public bool IsOnClimbLedge { get; private set; }
    void Awake()
    {
        envScanner = GetComponent<EnvironmentScanner>();
        playerController = GetComponent<PlayerController>();
    }

    void Update()
    {
        if (!playerController.IsHanging)
        {
            #region IdleToHang
            if (Input.GetButton("Jump") && !playerController.InAction)  //其他动作不在播放时
            {
                IsOnClimbLedge = envScanner.ClimbLedgeCheck(transform.forward, out RaycastHit ledgeHit);
                if (IsOnClimbLedge)
                {
                    //currentPoint = 击中点对象的组件ClimbPoint
                    currentPoint = ledgeHit.transform.GetComponent<ClimbPoint>();
                    playerController.SetControl(false);
                    StartCoroutine(JumpToLedge("IdleToHang", ledgeHit.transform, idleToHang.matchStartTime, idleToHang.matchTargetTime));
                }
            }
            #endregion
        }
        else
        {
            #region Ledge To Ledge
    
            //Mathf.Round(...):对输入值四舍五入,确保结果为 +-1 / 0。
            float h = Mathf.Round(Input.GetAxisRaw("Horizontal"));
            float v = Mathf.Round(Input.GetAxisRaw("Vertical"));
            var inputDir = new Vector2(h, v);
    
            if (playerController.InAction || inputDir == Vector2.zero)
                return;

            var neighbour = currentPoint.GetNeighbour(inputDir);

            if (neighbour == null)  
                return;
            if (neighbour.connectionType == ConnectionType.Jump && Input.GetButton("Jump"))
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.y == 1)
                    StartCoroutine(JumpToLedge("HangHopUp", currentPoint.transform, HangHopUp.matchStartTime, HangHopUp.matchTargetTime));
                else if (neighbour.direction.y == -1)
                    StartCoroutine(JumpToLedge("HangHopDown", currentPoint.transform, HangHopDown.matchStartTime, HangHopDown.matchTargetTime));
                else if (neighbour.direction.x == 1)
                    StartCoroutine(JumpToLedge("HangHopRight", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime));
                else if (neighbour.direction.x == -1)
                    StartCoroutine(JumpToLedge("HangHopLeft", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime));
            }

            #endregion

        }
    }

    IEnumerator JumpToLedge(string anim, Transform ledge, float matchStartTime, float matchTargetTime)
    {
        var matchParams = new MatchTargetParams()
        {
            matchPosition = getHandPos(ledge),
            matchBodyPart = AvatarTarget.RightHand,
            matchPositionXYZWeight = new Vector3(1, 1, 1),
            matchStartTime = matchStartTime,
            matchTargetTime = matchTargetTime
        };
        var targetRotation = Quaternion.LookRotation(-ledge.forward);
        yield return playerController.DoAction(anim, matchParams, targetRotation, true);
        playerController.IsHanging = true;
    }

    private Vector3 getHandPos(Transform ledge) {
        return ledge.position + Vector3.up * 0.3f / 2 + ledge.forward * 0.2f / 2 - ledge.right * 0.25f; //Ledge的左边也就是人物的右边
    }
}

[System.Serializable]
public struct MatchTimeParams 
{
    public float matchStartTime;
    public float matchTargetTime;
}

Day12 Shimmy Actions

创建一个长条边沿攀岩架:

1750078614636

然后去除预制体属性

1750078596021

创建子物体并像前面一样连线构成网络:

注意这里要勾选Move

1750088469115

1750088234070

连接攀岩架和边沿攀岩架:

1750088697477

修改动画1750087882594

Animator

1750087847937

因为左移的时候匹配的是左手,所以还需要设置一个变量来区分左右手

ClimbController.cs

    [SerializeField] public MatchTimeParams ShimmyRight;
            else if (neighbour.connectionType == ConnectionType.Move)
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.x == 1)
                    StartCoroutine(JumpToLedge("ShimmyRight", currentPoint.transform, ShimmyRight.matchStartTime, ShimmyRight.matchTargetTime));
                else if (neighbour.direction.x == -1)
                    StartCoroutine(JumpToLedge("ShimmyLeft", currentPoint.transform, ShimmyRight.matchStartTime, ShimmyRight.matchTargetTime, AvatarTarget.LeftHand));
   
            }
    IEnumerator JumpToLedge(string anim, Transform ledge, float matchStartTime, float matchTargetTime,
                        AvatarTarget hand = AvatarTarget.RightHand)
    {
        var matchParams = new MatchTargetParams()
        {
            matchPosition = getHandPos(ledge,hand),
            matchBodyPart = hand,
    private Vector3 getHandPos(Transform ledge,AvatarTarget hand) {
        var handDir = (hand == AvatarTarget.RightHand) ? ledge.right : -ledge.right;
        return ledge.position + Vector3.up * 0.3f / 2 + ledge.forward * 0.2f / 2 - handDir * 0.25f; //Ledge的左边也就是人物的右边
    }

1750088800702

这里出了一个问题,一直按着输入键,目标匹配会失效,这是因为过渡时间太长

修改PlayerController.cs的DoAction就行

        //从当前动画到指定的目标动画,平滑过渡0.2s
        animator.CrossFadeInFixedTime(animName, 0.2f);

CrossFadeInFixedTime()可以固定平滑过渡时间为0.2s

可优化点:增加标签数,减少标签间距,让横移更加顺滑,每次横移幅度变小

为每个动作设置不同的手部位置偏移

加一个参数handOffset

ClimbController.cs

    IEnumerator JumpToLedge(string anim, Transform ledge, float matchStartTime, float matchTargetTime,
                        AvatarTarget hand = AvatarTarget.RightHand,
                        Vector3? handOffset = null)
    {
        var matchParams = new MatchTargetParams()
        {
            matchPosition = getHandPos(ledge,hand,handOffset),
    private Vector3 getHandPos(Transform ledge,AvatarTarget hand, Vector3? handOffset) {
        var offsetValue = (handOffset != null) ? handOffset.Value : new Vector3(0.25f, 0.17f, 0.14f);
        var handDir = (hand == AvatarTarget.RightHand) ? ledge.right : -ledge.right;
        return ledge.position + Vector3.up * offsetValue.y + ledge.forward * offsetValue.z - handDir * offsetValue.x; //Ledge的左边也就是人物的右边
    }
[System.Serializable]
public struct MatchTimeParams
{
    public float matchStartTime;
    public float matchTargetTime;
    public Vector3 handOffset;
}
    [SerializeField] public MatchTimeParams idleToHang;//0.4~0.6    0.25,0.15,0.15
    [SerializeField] public MatchTimeParams HangHopUp;//0.34~0.65   0.25,0.18,0.15
    [SerializeField] public MatchTimeParams HangHopDown;//0.31~0.7  0.25,0.09,0.12
    [SerializeField] public MatchTimeParams HangHopRight;//0.2~0.8  0.25,0.19,0.09
    [SerializeField] public MatchTimeParams ShimmyRight;//0~0.38    0.25,0.18,0.12

    void Update()
    {
        if (!playerController.IsHanging)
        {
            #region IdleToHang
            if (Input.GetButton("Jump") && !playerController.InAction)  //其他动作不在播放时
            {
                IsOnClimbLedge = envScanner.ClimbLedgeCheck(transform.forward, out RaycastHit ledgeHit);
                if (IsOnClimbLedge)
                {
                    //currentPoint = 击中点对象的组件ClimbPoint
                    currentPoint = ledgeHit.transform.GetComponent<ClimbPoint>();
                    playerController.SetControl(false);
                    StartCoroutine(JumpToLedge("IdleToHang", ledgeHit.transform, idleToHang.matchStartTime, idleToHang.matchTargetTime));
                }
            }
            #endregion
        }
        else
        {
            #region Ledge To Ledge
      
            //Mathf.Round(...):对输入值四舍五入,确保结果为 +-1 / 0。
            float h = Mathf.Round(Input.GetAxisRaw("Horizontal"));
            float v = Mathf.Round(Input.GetAxisRaw("Vertical"));
            var inputDir = new Vector2(h, v);
      
            if (playerController.InAction || inputDir == Vector2.zero)
                return;

            var neighbour = currentPoint.GetNeighbour(inputDir);

            if (neighbour == null)  
                return;
            if (neighbour.connectionType == ConnectionType.Jump && Input.GetButton("Jump"))
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.y == 1)
                    StartCoroutine(JumpToLedge("HangHopUp", currentPoint.transform, HangHopUp.matchStartTime, HangHopUp.matchTargetTime, handOffset: HangHopUp.handOffset));
                else if (neighbour.direction.y == -1)
                    StartCoroutine(JumpToLedge("HangHopDown", currentPoint.transform, HangHopDown.matchStartTime, HangHopDown.matchTargetTime, handOffset: HangHopDown.handOffset));
                else if (neighbour.direction.x == 1)
                    StartCoroutine(JumpToLedge("HangHopRight", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime, handOffset: HangHopRight.handOffset));
                else if (neighbour.direction.x == -1)
                    StartCoroutine(JumpToLedge("HangHopLeft", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime, handOffset: HangHopRight.handOffset));
            }
            else if (neighbour.connectionType == ConnectionType.Move)
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.x == 1)
                    StartCoroutine(JumpToLedge("ShimmyRight", currentPoint.transform, ShimmyRight.matchStartTime, ShimmyRight.matchTargetTime, handOffset: ShimmyRight.handOffset));
                else if (neighbour.direction.x == -1)
                    StartCoroutine(JumpToLedge("ShimmyLeft", currentPoint.transform, ShimmyRight.matchStartTime, ShimmyRight.matchTargetTime, AvatarTarget.LeftHand, handOffset: ShimmyRight.handOffset));
            }

            #endregion

        }
    }

除了IdleToHang,其他的手部位置偏移量都在面板中设置:

1750161414311

效果如下:

1750125933004

该部分完整代码:

ClimbController.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ClimbController : MonoBehaviour
{
    [SerializeField] public MatchTimeParams idleToHang;//0.4~0.6    0.25,0.15,0.15
    [SerializeField] public MatchTimeParams HangHopUp;//0.34~0.65   0.25,0.18,0.15
    [SerializeField] public MatchTimeParams HangHopDown;//0.31~0.7  0.25,0.09,0.12
    [SerializeField] public MatchTimeParams HangHopRight;//0.2~0.8  0.25,0.19,0.09
    [SerializeField] public MatchTimeParams ShimmyRight;//0~0.38    0.25,0.18,0.12

    ClimbPoint currentPoint;
    EnvironmentScanner envScanner;
    PlayerController playerController;
    public bool IsOnClimbLedge { get; private set; }
    void Awake()
    {
        envScanner = GetComponent<EnvironmentScanner>();
        playerController = GetComponent<PlayerController>();
    }

    void Update()
    {
        if (!playerController.IsHanging)
        {
            #region IdleToHang
            if (Input.GetButton("Jump") && !playerController.InAction)  //其他动作不在播放时
            {
                IsOnClimbLedge = envScanner.ClimbLedgeCheck(transform.forward, out RaycastHit ledgeHit);
                if (IsOnClimbLedge)
                {
                    //currentPoint = 击中点对象的组件ClimbPoint
                    currentPoint = ledgeHit.transform.GetComponent<ClimbPoint>();
                    playerController.SetControl(false);
                    StartCoroutine(JumpToLedge("IdleToHang", ledgeHit.transform, idleToHang.matchStartTime, idleToHang.matchTargetTime));
                }
            }
            #endregion
        }
        else
        {
            #region Ledge To Ledge
      
            //Mathf.Round(...):对输入值四舍五入,确保结果为 +-1 / 0。
            float h = Mathf.Round(Input.GetAxisRaw("Horizontal"));
            float v = Mathf.Round(Input.GetAxisRaw("Vertical"));
            var inputDir = new Vector2(h, v);
      
            if (playerController.InAction || inputDir == Vector2.zero)
                return;

            var neighbour = currentPoint.GetNeighbour(inputDir);

            if (neighbour == null)  
                return;
            if (neighbour.connectionType == ConnectionType.Jump && Input.GetButton("Jump"))
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.y == 1)
                    StartCoroutine(JumpToLedge("HangHopUp", currentPoint.transform, HangHopUp.matchStartTime, HangHopUp.matchTargetTime, handOffset: HangHopUp.handOffset));
                else if (neighbour.direction.y == -1)
                    StartCoroutine(JumpToLedge("HangHopDown", currentPoint.transform, HangHopDown.matchStartTime, HangHopDown.matchTargetTime, handOffset: HangHopDown.handOffset));
                else if (neighbour.direction.x == 1)
                    StartCoroutine(JumpToLedge("HangHopRight", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime, handOffset: HangHopRight.handOffset));
                else if (neighbour.direction.x == -1)
                    StartCoroutine(JumpToLedge("HangHopLeft", currentPoint.transform, HangHopRight.matchStartTime, HangHopRight.matchTargetTime, handOffset: HangHopRight.handOffset));
            }
            else if (neighbour.connectionType == ConnectionType.Move)
            {
                //更新currentPoint为邻居攀岩架的point
                currentPoint = neighbour.point;
                if (neighbour.direction.x == 1)
                    StartCoroutine(JumpToLedge("ShimmyRight", currentPoint.transform, ShimmyRight.matchStartTime, ShimmyRight.matchTargetTime, handOffset: ShimmyRight.handOffset));
                else if (neighbour.direction.x == -1)
                    StartCoroutine(JumpToLedge("ShimmyLeft", currentPoint.transform, ShimmyRight.matchStartTime, ShimmyRight.matchTargetTime, AvatarTarget.LeftHand, handOffset: ShimmyRight.handOffset));
            }

            #endregion

        }
    }

    IEnumerator JumpToLedge(string anim, Transform ledge, float matchStartTime, float matchTargetTime,
                        AvatarTarget hand = AvatarTarget.RightHand,
                        Vector3? handOffset = null)
    {
        var matchParams = new MatchTargetParams()
        {
            matchPosition = getHandPos(ledge,hand,handOffset),
            matchBodyPart = hand,
            matchPositionXYZWeight = new Vector3(1, 1, 1),
            matchStartTime = matchStartTime,
            matchTargetTime = matchTargetTime
        };
        var targetRotation = Quaternion.LookRotation(-ledge.forward);
        yield return playerController.DoAction(anim, matchParams, targetRotation, true);
        playerController.IsHanging = true;
    }

    private Vector3 getHandPos(Transform ledge,AvatarTarget hand, Vector3? handOffset) {
        var offsetValue = (handOffset != null) ? handOffset.Value : new Vector3(0.25f, 0.17f, 0.14f);
        var handDir = (hand == AvatarTarget.RightHand) ? ledge.right : -ledge.right;
        return ledge.position + Vector3.up * offsetValue.y + ledge.forward * offsetValue.z - handDir * offsetValue.x; //Ledge的左边也就是人物的右边
    }
}

[System.Serializable]
public struct MatchTimeParams
{
    public float matchStartTime;
    public float matchTargetTime;
    public Vector3 handOffset;
}

多个边沿攀岩架之间横跳

添加为预制体:

1750127025016

构建连接关系

1750127007125

播放Jump类型

1750127508057

横跳的时候动画播放期间的转向在动作匹配开始时间之后才转向:

PlayerController.cs

DoAction()后面的参数都改为可选参数

    public IEnumerator DoAction(string animName, MatchTargetParams matchParams = null, Quaternion targetRotation = new Quaternion(),
                    bool needRotate = false, float actionDelay = 0f, bool mirrorAction = false)
    {
        //动画播放期间,暂停协程,并让角色平滑旋转向障碍物
        //动作匹配开始之后才进行旋转
        float rotationStartTime = (matchParams != null)? matchParams.matchStartTime : 0f;

        float timer = 0f;
        while (timer <= animStateInfo.length)
        {
            timer += Time.deltaTime;
            float normalizedTimer = timer / animStateInfo.length;
            //如果勾选该动作需要旋转向障碍物RotateToObstacle
            if (needRotate && normalizedTimer > rotationStartTime)
            {

Day13 Jump From Wall——从墙上跳下

添加动画,并修改y轴偏移

1750145663759

过渡到Falling

1750145883892

拷贝Jump重命名为Drop,改键为f和手柄的B

1750146162726

1750146174776

ClimbController.cs

Update()

        else
        {
            #region Jump from Hang
            if (Input.GetButton("Drop") && !playerController.InAction)
            {
                playerController.IsHanging = false;
                StartCoroutine(JumpFromHang());
                return;
            }
            #endregion
  
            #region Ledge To Ledge

为了防止在落地过程中角色转向错误,重置转向

    IEnumerator JumpFromHang()
    {
        yield return playerController.DoAction("JumpFromHang");
        playerController.ResetRotation();
        playerController.SetControl(true);
    }

PlayerController.cs

    //重置转向
    public void ResetRotation()
    {
        targetRotation = transform.rotation;
    }

1750569407682

Day14 Braced Hang To Crouch——从Hang状态爬到平台

添加动画

1750150602837

修改y轴根结点跟踪位置和偏移量

1750150640240

ClimbPoint.cs

    [SerializeField] bool mountPoint;
    public bool MountPoint => mountPoint;

每个边沿攀岩架的子标签 勾选Mount Point

1750155958650

ClimbController.cs

            #region Ledge To Ledge

            //Mathf.Round(...):对输入值四舍五入,确保结果为 +-1 / 0。
            float h = Mathf.Round(Input.GetAxisRaw("Horizontal"));
            float v = Mathf.Round(Input.GetAxisRaw("Vertical"));
            var inputDir = new Vector2(h, v);

      
            if (playerController.InAction || inputDir == Vector2.zero)
                return;

            //从Hang状态爬上Ledge
            if (currentPoint.MountPoint && inputDir.y == 1)
            {
                playerController.IsHanging = false;
                StartCoroutine(MountFromHang());
                return;
            }
    IEnumerator MountFromHang()
    {
        yield return playerController.DoAction("ClimbFromHang");
        playerController.ResetRotation();
        playerController.SetControl(true);
    }

这里有个问题,角色播放该动画的时候脚部会陷入一会儿障碍物

PlayerController.cs

    //角色物理控制(物理碰撞是否启用)
    public void EnableCharacterController(bool enabled)
    {
        charactercontroller.enabled = enabled;
    }

这个方法charactercontroller.enabled()是用来解决碰撞体问题的,启用则会开启角色的物理控制器,碰撞会正常进行

官方手册:Enabled Colliders will collide with other Colliders

我所写的 SetControl(true)EnableCharacterController(true)

在需要完全启用角色物理控制器(也就是物理碰撞)、输入、同步 UI/动画状态时,使用 SetControl(true):

    //角色输入控制
    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;

        }
    }

在仅需启用角色物理控制器而不影响输入时,使用 EnableCharacterController(true):

    //角色物理控制(物理碰撞是否启用)
    public void EnableCharacterController(bool enabled)
    {
        charactercontroller.enabled = enabled;
    }

Day15 Drop To Hang——从平台落回到Hang状态

添加动画并修改偏移量

1750165366948

1750166021621

需要一个检测边沿攀岩架的方法

EnvironmentScanner.cs

    /// <summary>
    /// 检测当前位置是否有边沿攀岩架
    /// </summary>
    /// <param name="ledgeHit"></param>
    /// <returns></returns>
    public bool DropLedgeCheck(out RaycastHit ledgeHit)
    {
        //out修饰的参数必须要先初始化
        ledgeHit = new RaycastHit();
        Vector3 origin = transform.position + Vector3.down * 0.1f + transform.forward * 2f;
        if (Physics.Raycast(origin, -transform.forward, out RaycastHit hit, 3f, climbLedgeLayer))
        {   
            ledgeHit = hit;
            return true;
        }
        return false;
    }

ClimbController.cs

    void Update()
    {
        if (!playerController.IsHanging)
        {
            #region IdleToHang
            if (Input.GetButton("Jump") && !playerController.InAction)  //其他动作不在播放时
            {
                IsOnClimbLedge = envScanner.ClimbLedgeCheck(transform.forward, out RaycastHit ledgeHit);
                if (IsOnClimbLedge)
                {
                    //currentPoint = 离射线击中点最近的ClimbPoint
                    currentPoint = GetNearestClimbPoint(ledgeHit.transform, ledgeHit.point);//击中点的物体本身(也就是对应的边沿攀岩架),和击中点
                    playerController.SetControl(false);
                    StartCoroutine(JumpToLedge("IdleToHang", currentPoint.transform, idleToHang.matchStartTime, idleToHang.matchTargetTime));
                }
            }
            #endregion

            #region Drop to Hang
            //这里发现动画有问题,转身不是完全180度转身,差大概30度,所以在JumpToLedge()中我对这个动画DropToHang加了一个旋转补偿
            if (Input.GetButton("Drop") && !playerController.InAction)
            {
                //需要一个检测边沿攀岩架的方法
                bool isOnDropLedge = envScanner.DropLedgeCheck(out RaycastHit ledgeHit);
                if (isOnDropLedge)
                {
                    //currentPoint = 离射线击中点最近的ClimbPoint
                    currentPoint = GetNearestClimbPoint(ledgeHit.transform, ledgeHit.point);//击中点的物体本身(也就是对应的边沿攀岩架),和击中点
                    playerController.SetControl(false);
                    StartCoroutine(JumpToLedge("DropToHang", currentPoint.transform, DropToHang.matchStartTime, DropToHang.matchTargetTime, handOffset: DropToHang.handOffset));
                }
            }
            #endregion
        }

从当前攀岩架上的每个点找到离射线击中点最近的挂点

    /// <summary>
    /// 从当前攀岩架上的每个点找到离射线击中点最近的挂点
    /// </summary>
    /// <param name="ledge"></param>攀岩架对象
    /// <param name="hitPoint"></param>射线击中攀岩架的点的位置
    /// <returns></returns>
    ClimbPoint GetNearestClimbPoint(Transform ledge, Vector3 hitPoint)
    {
        //获取边沿攀岩架的所有子对象的ClimbPoint节点 数组points
        var points = ledge.GetComponentsInChildren<ClimbPoint>();
        ClimbPoint nearestPoint = null;
        float minDistance = Mathf.Infinity;
        foreach (var point in points)
        {
            float distance = Vector3.Distance(point.transform.position, hitPoint);
            if (distance < minDistance)
            {
                minDistance = distance;
                nearestPoint = point;
            }
        }
        return nearestPoint;
    }

这个方法本质是个查找算法,这里因为数量较小就不用二分查找等优化算法了,如果边沿攀岩架的子标签比较密集的话可以考虑优化一下

1750170426074

1750170437368

// 这里发现动画有问题,转身不是完全180度转身,差大概30度,所以在JumpToLedge()中我对这个动画DropToHang加了一个绕y轴的旋转补偿,也就是水平旋转补偿

JumpToLedge()

        // 根据不同的动画类型添加额外的旋转补偿
        if (anim == "DropToHang")
        {
            // 添加180度旋转来补偿动画中的手部位置偏差
            targetRotation *= Quaternion.Euler(0, 30, 0);
        }

1750569461260

至此,一个类刺客信条的跑酷系统基本完毕!🎉🎉🎉

完整Demo演示视频:

【类刺客信条跑酷系统Demo】

后面我会加入战斗系统、第一人称视角切换、绳索跑酷、蹬墙跑等泰坦陨落中铁驭的玩法

posted @ 2025-05-10 20:02  EanoJiang  阅读(473)  评论(0)    收藏  举报