类刺客信条跑酷系统开发日志
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

第二部分(Day5~8):https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section2
视频太长,为了好上传这里我就上传了10帧率的GIF

完整部分,也就是第三部分(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);
}
}
怎么旋转这个相机呢?

摄像机向后移动的参量乘一个水平旋转角度
所以,引入四元数点欧拉Quaternion.Euler
这个水平视角旋转角度需要绕y轴的旋转角度,还需要鼠标控制这个角度
并且,当摄像头旋转的时候,摄像头始终对着player
public class CameraController : MonoBehaviour
{
//摄像机跟随的目标
[SerializeField] Transform followTarget;
//距离
[SerializeField] float distance;
//绕y轴的旋转角度
float rotationY;
private void Update()
{
//鼠标x轴控制rotationY
rotationY += Input.GetAxis("Mouse X");
//水平视角旋转参量
//想要水平旋转视角,所以需要的参量为绕y轴旋转角度
var horizontalRotation = Quaternion.Euler(0, rotationY, 0);
//摄像机放在目标后面5个单位的位置
transform.position = followTarget.position - horizontalRotation * new Vector3(0, 0, distance);
//摄像机始终朝向目标
transform.rotation = horizontalRotation;
}
}

完成了水平视角的旋转
让相机垂直旋转

还需要在垂直视角旋转的时候合理的限幅:让视角最高不超过45°,最低到人物的胸部位置
public class CameraController : MonoBehaviour
{
//摄像机跟随的目标
[SerializeField] Transform followTarget;
[SerializeField] float rotationSpeed = 1.5f;
//距离
[SerializeField] float distance;
//绕y轴的旋转角度——水平视角旋转
float rotationY;
//绕x轴的旋转角度——垂直视角旋转
float rotationX;
//限制rotationX幅度
[SerializeField] float minVerticalAngle = -20;
[SerializeField] float maxVerticalAngle = 45;
//框架偏移向量——摄像机位置视差偏移
[SerializeField] Vector2 frameOffset;
private void Update()
{
//鼠标x轴控制rotationY
rotationY += Input.GetAxis("Mouse X") * rotationSpeed;
//鼠标y轴控制rotationX
rotationX += Input.GetAxis("Mouse Y") * rotationSpeed;
//限制rotationX幅度
rotationX = Mathf.Clamp(rotationX, minVerticalAngle, maxVerticalAngle);
//视角旋转参量
//想要水平旋转视角,所以需要的参量为绕y轴旋转角度
var targetRotation = Quaternion.Euler(rotationX, rotationY, 0);
//摄像机的焦点位置
var focusPosition = followTarget.position + new Vector3(frameOffset.x, frameOffset.y, 0);
//摄像机放在目标后面5个单位的位置
transform.position = focusPosition - targetRotation * new Vector3(0, 0, distance);
//摄像机始终朝向目标
transform.rotation = targetRotation;
}
}

