游戏编程开发状态机 实现动画树边走边攻击
本文将介绍状态模式中简单状态机、并发状态机、层次状态机与下推状态机,通过本文我们可以学会如何处理拥有复杂状态的角色,写出更优雅的代码!
状态模式简介
状态模式可以概括为:“允许一个对象在其内部状态改变时改变其行为”。从这句话中可以看出,状态模式的内部包含了多个状态,并且拥有切换状态的方法,当状态发生变化时,就会执行不同行为,以下便是一个状态模式的实现:
//我们用一个枚举来列举出所有的状态
public enum StateType
{
Idle,
Walk,
Fire,
}
public class StateMode : MonoBehaviour
{
//维护一个内部状态
public StateType curstate;
void Update()
{
//不同状态拥有不同的行为
switch (curstate) {
case StateType.Idle:
//noting to do
break;
case StateType.Walk:
Walk();
break;
case StateType.Fire:
Fire();
break;
default :
break;
}
}
//切换状态
public void TransitionState(StateType type)
{
curstate = type;
}
}
以上代码麻雀虽小五脏俱全,概括了状态模式的所有概念,不过其代码的缺点也很明显,它是一种面向过程的写法,通常我们会使用更符合面向对象的原则的写法如下:
//定义一个状态接口 规范每个状态的行为
public interface IState
{
void OnEnter();
void OnExit();
void OnUpdate();
}
//状态具体类
public class IdleState :IState {...}
public class WalkState :IState
{
void OnEnter() {};
void OnExit() {};
void OnUpdate() { Walk();};
}
public class FireState :IState
{
void OnEnter() {};
void OnExit() {};
void OnUpdate() { Fire();};
}
public enum StateType
{
Idle,
Walk,
Fire,
}
public class FSM : MonoBehaviour
{
//使用字典存储枚举到状态类的映射
private Dictionary<StateType, IState> states = new Dictionary<StateType, IState>();
private IState normalStates; //维护一个内部状态
void Awake()
{
//添加状态
states.Add(StateType.Idle, new IdleState(this));
states.Add(StateType.Walk, new WalkState(this));
states.Add(StateType.Fire, new FireState(this));
}
void Update()
{
//执行当前状态的行为
normalStates.OnUpdate();
}
//切换状态
public void TransitionState(StateType type)
{
if(normalStates != null)
normalStates.OnExit();
normalStates = states[type];
normalStates.OnEnter();
}
}
至此你已经学会了如何制作一个简单状态机啦!接下来我们结合实例来学习如何从零开始设计并制作较为复杂的状态机吧!
角色设计与需求分析
我们的主角是一名名为施密特的黑客,他可以进行移动、跳跃、射击、攀爬、救助倒地的队友,受到一定的攻击后将倒地。作为一名顶级黑客,斯密特可以骇入门禁(开门)、骇入平台控制、骇入监控并将其摧毁、骇入机甲获得其操控权!
根据需求我们可以划分出以下角色的状态与给出其状态转移的时机:
- 站立:什么都不做的状态。
- 移动:在站立状态下按下水平方向键后进入移动状态。
- 跳跃:在站立或移动状态下按下跳跃键后进入跳跃状态,当角色落地时返回。
- 攀爬:在站立、移动、跳跃状态下在楼梯按下竖直方向键后进入爬楼梯状态,离开梯子后返回。
- 救援:在站立或移动状态下在另一名倒地玩家身边按下互动键后进入救援状态。
- 倒地:任何状态下,HP降为0则倒地,被救起后返回。
- 射击:在站立、移动、跳跃状态下按下鼠标左键后射击。
- 骇入门禁:在站立或移动状态下与门按钮互动后开门,互动后立即返回。
- 骇入平台:在站立或行走状态下与平台控制器互动,按下ESC键后退出控制。
- 骇入监控:在站立或移动状态下与监控按钮互动,互动后立即返回。
- 骇入机甲:在站立或移动状态下与机甲启动器互动,按下ESC键后退出控制。
状态设计
简单地总结下功能就出现了11个状态!我们不可能为每个状态都定制一个类,某些相似的状态可以被放在一起处理,比如:
- 站立、移动、跳跃:从需求分析中我们可以看到大多数状态都是从站立、行走状态出发的,我们可以这样设计:无论有无输入,角色都将默认处于移动状态,获得输入时角色再动起来,不进行状态转移,这样我们便省下了一个站立状态。关于跳跃,大多时候我们都只会在行走状态下进行跳跃,将跳跃写入行走状态中,也可以为我们省下一个类以及一些难缠的状态转移条件。
- 骇入门禁、骇入平台、骇入监控、骇入机甲:这四个状态十分相似,尽管它们的内容千差万别,但它们都是在角色与按钮互动后进入的状态,我们可以将这四个状态归类为“骇入状态”,角色将在骇入状态中处理这四个逻辑。
对于哪些行为应该被放在一起处理是因人而异的,我们只需要在保证我们代码的可读性下去设计这些状态,比如我认为不应该合并移动与攀爬状态,因为攀爬是一个比较复杂的行为;不应该合并移动和救援状态,因为我不希望接手我工作的同学费劲九牛二虎之力才找到这藏在移动状态中的可恶的救援代码。
完成状态合并后,现在只包含6个状态:行走、攀爬、倒地、射击、骇入、救援。这就好处理多啦!
有时候我们无法确定转移状态,你可能和我一样有这样的困惑:行走、跳跃时角色可以进行射击,但这似乎是同时存在的状态。
传统有限状态机最大的优点也是他的缺点:状态机同一时刻只存在一种状态。如果此时我们还执着于传统的有限状态机,我们可能做出以下的解决方案:
- 添加一个行走射击状态和在行走状态中写下射击的代码。
- 声明一个单独的射击类,然后在行走和跳跃状态中引用他。
显而易见,这两种写法都可行,但这一点都不优雅。这些写法意味着代码复用性差或者可读性降低,此时我们可以通过并发状态机来解决这个问题。
并发状态机
并发状态机实现其实很简单,有别于传统状态机的地方在于我们不再只维护一个状态,而是可以维护多个状态。
各大游戏引擎都能通过骨骼半身分层方式(UE Layered Blend Per Bone\Godot Bones Blend Filters),来实现走动和挥砍同时进行的动画姿态。
实现如下:
public enum StateType
{
Walk,
Climb,
Hack,
Fall,
Help,
Fire,
}
public class FSM : NetworkBehaviour
{
private Dictionary<StateType, IState> states = new Dictionary<StateType, IState>();
private IState normalState;
private IState battleState; //再维护一个战斗状态
void Awake()
{
...
TransitionNormalState(StateType.Walk);
TransitionBattleState(StateType.Fire);
}
public void Update()
{
normalState.OnUpdate();
//只在行走状态下进行射击
if(normalStates == states[StateType.Walk])
battleState.OnUpdate();
}
public void TransitionNormalState(StateType type)
{
if (normalState != null)
normalState.OnExit();
normalState = states[type];
normalState.OnEnter();
}
public void TransitionBattleState(StateType type)
{
if (battleState != null)
battleState.OnExit();
battleState = states[type];
battleState.OnEnter();
}
...
}
使用并发状态使得我们的选择更加灵活,同时代码的可读性也更高。当我们想要只在行走状态下进行射击时,可以轻易地使用if语句实现。当我们想要实现受击和倒地状态时,也可以为此增加一个新的状态,同样可以轻易地用if语句实现倒地后不执行其他状态。
private IState damageState;
public void Update()
{
damageState.OnUpdate();
if(damageState == StateType.Fall)
return;
normalState.OnUpdate();
if(normalState == StateType.Walk)
battleState.OnUpdate();
}
想要实现所有状态下都能切换到倒地状态也可以不增加新的状态,如下所示。我认为不同的做法取决于你想要维护的状态数量,我们只需要在保证可读性的前提下编写代码即可。
private IState damageState;
public void Update()
{
Fall();
normalState.OnUpdate();
if(normalState == StateType.Walk)
battleState.OnUpdate();
}
void Fall()
{
if(hp == 0)
TransitionNormalState(StateType.Fall);
}
并发状态机是比较常用的一种状态机,现在你可以解决大多数状态机中的问题啦,接下来我再介绍两种可能对你有帮助的层次状态机和下推状态机。
层次状态机
当多个状态都需要响应同一个行为时,比如在移动、攀爬状态下都需要响应跳跃这个行为,我们的做法可能是在那边都写下相同跳跃代码,当我们写下相同代码时,我们就该思考能不能进行代码复用,代码复用最普遍的做法就是继承,状态之间的继承在状态模式中称为层次状态机。状态拥有父状态,当子状态对输入不进行处理的情况下,就调用父状态进行处理。
//状态具体类
public class JumpState :IState
{
void OnEnter() {};
void OnExit() {};
void OnUpdate() { Jump();};
void Jump();
}
public class WalkState :JumpState, IState
{
void OnEnter() {};
void OnExit() {};
void OnUpdate() { Jump() };
}
继承的缺点也很明显,会导致父子类的紧耦合,所以还是需要谨慎使用。
下推状态机
简单状态机的另一个缺点:当前状态不知道上一个状态是谁。之前我们基本执行完某个状态后(比如骇入、倒地)都是返回移动状态,但有时候我们可能需要返回上一个状态,而不是指定的某个状态,这个时候我们就需要记录每个状态,我们可以通过维护一个状态栈来实现:进入状态时压到栈中,离开状态时将状态弹出,并切换到栈顶状态。
状态机的坑点
在Unity中,当状态切换时,并不会立即执行切换状态后的Update函数,而是执行完当前这一帧的内容后,下一帧才会执行切换后的状态的Update函数。比如人物在移动过程中按下互动键,此时发生了状态的切换,但仍然在执行行走状态的Update,此时玩家仍可以移动一会会,但就是因为这一会会,导致玩家逃离了互动物品的范围,再下一帧中骇入状态的Update函数中出现了Bug。所以所有的状态转移都应该被写在Update函数中并及时return去结束该状态这一帧的操作。
结语
以上便是我在读完《游戏编程模式》这本书后,对状态模式的一点思考与总结。如果您有更好解决方案,欢迎在评论区告诉我。总而言之,感谢您耐心看完,希望能给您带来一点帮助。