[Unity] 人物悬挂与攀爬的实现思路

1. 做什么?

  • 悬挂在墙体边缘上

image-20250705171120116

  • 悬挂时可以左右移动

  • 在悬挂时可以攀爬上去

image-20250705171249154

2. 怎么做?

悬挂

LedgeHangingPlayerState就是我们的主角了,悬挂状态,因为是拆解理解,所以这里就从入口开始梳理,首先是入口

在开始之前,我们需要一个前置的检测方法

// Player.cs
protected virtual bool DetectingLedge(float forwardDistance, float downwardDistance, out RaycastHit ledgeHit)
    {
    // positionDelta = (position - lastPosition).magnitude;
    // 动态计算的碰撞偏移量,增加了本帧的移动距离。这是一种预测性检测,确保即使在高速移动中也能提前检测到边缘,防止穿模。
        var contactOffset = Physics.defaultContactOffset + positionDelta;
    // radius是当前CC的半径,射线检测的最大向前距离。它等于角色自身半径再加上一个自定义的向前探索距离,确保射线起点在角色体外
        var ledgeMaxDistance = radius + forwardDistance;
    // 检测射线的垂直高度,这里再加上了contactOffset动态检测距离(height*0.5f)算出来半个身位,也就是头顶的位置,这时候由于浮点数精度问题,不一定是在理想		的位置,为了保险起见这里九加上contactOffset,其实这里也会有个风险,当速度很快的时候这个值会特别大
        var ledgeHeightOffset = height * 0.5f + contactOffset;
    // 向上偏移量
        var upwardOffset = transform.up * ledgeHeightOffset;
    // 向前偏移量
        var forwardOffset = transform.forward * ledgeMaxDistance;
 	// 1.CC胶囊头顶上一点偏移作为起点,向前radius+检测距离
    // 2.CC胶囊中心点向前移动forwardOffset * 0.01f距离,向上检测ledgeHeightOffset距离有没有物理碰撞
        if (Physics.Raycast(position + upwardOffset, transform.forward, ledgeMaxDistance, Physics.DefaultRaycastLayers,
                QueryTriggerInteraction.Ignore)
            || Physics.Raycast(position + forwardOffset * 0.01f, transform.up, ledgeHeightOffset,
                Physics.DefaultRaycastLayers,
                QueryTriggerInteraction.Ignore))
        {
            ledgeHit = new RaycastHit();
            return false;
        }
	// origin是指攀爬手抓的那个地方,也就是上方+前方过后的那个点
        var origin = position + upwardOffset + forwardOffset;
    // 向下检测的距离
        var distance = downwardDistance + contactOffset;
	// 检测当前可以悬挂的图层,是否可以悬挂并且攀爬上去
        return Physics.Raycast(origin, Vector3.down, out ledgeHit, distance, stats.current.ledgeHangingLayers,
            QueryTriggerInteraction.Ignore);
    }

image-20250705233150214

红色的为1,黄色的为2,绿色的为实际返回的Collider *(图上箭头的位置并不精准,主要是示意)

public virtual void LedgeGrab()
{
    // 是否可以悬挂,当前垂直速度是否小于0(是否下落),是否持有物品,StatesMap里是否有这个状态,DetectingLedge返回当前检测到的碰撞物
    // 这里再补充一下,如果这个方法检测到头顶上方或者头顶上方的前方有障碍,就会返回那个障碍并且return方法
    if (stats.current.canLedgeHang && velocity.y < 0 && !holding &&
        states.ContainsStateOfType(typeof(LedgeHangingPlayerState)) && DetectingLedge(
            stats.current.ledgeMaxForwardDistance, stats.current.ledgeMaxDownwardDistance, out var hit))
    {
        // 当前碰撞到的Collider不是圆型或圆柱形
        if (!(hit.collider is CapsuleCollider) && !(hit.collider is SphereCollider))
        {
            // 悬挂状态离墙壁的距离
            var ledgeDistance = radius + stats.current.ledgeMaxForwardDistance;
            // 向前的偏移量
            var lateralOffset = transform.forward * ledgeDistance;
            // 悬挂在墙壁的往下垂直偏移量
            var verticalOffset = Vector3.down * height * 0.5f - center;

           	//静止
            velocity = Vector3.zero;
			
            transform.parent = hit.collider.CompareTag(GameTag.Platform) ? hit.transform.parent : null;
            // 将玩家position移动到碰撞点向玩家后方移动一个半个身位再往下移动算出来的身位
            transform.position = hit.point - lateralOffset + verticalOffset;
            
            states.Change<LedgeHangingPlayerState>();

            playerEvents.OnLedgeGrabbed?.Invoke();
        }
    }
}

悬挂状态

进入