大致实现了摄像机跟随人物进行旋转
还需要一些细节调整:
private void Start()
{
//隐藏光标
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
考虑到存在大多数角色控制器都有控制反转的选项
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraController : MonoBehaviour
{
//摄像机跟随的目标
[SerializeField] Transform followTarget;
[SerializeField] float rotationSpeed = 1.5f;
//距离
[SerializeField] float distance;
//绕y轴的旋转角度——水平视角旋转
float rotationY;
//绕x轴的旋转角度——垂直视角旋转
float rotationX;
//限制rotationX幅度
[SerializeField] float minVerticalAngle = -20;
[SerializeField] float maxVerticalAngle = 45;
//框架偏移向量——摄像机位置视差偏移
[SerializeField] Vector2 frameOffset;
//视角控制反转
[Header("视角控制反转:invertX是否反转垂直视角,invertY是否反转水平视角")]
[SerializeField] bool invertX;
[SerializeField] bool invertY;
float invertXValue;
float invertYValue;
private void Start()
{
//隐藏光标
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
private void Update()
{
//视角控制反转参数
invertXValue = (invertX)? -1 : 1;
invertYValue = (invertY)? -1 : 1;
//水平视角控制——鼠标x轴控制rotationY
rotationY += Input.GetAxis("Mouse X") * rotationSpeed * invertYValue;
//垂直视角控制——鼠标y轴控制rotationX
rotationX += Input.GetAxis("Mouse Y") * rotationSpeed * invertXValue;
//限制rotationX幅度
rotationX = Mathf.Clamp(rotationX, minVerticalAngle, maxVerticalAngle);
//视角旋转参量
//想要水平旋转视角,所以需要的参量为绕y轴旋转角度
var targetRotation = Quaternion.Euler(rotationX, rotationY, 0);
//摄像机的焦点位置
var focusPosition = followTarget.position + new Vector3(frameOffset.x, frameOffset.y, 0);
//摄像机放在目标后面5个单位的位置
transform.position = focusPosition - targetRotation * new Vector3(0, 0, distance);
//摄像机始终朝向目标
transform.rotation = targetRotation;
}
}
我通常喜欢这样选择,垂直反转(鼠标向上就看上面),水平不反转(鼠标向左就看左边)
就是让摄像机视角和鼠标移动方向对我来说是同步的,相当于第一人称视角控制的习惯
Day2 第三人称人物控制脚本
前序准备
先创建个人物模型( 从Mixamo下载的)
导入unity中,选择模型后点开inspector-Materials-Textures,选一个文件夹存放纹理


OK,下面就开始为这个角色写控制脚本吧!
最简化的第三人称角色控制
public class PlayerController : MonoBehaviour
{
[SerializeField]float moveSpeed = 5f;
private void Update()
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
//标准化 moveInput 向量
var moveInput = new Vector3(h, 0, v).normalized;
transform.position += moveInput * moveSpeed * Time.deltaTime;
}
}
需要注意的:
-
.normalized:如果不进行标准化,
moveInput向量的长度会变得大于1(具体来说,比如h,v长度都为1,\(\sqrt{h^2 + 0^2 + v^2} = \sqrt{1^2 + 0^2 + 1^2} = \sqrt{2} \approx 1.414\))。这意味着在对角线方向上移动时,玩家的移动速度会比只在一个方向上移动时快。为了确保玩家在所有方向上移动时速度一致,需要对向量进行标准化。 -
Time.deltaTime:Time.deltaTime是Unity引擎提供的一个浮点数,表示从上一帧到当前帧所用的时间(以秒为单位)。使用Time.deltaTime可以确保玩家的移动速度在不同帧率下保持一致。如果不使用Time.deltaTime,在高帧率下玩家会移动得更快,在低帧率下玩家会移动得更慢。
改进
上面这样显然不能满足角色控制,因为当我们按下前进方向键的时候,人物并没有根据当前摄像机显示的方向移动
还需要进行如下改进:
在CameraController.cs里面加入
//水平方向的旋转,返回摄像机的水平旋转四元数。
public Quaternion PlanarRotation => Quaternion.Euler(0, rotationY, 0);
这里提一句C#中的特性:
大多数语言中想要获取一个返回值,需要定义一个函数,然后返回
public Quaternion GetPlanarRotation() { return Quaternion.Euler(0, rotationY, 0); }但是C#可以优雅的利用表达式主体定义的属性,直接获取这个属性
然后在PlayerController.cs里调用这个返回值
public class PlayerController : MonoBehaviour
{
[SerializeField]float moveSpeed = 5f;
CameraController cameraController;
private void Awake()
{
//相机控制器设置为main camera
cameraController = Camera.main.GetComponent<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);
实现效果如下:

该部分完整代码:
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动画
前序准备
有一个待解决的问题:如何让动画匹配任意人物模型?
- 找到人物模型,进行如下设置:

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

最后Done完成
- 找到要用到的动画,如下设置:

注意Avatar Definition要选择 从其他avatar复制,然后在source里面选择要应用的avatar
- 然后每个动画都进行如下设置:

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

而且几个动画的Length值最好要尽可能接近,以免后面切换的时候出现问题
- 新建一个角色控制器的动画脚本

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

拖入对应动画

在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()有四个参数的重载,第三个参数是要平滑到达的值
基本的第三人称角色控制器效果如下:

修改后的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
玩家:

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组件的特性。
效果如下:

碰撞检测
PlayerController.cs
[Header("ground check")]
[SerializeField]float groundCheckRadius = 0.5f;
//检测射线偏移量
[SerializeField]Vector3 groundCheckOffset;
[SerializeField]LayerMask groundLayer;
bool isGrounded;
Update()
#region 碰撞检测
GroundCheck();
Debug.Log("isGrounded: "+ isGrounded);
#endregion
检测函数和画线函数
private void GroundCheck()
{
// Physics.CheckSphere()方法会向场景中的所有碰撞体投射一个胶囊体(capsule),有相交就返回true
// 位置偏移用来在unity控制台里面调整
isGrounded = Physics.CheckSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius, groundLayer);
}
//画检测射线
private void OnDrawGizmosSelected()
{
//射线颜色,最后一个参数是透明度
Gizmos.color = new Color(0, 1, 0, 0.5f);
Gizmos.DrawSphere(transform.TransformPoint(groundCheckOffset), groundCheckRadius);
}
回到控制台进行如下设置:

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

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

重力设置

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轴方向的位置移动实时更新,即使没有输入也要更新。
效果如下:

下面设置skin width:
官方手册描述:
Character Controller——Skin width
两个碰撞体可以穿透彼此且穿透深度最多为皮肤宽度 (Skin Width)。较大的皮肤宽度可减少抖动。较小的皮肤宽度可能导致角色卡住。合理设置是将此值设为半径的 10%。

Center的更准确的设置:
Center.Y = Height /2 + Skin Width

改之前:

改之后:

可以看到,脚部完全贴合地面,that's good.
有个问题仍然存在:当我们在下落过程中,角色仍在播放走路动画
因此我们需要编写相应的动画逻辑,还可以加下落动画,这个放在后面再写,坑+1
手柄适配
下面改一下对手柄的适配,因为在写摄像机输入控制的时候用的是Mouse:
在Project Settings - Input Manager里面找到Mouse X和Y,分别复制两个副本,重命名为Camera X/Y

对第二个Camera进行如下设置
注意死区和灵敏度最好设置成和下面的Horizontal一样的值


回到CameraController.cs脚本,修改如下即可
//水平视角控制——鼠标(手柄)x轴控制rotationY
rotationY += Input.GetAxis("Camera X") * rotationSpeed * invertYValue;
//垂直视角控制——鼠标(手柄)y轴控制rotationX
rotationX += Input.GetAxis("Camera Y") * rotationSpeed * invertXValue;
有个小问题:键鼠控制的话就勾上X轴反转,手柄控制就不要勾了

效果就是手柄右摇杆也能控制相机视角了,演示就不放了,没啥区别
以上部分代码我放到了GitHub仓库:
https://github.com/EanoJiang/Parkour-Climbing-System/releases/tag/Section1
ok,可以开始编写最有意思的跑酷系统脚本了!
Day 5 跑酷系统——StepUp&JumpUp
跑酷系统构成:跑酷控制器+环境扫描

环境扫描
Environment Scanner
The environment scanner will scan for obstacles in front of the player by using multiple RayCasts.

放置不同高度的Cube,设置Position.y = Scale.y / 2
EnvironmentScanner.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnvironmentScanner : MonoBehaviour
{
[Header ("向前发送的射线相关参数")]
//y轴(竖直方向)偏移量
[SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);
//长度
[SerializeField] float forwardRayLength = 0.8f;
//障碍物层
[SerializeField] LayerMask obstacleLayer;
public void ObstacleCheck()
{
//让射线从膝盖位置开始发送
//射线的起始位置 = 角色位置 + 一个偏移量
var forwardOrigin = transform.position + forwardRayOffset;
//用来存射线检测的信息
RaycastHit hitInfo;
//是否击中障碍物
bool hitFound = Physics.Raycast(forwardOrigin, transform.forward,
out hitInfo,forwardRayLength, obstacleLayer);
//调试用的射线
//第二个参数dir:Direction and length of the ray.
Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength,
(hitFound) ?Color.red:Color.white);
}
}
然后在ParkourController.cs里调用ObstacleCheck()方法
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ParkourController : MonoBehaviour
{
EnvironmentScanner environmentScanner;
private void Awake()
{
environmentScanner = GetComponent<EnvironmentScanner>();
}
// Update is called once per frame
void Update()
{
environmentScanner.ObstacleCheck();
}
}
效果:射线会从膝盖左右的位置射出

