Unity笔记-29-ARPG游戏项目-03-攀爬系统
需求说明
当人物靠近墙壁时,并且对着墙壁执行移动按键时,此时会从普通状态进入到爬墙状态,这是一个进入攀爬的初始化状态,进入攀爬状态后,WASD
控制攀爬方向而不能控制人物移动,跳跃键提供攀跳而不能控制人物普通跳跃;按下X
会立刻退出攀爬状态,半空中则会直接坠落;当人物攀爬到墙顶,会播放攀顶动画,应用根运动,攀爬到墙顶,并推出攀爬状态;
特别强调:攀爬状态下,无法移动和跳跃;
不同的攀爬环境
这张图中,我们可以看到至少三种攀爬环境:竖直;弧形;倾斜;
人物需要能够在这些不同的环境下攀爬并且能平滑过渡与环境的变化
第一步:上墙
我们首先需要实现人物从普通状态进入攀爬初始状态,简单来说就是先能够攀在墙上,简称上墙
那么这里我们就需要通过射线检测的方式去检测面前是否有墙,检测到了还需要判断人物是否持续向这个方向移动,否则容易让角色以靠近墙就进入上墙,很多之后只是路过,否则会很影响体验。这个检测在每次上墙的短暂时间里只检测一次,不能多次检测否则会一直初始化,导致一直上墙,就导致BUG了。
注:我这里的墙体检测是写在移动脚本组件里的
/// <summary>
/// 墙壁检测-判断是否进入攀爬状态
/// </summary>
private void WallProcess()
{
if (climbTest.canClimb) return;
Vector3 rayDir = moveDirection.magnitude > 0.1 ? moveDirection : transform.forward;
if (!Physics.Raycast(transform.position + wallRayHeightOffset * Vector3.up, rayDir,out wallHit,wallRayLength, environmentLayerMask))
{
isExistWall = false;
isCheckWall = false;
wallDelayCounter = wallDelayTime;
return;
}
else
{
isExistWall = true;
}
//如果检测到墙壁并且当前还未检测过墙壁
if (isExistWall && !isCheckWall)
{
isCheckWall = true;
}
if (isCheckWall)//如果检测到墙壁了
{
if (wallDelayCounter < 0)
{
jumpTest.canJump = false;
canMove = false;
rigidbody.useGravity = false;
rigidbody.isKinematic = true;
climbTest.wallHit = wallHit;
climbTest.InitClimb();
climbTest.canClimb = true;
}
else
{
wallDelayCounter -= Time.deltaTime;
}
}
}
首先判断是否能够攀爬,则直接返回;因为正常情况下是不能攀爬的,这个值为false
,如果能攀爬就说明已经进入了攀爬状态,不需要检测上墙过程了;
然后以移动方向作为射线检测的方向,这里还要注意,由于一般人物模型的坐标点在脚底,因此这里需要添加一点高度上的偏移,否则容易检测到地板,导致上墙上在了地板上,那就闹出笑话来了;这里还需要使用wallRayLength
限定墙体检测距离,不可能距离墙很远也能上墙吧;如果检测到了则将isExistWall
置为true
,否则将一系列参数重置;这里说明一下wallDelayCounter
这是一个计时器,判断玩家是否持续向这个方向移动,因为如果朝向更改就不会检测到了,就能避免玩家不小心往墙体移动就导致上墙,但实际上并不想上墙;
在判断如果isExistWall
并且!isCheckWall
则将isCheckWall
置为true
。这里貌似有点多余,阅者可以自己适当删减。
如果isCheckWall
为true
,则判断已经检测到墙壁了,此时开始计时器的倒计时,判断玩家是否持续朝这个方向移动,如果计时器小于0
,则表示上墙判定成功,则执行上墙操作:首先将跳跃和移动的允许执行改为不允许,并将重力关闭,
将检测到的墙体对象传给攀爬脚本组件,并执行攀爬脚本的初始化方法,并将canClimb
置为true
。
到这里,上墙的准备工作:墙体检测算是完成了。
接下来初始化上墙位置
/// <summary>
/// 初始化上墙位置
/// </summary>
/// <param name="hit"></param>
public void InitClimb()
{
onWall = false;
targetPos = wallHit.point + wallHit.normal * wallOffset-GameConst.WALL_CHECK_ORIGION_OFFSET*Vector3.up;
//Debug.Log(targetPos);
ani.CrossFade(GameConst.IDLETOCLIMB_STATE, 0.2f);
ani.SetFloat(GameConst.CLIMB_PARAM_IDLETOCLIMB, 1, 0.5f, Time.deltaTime);
}
这里引入onWall
表示玩家此时是否在墙体上,上墙初始化不算在墙上,因此置为false
,并将墙体位置通过墙面法线向量计算偏移后的到适当的上墙位置targetPos
即可,随后就是动画设置,这里阅者自己完成;
获得上墙位置后,需要对人物位置进行设置
private void SetBodyPosition()
{
if (Vector3.Distance(transform.position, targetPos) < 0.01f)
{
onWall = true;
transform.position = targetPos;
return;
}
Vector3 lerpTargetPos = Vector3.MoveTowards(transform.position, targetPos, 0.2f);
transform.position = lerpTargetPos;
}
首先判断如果人物位置已经接近墙体位置,则将onWall
置为true
,并直接将位置赋值,再返回;否则则通过lerp
的方式将玩家移动到目标位置
Update
private void Update()
{
if (Input.GetButtonDown(GameConst.EXITCLIMB_BUTTON) && canClimb)
{
ExitClimb();
}
if (canClimb)
{
inputDelta = new Vector2(Input.GetAxis(GameConst.HORIZONTAL_AXIS), Input.GetAxis(GameConst.VERTICAL_AXIS));
if (!onWall)
{
SetBodyPosition();
}
else
{
ClimbUpToLand();
HopHandler();
MoveHandler();
FixBody();
}
}
}
这里首先判断是否按下X
推出攀爬按键并且当前正在攀爬,如果成立则推出攀爬,如果不在攀爬状态,按下X
是无效的。
在判断是否进入攀爬状态,如果进入,则通过虚拟轴获取移动的二维方向向量;
在判断时候以及完成上墙初始化,如果没有完成onWall
为false
,会继续设置初始化位置,如果完成则执行else
里的语句。
第二步:爬起来
这里通过动画根动作实现,逻辑十分简单,直接上代码,仅供参考,需要阅者自己完成
public void MoveHandler()
{
if (inputDelta != Vector2.zero)
{
//count = 1;
ani.SetFloat(GameConst.CLIMB_PARAM_HORIZONTAL, inputDelta.x);
ani.SetFloat(GameConst.CLIMB_PARAM_VERTICAL, inputDelta.y);
}
else
{
ani.SetFloat(GameConst.CLIMB_PARAM_HORIZONTAL, 0);
ani.SetFloat(GameConst.CLIMB_PARAM_VERTICAL, 0);
}
}
public void HopHandler()
{
if (Input.GetButtonDown(GameConst.JUMP_BUTTON)&inputDelta.magnitude>0.4f)
{
ani.CrossFade(GameConst.CLIMBHOP_STATE,0f);
}
}
第三步:位置与旋转修正
这一步最为重要,因为,人物在攀爬过程中,如果没有位置和旋转的不断修正,是会导致穿模的,这对于玩家来说是难以接受的。
因此这里需要详细的说明,老规矩先上代码,再讲解
private void FixBody()
{
//世界坐标转局部坐标
localHelperPos = transform.InverseTransformPoint(climbHelper.position);
//头部世界坐标
headPos = transform.TransformPoint(localHelperPos.x, localHelperPos.y, 0);
//计算固定位置
RaycastHit hit;
if (Physics.SphereCast(new Ray(headPos, transform.forward), 0.1f, out hit,fixedCheckLength, environmentLayerMask))
{
//位置修正
Vector3 temp = transform.position - climbHelper.position;
if (Vector3.Distance(transform.position, hit.point + temp) > 0.01f)
{
transform.position = hit.point + temp;
}
//Yaw轴修正
Vector3 normal_Yaw = Vector3.ProjectOnPlane(hit.normal, upAxis);
Vector3 forward_Yaw = Vector3.ProjectOnPlane(transform.forward, upAxis);
float angle_Yaw = 180 - Vector3.Angle(normal_Yaw, forward_Yaw);
Vector3 cross_Yaw = Vector3.Cross(normal_Yaw, forward_Yaw);
if (cross_Yaw.y < 0) angle_Yaw = -angle_Yaw;
transform.rotation = Quaternion.AngleAxis(angle_Yaw, transform.up) * transform.rotation;
//Pitch轴修正
Vector3 normal_Pitch = Vector3.ProjectOnPlane(hit.normal, transform.right);
Vector3 forward_Pitch = Vector3.ProjectOnPlane(transform.forward, transform.right);
float angle_Pitch = 180 - Vector3.Angle(normal_Pitch, forward_Pitch);
Vector3 cross_Pitch = Vector3.Cross(normal_Pitch, forward_Pitch);
if (Vector3.Angle(transform.right, cross_Pitch)>90) angle_Pitch = -angle_Pitch;
//Debug.Log(angle_Pitch);
if (angle_Pitch < -45)
{
ExitClimb();
return;
}
Quaternion relative = Quaternion.AngleAxis(angle_Pitch, transform.right) * transform.rotation;
transform.rotation = Quaternion.Lerp(transform.rotation,relative,fixTime);
}
}
首先我们需要一个空对象,这个对象事先创建好作为人物的子级跟随人物移动,将他的位置放在人物模型的前方,作为攀爬位置修正的辅助位置,人物需要与墙体保持一段恒定的距离以防止穿模,这个恒定距离就是事先设定好的辅助位置到固定直线距离(不是坐标的直线距离)的固定直线位置,图解如下:
当然只是修正位置还不够,攀爬时,人物朝向必须与墙面垂直,人物纵向朝向必须与墙面平行,因此,这里就需要两个自由度上的角度修正
思路都是一样的:
射线检测墙体,通过墙体的法向向量与人物朝向的夹角修正即可,左右通过叉乘判断,注意获取夹角时,需要先将想来那个投影在对应的面上,确保获得的夹角时正确的,可以用Debug
确定,注意一点,在pitch
轴修正的时候角度的正负需要通过判断叉乘向量和人物的右方是否一致,而不是简单的大于0
或者小于0
,Yaw轴由于重力方向不会变化,因此左右判断通过大于0
或小于0
判断即可,这是二者的细微不同之处,修正完方向后,叠加四元数后lerp
给角色的rotation
即可。pitch
轴注意一个,当墙体负倾斜过大时,人物应当直接掉落而非能够继续攀爬;
第四部:攀爬到墙顶
添加一个辅助检测位置,将这个位置放在头顶上方,检测是否有墙体,如果头顶已经检测不到墙体了,则执行攀到墙顶的操作,值得注意的是:注意射线检测距离,设置的近一点,防止由于地形倾斜导致检测到地面,其余较为简单不再赘述
public void ClimbUpToLand()
{
Vector3 rayDir = moveTest.moveDirection.magnitude > 0.2f ?moveTest.moveDirection:transform.forward;
//Vector3 rayDir = transform.forward;
if(!Physics.SphereCast(new Ray(climbUpHelper.position, rayDir),0.1f,upCheckLength,environmentLayerMask))
{
canClimb = false;
ani.CrossFade(GameConst.CLIMBTOUP_STATE, 0f);
Invoke("ExitClimb", ani.GetCurrentAnimatorStateInfo(1).length);
}
}
最后,其实还有IK
动画的设置,这就交给阅者自己完成了,这个相对前面的会简单些。
当然,这样的攀爬系统仍然不够完善,阅者自己完成它吧!
最后补充各类参数的定义
[Header("是否能够攀爬")]
public bool canClimb;
[Header("是否在墙上")]
public bool onWall;
[Header("地形层")]
public LayerMask environmentLayerMask;
[Header("贴墙位置偏移")]
[Range(0,1)]
public float wallOffset;
[Header("辅助坐标-贴墙")]
public Transform climbHelper;
[Header("辅助坐标-墙顶")]
public Transform climbUpHelper;
[Header("修正过渡时间")]
[Range(0,0.5f)]
public float fixTime;
[Header("墙顶持续检测范围")]
[Range(0, 2)]
public float upCheckLength;
[Header("修正持续检测范围")]
[Range(0, 2)]
public float fixedCheckLength;
private Animator ani;//动画组件
private Rigidbody rigidbody;//刚体组件
[HideInInspector]
public RaycastHit wallHit;//墙体检测点
private Vector3 upAxis;//Yaw轴
private Vector2 inputDelta;//攀爬二维输入量
private JumpTest jumpTest;//跳跃组件
private MoveTest moveTest;//移动组件
private Vector3 targetPos;//贴墙初始目标位置
private Vector3 localHelperPos;//临时存储位置
private Vector3 headPos;//头部位置