转载请注明出处:🔗https://blog.csdn.net/weixin_44013533/article/details/146409453
作者:CSDN@|Ringleader|
1 结构
1.1 状态机
1.2 SMB
2 代码实现
2.1 核心控制
Player_Base_SMB 继承 StateMachineBehaviour ,控制变量初始化,以及OnStateUpdate每帧控制状态切换和逻辑处理。
具体 SwitchState
DoStateJob
交由继承的SMB来实现。
public class Player_Base_SMB : StateMachineBehaviour
{
protected static int PLAYER_STATE_IDLE = Animator.StringToHash("Idle");
protected static int PLAYER_STATE_RUN = Animator.StringToHash("Run");
protected static int PLAYER_STATE_JUMPUP = Animator.StringToHash("JumpUp");
protected static int PLAYER_STATE_FALL = Animator.StringToHash("Fall");
protected static int PLAYER_STATE_LAND = Animator.StringToHash("Land");
// Combat State
protected static int PLAYER_COMBAT_IDLE = Animator.StringToHash("Combat_Idle");
protected static int PLAYER_COMBAT_BAREHANDS_COMBO1 = Animator.StringToHash("Combat_BareHands_Combo1");
protected static int PLAYER_COMBAT_BAREHANDS_COMBO2 = Animator.StringToHash("Combat_BareHands_Combo2");
protected static int PLAYER_COMBAT_BAREHANDS_COMBO4 = Animator.StringToHash("Combat_BareHands_Combo4");
public string StateName;
protected PlayerInput _playerInput;
protected PlayerController _playerController;
protected Transform _playerTransform;
protected Transform _camTransform;
protected Rigidbody _playerRig;
protected PlayableDirector _playerTimeline;
[Tooltip("在project中右键添加对应SO,并在状态机状态中添加SO,那样运行时就可在SO中调整参数")]
public Player_State_SO playerStateSo;
protected bool isOnGround() => _playerController.isOnGround();
protected bool AnimationPlayFinished(AnimatorStateInfo stateInfo)
{
return stateInfo.normalizedTime >= 1.0f;
}
// 只进行一次的初始化
private void Initiate(Animator animator)
{
// 如果当前状态已经初始化过,则跳过Initiate
if (_playerInput != null && _playerRig != null)
{
return;
}
_playerInput = animator.GetComponent<PlayerInput>();
_playerController = animator.GetComponent<PlayerController>();
_playerTransform = _playerController.transform;
_playerRig = animator.GetComponent<Rigidbody>();
_camTransform = Camera.main.transform;
_playerTimeline = _playerController.playerTimeline;
// 注意log用到了PlayerController logEnable之类参数,使用Log方法前要确保依赖类已经初始化
LogStateAndMethod(StateName, "StateInitiation");
}
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
Initiate(animator);
// 注意log用到了PlayerController logEnable之类参数,使用Log方法前要确保依赖类已经初始化
LogStateAndMethod(StateName, "OnStateEnter");
}
public override void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
SwitchState(animator, stateInfo, layerIndex);
DoStateJob(animator, stateInfo, layerIndex);
}
protected virtual void DoStateJob(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
LogStateAndUpdateMethod(StateName, "OnStateUpdate-DoStateJob");
}
protected virtual void SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
LogStateAndUpdateMethod(StateName, "OnStateUpdate-SwitchState");
}
public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
LogStateAndMethod(StateName, "OnStateExit");
}
protected void DoMoveInPhysics()
{
if (_playerInput.moveInput != Vector2.zero)
{
Vector3 moveInput = new Vector3(_playerInput.moveInput.x, 0, _playerInput.moveInput.y);
// slopeNormal用于计算地面坡度
var slopeNormal = _playerController.slopeNormal();
// 相对主摄的移动(注意最后需要投影到水平面,否则会有上下位移导致镜头波动)
Vector3 _camMoveWithSlope = Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized,
slopeNormal != Vector3.zero ? slopeNormal : Vector3.up);
Vector3 _camMoveWithoutSlope =
Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized, Vector3.up);
// 转向
_playerRig.MoveRotation(Quaternion.RotateTowards(_playerTransform.rotation,
Quaternion.LookRotation(_camMoveWithoutSlope), playerStateSo.rotateSpeed));
// 移动
_playerRig.MovePosition(_playerRig.position +
_camMoveWithSlope * playerStateSo.runSpeed * Time.fixedDeltaTime);
}
}
protected void SetVelocityY(float y)
{
var velocity = _playerRig.linearVelocity;
velocity = new Vector3(velocity.x, y, velocity.z);
_playerRig.linearVelocity = velocity;
}
protected void SoundPlayRandom(AudioClip[] clips, float minPitch, float maxPitch, float minVolume, float maxVolume,
bool loop = false)
{
if (clips.Length == 0)
{
LogDebug($"请检查{StateName}状态的audio音效是否添加");
return;
}
_playerController.PlayRandomSound(clips, minPitch, maxPitch, minVolume, maxVolume, loop);
}
protected void StopSound()
{
_playerController.StopSound();
}
#region Log Method
protected void LogDebug(string str)
{
if (_playerController.logEnable)
{
Debug.Log(str + " Current frame:" + Time.frameCount);
}
}
protected void LogStateAndMethod(string StateName, string methodName)
{
LogDebug($"Current state: {StateName}, Current method execute : {methodName};\r\n");
}
protected void LogStateAndUpdateMethod(string StateName, string methodName)
{
if (_playerController.stateUpdateLogEnable)
{
Debug.Log($"Current state: {StateName}, Current method execute : {methodName};\r\n" + " Current frame:" +
Time.frameCount);
}
}
#endregion
}
2.2 combat状态 统一父类+combat状态入口&出口
出口就是Interrupt
方法,决定何时中断当前状态。
父类主要做通用技能中断,比如被 移动、跳跃、坠落等状态中断的情况。
技能衔接如combo之类交由子类SMB控制。
public class SMB_Combat_Base : Player_Base_SMB
{
protected float interruptNormalizedTime = 0.8f;//todo 待细化
protected bool canInterrup = true;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateEnter(animator, stateInfo, layerIndex);
// combat 采用 root motion,动作产生位移
if (!animator.applyRootMotion)
{
animator.applyRootMotion = true;//必须加,就算有OnStateMove也要开启。
}
}
protected override void SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.SwitchState(animator, stateInfo, layerIndex);
Interrupt(animator);
}
protected void Interrupt(Animator animator)
{
// 如果攻击正在播放,无法中断,攻击收尾阶段方可中断(当然中断还可以细化,产生优先级,最高优先级甚至可以无视当前出手)
if (!canInterrup) return;
// any state transform
// 1.any combat state → movement state
// 1.1 combat to Run(copy from movement Idle的状态转换)
if (_playerInput.moveInput != Vector2.zero)
{
StopTimeline(_playerTimeline);
animator.Play(PLAYER_STATE_RUN);
}
// 1.2 combat to jump
if (_playerInput.jumpInput)
{
StopTimeline(_playerTimeline);
animator.Play(PLAYER_STATE_JUMPUP);
}
// 1.3 combat to fall
if (!isOnGround())
{
StopTimeline(_playerTimeline);
animator.CrossFade(PLAYER_STATE_FALL, playerStateSo.idle_to_fall_duration);
}
}
private void StopTimeline(PlayableDirector timeline)
{
Debug.Log("timeline.duration="+timeline.duration);
// timeline.Stop();
}
}
2.3 具体状态SMB
2.3.1 战斗待机
// 战斗待机
public class SMB_Combat_Idle : SMB_Combat_Base
{
protected override void SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.SwitchState(animator, stateInfo, layerIndex);
// 攻击
if (_playerInput.fireInput)
{
animator.Play(PLAYER_COMBAT_BAREHANDS_COMBO1);
}
// 脱离战斗,这里简化为战斗待机播两次后回答基础待机(实战时可能条件很复杂,比如主动收刀、周围无敌人、未受到伤害等)
if (stateInfo.normalizedTime > 1f)
{
animator.Play(PLAYER_STATE_IDLE);
}
}
}
2.3.2 空手连接1
// 空手连击1
public class SMB_Combat_Barehands_Combo1 : SMB_Combat_Base
{
private Vector3 moveInput;
private Vector3 _camMoveWithoutSlope;
private Vector3 _camMoveWithSlope;
private bool canMoveBeforeAttack = false;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateEnter(animator, stateInfo, layerIndex);
canInterrup = false;
}
private void StopTimeline(PlayableDirector timeline,string reason)
{
Debug.Log("for reason:"+reason+",timeline.duration="+timeline.duration);
timeline.Stop();
}
protected override void SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.SwitchState(animator, stateInfo, layerIndex);
if (animator.IsInTransition(layerIndex))
{
return;
}
// 播放结束回到战斗待机状态
if (stateInfo.normalizedTime >= 1f)
{
StopTimeline(_playerTimeline,"播放结束回到战斗待机状态");
animator.Play(PLAYER_COMBAT_IDLE);
}
else if (stateInfo.normalizedTime >= 0.4f)
{
// 慢击
if (_playerInput.fireInput)
{
StopTimeline(_playerTimeline,"慢击");
animator.Play(PLAYER_COMBAT_BAREHANDS_COMBO1,layerIndex,0f);
}
}
else if (stateInfo.normalizedTime is > 0.3f and < 0.4f)
{
// 连击
if (_playerInput.fireInput)
{
StopTimeline(_playerTimeline,"连击");
animator.CrossFade(PLAYER_COMBAT_BAREHANDS_COMBO2,0.1f);
}
}
// 前摇时接收最后的转向
if (stateInfo.normalizedTime <= 0.2f)
{
if (_playerInput.moveInput != Vector2.zero)
{
canMoveBeforeAttack = true;
moveInput = new Vector3(_playerInput.moveInput.x, 0, _playerInput.moveInput.y);
// slopeNormal用于计算地面坡度
var slopeNormal = _playerController.slopeNormal();
// 相对主摄的移动(注意最后需要投影到水平面,否则会有上下位移导致镜头波动)
_camMoveWithSlope = Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized,
slopeNormal != Vector3.zero ? slopeNormal : Vector3.up);
_camMoveWithoutSlope =
Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized, Vector3.up);
}
}
// 设定攻击打断条件,一般与连招断续窗口一致,即攻击打实后准备收招那一刻(后摇开始时)
if (stateInfo.normalizedTime >= 0.4f)
{
canInterrup = true;
}
}
// 执行转向和移动
public override void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateMove(animator, stateInfo, layerIndex);
// animator.bodyPosition += animator.deltaPosition;//有问题,没有成功执行
// _playerRig.linearVelocity = animator.velocity;//移动跳跃异常,转向正常
_playerRig.MovePosition(_playerRig.position+animator.deltaPosition);//完美!
if (canMoveBeforeAttack && stateInfo.normalizedTime > 0.2f)
{
canMoveBeforeAttack = false;
// 转向
_playerRig.MoveRotation(Quaternion.LookRotation(_camMoveWithSlope));
// 移动(可以添加索敌吸附功能)
_playerRig.MovePosition(_playerRig.position + _camMoveWithSlope * 5);
}
}
}
2.3.3 空手连击2
// 空手连击2
public class SMB_Combat_Barehands_Combo2 : SMB_Combat_Base
{
private Vector3 moveInput;
private Vector3 _camMoveWithoutSlope;
private Vector3 _camMoveWithSlope;
private bool canMoveBeforeAttack = false;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateEnter(animator, stateInfo, layerIndex);
canInterrup = false;
}
protected override void SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.SwitchState(animator, stateInfo, layerIndex);
if (animator.IsInTransition(layerIndex))
{
return;
}
// 播放结束回到战斗待机状态
if (stateInfo.normalizedTime >= 1f)
{
animator.Play(PLAYER_COMBAT_IDLE);
}
else if (stateInfo.normalizedTime >= 0.3f)
{
// 慢击
if (_playerInput.fireInput)
{
animator.Play(PLAYER_COMBAT_BAREHANDS_COMBO1);
}
}
else if (stateInfo.normalizedTime is > 0.2f and < 0.3f)
{
// 连击
if (_playerInput.fireInput)
{
animator.CrossFade(PLAYER_COMBAT_BAREHANDS_COMBO4,0.1f);
}
}
// 前摇时接收最后的转向
if (stateInfo.normalizedTime <= 0.1f)
{
if (_playerInput.moveInput != Vector2.zero)
{
canMoveBeforeAttack = true;
moveInput = new Vector3(_playerInput.moveInput.x, 0, _playerInput.moveInput.y);
// slopeNormal用于计算地面坡度
var slopeNormal = _playerController.slopeNormal();
// 相对主摄的移动(注意最后需要投影到水平面,否则会有上下位移导致镜头波动)
_camMoveWithSlope = Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized,
slopeNormal != Vector3.zero ? slopeNormal : Vector3.up);
_camMoveWithoutSlope =
Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized, Vector3.up);
}
}
// 设定攻击打断条件,一般与连招断续窗口一致,即攻击打实后准备收招那一刻(后摇开始时)
if (stateInfo.normalizedTime >= 0.3f)
{
canInterrup = true;
}
}
// 执行转向和移动
public override void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateMove(animator, stateInfo, layerIndex);
// animator.bodyPosition += animator.deltaPosition;//有问题,没有成功执行
// _playerRig.linearVelocity = animator.velocity;//移动跳跃异常,转向正常
_playerRig.MovePosition(_playerRig.position+animator.deltaPosition);//完美!
if (canMoveBeforeAttack && stateInfo.normalizedTime > 0.1f)
{
canMoveBeforeAttack = false;
// 转向
_playerRig.MoveRotation(Quaternion.LookRotation(_camMoveWithSlope));
}
}
}
2.3.4 空手连击3
(我取的动画是combo4)
// 空手连击4
public class SMB_Combat_Barehands_Combo4 : SMB_Combat_Base
{
private Vector3 moveInput;
private Vector3 _camMoveWithoutSlope;
private Vector3 _camMoveWithSlope;
private bool canMoveBeforeAttack = false;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateEnter(animator, stateInfo, layerIndex);
canInterrup = false;
}
protected override void SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.SwitchState(animator, stateInfo, layerIndex);
// 播放结束回到战斗待机状态
if (stateInfo.normalizedTime >= 1f)
{
animator.Play(PLAYER_COMBAT_IDLE);
}
else if (stateInfo.normalizedTime >= 0.4f)
{
// 慢击
if (_playerInput.fireInput)
{
animator.Play(PLAYER_COMBAT_BAREHANDS_COMBO1);
}
}
// 前摇时接收最后的转向
if (stateInfo.normalizedTime <= 0.2f)
{
if (_playerInput.moveInput != Vector2.zero)
{
canMoveBeforeAttack = true;
moveInput = new Vector3(_playerInput.moveInput.x, 0, _playerInput.moveInput.y);
// slopeNormal用于计算地面坡度
var slopeNormal = _playerController.slopeNormal();
// 相对主摄的移动(注意最后需要投影到水平面,否则会有上下位移导致镜头波动)
_camMoveWithSlope = Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized,
slopeNormal != Vector3.zero ? slopeNormal : Vector3.up);
_camMoveWithoutSlope =
Vector3.ProjectOnPlane(_camTransform.TransformDirection(moveInput).normalized, Vector3.up);
}
}
// 设定攻击打断条件,一般与连招断续窗口一致,即攻击打实后准备收招那一刻(后摇开始时)
if (stateInfo.normalizedTime >= 0.4f)
{
canInterrup = true;
}
}
// 执行转向和移动
public override void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateMove(animator, stateInfo, layerIndex);
// animator.bodyPosition += animator.deltaPosition;//有问题,没有成功执行
// _playerRig.linearVelocity = animator.velocity;//移动跳跃异常,转向正常
_playerRig.MovePosition(_playerRig.position+animator.deltaPosition);//完美!
if (canMoveBeforeAttack && stateInfo.normalizedTime > 0.2f)
{
canMoveBeforeAttack = false;
// 转向
_playerRig.MoveRotation(Quaternion.LookRotation(_camMoveWithSlope));
}
}
}
2.4 从移动向战斗状态的切换
这里省略了移动相关SMB,这里只取移动的基类,控制 any movement state → 攻击
// 移动状态基类,主要用于anystate转换
public class SMB_Movement_Base : Player_Base_SMB
{
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.OnStateEnter(animator, stateInfo, layerIndex);
// movement 不采用 root motion,由开发者控制位移
if (animator.applyRootMotion)
{
animator.applyRootMotion = false;
}
}
protected override void SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
base.SwitchState(animator, stateInfo, layerIndex);
// any state transform
// any movement state → 攻击
if (_playerInput.fireInput)
{
animator.Play(PLAYER_COMBAT_BAREHANDS_COMBO1);
}
}
}
3 最终效果展示
4 遇到的问题及解决
4.1 角色原地攻击、攻击完毕后瞬移
添加连击状态后,发现角色只能原地运动。
于是可以考虑将攻击动画的RootTransform Position xz 分量 bake into pose,但会发现角色攻击完毕后会瞬移回模型原点。
在我第十一篇文章《动画基础》7.6.2 节中曾详细比较了humanoid动画 root motion和bake into pose的情况:
从上面可以知道,要想模型父节点跟随模型必须应用 root motion
而基础移动(movement
)又需要交给程序精确控制,如果不使用OnAnimatorMove()
(rootMotion handle by script),可以这样分开处理:
- 在进入
movement
的状态,animator.applyRootMotion = false;
; - 进入
combat
的状态,animator.applyRootMotion = true;
;
这样攻击就能正常位移了(注意攻击动画的RootTransform Position xz 分量不要 bake into pose)
4.2 连击动画父节点瞬移
但如果做连击动画
会发现第二、三段攻击父节点发生位移。
原因就是原动画第二三段模型就是偏离模型空间原点。
解决办法就是将动画的RootTransform Position xz 选择based upon Center of Mass
。
最终效果:
4.3 同时移动和攻击会产生鬼畜
anystate transform的问题,移动时攻击就会瞬间高速循环切换状态:移动→combat→移动
move combat状态瞬时切换
解决方法就是让攻击动画不能无条件随时打断,至少播放到40%才能打断(技能打断会开新专题)
protected bool canInterrup = true;
OnStateEnter(){
...
canInterrup = false;
...
}
switchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex){
...
if (stateInfo.normalizedTime >= 0.4f)
{
// 攻击产生后摇后方可打断
canInterrup = true;
}
if (canInterrup) {
// movement 相关输入判断
}
...
}
4.4 状态无法自转移(无法重播当前动画)
假设有攻击1、攻击2、攻击3,这三段连续的攻击动画,当播放到20%~40%按下攻击,便可触发连击,40%后再按攻击键便回到攻击1播放。
现在的问题是,animator.Play(“stateName”) 的状态是自身的话,也就是慢速连按攻击键,攻击1动画无法再次触发,状态也没有切换(exit state 然后再enter state)。
if (stateInfo.normalizedTime >= 0.4f)
{
// 慢击
if (_playerInput.fireInput)
{
animator.Play(PLAYER_COMBAT_BAREHANDS_COMBO1);
}
}
可能是animator.Play
方法优化的原因,不指定方法的normalizedTime
参数,当前stateName
与待播放的stateName
相同,则不触发状态切换。
解决方法就是指定normalizedTime
参数。
animator.Play(PLAYER_COMBAT_BAREHANDS_COMBO1,layerIndex,normalizedTime: 0f);
4.5 连击切换死板,加过渡后产生鬼畜
如果连击动画交接处差异过大,会明显感觉到跳切,于是可以考虑加上过渡
animator.CrossFade(PLAYER_COMBAT_BAREHANDS_COMBO4,0.1f);
但快速连点攻击时,会发现鬼畜/慢动作,原因在于,过渡过程两个状态的update其实都是进行中的,过渡过程如果也按下攻击,便会反复触发这个转换过程,便是鬼畜。
需要在前者状态切换添加过滤
SwitchState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex){
...
if (animator.IsInTransition(layerIndex))
{
return;
}
...
}
这一点在我 【Unity实战笔记】第二十二 跳跃到fall有突然前移现象 小节有提到
5 总结
本文使用SMB+Animator 实现了基础战斗系统,但可以看到技能衔接是通过 stateInfo.normalizedTime
判断的,这种方式不够完美:
- 第一,基于时间的控制不够准确,调整起来也很麻烦
- 第二,当需要中断的条件变多变复杂时,这种if else判断出现bug的可能会越来越大
- 第三,如果要添加匹配音效、特效,以及更复杂的需求,这种技能编辑方式就捉襟见肘了
所以我希望有一种更直观的方式去编辑技能,且基于帧的控制,除了能编辑动画,还能配置音效、粒子特效等。
这就是Timeline!
下一篇见~