下面把检测信息hitData抽象成一个结构体,
EnvironmentScanner.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnvironmentScanner : MonoBehaviour
{
[Header ("向前发送的射线相关参数")]
//y轴(竖直方向)偏移量
[SerializeField] Vector3 forwardRayOffset = new Vector3(0, 0.25f, 0);
//长度
[SerializeField] float forwardRayLength = 0.8f;
//障碍物层
[SerializeField] LayerMask obstacleLayer;
public ObstacleHitData ObstacleCheck()
{
var hitData = new ObstacleHitData();
//让射线从膝盖位置开始发送
//射线的起始位置 = 角色位置 + 一个偏移量
var forwardOrigin = transform.position + forwardRayOffset;
//是否击中障碍物
hitData.forwardHitFound = Physics.Raycast(forwardOrigin, transform.forward,
out hitData.forwardHitInfo, forwardRayLength, obstacleLayer);
//调试用的射线
//第二个参数dir:Direction and length of the ray.
Debug.DrawRay(forwardOrigin, transform.forward * forwardRayLength,
(hitData.forwardHitFound)? Color.red : Color.white);
return hitData;
}
}
public struct ObstacleHitData
{
//是否击中障碍物
public bool forwardHitFound;
//用来存射线检测的信息
public RaycastHit forwardHitInfo;
}
ParkourController.cs打印检测信息:击中的障碍物名字
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ParkourController : MonoBehaviour
{
EnvironmentScanner environmentScanner;
private void Awake()
{
environmentScanner = GetComponent<EnvironmentScanner>();
}
// Update is called once per frame
private void Update()
{
var hitData = environmentScanner.ObstacleCheck();
if (hitData.forwardHitFound)
{
//调试用:打印障碍物名称
Debug.Log("找到障碍:"+ hitData.forwardHitInfo.transform.name);
}
}
}
效果如下:

障碍高度检测
EnvironmentScanner.cs
ObstacleCheck()
//如果击中,则从击中点上方高度heightRayLength向下发射的射线
if(hitData.forwardHitFound){
var heightOrigin = hitData.forwardHitInfo.point + Vector3.up * heightRayLength;
hitData.heightHitFound = Physics.Raycast(heightOrigin,Vector3.down,
out hitData.heightHitInfo, heightRayLength, obstacleLayer);
//调试用的射线
//第二个参数dir:Direction and length of the ray.
Debug.DrawRay(heightOrigin, Vector3.down * heightRayLength,
(hitData.heightHitFound)? Color.red : Color.white);
}
结构体属性更新:
public struct ObstacleHitData
{
#region 从角色膝盖出发的向前射线检测相关
//是否击中障碍物
public bool forwardHitFound;
//用来存射线检测的信息
public RaycastHit forwardHitInfo;
#endregion
#region 从击中点垂直方向发射的射线检测相关
public bool heightHitFound;
public RaycastHit heightHitInfo;
#endregion
}
效果如下:

添加跑酷动作——翻越障碍StepUp
加一个动画StepUp
带位移动作的动画需要在角色的Animator组件里面勾选 Apply Root Motion
而且在这个动画实际作用的时候,角色的位置是向上移动的,所以不要勾选Bake Into Pose

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

ParkourController.cs
EnvironmentScanner environmentScanner;
Animator animator;
//是否在动作中
bool inAction;
private void Awake()
{
environmentScanner = GetComponent<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;
}
效果如下:

该部分完整修改代码:
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的右键菜单就可以看到

新建两个跑酷动作



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

结束帧:

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

ParkourAction.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
[SerializeField] string animName;
[SerializeField] float minHeigth;
[SerializeField] float maxHeigth;
public bool CheckIfPossible(ObstacleHitData hitData, Transform player)
{
//获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标
float height = hitData.heightHitInfo.point.y - player.position.y;
//只有在这个区间内才会返回true
if(height < minHeigth || height > maxHeigth)
{
return false;
}
else
{
return true;
}
}
//外部可访问的动画名称
public string AnimName => animName;
}
ParkourController.cs
private void Update()
{
if (Input.GetButton("Jump") && !inAction)
{
//调用环境扫描器environment scanner的ObstacleCheck方法的返回值:ObstacleHitData结构体
var hitData = environmentScanner.ObstacleCheck();
if (hitData.forwardHitFound)
{
//对于每一个在跑酷动作列表中的跑酷动作
foreach (var action in parkourActions)
{
//如果动作可行
if(action.CheckIfPossible(hitData, transform))
{
//播放对应动画
//StartCoroutine()方法:开启一个协程
//启动 DoParkourAction 协程,播放跑酷动画
StartCoroutine(DoParkourAction(action));
}
}
//调试用:打印障碍物名称
Debug.Log("找到障碍:" + hitData.forwardHitInfo.transform.name);
}
}
}
//跑酷动作
IEnumerator DoParkourAction(ParkourAction action)
{
//跑酷动作开始
inAction = true;
//禁用玩家控制
playerController.SetControl(false);
//从当前动画到指定的目标动画,平滑过渡0.2s
//CrossFade()方法:平滑地从当前动画过渡到指定的目标动画
animator.CrossFade(action.AnimName, 0.2f);
//暂停协程,直到下一帧继续执行,确保动画过渡已经开始。
yield return null;
//第0层动画,也就是StepUp,用来后面调用这个动画的长度等属性
var animStateInfo = animator.GetCurrentAnimatorStateInfo(0);
#region 调试用
if (!animStateInfo.IsName(action.AnimName))
{
Debug.LogError("动画名称不匹配!");
}
#endregion
//暂停协程,直到 "StepUp" 动画播放完毕。
yield return new WaitForSeconds(animStateInfo.length);
//启用玩家控制
playerController.SetControl(true);
//跑酷动作结束
inAction = false;
}
回到unity面板

