(已完结)仿神秘海域/美末环境交互的程序化动画学习

写在前面:

真正实现这些细枝末节的东西的时候才能感受到这种技术力的恐怖。

——致敬顽皮狗工作室

插件安装

1755773270538

为角色添加组件

1755773337095

1755773371949

1755773411325

1755773431970

右手同理

状态机脚本编写

1755775744433

BaseState.cs

using UnityEngine;
using System;

/// <summary>
/// 状态基类,定义了状态机中所有状态的基本行为规范
/// 泛型参数EEState限制为枚举类型,用于表示具体的状态类型
/// </summary>
/// <typeparam name="EState">状态枚举类型,继承自Enum</typeparam>
public abstract class BaseState<EState> where EState : Enum
{
    //构造函数
    public BaseState(EState key)
    {
        StateKey = key;
    }
    public EState StateKey { get; private set; }

    public abstract void EnterState();
    public abstract void ExitState();
    public abstract void UpdateState();
    public abstract EState GetNextState();
    public abstract void OnTriggerEnter(Collider other);
    public abstract void OnTriggerStay(Collider other);
    public abstract void OnTriggerExit(Collider other);
}

NewBaseState.cs

using UnityEngine;
using System;
using System.Collections.Generic;

/// <summary>
/// 状态管理器泛型抽象类
/// </summary>
/// <typeparam name="EState">状态枚举类型,需继承自Enum</typeparam>
public abstract class StateManager<EState> : MonoBehaviour where EState : Enum
{
    // 存储所有状态的字典,键为状态枚举,值为对应的状态实例
    protected Dictionary<EState, BaseState<EState>> States = new Dictionary<EState, BaseState<EState>>();
    // 当前激活的状态
    protected BaseState<EState> CurrentState;

    // 标志位:是否处于状态切换中
    protected bool IsTransitioningState = false;

    void Start()
    {
        CurrentState.EnterState();
    }

    void Update()
    {
        EState nextStateKey = CurrentState.GetNextState();

        if (!IsTransitioningState && nextStateKey.Equals(CurrentState.StateKey))
        {
            // 如果当前状态和下一状态相同,则更新当前状态
            CurrentState.UpdateState();
        }
        else if(!IsTransitioningState)
        {
            // 不同,则切换到下一状态
            TransitionToState(nextStateKey);
        }
    }

    /// <summary>
    /// 状态切换方法,用于从当前状态切换到目标状态
    /// </summary>
    /// <param name="stateKey">目标状态的枚举标识</param>
    protected virtual void TransitionToState(EState stateKey)
    {
        IsTransitioningState = true;

        // 退出当前状态
        CurrentState.ExitState();
        // 进入目标状态
        CurrentState = States[stateKey];
        CurrentState.EnterState();

        IsTransitioningState = false;
    }

    /// <summary>
    /// 当碰撞体进入触发器时调用的方法,转发给当前状态处理
    /// </summary>
    /// <param name="other">进入触发器的碰撞体</param>
    void OnTriggerEnter(Collider other)
    {
        CurrentState.OnTriggerEnter(other);
    }

    /// <summary>
    /// 当碰撞体持续处于触发器中时调用的方法,转发给当前状态处理
    /// </summary>
    /// <param name="other">处于触发器中的碰撞体</param>
    void OnTriggerStay(Collider other)
    {
        CurrentState.OnTriggerStay(other);
    }

    /// <summary>
    /// 当碰撞体退出触发器时调用的方法,转发给当前状态处理
    /// </summary>
    /// <param name="other">退出触发器的碰撞体</param>
    void OnTriggerExit(Collider other)
    {
        CurrentState.OnTriggerExit(other);
    }
}

Animation Rigging

Rig Builder组件要放在Animator的同级

1755784900189

1755784962449

1755785011835

Rig放置的位置

1755784884750

1755785030047

环境交互状态机的编写

1755785081711

1755789073630

EnvironmentInteractionStateMachine

using UnityEngine;
using UnityEngine.Animations.Rigging;
using UnityEngine.Assertions;   //调试用


public class EnvironmentInteractionStateMachine : StateManager<EnvironmentInteractionStateMachine.EEnvironmentInteractionState>
{
    // 环境交互状态
    public enum EEnvironmentInteractionState
    {
        Search,   // 搜索状态
        Approach, // 接近状态
        Rise,     // 起身状态
        Touch,    // 触碰状态
        Reset     // 重置状态
    }

    private EnvironmentInteractionContext _context;

    // 约束、组件等引用
    [SerializeField] private TwoBoneIKConstraint _leftIkConstraint;
    [SerializeField] private TwoBoneIKConstraint _rightIkConstraint;
    [SerializeField] private MultiRotationConstraint _leftMultiRotationConstraint;
    [SerializeField] private MultiRotationConstraint _rightMultiRotationConstraint;
    [SerializeField] private CharacterController characterController;

    void Awake()
    {
        ValidateConstraints();

        _context = new EnvironmentInteractionContext(_leftIkConstraint, _rightIkConstraint, _leftMultiRotationConstraint, _rightMultiRotationConstraint, characterController);
    }

    // 校验各类约束、组件是否正确赋值
    private void ValidateConstraints()
    {
        Assert.IsNotNull(_leftIkConstraint, "Left IK constraint 没有赋值");
        Assert.IsNotNull(_rightIkConstraint, "Right IK constraint 没有赋值");
        Assert.IsNotNull(_leftMultiRotationConstraint, "Left multi-rotation constraint 没有赋值");
        Assert.IsNotNull(_rightMultiRotationConstraint, "Right multi-rotation constraint 没有赋值");
        Assert.IsNotNull(characterController, "characterController used to control character 没有赋值");
    }


}

EnvironmentInteractionContext用来管理各种属性

using UnityEngine;
using UnityEngine.Animations.Rigging;

