(已完结)仿神秘海域/美末环境交互的程序化动画学习
写在前面:
真正实现这些细枝末节的东西的时候才能感受到这种技术力的恐怖。
——致敬顽皮狗工作室
插件安装
为角色添加组件
右手同理
状态机脚本编写
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的同级
Rig放置的位置
环境交互状态机的编写
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];
}
状态机运行正常
环境检测
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;
}
2.碰撞体触发器的交互机制
- 角色进入 “触发器区域” →
OnTriggerEnter
触发(一次) - 角色持续待在区域内 → 每帧触发
OnTriggerStay
- 角色离开区域 →
OnTriggerExit
触发(一次)
3.找到离角色更近的一侧,用来决定后面开启哪边的IK
在EnvironmentInteractionContext加入:判断碰撞相交位置更靠近哪一侧
// 身体两侧
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;
}
注意:
在SearchState的OnTriggerEnter()中调用StartIkTargetPositionTracking()启动 IK 目标位置追踪
public override void OnTriggerEnter(Collider other) {
// 进入搜索状态时,开始跟踪目标位置
StartIkTargetPositionTracking(other);
}
测试一下功能是否正常:
效果倒是正常,不过这是我调试好久发现的问题,只有挂载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);
}
}
新的问题出现了:
当角色行走的时候,由于身体会浮动,这个最近的碰撞点也在上下浮动,后面加上动画会出现手一直在墙上 上下乱摸。。。
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));
}
问题解决
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;
}
}
效果:
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,效果是这样的:
当然,我们还得根据具体的状态写Ik权重的控制脚本
每个具体状态的Ik控制逻辑的脚本编写
也就是根据状态决定是否/怎样更新手部Two Bone IK Constraint的权重
1.对现有代码进行一些小改动,更符合真实世界的运作机制
ResetState <->
SearchState:这个切换不应该是瞬时发生的,应该要加入一个延迟
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在更低的位置
以左手为例,面板做如下调整:
开始编写脚本,让进入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组件需要权重过渡到一个目标值
// 接近状态的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);
}
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);
回到Reset之后,需要让ik控制器部件也回到原来的position和rotation
在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
);
5)RiseState
先更新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);
}
}
Reset事件的几个触发机制
另加一个可能的情况:角色跳的时候也触发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;
最终效果如下:
我的评价是很丝滑很自然,这是我做过细节最多最复杂的动作拆解系统