效果如下:

修改后的完整代码:
ParkourAction.cs上面放了
ParkourController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ParkourController : MonoBehaviour
{
//定义一个面板可见的跑酷动作属性列表
[SerializeField] List<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勾选该动作播放期间需要转向障碍物

效果如下:

该部分修改的完整代码:
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);
}
这里我们匹配角色右脚(先抬起)就行,
首先,在动画剪辑中找到角色右脚开始离地的位置

再找到即将落地的位置

把标准化结果填入action里

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



其实只要结束的时间MatchTargetTime弄准确了就好,开始时间大概就行
有个问题需要特别注意的:
如果出现了动画播放后角色的脚位置莫名浮动一下,需要在动画里面勾选如下选项

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

完整的修改代码如下:
ParkourAction.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Parkour System/Parkour Action")]
public class ParkourAction : ScriptableObject
{
//动画名称
[SerializeField] string animName;
//高度区间
[SerializeField] float minHeigth;
[SerializeField] float maxHeigth;
[Header ("自主勾选该动作是否需要转向障碍物")]
[SerializeField] bool rotateToObstacle;
[Header("Target Matching")]
[SerializeField] bool enableTargetMatching;
[SerializeField] AvatarTarget matchBodyPart;
[SerializeField] float matchStartTime;
[SerializeField] float matchTargetTime;
//目标旋转量
public Quaternion TargetRotation { get; set; }
//匹配的位置
public Vector3 MatchPosition { get; set; }
public bool CheckIfPossible(ObstacleHitData hitData, Transform player)
{
//获取面前的障碍物高度 = 击中点上方一定高度的y轴坐标 - 玩家的y轴坐标
float height = hitData.heightHitInfo.point.y - player.position.y;
//只有在这个区间内才会返回true
if(height < minHeigth || height > maxHeigth)
{
return false;
}
//如果需要转向障碍物,才会计算目标旋转量
if (rotateToObstacle)
{
//目标旋转量 = 障碍物法线的反方向normal
TargetRotation = Quaternion.LookRotation(-hitData.forwardHitInfo.normal);
}
//如果需要匹配位置,才会计算匹配的位置
if (enableTargetMatching)
{
//heightHitInfo 是 从击中点垂直方向发射的射线 向下击中障碍物的检测信息
MatchPosition = hitData.heightHitInfo.point;
}
Debug.Log("障碍物的高度"+hitData.heightHitInfo.point.y);
return true;
}
//外部可访问的属性
public string AnimName => animName;
public bool RotateToObstacle => rotateToObstacle;
public bool EnableTargetMatching => enableTargetMatching;
public AvatarTarget MatchBodyPart => matchBodyPart;
public float MatchStartTime => matchStartTime;
public float MatchTargetTime => matchTargetTime;
}
ParkourController.cs
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
public class ParkourController : MonoBehaviour
{
//定义一个面板可见的跑酷动作属性列表
[SerializeField] List<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组合来完成这个动画,并且剪辑动画只保留必要的部分



Action脚本设置如下:


注意这个动作选右手。
Animator

注意名字。
而且由于脚本里,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;
}
效果如下:

该部分修改的完整代码
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的判断




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

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右手
如果从障碍物的右边沿开始撑手翻越,动画则不镜像翻转并target matching左手
fine,开始修改相应脚本:
新建一个脚本VaultAction.cs继承ParkourAction
在ParkourAction中将CheckIfPossible()设置为虚函数,并在VaultAction中重写
ParkourAction.cs
public virtual bool CheckIfPossible(ObstacleHitData hitData, Transform player)
{
...
}
可以对带有Fence标签的障碍物进行边沿判断,以水平方向中心位置为0,小于0就是左边沿,大于0就是右边沿

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

在局部空间坐标系中,
如果击中点的
z<0 && x<0 或者 z>0 && x>0 就镜像,跟踪右手
反之就不镜像,跟踪左手
所以还需要一个标志位 Mirror
并且在VaultFence的动画里,Mirror是否镜像动画可以由一个自定义参数决定,参数名这里设置为 mirrorAction