public class EnvironmentInteractionContext
{
    private TwoBoneIKConstraint _leftIkConstraint;
    private TwoBoneIKConstraint _rightIkConstraint;
    private MultiRotationConstraint _leftMultiRotationConstraint;
    private MultiRotationConstraint _rightMultiRotationConstraint;
    private CharacterController _characterController;

    public EnvironmentInteractionContext(
        TwoBoneIKConstraint leftIkConstraint,
        TwoBoneIKConstraint rightIkConstraint,
        MultiRotationConstraint leftMultiRotationConstraint,
        MultiRotationConstraint rightMultiRotationConstraint,
        CharacterController characterController)
    {
        _leftIkConstraint = leftIkConstraint;
        _rightIkConstraint = rightIkConstraint;
        _leftMultiRotationConstraint = leftMultiRotationConstraint;
        _rightMultiRotationConstraint = rightMultiRotationConstraint;
        _characterController = characterController;
    }

    // 外部可以访问的属性
    public TwoBoneIKConstraint LeftIkConstraint => _leftIkConstraint;
    public TwoBoneIKConstraint RightIkConstraint => _rightIkConstraint;
    public MultiRotationConstraint LeftMultiRotationConstraint => _leftMultiRotationConstraint;
    public MultiRotationConstraint RightMultiRotationConstraint => _rightMultiRotationConstraint;
    public CharacterController CharacterController => _characterController;
}

从ResetState开始

using UnityEngine;

public class ResetState : EnvironmentInteractionState
{
    // 构造函数
    public ResetState(EnvironmentInteractionContext context, EnvironmentInteractionStateMachine.EEnvironmentInteractionState estate) : base(context, estate)
    {
        EnvironmentInteractionContext Context = context;
    }
    public override void EnterState(){}
    public override void ExitState() { }
    public override void UpdateState() { }
    public override EnvironmentInteractionStateMachine.EEnvironmentInteractionState GetNextState() 
    { 
        return StateKey; 
    }
    public override void OnTriggerEnter(Collider other) { }
    public override void OnTriggerStay(Collider other) { }
    public override void OnTriggerExit(Collider other) { }
}

EnvironmentInteractionStateMachine中加入初始化函数

    void Awake()
    {
	//原来的代码

        InitalizeStates();
    }
    /// <summary>
    /// 初始化状态机
    /// </summary>
    private void InitalizeStates()
    {
        //添加状态
        States.Add(EEnvironmentInteractionState.Reset, new ResetState(_context, EEnvironmentInteractionState.Reset));
        States.Add(EEnvironmentInteractionState.Search, new SearchState(_context, EEnvironmentInteractionState.Search));
        States.Add(EEnvironmentInteractionState.Approach, new ApproachState(_context, EEnvironmentInteractionState.Approach));
        States.Add(EEnvironmentInteractionState.Rise, new RiseState(_context, EEnvironmentInteractionState.Rise));
        States.Add(EEnvironmentInteractionState.Touch, new TouchState(_context, EEnvironmentInteractionState.Touch));

        //设置初始状态为Reset
        CurrentState = States[EEnvironmentInteractionState.Reset];

    }

1755793245712

状态机运行正常

环境检测

1755793230388

1.在角色身上创建一个稍大于臂展的碰撞盒

EnvironmentInteractionStateMachine

    void Awake()
    {
        ///原来的代码

        ConstructEnvironmentDetectionCollider();
    }
    /// <summary>
    /// 创建一个环境检测用的碰撞体
    /// </summary>
    private void ConstructEnvironmentDetectionCollider()
    {
        // 碰撞体大小的基准值
        float wingspan = characterController.height;

        // 给当前游戏对象添加盒型碰撞体组件
        BoxCollider boxCollider = gameObject.AddComponent<BoxCollider>();

        // 设置碰撞体大小为立方体,各边长度等于翼展
        boxCollider.size = new Vector3(wingspan, wingspan, wingspan);

        // 设置碰撞体中心位置
        // 基于角色控制器的中心位置进行偏移:
        // Y轴方向上移翼展的25%,Z轴方向前移翼展的50%
        boxCollider.center = new Vector3(
            characterController.center.x,
            characterController.center.y + (.25f * wingspan),
            characterController.center.z + (.5f * wingspan)
        );

        // 将碰撞体设置为触发器模式(用于检测碰撞而非物理碰撞响应)
        boxCollider.isTrigger = true;
    }

1755800944612

1755800951964

2.碰撞体触发器的交互机制

  1. 角色进入 “触发器区域” → OnTriggerEnter 触发(一次)
  2. 角色持续待在区域内 → 每帧触发 OnTriggerStay
  3. 角色离开区域 → OnTriggerExit 触发(一次)

1755794086675

1755794176628

1755794192951

3.找到离角色更近的一侧,用来决定后面开启哪边的IK

EnvironmentInteractionContext加入:判断碰撞相交位置更靠近哪一侧

1755801127469

    // 身体两侧
    public enum EBodySide
    {
        RIGHT,
        LEFT
    }
    // 当前IK约束
    public TwoBoneIKConstraint CurrentIkConstraint { get; private set; }
    // 当前多旋转约束
    public MultiRotationConstraint CurrentMultiRotationConstraint { get; private set; }
    // 当前IK控制的目标位置
    public Transform CurrentIkTargetTransform { get; private set; }
    // 当前肩部骨骼
    public Transform CurrentShoulderTransform { get; private set; }
    // 当前身体的侧边(左或右)
    public EBodySide CurrentBodySide { get; private set; }

    /// <summary>
    /// 根据传入位置,判断目标更靠近左侧还是右侧肩部,设置当前身体的侧边
    /// </summary>
    /// <param name="positionToCheck">需要检测的目标位置</param>
    public void SetCurrentSide(Vector3 positionToCheck)
    {
        // 左肩部骨骼
        Vector3 leftShoulder = _leftIkConstraint.data.root.transform.position;
        // 右肩部骨骼
        Vector3 rightShoulder = _rightIkConstraint.data.root.transform.position;

        // 标志位:目标位置是否更靠近左侧
        bool isLeftCloser = Vector3.Distance(positionToCheck, leftShoulder) <
                            Vector3.Distance(positionToCheck, rightShoulder);
        if (isLeftCloser)
        {
            CurrentBodySide = EBodySide.LEFT;
            CurrentIkConstraint = _leftIkConstraint;
            CurrentMultiRotationConstraint = _leftMultiRotationConstraint;
        }
        else
        {
            CurrentBodySide = EBodySide.RIGHT;
            CurrentIkConstraint = _rightIkConstraint;
            CurrentMultiRotationConstraint = _rightMultiRotationConstraint;
        }
        // 记录当前肩部骨骼 和 IK控制的目标位置
        CurrentShoulderTransform = CurrentIkConstraint.data.root.transform;
        CurrentIkTargetTransform = CurrentIkConstraint.data.target.transform;
    }

