Unity 攀爬系统

说明

角色的位移主要是靠爬墙动画的根运动来实现

本代码主要是对墙体进行检测,将角色固定在墙体表面,以及对攀爬状态进行管理

思路

我们先来想一下整个实现的过程:

一开始人物是正常行走,然后发现前面有一堵墙,然后开始往上爬,爬呀爬,爬到顶上的时候,最后手掌撑着墙爬到墙顶。

这样,我们就将攀爬分为大体四个步骤:

①进入爬墙状态 ②爬墙 ③爬到墙顶④退出爬墙状态

一、进入爬墙状态

已经检测到墙壁,并且在玩家控制按键向前走的时候,并且要持续一段时间,才可以上墙。

进入爬墙状态时,为了根运动动画的正确播放,要勾选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);
    }

}

效果

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值