ParkourAction.cs里设置标志位参量 Mirror,在VaultAction里赋值 Mirror,再去ParkourController里SetBool赋值给animator动画组件的参量 mirrorAction
ParkourAction.cs
//动作镜像
public bool Mirror { get; set; }
VaultAction.cs里编写 Mirror和 matchBodyPart的赋值逻辑
public class VaultAction : ParkourAction
{
public override bool CheckIfPossible(ObstacleHitData hitData, Transform player)
{
// 虚函数原有逻辑不变
if(!base.CheckIfPossible(hitData, player)){
return false;
}
// 增加额外的检查条件
//击中点从全局坐标空间转到Fence上的局部坐标空间
var hitPointFence = hitData.forwardHitInfo.transform.InverseTransformPoint(hitData.forwardHitInfo.point);
if( (hitPointFence.z < 0 && hitPointFence.x < 0) || (hitPointFence.z > 0 && hitPointFence.x > 0) ){
//左边沿,镜像,跟踪右手
Mirror = true;
matchBodyPart = AvatarTarget.RightHand;
}
else{
//右边沿,不镜像,跟踪左手
Mirror = false;
matchBodyPart = AvatarTarget.LeftHand;
}
return true;
}
}
注意:matchBodyPart需要在ParkourAction.cs里面加上访问修饰符 protected,让子类VaultAction.cs能够访问
[SerializeField] protected AvatarTarget matchBodyPart; //在内部和子类可访问
ParkourController.cs里赋值给动画组件animator
//跑酷动作
IEnumerator DoParkourAction(ParkourAction action)
{
...
//设置动画是否镜像
animator.SetBool("mirrorAction", action.Mirror);
...
}
然后需要新建一个VaultAction脚本的对象,
VaultAction.cs
// 和ParkourAction一样,可以通过右键菜单新建脚本相应的对象
[CreateAssetMenu(menuName = "Parkour System/Custom Actions/New Vault Action")]

参数和VaultFence一样,但是是VaultAction类的对象
记得更新Player的面板参数:

大功告成!效果如下:

完整修改代码如下:
ParkourController.cs
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
public class ParkourController : MonoBehaviour
{
//定义一个面板可见的跑酷动作属性列表
[SerializeField] List<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
}
效果如下:


该部分完整修改代码:
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一样需要组合动画来实现:
起跳 + 空中下落 + 着陆



注意:
Falling 要勾选LoopPose,动画循环
起跳和着陆动画选择Y轴变化跟踪Feet,这样可以避免起跳和着陆时脚部的y轴位置浮动
Animator如图设置

加一个isGrounded标志位用来在Falling->Standing时条件判断,只有在isGrounded == true 的时候才会播放着陆动画。
isGrounded需要在PlayerController.cs中地面检测后赋值
PlayerController.cs
#region 地面检测
GroundCheck();
animator.SetBool("isGrounded", isGrounded);
ParkourController.cs
[Header("跳下悬崖动画")]
[SerializeField] ParkourAction JumpDownAction;
Update():
#region 悬崖跳下动作
//在悬崖边沿且不在播放动作中
if(playerController.IsOnLedge && !inAction)
{
playerController.IsOnLedge = false;
StartCoroutine(DoParkourAction(JumpDownAction));
}
#endregion
一个Bug:如果跳落的时候不按方向键,就会出现Falling动画播放异常
Bug修复——Falling动画异常
这里有个问题折磨我一个小时才想明白:
因为GroundCheck()方法在Update()调用,但是每次进入动画的时候,角色控制禁用,PlayerdController是被禁用的。
禁用角色控制后,PlayerController中的Update方法会提前返回(因为!hasControl为true时会执行return),导致GroundCheck()不会被调用,isGrounded也就不会更新。
所以如果不按方向键,这个isGrounded就一直是true,所以Falling动画只播放一遍就切换到了Standing动画,没有循环。
ParkourController.cs 的 DoParkourAction()方法:
IEnumerator DoParkourAction(ParkourAction action)
{
//跑酷动作开始
inAction = true;
//禁用玩家控制
playerController.SetControl(false);
这个问题应该这么被解决:
只有在地上的时候,速度才会被更新,这样就不会在空中也能控制角色,也解决了Falling播放异常的问题。
PlayerController.cs
var velocity = Vector3.zero;
#region 地面检测
GroundCheck();
animator.SetBool("isGrounded", isGrounded);
if (isGrounded)
{
//设置一个较小的负值,让角色在地上的时候被地面吸住
ySpeed = -0.5f;
velocity = moveDir * moveSpeed;
//在地上的时候进行悬崖检测,传给isOnLedge变量
IsOnLedge = environmentScanner.LedgeCheck(moveDir);
#region 调试用
if (IsOnLedge)
{
Debug.Log("On Ledge");
}
#endregion
}
else
{
//在空中时,ySpeed受重力控制
ySpeed += Physics.gravity.y * Time.deltaTime;
//简单模拟有空气阻力的平抛运动:空中时的速度设置为角色朝向速度的一半
velocity = transform.forward * moveSpeed / 2;
}
#endregion
//更新y轴方向的速度
velocity.y = ySpeed;
还有bug:当角色到达悬崖边沿的时候按下反方向键,角色动作会出现异常
Bug修复——转向过大动画异常
这是因为要转向的角度过大,所以要限制当转向角过大时不允许播放JumpDown
这就需要得到一个如图所示的转向角:

怎么得到转向角?
Vector3.Angle(transform.forward, ledgeHitData.hitSurface.normal)
怎么找到hitSurface

EnvironmentScanner.cs
/// <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
}
效果如下:

该部分修改的完整代码:
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一下游科,为什么黑神话刚发售的时候浮屠界设计的那么恶心,你知道放空气墙,但是一个简单的防跌落机制却不做
这个机制和空气墙很不一样,玩家可能无心来到地形边缘,你放空气墙虽然能解决这个问题,但是当玩家有心想要过去,这时空气墙的弊端就出现了,它会极大挫败玩家的心流,也就是出戏,沉浸感大打折扣。
如何实现?

法线方向(蓝色)与人物将要移动的方向如果小于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
效果如下:

该部分完整修改代码如下:
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——摇杆移动过于精确导致的撞墙问题

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

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

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;
}
}
另一个问题存在:

如果这样站在悬崖边沿,此时按下前进键,角色不会有任何变化
所以我想让他这样之后转向朝向悬崖边沿,但是只是转向不会移动
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);
效果如下:

该部分修改的完整代码如下:
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检测射线是以人物中轴为起点的
所以可以用三条射线来同步检查悬崖边沿。

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

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

存在问题如下:
悬崖边沿竖直表面检测射线hitSurface还是和原来一样与角色当前位置关联,而不是与这三条hitGround关联,所以实际的边沿限制机制会出问题。
解决方案:
当hitsGround列表里只有一条线击中地面,悬崖边沿竖直表面检测射线hitSurface(也就是从外向里发送的射线)也只能以这个hitGround射线为起点,也就是以validHits[0]的位置为surfaceRay的基准点(也就是surfaceRayOrigin),surfaceRayOrigin的y坐标和之前一样再以角色当前位置的y坐标向下偏移一点即可
修改后:
// 射线起始位置:脚底向前moveDir再向下偏移一些
var surfaceRayOrigin = validHits[0].point;
surfaceRayOrigin.y = transform.position.y - 0.1f;
// 射线是否击中:击中点在悬崖竖直表面,赋值给hitSurface
if (Physics.Raycast(surfaceRayOrigin, transform.position - surfaceRayOrigin, out RaycastHit hitSurface, 2f, obstacleLayer))
{
Debug.DrawLine(surfaceRayOrigin, transform.position, Color.cyan);
...
也就是修改后的悬崖边沿竖直表面检测射线hitSurface 以 validhits[0] 为基准点。

如果validHits列表有两个三个,那也没事,因为这时三个点的位置以哪个为准都行,都可以用来给限制边沿移动机制用。
如果想让边沿检测更严格,也就是离边沿更远,可以增大
originOffset
Bug——人物着陆后的滑步问题
状态机行为的引入
需要在着陆时短暂禁用角色输入控制
在原有的动画中,人物JumpDown是由起跳+下落+着陆 组成的,所以在代码中我们找不到控制着陆的参数
ok,我们可以把现有动画用状态机控制,加一个 ControlStoppingAction脚本
让进入该动画的时候禁用控制,结束该动画恢复控制
这就需要两个接口,一个进入状态一个退出状态时调用
实际上所有动画都可以这样做,方便我们实现自己想要的效果
ControlStoppingAction脚本继承于状态机行为


PlayerController.cs加一个公开的可以外部传参的角色控制权属性:
//角色控制权属性,可以外部传参
public bool HasControl{
get => hasControl;
set => hasControl = value;
}
ControlStoppingAction.cs
using System.Buffers.Text;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ControlStoppingAction : StateMachineBehaviour
{
PlayerController player;
//进入该状态时调用
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
//如果player还没有被赋值,则获取player组件
if (player == null)
{
player = animator.GetComponent<PlayerController>();
}
//禁用玩家的控制
player.HasControl = false;
}
//退出该状态时调用
public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
//恢复玩家的控制
animator.GetComponent<PlayerController>().HasControl = true;
}
}
回到Unity-Animator面板
给着陆动画加入上面状态机行为脚本