EnvironmentInteractionState

    /// <summary>
    /// 启动 IK 目标位置追踪
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,作为追踪关联对象</param>
    protected void StartIkTargetPositionTracking(Collider intersectingCollider)
    {
        //只有碰撞体的层级为Interactable时才进行IK目标位置追踪
        if (intersectingCollider.gameObject.layer == LayerMask.NameToLayer("Interactable"))
        {
            // 最近的碰撞点
            Vector3 closestPointFromRoot = GetClosestPointOnCollider(intersectingCollider, Context.RootTransform.position);
            // 设置当前更靠近的侧面(根据最近的碰撞点)
            Context.SetCurrentSide(closestPointFromRoot);
        }

    }

    /// <summary>
    /// 更新 IK 目标位置
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,依据其状态更新目标位置</param>
    protected void UpdateIkTargetPosition(Collider intersectingCollider)
    {

    }

    /// <summary>
    /// 重置 IK 目标位置追踪
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,针对其执行追踪重置</param>
    protected void ResetIkTargetPositionTracking(Collider intersectingCollider)
    {

    }

这里要用到一个新的变量RootTransform用来在GetClosestPointOnCollider()方法中传入参数positionToCheck

EnvironmentInteractionContext

    // 根对象
    private Transform _rootTransform;

构造函数要加入这个变量

    public EnvironmentInteractionContext(
        TwoBoneIKConstraint leftIkConstraint,
        TwoBoneIKConstraint rightIkConstraint,
        MultiRotationConstraint leftMultiRotationConstraint,
        MultiRotationConstraint rightMultiRotationConstraint,
        CharacterController characterController,
        Transform rootTransform)
    {
        _leftIkConstraint = leftIkConstraint;
        _rightIkConstraint = rightIkConstraint;
        _leftMultiRotationConstraint = leftMultiRotationConstraint;
        _rightMultiRotationConstraint = rightMultiRotationConstraint;
        _characterController = characterController;
        _rootTransform = rootTransform;
    }
    public Transform RootTransform => _rootTransform;

当然,在EnvironmentInteractionStateMachine中也要传入这个变量

Awake()

        _context = new EnvironmentInteractionContext(_leftIkConstraint, _rightIkConstraint, _leftMultiRotationConstraint, _rightMultiRotationConstraint, characterController,transform.root);

写一下ResetState的GetNextState()的下一状态切换逻辑

    public override EnvironmentInteractionStateMachine.EEnvironmentInteractionState GetNextState() 
    { 
        // 下一个状态为 SearchState
        return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Search;
        //return StateKey; 
    }

注意:

1755802950451

SearchStateOnTriggerEnter()中调用StartIkTargetPositionTracking()启动 IK 目标位置追踪

    public override void OnTriggerEnter(Collider other) {
        // 进入搜索状态时,开始跟踪目标位置
        StartIkTargetPositionTracking(other);
    }

测试一下功能是否正常:

1755803008162

1755807104145

效果倒是正常,不过这是我调试好久发现的问题,只有挂载rigidbody的物体才会触发Trigger回调函数,正常来说只要一方有rigidbody就能触发,不知道为什么这里会出现这个问题,角色身上的这个触发器肯定是rigidbody,那已经满足条件了,为什么还要其他物体也要挂载rigidbody,想不明白。。。

不过实现了就好,后面再排查问题吧,先完成最要紧

4.解决一下在狭窄通道走过的时候,左右频繁触发的问题

EnvironmentInteractionContext

    // 当前交互的碰撞体
    public Collider CurrentIntersectingCollider { get; set; }

EnvironmentInteractionState

    /// <summary>
    /// 启动 IK 目标位置追踪
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,作为追踪关联对象</param>
    protected void StartIkTargetPositionTracking(Collider intersectingCollider)
    {
        //只有碰撞体的层级为Interactable && 当前没有可交互的碰撞体 时才进行IK目标位置追踪
        // 防止频繁触发
        if (intersectingCollider.gameObject.layer == LayerMask.NameToLayer("Interactable") && Context.CurrentIntersectingCollider == null)
        {
            // 记录当前碰撞体
            Context.CurrentIntersectingCollider = intersectingCollider;
            // 最近的碰撞点
            Vector3 closestPointFromRoot = GetClosestPointOnCollider(intersectingCollider, Context.RootTransform.position);
            // 设置当前更靠近的侧面(根据最近的碰撞点)
            Context.SetCurrentSide(closestPointFromRoot);
        }
    }
    /// <summary>
    /// 重置 IK 目标位置追踪
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,针对其执行追踪重置</param>
    protected void ResetIkTargetPositionTracking(Collider intersectingCollider)
    {
        if(intersectingCollider == Context.CurrentIntersectingCollider)
        {
            Context.CurrentIntersectingCollider = null;
        }
    }

SearchState

    public override void OnTriggerEnter(Collider other) {
        Debug.Log("Trigger:Enter");
        // 进入搜索状态,开始跟踪目标位置
        StartIkTargetPositionTracking(other);
    }
    public override void OnTriggerStay(Collider other) { }
    public override void OnTriggerExit(Collider other) {
        Debug.Log("Trigger:Exit");
        // 退出搜索状态,停止跟踪目标位置
        ResetIkTargetPositionTracking(other);
    }