protected override void OnEnter(Player player)
{
    if (m_clearParentRoutine != null)
    {
        player.StopCoroutine(m_clearParentRoutine);
    }

    m_keepParent = false;
    // 适当的SkinOffset避免穿模
    player.skin.position += player.transform.rotation * player.stats.current.ledgeHangingSkinOffset;
    player.ResetJumps();
    player.ResetAirSpinCount();
    player.ResetAirDashCounter();
}

退出

protected override void OnExit(Player player)
{
    m_clearParentRoutine = player.StartCoroutine(ClearParentRoutine(player));
    // 减去SkinOffset防止偏移量误差累赠
    player.skin.position -= player.transform.rotation * player.stats.current.ledgeHangingSkinOffset;
}

状态中

做之前要有个具体的思路,不然前面就不知道该算什么变量了

protected override void OnStep(Player player)
{
    
    var ledgeTopMaxDistance = player.radius + player.stats.current.ledgeMaxForwardDistance;
    var ledgeTopHeightOffset = player.height * 0.5f + player.stats.current.ledgeMaxDownwardDistance;
    var topOrigin = player.position + Vector3.up * ledgeTopHeightOffset +
                    player.transform.forward * ledgeTopMaxDistance;
    // CC胶囊Collider正中心头顶向下Vector3.down * player.stats.current.ledgeSideHeightOffset个单位的距离
    var sideOrigin = player.position + Vector3.up * player.height * 0.5f +
                     Vector3.down * player.stats.current.ledgeSideHeightOffset;
    // 射线检测的距离
    var rayDistance = player.radius + player.stats.current.ledgeSideMaxDistance;
    // 射线半径
    var rayRadius = player.stats.current.ledgeSideCollisionRadius;

    // 1. CC胶囊中心头顶向前检测Radius+一些偏移量的距离(+Radius是为了检测到胶囊外)
    // 2. CC胶囊(中心+头顶往上一些的位置)+(向前胶囊外一些的位置),向下检测玩家CC的高度
    if (Physics.SphereCast(sideOrigin, rayRadius, player.transform.forward, out var sideHit, rayDistance,
            player.stats.current.ledgeHangingLayers, QueryTriggerInteraction.Ignore) &&
        Physics.Raycast(topOrigin, Vector3.down, out var topHit, player.height,
            player.stats.current.ledgeHangingLayers, QueryTriggerInteraction.Ignore))
    {
        var inputDirection = player.inputs.GetMovementDirection();
        // Sign返回-1和1,这里就是将点设置成玩家的左手和右手
        var ledgeSideOrigin = sideOrigin + player.transform.right * Mathf.Sign(inputDirection.x) * player.radius;
        var ledgeHeight = topHit.point.y - player.height * 0.5f;
        // 这里需要单独拿出来理解一下
        var sideForward = -new Vector3(sideHit.normal.x, 0, sideHit.normal.z).normalized;
        var destinationHeight = player.height * 0.5f + Physics.defaultContactOffset;
        var climbDestination = topHit.point + Vector3.up * destinationHeight +
                               player.transform.forward * player.radius;
        player.FaceDirection(sideForward);
		// 3. 在我将要移动到的方向上,还有墙可以让我抓着吗?
        if (Physics.Raycast(ledgeSideOrigin, sideForward, rayDistance, player.stats.current.ledgeHangingLayers,
                QueryTriggerInteraction.Ignore))
        {
            // 这里就是简单的水平移动
            player.lateralVelocity =
                player.transform.right * inputDirection.x * player.stats.current.ledgeMovementSpeed;
        } else
        {
            // 没有输入的话就静止
            player.lateralVelocity = Vector3.zero;
        }
		// 强制校准玩家的位置,确保他能像磁铁一样精确地“吸附”在墙壁边缘,既不会掉下去,也不会穿进墙里。
        player.transform.position =
            new Vector3(sideHit.point.x, ledgeHeight, sideHit.point.z) - sideForward * player.radius -
            player.center;

        if (player.inputs.GetReleaseLedgeDown())
        {
            player.FaceDirection(-sideForward);
            player.states.Change<FallPlayerState>();
        } else if (player.inputs.GetJumpDown())
        {
            player.Jump(player.stats.current.maxJumpHeight);
            player.states.Change<FallPlayerState>();
        } 
        
        else if (inputDirection.z > 0 && player.stats.current.canClimbLedge &&
                   ((1 << topHit.collider.gameObject.layer) & player.stats.current.ledgeClimbingLayers) != 0 &&
                   player.FitsInPosition(climbDestination))
        {
            m_keepParent = true;
            player.states.Change<LedgeClimbingPlayerState>();
            player.playerEvents.OnLedgeClimbing?.Invoke();
        }
    } else
    {
        player.states.Change<FallPlayerState>();
    }
}

image-20250706181057324

红色为检测1,蓝色为检测2 *(箭头仅示意,没有精确到数值)