原理:
进入该动画的时候调用OnStateEnter()禁用控制,
结束该动画调用ExitStateEnter()恢复控制。
这就实现了着陆动画完全播放完才会启用玩家输入控制
(其他动画都可以添加这个脚本,可以很好解决滑步问题)
效果如下:

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


增加起跳的退出时间,缩短过渡时间即可
体验优化——当落差不大的时候可以自动 JumpDown 而不是手动按下Jump键
ParkourController.cs
[Header("自动跳下高度")]
[SerializeField] float autoJumpDownHeight = 1f;
#region 悬崖跳下动作
//在悬崖边沿且不在播放动作中且前方没有障碍物
if(playerController.IsOnLedge && !inAction && !hitData.forwardHitFound)
{
//低矮的落差shouldJump == true,直接播放JumpDown动画
bool shouldJump = true;
//只有高度大于autoJumpDownHeight 且 玩家按下跳跃键才会跳下悬崖
if(playerController.LedgeHitData.height > autoJumpDownHeight && !Input.GetButtonDown("Jump")){
shouldJump = false;
}
//偏差角度小于50度,才会播放JumpDown动画
if(playerController.LedgeHitData.angle <= 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 代码整理
在开始实现后面的爬墙系统之前,对已有代码进行整理和去耦合
一个小问题
检查的时候发现的一个小问题:

这个参数其实只适合解决非组合动画的滑步问题
而组合动画,最好要用这个状态机行为脚本才能控制最后一个动画

解耦一些可复用的代码
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()
实现思路:

在人物朝向上循环发射多条平行检测射线,
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
这里由于新的动画与原有模型不匹配,可以下载新模型,然后继承模型选择新的模型(与新动画骨骼名字是匹配的)

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

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



为新动作Idle To Braced Hang & Hanging Idle编写控制脚本
注意:换人物模型的时候需要给Player的Animator组件进行对应设置:Prefab和Avatar都需要选择对应模型映射到unity的Avatar文件
HangingIdle

匹配时间:


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

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

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


解决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的左边也就是人物的右边
}
}

至此,只有当攀岩架的z轴指向外侧才能正确播放动画,后面来解决这个问题
Climbing Network——可视化各个攀岩架之间的连接关系
这将决定攀岩是如何从一个跳向另一个
创建一个预制体


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);
}
}
}
效果:

Ledge To Ledge——跳到另一个攀岩架
加入动画,注意高度是否统一,用y轴偏移量让他们统一




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






仍有问题,每个动画匹配位置参数中的匹配位置getHandPos应当自适应
这个后面会解决
HangHopRight/Left——左右跳



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)
最终效果:

该部分完整代码如下:
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
创建一个长条边沿攀岩架:

然后去除预制体属性

创建子物体并像前面一样连线构成网络:
注意这里要勾选Move


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

修改动画
Animator

因为左移的时候匹配的是左手,所以还需要设置一个变量来区分左右手
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的左边也就是人物的右边
}

这里出了一个问题,一直按着输入键,目标匹配会失效,这是因为过渡时间太长
修改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,其他的手部位置偏移量都在面板中设置:

效果如下:

该部分完整代码:
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;
}
多个边沿攀岩架之间横跳
添加为预制体:

构建连接关系

播放Jump类型

横跳的时候动画播放期间的转向在动作匹配开始时间之后才转向:
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轴偏移

过渡到Falling

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


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

Day14 Braced Hang To Crouch——从Hang状态爬到平台
添加动画

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

ClimbPoint.cs
[SerializeField] bool mountPoint;
public bool MountPoint => mountPoint;
每个边沿攀岩架的子标签 勾选Mount Point

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状态
添加动画并修改偏移量


需要一个检测边沿攀岩架的方法
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;
}
这个方法本质是个查找算法,这里因为数量较小就不用二分查找等优化算法了,如果边沿攀岩架的子标签比较密集的话可以考虑优化一下


// 这里发现动画有问题,转身不是完全180度转身,差大概30度,所以在JumpToLedge()中我对这个动画DropToHang加了一个绕y轴的旋转补偿,也就是水平旋转补偿
JumpToLedge()
// 根据不同的动画类型添加额外的旋转补偿
if (anim == "DropToHang")
{
// 添加180度旋转来补偿动画中的手部位置偏差
targetRotation *= Quaternion.Euler(0, 30, 0);
}

至此,一个类刺客信条的跑酷系统基本完毕!🎉🎉🎉
完整Demo演示视频:
后面我会加入战斗系统、第一人称视角切换、绳索跑酷、蹬墙跑等泰坦陨落中铁驭的玩法





浙公网安备 33010602011771号