5.设置IK的目标位置

EnvironmentInteractionContext

    // 相交碰撞体的最近点——默认值设为无穷大
    public Vector3 ClosestPointOnColliderFromShoulder { get; set; } = Vector3.positiveInfinity;

EnvironmentInteractionState

    /// <summary>
    /// 设置 IK 目标位置
    /// </summary>
    /// <param name="targetPosition"></param>
    private void SetIkTargetPosition()
    {
        // 最近的碰撞点
        Context.ClosestPointOnColliderFromShoulder = GetClosestPointOnCollider(Context.CurrentIntersectingCollider, Context.CurrentShoulderTransform.position);
    }
    /// <summary>
    /// 启动 IK 目标位置追踪
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,作为追踪关联对象</param>
    protected void StartIkTargetPositionTracking(Collider intersectingCollider)
    {
        //只有碰撞体的层级为Interactable && 当前没有可交互的碰撞体 时才进行IK目标位置追踪
        // 防止频繁触发
        if (intersectingCollider.gameObject.layer == LayerMask.NameToLayer("Interactable") && Context.CurrentIntersectingCollider == null)
        {
            // 原来的代码不变


            //设置IK目标位置
            SetIkTargetPosition();
        }
    }
    /// <summary>
    /// 更新 IK 目标位置
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,依据其状态更新目标位置</param>
    protected void UpdateIkTargetPosition(Collider intersectingCollider)
    {
        // 在接触过程中,一直更新IK目标位置
        if (Context.CurrentIntersectingCollider == intersectingCollider)
        {
            SetIkTargetPosition();
        }
    }

SearchState

    public override void OnTriggerStay(Collider other) {
        // 跟踪目标位置
        UpdateIkTargetPosition(other);
    }

然后在EnvironmentInteractionStateMachine中加入可视化

    /// <summary>
    /// 当物体被选中时调用Gizmos绘制
    /// </summary>
    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.red;

        // 在最近碰撞点处绘制一个红色的球
        if (_context != null && _context.ClosestPointOnColliderFromShoulder != null)
        {
            Gizmos.DrawSphere(_context.ClosestPointOnColliderFromShoulder, 0.03f);
        }
    }

1755809630756

新的问题出现了:

当角色行走的时候,由于身体会浮动,这个最近的碰撞点也在上下浮动,后面加上动画会出现手一直在墙上 上下乱摸。。。

6.解决最近碰撞点上下浮动问题

其实加一个变量记录一下角色的肩高就行,设定ik位置的时候传入该参数,这个点的高度就保持不变了

EnvironmentInteractionContext的构造函数加入一个角色的肩部高度变量

    public EnvironmentInteractionContext(
        TwoBoneIKConstraint leftIkConstraint,
        TwoBoneIKConstraint rightIkConstraint,
        MultiRotationConstraint leftMultiRotationConstraint,
        MultiRotationConstraint rightMultiRotationConstraint,
        CharacterController characterController,
        Transform rootTransform)
    {
        _leftIkConstraint = leftIkConstraint;
        _rightIkConstraint = rightIkConstraint;
        _leftMultiRotationConstraint = leftMultiRotationConstraint;
        _rightMultiRotationConstraint = rightMultiRotationConstraint;
        _characterController = characterController;
        _rootTransform = rootTransform;

        CharacterShoulderHeight = leftIkConstraint.data.root.transform.position.y;
    }
    // 角色的肩部高度,用来约束Ik的高度
    public float CharacterShoulderHeight { get; private set; }

EnvironmentInteractionState传入目标位置的参数的y轴改成角色肩高CharacterShoulderHeight

    /// <summary>
    /// 设置 IK 目标位置
    /// </summary>
    /// <param name="targetPosition"></param>
    private void SetIkTargetPosition()
    {
        // 最近的碰撞点
        Context.ClosestPointOnColliderFromShoulder = GetClosestPointOnCollider(Context.CurrentIntersectingCollider, 
            // 目标位置:上半身的xz位置 角色肩高的y位置(高度位置)
            new Vector3(Context.RootTransform.position.x, Context.CharacterShoulderHeight, Context.RootTransform.position.z));
    }

问题解决

1755810520182

7.在离开当前碰撞体后,重置Ik的目标位置为无穷大

EnvironmentInteractionState

    /// <summary>
    /// 重置 IK 目标位置追踪
    /// </summary>
    /// <param name="intersectingCollider">相交的碰撞体,针对其执行追踪重置</param>
    protected void ResetIkTargetPositionTracking(Collider intersectingCollider)
    {
        if(intersectingCollider == Context.CurrentIntersectingCollider)
        {
            // 重置当前碰撞体为空
            Context.CurrentIntersectingCollider = null;
            // 重置IK目标位置为无穷大
            Context.ClosestPointOnColliderFromShoulder = Vector3.positiveInfinity;
        }
    }

效果:

1755810786266

8.开始对手部的IK组件目标位置进行更新

注意:需要为ik的目标位置加一个法向的偏移,防止手部穿模(因为手是有厚度的,不是纸片人)