image-20250706181439002

红绿两点就是Physics.Raycast(ledgeSideOrigin, sideForward, rayDistance, player.stats.current.ledgeHangingLayers, QueryTriggerInteraction.Ignore)的检测起始点,那么什么是sideForward呢?

法线

|  墙壁
                        |
 (起点Origin) --------->* (碰撞点 Point)
                        |
                        |
|  墙壁
                        |
             <--------- * (Point)
             (Normal)   |
                        |
/
      /
     /  <-- 这是一个斜坡,由很多小平面组成
    /
   /
  /
 /____________ (地面)
/
                /
               /
(你的射线) --> *   <-- 射线击中了一个三角形
              /
             /
            /_____________
↖ (法线 Normal)
                     \
                      \
                       * (碰撞点 Point)
                      /
                     /
                    /
                   /_____________

这里也讲到法线是有倾斜角度的

  1. 法线是倾斜的: 因为表面是倾斜的,所以法线也是倾斜的。它不再是单纯的水平向量 (-1, 0, 0) 或垂直向量 (0, 1, 0)。
  2. 法线有Y分量:一个倾斜的法线,它的 Vector3 值会同时包含水平(X/Z)和垂直(Y)的分量。例如,一个45度的斜坡,它的法线可能近似于 (-0.707, 0.707, 0)。
  3. 法线是局部的: 如果你的斜坡是一个看起来很平滑的曲面,它在物理上仍然是由成百上千个小三角形组成的。你的射线击中哪个三角形,返回的就是哪个三角形的法线。当你把碰撞点沿着曲面移动时,你会发现法线的方向会平滑地(或轻微跳跃地)改变

在代码当中,这里就是取Y值来进行旋转,这里使用反向法线能更好的进行碰撞检测

就能做到这种

image-20250706183206893

攀爬

// LedgeClimbingPlayerState.cs
protected virtual IEnumerator SetPositionRoutine(Player player)
    {
        var elapsedTime = 0f;
        var totalDuration = player.stats.current.leggeClimbingDuration;
        var halfDuration = totalDuration / 2f;
		// 当前位置
        var initialPosition = player.transform.localPosition;
        // 垂直目标位置
        var targetVerticalPosition =
            player.transform.position + Vector3.up * (player.height + Physics.defaultContactOffset);
    	// 向前目标位置
        var targetLateralPosition = targetVerticalPosition + player.transform.forward * player.radius * 2f;
		
    	// 如果玩家当前的父对象不为null的话,将目标的targetPosition转化为相对于parent的position
    	// 如果没有这个if的话,就会出现一些奇怪的情况,如果当前的玩家position是在parent里的,此时localX=0,但是世界wordX不为0
    	// 这时候算出来的值是world值,我们要将这个world值转化为local值,相对于父gameobject的运动
        if (player.transform.parent != null)
        {
            targetVerticalPosition = player.transform.parent.InverseTransformPoint(targetVerticalPosition);
            targetLateralPosition = player.transform.parent.InverseTransformPoint(targetLateralPosition);
        }

        player.SetSkinParent(player.transform.parent);
        player.skin.position += player.transform.rotation * player.stats.current.ledgeClimbingSkinOffset;
        while (elapsedTime <= halfDuration)
        {
            elapsedTime += Time.deltaTime;
            player.transform.localPosition =
                Vector3.Lerp(initialPosition, targetVerticalPosition, elapsedTime / halfDuration);
            yield return null;
        }

        elapsedTime = 0;
        player.transform.localPosition = targetLateralPosition;
        while (elapsedTime <= halfDuration)
        {
            elapsedTime += Time.deltaTime;
            player.transform.localPosition =
                Vector3.Lerp(targetVerticalPosition, targetLateralPosition, elapsedTime / halfDuration);
            yield return null;
        }

        player.transform.localPosition = targetLateralPosition;
        player.states.Change<IdlePlayerState>();
    }

3. 其他

剩下的一些内容如果还有不理解的话就去看看 阑夜听风 的详细文档,我把个人认为比较难的部分整理出来了,还有一点就是 1<< 那边的位运算法,这种看文字文档就能明白了.

4. 总结

悬挂进入(黄色和红色不能检测到物体):

image-20250705233150214

悬挂移动:

image-20250706181057324

image-20250706181439002

image-20250706183206893

攀爬:

分为两部分,Position移动均用Lerp进行插值

  1. 向上攀爬,持续时间为 duration/2,物体向上移动一个height+偏移量
  2. 向前移动,具体看代码

代码当中还有很多细节,这里知识大概讲了一下攀爬的思路和这里面的物理碰撞检测,如果细节拉出来讲没完没了了

posted @ 2025-07-30 22:15  MingHaiZ  阅读(42)  评论(0)    收藏  举报