说明
角色的位移主要是靠爬墙动画的根运动来实现
本代码主要是对墙体进行检测,将角色固定在墙体表面,以及对攀爬状态进行管理
思路
我们先来想一下整个实现的过程:
一开始人物是正常行走,然后发现前面有一堵墙,然后开始往上爬,爬呀爬,爬到顶上的时候,最后手掌撑着墙爬到墙顶。
这样,我们就将攀爬分为大体四个步骤:
①进入爬墙状态 ②爬墙 ③爬到墙顶④退出爬墙状态
一、进入爬墙状态
已经检测到墙壁,并且在玩家控制按键向前走的时候,并且要持续一段时间,才可以上墙。
进入爬墙状态时,为了根运动动画的正确播放,要勾选IsKinematic,把角色刚体设为运动学刚体(即不受外力影响)。
二、爬墙
角色在墙壁上移动时,必须时刻贴着墙壁,并且面朝墙壁,但又应该有一段距离。
我们需要时刻约束角色的x轴、z轴的位置(不约束物体所处的高度),以使其贴于墙面。
由 墙体某点 + 墙体某点的法向 * 水平偏移量(需丢弃y值,令其不变),可以计算得到角色的目标位置。
然后让角色的朝向与墙面的法向保持一致。
三、爬到墙顶
播放登顶动画,这个动画需要应用根运动,也就是让动画改变角色的位置。
为了应对动画资源的根运动不准确,在动画播放到最后时,更改角色的位置,令角色置于墙体顶部。
四、退出爬墙状态
要将角色的运动学刚体设为普通刚体。
墙体的检测
首先,先从角色的体心进行一次球形检测,范围为角色的两倍半径,获取范围内的全部碰撞体。
然后在这些碰撞体中,找一个距离角色体心最近的点。
将这个点与角色体心连起来,丢弃y轴的信息,就是水平面上射线检测的方向。
从角色的底部和顶部分别依照这个方向发射射线,如果都检测到了,才能确定检测到了墙壁。
动画状态的管理
动画状态机连线
仅 Climb Top -> start 有退出时间
所需参数
需要一个 int 类型 的参数 Climb 、 一个 float 类型 的参数 ClimbSpeed
各参数其值代表的含义:
Climb
Climb = 0 在墙面上保持静止
Climb = 1 向上爬 或 向下爬
Climb = 2 攀顶
ClimbSpeed
用于控制播放上爬动画的速度和方向
向上爬时,ClimbSpeed设为1
向下爬时,ClimbSpeed设为-1
静止时,ClimbSpeed设为0
关键代码
判断攀顶动画播放至末尾
if(GetComponent<PlayerControl>().anim.GetCurrentAnimatorStateInfo(0).IsName("Climb Top") &&
GetComponent<PlayerControl>().anim.GetCurrentAnimatorStateInfo(0).normalizedTime > 0.95)
将角色置于墙体顶部
//把角色放到墙顶
transform.position = new Vector3(transform.position.x,
currentCollider.bounds.center.y + currentCollider.bounds.size.y * 0.5f + 0.03f,
transform.position.z);
//往前走1个单位
transform.position = transform.position + transform.forward;
代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ClimbingSystem : MonoBehaviour
{
public ClimbStatus climbStatus;
public enum ClimbStatus
{
notAtClimbing,
isOnTheWall,
isOnTheTopOfTheWall,
}
void Awake()
{
if (wall == null)
wall = new GameObject("Wall").transform;
}
float v;
float h;
float p;
void FixedUpdate()
{
Debug.DrawRay(transform.position + new Vector3(-0.5f, 0, 0), -Vector3.up, Color.yellow);
v = Input.GetAxis("Vertical");
h = Input.GetAxis("Horizontal");
p = Input.GetAxis("QE");
//不在墙上时的移动
if (climbStatus == ClimbStatus.notAtClimbing)
{
MoveOnGround();
}
//每60帧检测一次墙壁
DetectWallOnDepends();
//在墙上爬
if (climbStatus == ClimbStatus.isOnTheWall)
Climb();
if (climbStatus == ClimbStatus.isOnTheTopOfTheWall)
TheLastClimbOnTheTop();
//退出爬墙状态
if (Input.GetKeyDown(KeyCode.LeftShift))
{
ExitClimbWall();
}
}
bool IsGrounded()
{
return Physics.Raycast(transform.position, -Vector3.up, 0.05f);
}
void MoveOnGround()
{
//transform.Translate(Vector3.forward * v * Time.deltaTime * 3);
//transform.Translate(Vector3.right * h * Time.deltaTime * 3);
//transform.Rotate(Vector3.up * p * Time.deltaTime * 45);
}
//配合PlayerControl脚本使用时,不执行
private Vector3 targetPosition; //目标位置
private Quaternion targetRotation; //目标姿态
private float step; //每秒移动的距离
void Climb()
{
//对角色的位移和旋转进行插值
//上下移动
transform.position = Vector3.MoveTowards(transform.position, targetPosition, step * Time.deltaTime);
//旋转
transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation, 0.2f);
//左右移动
transform.Translate(Vector3.right * h * Time.deltaTime * 3);
//动画根运动
if (v > 0)
{
GetComponent<PlayerControl>().anim.SetFloat("ClimbSpeed", 1);
}
else if (v < 0)
{
GetComponent<PlayerControl>().anim.SetFloat("ClimbSpeed", -1);
}
else if (v == 0)
{
GetComponent<PlayerControl>().anim.SetFloat("ClimbSpeed", 0);
}
}
//注:只能通过修改targetPosition来改变角色的位置
public float charactorHeight = 1.58f;
public float charactorRasius = 0.25f;
public LayerMask climbableMask = 1 << 26; //把1左移26位,打开第26层,在以下的射线检测中,都只检测第26层。可以在面板手动进行设置。
public float maxDetectDistance = 1.5f; //最大检测距离,包含角色的半径
public float horizontalOffsetFromWall = 0.32f; //水平偏移量
public Collider currentCollider; //当前碰撞体
private Transform wall; //墙壁姿态,仅用于检测是否到达边缘
public bool isTopDetected;
public bool isBottomDetected;
public bool isRightDetected;
public bool isLeftDetected;
private Vector3 playerBodyCentre;
private Vector3 closestPoint;
public Collider[] detectedColliders;
bool DetectWallOnDepends()
{
if (climbStatus == ClimbStatus.notAtClimbing && Time.frameCount % 60 != 0 && v > 0) //必须在按键向前的情况下才能上墙
{
if (DetectWallRightNow(out bottom, out top))
{
EnterClimbWall();
return true;
}
}
if (climbStatus == ClimbStatus.isOnTheWall || climbStatus == ClimbStatus.isOnTheTopOfTheWall)
{
if (IsGrounded() && Time.frameCount % 60 == 0 || IsGrounded() && v < 0) //着地并且按下键,立即退出爬墙
{
Debug.Log("着地");
ExitClimbWall();
}
if (DetectWallRightNow(out bottom, out top))
{
UpdateClimbWall();
return true;
}
else
{
//如果顶部没有墙壁
if (top.collider == null)
{
climbStatus = ClimbStatus.isOnTheTopOfTheWall;
OnTheLastClimbOnTheTop();
}
//如果底部没有碰撞体
if (bottom.collider == null)
{
ExitClimbWall();
}
return false;
}
}
return false;
}
void EnterClimbWall()
{
climbStatus = ClimbStatus.isOnTheWall;
GetComponent<Rigidbody>().isKinematic = true;
step = Vector3.Distance(transform.position, targetPosition) / 0.05f;
SetCharacterPosition(bottom, true);
currentCollider = bottom.collider; //设定当前攀附的碰撞体
GetComponent<PlayerControl>().playerStatus = PlayerControl.PlayerStatus.Climb;
GetComponent<PlayerControl>().anim.SetInteger("Climb", 1);
Debug.Log("进入爬墙状态");
}
void UpdateClimbWall()
{
SetCharacterPosition(bottom, true);
currentCollider = bottom.collider;
}
void TheLastClimbOnTheTop()
{
UpdateClimbWall();
if(GetComponent<PlayerControl>().anim.GetCurrentAnimatorStateInfo(0).IsName("Climb Top") &&
GetComponent<PlayerControl>().anim.GetCurrentAnimatorStateInfo(0).normalizedTime > 0.95)
{
//把角色放到墙顶
transform.position = new Vector3(transform.position.x,
currentCollider.bounds.center.y + currentCollider.bounds.size.y * 0.5f + 0.03f,
transform.position.z);
//往前走1个单位
transform.position = transform.position + transform.forward;
}
if (GetComponent<PlayerControl>().anim.GetCurrentAnimatorStateInfo(0).IsName("start"))
{
ExitClimbWall();
}
}
void OnTheLastClimbOnTheTop()
{
//播放完这个动画就退出爬墙状态
GetComponent<PlayerControl>().anim.SetInteger("Climb", 2);
}
void ExitClimbWall()
{
climbStatus = ClimbStatus.notAtClimbing;
GetComponent<Rigidbody>().isKinematic = false;
GetComponent<PlayerControl>().playerStatus = PlayerControl.PlayerStatus.Basic;
GetComponent<PlayerControl>().anim.SetInteger("Climb", 0);
Debug.Log("退出爬墙状态");
}
RaycastHit bottom, top;
bool DetectWallRightNow(out RaycastHit hitFromBottom, out RaycastHit hitFromTop)//从底部和顶部发射射线
{
//顶部检测点是 角色原点+角色的高
Vector3 topDetectPoint = transform.position + Vector3.up * (charactorHeight);
Vector3 bottomDetectPoint = transform.position + new Vector3(0, 0.2f, 0);
Vector3 detectDirection = transform.forward;
Debug.DrawRay(topDetectPoint, transform.forward, Color.blue);
Debug.DrawRay(bottomDetectPoint, transform.forward, Color.red);
Debug.DrawLine(topDetectPoint, bottomDetectPoint, Color.magenta);
if (climbStatus == ClimbStatus.notAtClimbing)//没有爬墙才进行判断
{
//获取球体范围内的碰撞体
detectedColliders = Physics.OverlapSphere(playerBodyCentre, charactorRasius * 2, climbableMask, QueryTriggerInteraction.Collide);//不能用全部layer
//如果有
if (detectedColliders.Length > 0)
{
//体心
playerBodyCentre = transform.position + Vector3.up * (charactorHeight - charactorRasius);
//在全部碰撞体上获取一个距离体心最近的一个点
closestPoint = detectedColliders[0].ClosestPoint(playerBodyCentre);
//选择一个最近的攀爬点
foreach (Collider coll in detectedColliders)
{
//找到一个高度低于体心的碰撞体
if (coll.transform.position.y > playerBodyCentre.y)
continue;
Vector3 point = coll.ClosestPoint(playerBodyCentre);
//如果体心与该碰撞体的最近点的距离 小于 体心与之前的最近点的距离,就更改最近点
if (Vector3.Distance(playerBodyCentre, point) < Vector3.Distance(playerBodyCentre, closestPoint))
closestPoint = point;
}
//因为检测要在水平面上,所以不需要最近点的高度信息,用体心的高代替
closestPoint.y = playerBodyCentre.y;
//检测方向为: 从体心出发,到最近点
detectDirection = closestPoint - playerBodyCentre;
//获取检测方向与角色朝向在水平面的夹角
float angle = Mathf.Abs(Vector3.SignedAngle(detectDirection, transform.forward, Vector3.up));
//如果夹角大于100度,将检测方向设定为角色的朝向
if (angle > 100)
detectDirection = transform.forward;
}
}
isTopDetected = Physics.Raycast(topDetectPoint, detectDirection, out hitFromTop, 2 * maxDetectDistance, climbableMask); //上面的检测距离应该远一些
isBottomDetected = Physics.Raycast(bottomDetectPoint, detectDirection, out hitFromBottom, maxDetectDistance, climbableMask);
//绘制射线
Debug.DrawRay(bottomDetectPoint, detectDirection * maxDetectDistance, Color.cyan);
Debug.DrawRay(topDetectPoint, detectDirection * maxDetectDistance, Color.cyan);
//检测是否到达了墙壁边缘
if (isBottomDetected && isBottomDetected)
{
//将墙壁的位置设置为底部碰撞点
//将墙壁的前向设置为底部的法向
wall.position = hitFromBottom.point;
wall.forward = hitFromBottom.normal;
//从底部检测点的右侧某处、左侧某处,向上述方向发射射线
RaycastHit right, left;
isRightDetected = Physics.Raycast(bottomDetectPoint + wall.right * 0.5f, detectDirection, out right, Mathf.Infinity, climbableMask);
isLeftDetected = Physics.Raycast(bottomDetectPoint - wall.right * 0.5f, detectDirection, out left, Mathf.Infinity, climbableMask);
}
//两个方向都检测到了,才是真的检测到了
return (isTopDetected && isBottomDetected);
}
//仅仅负责上下移动
private void SetCharacterPosition(RaycastHit bottomHit, bool setToTarget = false)
{
//目标位置是 底部碰撞点 + 底部碰撞点的法向 * 一段偏移量。也就是说,要距离墙壁一定距离。
targetPosition = bottomHit.point + bottomHit.normal * horizontalOffsetFromWall;
targetPosition.y = transform.position.y;
//设定高度
//targetPosition.y += v * 2f * Time.deltaTime;
//应用根运动时,不再人为改变高度
//让角色朝向与墙面法向保持一致
targetRotation = GetRotationFromDirection(-bottomHit.normal);
//角色设定到预定位置
if (setToTarget)
{
transform.position = targetPosition;
transform.rotation = targetRotation;
}
}
private float yaw;//所给方向与世界坐标系的z轴夹角
Quaternion GetRotationFromDirection(Vector3 direction)
{
//根据所给的方向向量求夹角,然后在原姿态上旋转到这个角度
yaw = Mathf.Atan2(direction.x, direction.z);
return Quaternion.Euler(0, yaw * Mathf.Rad2Deg, 0);
}
}
效果