EnvironmentInteractionState

    /// <summary>
    /// 设置 IK 目标位置
    /// </summary>
    /// <param name="targetPosition"></param>
    private void SetIkTargetPosition()
    {
        // 最近的碰撞点
        Context.ClosestPointOnColliderFromShoulder = GetClosestPointOnCollider(Context.CurrentIntersectingCollider, 
            // 目标位置:上半身的xz位置 角色肩高的y位置(高度位置)
            new Vector3(Context.RootTransform.position.x, Context.CharacterShoulderHeight, Context.RootTransform.position.z));

        #region 让手部的IK目标移动到这个最近碰撞点
        // 1. 射线方向:从“最近碰撞点”指向“当前肩部位置”的向量
        Vector3 rayDirection = Context.CurrentShoulderTransform.position
                             - Context.ClosestPointOnColliderFromShoulder;
            // Unity 中向量的运算:Vector3 终点 - Vector3 起点

        // 2. 归一化,得到单位向量
        Vector3 normalizedRayDirection = rayDirection.normalized;

        // 3. 偏移距离,防止手部穿模
        float offsetDistance = 0.05f;

        // 4. 最终要到达的位置:在“最近碰撞点”基础上,加上 沿rayDirection射线方向偏移 offsetDistance 距离
        Vector3 targettPosition = Context.ClosestPointOnColliderFromShoulder 
            + normalizedRayDirection * offsetDistance;

        // 5. 更新 IK 目标位置
        Context.CurrentIkTargetTransform.position = targettPosition;
        #endregion
    }

如果把权重一开始就拉到1,效果是这样的:

1755812110107

当然,我们还得根据具体的状态写Ik权重的控制脚本

每个具体状态的Ik控制逻辑的脚本编写

也就是根据状态决定是否/怎样更新手部Two Bone IK Constraint的权重

1.对现有代码进行一些小改动,更符合真实世界的运作机制

ResetState <-> SearchState:这个切换不应该是瞬时发生的,应该要加入一个延迟

1755813204514

1)先解决 ResetState -> SearchState

    // 持续时间计时器
    float _elapsedTimer = 0.0f;
    // 持续时间的阈值
    float _resetDuration = 2.0f;
    public override void EnterState(){
        // 重置 持续时间计时器
        _elapsedTimer = 0.0f;
        // 重置 最近碰撞点 和 当前碰撞体
        Context.ClosestPointOnColliderFromShoulder = Vector3.positiveInfinity;
        Context.CurrentIntersectingCollider = null;
        Debug.Log("ResetState EnterState");
    }
    public override void UpdateState() {
        _elapsedTimer += Time.deltaTime;
    }
    public override EnvironmentInteractionStateMachine.EEnvironmentInteractionState GetNextState() 
    { 
        bool isMoving = Context.CharacterController.velocity != Vector3.zero;
        //只有当持续时间超过阈值,且角色正在移动时,才会切换到 SearchState
        if(_elapsedTimer > _resetDuration && isMoving)
        {
            // 下一个状态为 SearchState
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Search;
        }
        return StateKey; 
    }

2)解决 SearchState 的状态跳转

    // 接近碰撞点的距离阈值
    public float _approachDistanceThreshold = 2.0f;
    public override EnvironmentInteractionStateMachine.EEnvironmentInteractionState GetNextState()
    {
        // 标志位:是否接近目标
        bool isCloseToTarget = Vector3.Distance(Context.ClosestPointOnColliderFromShoulder, Context.RootTransform.position) < _approachDistanceThreshold;
        // 标志位:是否是最近碰撞点(只要不是无穷大,就是最近碰撞点)
        bool isClosestPointOnColliderValid = Context.ClosestPointOnColliderFromShoulder != Vector3.positiveInfinity;
        // 状态转移到接近状态ApproachState
        if (isCloseToTarget && isClosestPointOnColliderValid)
        {
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Approach;
        }
        return StateKey;
    }

3)ApproachState

    // 接近状态的计时器
    float _elapsedTimer = 0.0f;
    // 过渡时间
    float _lerpduration = 5.0f;
    // 接近状态的目标权重
    float _approachWeight = 0.5f;
    public override void EnterState() {
        Debug.Log("ApproachState OnTriggerEnter");
        // 重置计时器
        _elapsedTimer = 0.0f;
    }
    public override void ExitState() { }
    public override void UpdateState() { 
        _elapsedTimer += Time.deltaTime;
        // 从当前的权重过渡到接近状态的权重
        Context.CurrentIkConstraint.weight = Mathf.Lerp(Context.CurrentIkConstraint.weight, _approachWeight, _elapsedTimer / _lerpduration);
    }
    public override void OnTriggerEnter(Collider other) {
        StartIkTargetPositionTracking(other);
        }
    public override void OnTriggerStay(Collider other) {
        UpdateIkTargetPosition(other);
    }
    public override void OnTriggerExit(Collider other) { 
        ResetIkTargetPositionTracking(other);
    }

现在能够在进入ApproachState状态时,随时间从当前的权重平滑过渡到Approach的目标权重

ApproachState状态需要让手部ik在更低的位置

1755849093630

以左手为例,面板做如下调整:

1755850535804

开始编写脚本,让进入Approach时手部ik目标高度在角色腰部,也就是角色碰撞体的中心的y轴坐标

EnvironmentInteractionContext

    // 交互点的Y轴偏移量,用来细调每个具体状态的交互点的高度
    public float InteractionPoint_Y_Offset { get; set; } = 0.0f;
    // 角色碰撞体的中心点的高度
    public float CharacterColliderCenterY { get; set; }

EnvironmentInteractionStateMachine 的 ConstructEnvironmentDetectionCollider()

        _context.CharacterColliderCenterY = characterController.center.y;

ResetState

    // 平滑过渡的持续时间
    float _lerpDuration = 10.0f;
    public override void UpdateState() {
        _elapsedTimer += Time.deltaTime;
        // 碰撞点的 Y 轴偏移,平滑过渡到角色碰撞体中心的高度
        Context.InteractionPoint_Y_Offset = Mathf.Lerp(Context.InteractionPoint_Y_Offset, Context.CharacterColliderCenterY, _elapsedTimer / _lerpDuration);
    }

EnvironmentInteractionState 的 SetIkTargetPosition(),y轴方向换成碰撞点的y轴偏移

        // 5. 更新 IK 目标位置
        Context.CurrentIkTargetTransform.position = 
            new Vector3(
                targettPosition.x,
                Context.InteractionPoint_Y_Offset,
                targettPosition.z);
ApproachState状态需要手腕旋转到让手掌朝向地面,也就是Multi-Rotation Constraint组件需要权重过渡到一个目标值

1755850711437

    // 接近状态的IkConstraint目标权重
    float _approachWeight = 0.5f;
    // 接近状态的MultiRotationConstraint目标旋转权重
    float _approachRotationWeight = 0.75f;
    // 旋转速度
    float _rotationSpeed = 500f;
    public override void UpdateState() { 
        //目标朝向:让手掌朝向地面,forwad=向下,up=角色的朝向
        Quaternion targetGroundRotation = Quaternion.LookRotation(-Vector3.up, Context.RootTransform.forward);

        _elapsedTimer += Time.deltaTime;
  
        // 控制手腕旋转ik的控制器朝向 旋转到 目标朝向
        Context.CurrentIkTargetTransform.rotation = Quaternion.RotateTowards(
            Context.CurrentIkTargetTransform.rotation, 
            targetGroundRotation, 
            _rotationSpeed * Time.deltaTime);

        // 更新权重:从当前的权重过渡到接近状态的对应权重
        //MultiRotationConstraint:
        Context.CurrentMultiRotationConstraint.weight = Mathf.Lerp(
            Context.CurrentMultiRotationConstraint.weight, 
            _approachRotationWeight, 
            _elapsedTimer / _lerpduration);
        //IkConstraint:
        Context.CurrentIkConstraint.weight = Mathf.Lerp(
            Context.CurrentIkConstraint.weight, 
            _approachWeight, 
            _elapsedTimer / _lerpduration);
    }

1755853694085

4) ApproachState的切换 -> RiseState / ResetState (有两种切换方式)

状态切换

如果继续接近碰撞点到一定距离阈值:ApproachState -> RiseState

如果在ApproachState状态持续时间超过一个阈值:ApproachState -> ResetState

ApproachState

    // 接近状态持续时间,超过就回到ResetState状态
    float _approachDuration = 2.0f;
    // 是否能切换到上升状态的距离阈值
    float _riseDistanceThreshold = 0.5f;
    public override EnvironmentInteractionStateMachine.EEnvironmentInteractionState GetNextState()
    {
        // 是否超过Approach状态的持续时间
        bool isOverStateLifeTime = _elapsedTimer > _approachDuration;
        if (isOverStateLifeTime)
        {
            // 切换到Reset状态
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Reset;
        }

        // 是否在手臂伸手范围内
        bool isWithArmsReach = Vector3.Distance(Context.ClosestPointOnColliderFromShoulder, Context.CurrentShoulderTransform.position) < _riseDistanceThreshold;
        bool isClosestPointOnColliderValid = Context.ClosestPointOnColliderFromShoulder != Vector3.positiveInfinity;
        if (isWithArmsReach && isClosestPointOnColliderValid)
        {
            // 切换到上升状态
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Rise;
        }
        return StateKey;
    }
ResetState中重置权重

UpdateState()

        // 更新权重:平滑重置当前的权重
        //MultiRotationConstraint:
        Context.CurrentMultiRotationConstraint.weight = Mathf.Lerp(
            Context.CurrentMultiRotationConstraint.weight,
            0,
            _elapsedTimer / _lerpDuration);
        //IkConstraint:
        Context.CurrentIkConstraint.weight = Mathf.Lerp(
            Context.CurrentIkConstraint.weight,
            0,
            _elapsedTimer / _lerpDuration);

EnvironmentInteractionContext的构造函数加入身体侧边的默认设置,也就是把一侧Rig相关参数传入CurrentXXX参数(CurrentIkConstraint、CurrentMultiRotationConstraint)

        // 默认设置当前身体的侧边为无穷大
        SetCurrentSide(Vector3.positiveInfinity);

1755857122673

1755856962570

回到Reset之后,需要让ik控制器部件也回到原来的position和rotation

1755857362835

EnvironmentInteractionContext中记录初始position和rotation信息

    // 记录初始位置
    private Vector3 _leftOriginalTransformPosition;
    private Vector3 _rightOriginalTransformPosition;

构造函数中

        _leftOriginalTransformPosition = _leftIkConstraint.data.target.transform.localPosition;
        _rightOriginalTransformPosition = _rightIkConstraint.data.target.transform.localPosition;
        OriginalTargetRotation = _leftIkConstraint.data.target.rotation;            // 初始的目标旋转(左右侧一样)

公开属性

    public Vector3 CurrentOriginalTargetPosition { get; private set; }
    public Quaternion OriginalTargetRotation { get; private set; }

SetCurrentSide()中赋值

        //靠近哪边就赋值哪边的Rig相关参数到CurrentXXX参数
        if (isLeftCloser)
        {
            Debug.Log("目标更靠近角色的左侧");
            CurrentBodySide = EBodySide.LEFT;
            CurrentIkConstraint = _leftIkConstraint;
            CurrentMultiRotationConstraint = _leftMultiRotationConstraint;
            CurrentOriginalTargetPosition = _leftOriginalTargetPosition;
        }
        else
        {
            Debug.Log("目标更靠近角色的右侧");
            CurrentBodySide = EBodySide.RIGHT;
            CurrentIkConstraint = _rightIkConstraint;
            CurrentMultiRotationConstraint = _rightMultiRotationConstraint;
            CurrentOriginalTargetPosition = _rightOriginalTargetPosition;
        }

ResetState中让ik目标控制器部件回到原来的position和rotation

    // 转向速度
    float _rotationSpeed = 500f;

UpdateState()

        // ik目标控制器部件也回到原来的position和rotation
        Context.CurrentIkTargetTransform.localPosition = Vector3.Lerp(
            Context.CurrentIkTargetTransform.localPosition,
            Context.CurrentOriginalTargetPosition,
            _elapsedTimer / _lerpDuration
        );

        Context.CurrentIkTargetTransform.rotation = Quaternion.RotateTowards(
            Context.CurrentIkTargetTransform.rotation,
            Context.OriginalTargetRotation,
            _rotationSpeed * Time.deltaTime
        );

1755859484290

5)RiseState

1755859593481

先更新ik目标控制器的y轴高度:

RiseState

    float _elapsedTimer = 0.0f;         // 已消耗时间,用于控制插值进度
    float _lerpDuration = 5.0f;         // 插值总时长,决定状态过渡的“慢/快”
    float _riseWeight = 1.0f;           // 权重目标值,用于IK和旋转约束的过渡

    public override void UpdateState()
    {
        // 1. 碰撞点的y轴高度偏移 平滑更新到 最近碰撞点的Y坐标
        Context.InteractionPoint_Y_Offset = Mathf.Lerp(
            Context.InteractionPoint_Y_Offset,
            Context.ClosestPointOnColliderFromShoulder.y,
            _elapsedTimer / _lerpDuration
        );

        // 2. 更新IK约束CurrentIkConstraint的权重:从当前权重到目标权重_riseWeight
        Context.CurrentIkConstraint.weight = Mathf.Lerp(
            Context.CurrentIkConstraint.weight,
            _riseWeight,
            _elapsedTimer / _lerpDuration
        );

        // 3. 更新多旋转约束CurrentMultiRotationConstraint的权重:从当前权重到目标权重_riseWeight
        Context.CurrentMultiRotationConstraint.weight = Mathf.Lerp(
            Context.CurrentMultiRotationConstraint.weight,
            _riseWeight,
            _elapsedTimer / _lerpDuration
        );

        _elapsedTimer += Time.deltaTime;
    }

再更新手掌的朝向:

RiseState

    Quaternion _targetHandRotation;   // 手部的目标旋转角度,用于让手部贴合交互物体表面
    float _maxDistance = 0.5f;         // 射线检测的最大距离
    protected LayerMask _interactableLayerMask = LayerMask.GetMask("Interactable");
    float _rotationSpeed = 1000f;      // 旋转速度
    /// <summary>
    /// 计算期望的手部旋转角度,用于让手部贴合交互物体表面
    /// </summary>
    private void CalculateExpectedHandRotation()
    {
        // 1. 获取起始点(肩部位置)和终点(最近碰撞点)
        Vector3 startPos = Context.CurrentShoulderTransform.position;
        Vector3 endPos = Context.ClosestPointOnColliderFromShoulder;

        // 2. 射线方向:肩部指向碰撞点的归一化方向向量
        Vector3 direction = (endPos - startPos).normalized;

        // 3. 发射射线
        if (Physics.Raycast(startPos, direction, out RaycastHit hit, _maxDistance, _interactableLayerMask))
        {
            // 碰撞点的表面法线
            Vector3 surfaceNormal = hit.normal;

            // 目标朝向:与表面法线相反(让手部朝向碰撞点的表面法线的反方向)
            Vector3 targetForward = -surfaceNormal;

            // 手部的目标旋转方向:与目标朝向相同,但绕着Y轴旋转90度
            _targetHandRotation = Quaternion.LookRotation(targetForward, Vector3.up);
        }
    }

UpdateState()

        // 计算期望的手部旋转角度
        CalculateExpectedHandRotation();
        // 4. 让 IK目标控制器 朝着 预期的手部旋转角度 平滑旋转
        Context.CurrentIkTargetTransform.rotation = Quaternion.RotateTowards(
            Context.CurrentIkTargetTransform.rotation,
            _targetHandRotation,
            _rotationSpeed * Time.deltaTime
        );

6)RiseState 的状态切换 -> TouchState / ResetState (两种切换方式)

状态切换

如果继续接近碰撞点到一定距离阈值:RiseState -> TouchState

如果在RiseState状态持续时间超过一个阈值:RiseState-> ResetState

RiseState

    // 用于判断是否能够进入TouchState状态的阈值
    float _touchDistanceThreshold = 0.05f;  // TouchState的距离阈值
    float _touchTimeThreshold = 1f;         // TouchState的持续时间阈值
    public override void EnterState()
    {
        // 重置计时器
        _elapsedTimer = 0.0f;
    }
    public override EnvironmentInteractionStateMachine.EEnvironmentInteractionState GetNextState()
    {
        // 标志位: 是否达到能够Touch的距离阈值
        bool isCloseToTouch = Vector3.Distance(
                Context.CurrentIkTargetTransform.position,
                Context.ClosestPointOnColliderFromShoulder
            ) < _touchDistanceThreshold;
        // 标志位: 是否达到能够Touch的持续时间阈值
        bool isTouchTimeOver = _elapsedTimer >= _touchTimeThreshold;

        if (isCloseToTouch && isTouchTimeOver)
        {
            // 切换到Touch状态
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Touch;
        }
        return StateKey;
    }

7)TouchState -> ResetState

切换条件只有时间阈值,超过就切换到ResetState

using UnityEngine;

public class TouchState : EnvironmentInteractionState
{
    public float _elapsedTime = 0.0f;
    public float _resetThreshold = 0.5f;    // 重置阈值:超过该时长就切换到 Reset 状态

    public TouchState(EnvironmentInteractionContext context,EnvironmentInteractionStateMachine.EEnvironmentInteractionState estate): base(context, estate)
    {
        EnvironmentInteractionContext Context = context; 
    }

    public override void EnterState()
    {
        // 重置计时器
        _elapsedTime = 0.0f;
    }

    public override void ExitState() { }

    public override void UpdateState()
    {
        _elapsedTime += Time.deltaTime;
    }

    public override EnvironmentInteractionStateMachine.EEnvironmentInteractionState GetNextState()
    {
        if (_elapsedTime > _resetThreshold)
        {
            // 切换到 ResetState
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Reset;
        }

        return StateKey;
    }

    public override void OnTriggerEnter(Collider other)
    {
        StartIkTargetPositionTracking(other);
    }
    public override void OnTriggerStay(Collider other)
    {
        UpdateIkTargetPosition(other);
    }
    public override void OnTriggerExit(Collider other)
    {
        ResetIkTargetPositionTracking(other);
    }
}

1755863473113

Reset事件的几个触发机制

1755863775713

另加一个可能的情况:角色跳的时候也触发Reset(虽然现在没给角色加入跳跃)

EnvironmentInteractionState

    private float _movingAwayOffset = 0.005f;       // 远离目标的偏移值

    bool _shouldReset;      // 标志位:是否能够进入ResetState
    /// <summary>
    /// 是否能够进入ResetState
    /// </summary>
    /// <returns>能够进入时返回 true,否则返回 false</returns>
    protected bool CheckShouldReset()
    {
        if (_shouldReset)
        {
            // 重置「最近距离」为无穷大
            Context.LowestDistance = Mathf.Infinity;
            // 重置标志位
            _shouldReset = false;
            return true;
        }

        // 标志位:是否停止移动
        bool isPlayerStopped = CheckIsStopped();
        // 标志位:是否正在远离目标交互点
        bool isMovingAway = CheckIsMovingAway();
        // 标志位:是否是非法角度
        bool isInvalidAngle = CheckIsInvalidAngle();
        // 标志位:是否正在跳跃
        bool isPlayerJumping = CheckIsJumping();

        if(isPlayerStopped || isMovingAway || isInvalidAngle || isPlayerJumping)
        {
            // 重置「最近距离」为无穷大
            Context.LowestDistance = Mathf.Infinity;
            return true;
        }

        return false;
    }

触发机制的检测函数

    /// <summary>
    /// Reset事件的触发机制1: ———— 玩家是否停止移动
    /// </summary>
    /// <returns></returns>
    protected bool CheckIsStopped()
    {
        bool isPlayerStopped = GameInputManager.MainInstance.Movement == Vector2.zero;
        return isPlayerStopped;
    }
    /// <summary>
    /// Reset事件的触发机制2: ———— 玩家是否正在远离目标交互点
    /// </summary>
    /// <returns>玩家远离目标时返回 true,否则返回 false</returns>
    protected bool CheckIsMovingAway()
    {
        // 1. 角色根节点到目标碰撞点的当前距离
        float currentDistanceToTarget = Vector3.Distance(
            Context.RootTransform.position,
            Context.ClosestPointOnColliderFromShoulder
        );

        // 标志位:是否正在搜索新的交互点
        bool isSearchingForNewInteraction = Context.CurrentIntersectingCollider == null;
        if (isSearchingForNewInteraction)
        {
            return false;
        }

        // 标志位:是否在靠近目标
        bool isGettingCloserToTarget = currentDistanceToTarget <= Context.LowestDistance;
        if (isGettingCloserToTarget)
        {
            // 更新最近距离
            Context.LowestDistance = currentDistanceToTarget;
            // 未远离
            return false;
        }

        // 标志位:是否已远离目标(当前距离超过「最近距离 + 偏移值」)
        bool isMovingAwayFromTarget = currentDistanceToTarget > Context.LowestDistance + _movingAwayOffset;
        if (isMovingAwayFromTarget)
        {
            // 标记为远离,重置「最近距离」(下次重新开始计算)
            Context.LowestDistance = Mathf.Infinity;
            // 远离
            return true;
        }

        return false;
    }
    /// <summary>
    /// Reset事件的触发机制3: ———— 当前交互的角度是否为“非法角度”
    /// </summary>
    /// <returns>如果是非法角度返回 true,否则返回 false</returns>
    protected bool CheckIsInvalidAngle()
    {
        // 如果当前交互的碰撞体为空,直接判定不是不良角度
        if (Context.CurrentIntersectingCollider == null)
        {
            return false;
        }

        // 计算从肩部指向碰撞点的方向向量
        Vector3 targetDirection = Context.ClosestPointOnColliderFromShoulder
                                 - Context.CurrentShoulderTransform.position;

        // 根据身体侧别(左/右)确定肩部的参考方向
        Vector3 shoulderDirection = (Context.CurrentBodySide == EnvironmentInteractionContext.EBodySide.RIGHT) ?
            Context.RootTransform.right
            : -Context.RootTransform.right;

        // 计算肩部参考方向与目标方向的点积(用于判断夹角方向)
        float dotProduct = Vector3.Dot(shoulderDirection, targetDirection.normalized);

        // 非法角度 = 点积小于 0 (目标方向与肩部参考方向夹角大于 90 度)
        bool isInvalidAngle = dotProduct < 0;

        return isInvalidAngle;
    }
    /// <summary>
    /// Reset事件的触发机制4: ———— 玩家是否正在跳跃
    /// </summary>
    /// <returns></returns>
    protected bool CheckIsJumping()
    {
        bool isPlayerJumping = Mathf.Round(Context.CharacterController.velocity.y) >= 1;
        return isPlayerJumping;
    }

在每个状态的状态切换函数GetNextState()中加入 切换到ResetState的触发条件

SearchState

        if (CheckShouldReset())
        {
            // 切换到Reset状态
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Reset;
        }

ApproachState

        if (isOverStateLifeTime || CheckShouldReset())
        {
            // 切换到Reset状态
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Reset;
        }

RiseState

        if (CheckShouldReset())
        {
            // 切换到Reset状态
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Reset;
        }

TouchState

        if (_elapsedTime > _resetThreshold || CheckShouldReset())
        {
            // 切换到 ResetState
            return EnvironmentInteractionStateMachine.EEnvironmentInteractionState.Reset;
        }

找到了之前从ResetState切换到SearchState一直响应慢的问题根源:

动画根运动驱动,需要用输入来判断是否在移动

ResetState的GetNextState()函数

        // 标志位:是否正在移动(是否有Movement输入)
        bool isMoving = GameInputManager.MainInstance.Movement != Vector2.zero;

最终效果如下:

1755877190523

1755877441326

我的评价是很丝滑很自然,这是我做过细节最多最复杂的动作拆解系统

posted @ 2025-08-24 16:20  EanoJiang  阅读(283)  评论(0)    收藏  举报