在制作游戏时,经常需要创建一个Player或Enemy对象,这类对象通常都可以移动、跳跃、攻击等等。来简单的设想一下怎么实现这些功能吧。
首先实现移动
[SerializeField]private float speed=10.0f;
void Update()
{
var horizontal = Input.GetAxis("Horizontal");
var vertical = Input.GetAxis("Vertical");
Vector2 moveDirection = new Vector2(horizontal, vertical);
if (moveDirection.magnitude > 1)
moveDirection = moveDirection.normalized;
transform.Translate(speed*Time.deltaTime*moveDirection);
}
如上代码就可以简单的实现一个2D平面物体的简单移动了
接下来加入一个冲刺功能
[SerializeField]private float speed=10.0f;
private bool _isDash;
void Update()
{
var horizontal = Input.GetAxis("Horizontal");
var vertical = Input.GetAxis("Vertical");
Vector2 moveDirection = new Vector2(horizontal, vertical);
if (moveDirection.magnitude > 1)
moveDirection = moveDirection.normalized;
if(!_isDash)
transform.Translate(speed*Time.deltaTime*moveDirection);
if (Input.GetKeyDown(KeyCode.Space))
_isDash = true;
if(_isDash)
Dash();
}
private void Dash()
{
//dash的具体方法
_isDash = false;
}
可以看到,代码添加了一个布尔变量来判断冲刺时间,通过按下Space键来执行一次Dash方法,当然,冲刺可能是一段时间内执行的,可以通过添加一个计时器来在一段时间内执行Dash方法,但笔者想要强调的并不是如何去实现,而是直接将两个状态混合在同一段代码中的弊端。
在代码中,冲刺的时候应该是不能去移动的,所以使用了一个bool去规定使用Translate方法的时机,很明显,冲刺状态影响到了原有的移动状态。现在设想一下,我们需要添加一个新的攻击状态,在攻击时不可以移动和冲刺,很明显,我们需要一个新的布尔值来约束Dash方法和Translate方法的使用时机。
可想而知,当状态不断添加的时候,过去的状态需要不断的根据添加的新状态添加新的约束,这对较大的项目来说会逐渐变得相当难管理。
那么,有什么方法可以解决这个问题呢。
答案之一 就是有限状态机
现在先来看看怎么创建一个有限状态机
首先创建文件夹Base
在该文件夹下创建三个对应脚本
名字视需要的对象而定 如果您需要创建一个敌人对象的状态机 也可以命名为Enemy EnemyFSM EnemyState
首先来编辑ActorState脚本
public class ActorState
{
protected Actor actor; //角色对象类
protected ActorFSM actorFsm; //角色Fsm
/// <summary>
/// 在构造函数中获得角色和角色状态机引用
/// </summary>
/// <param name="actor">角色类</param>
/// <param name="actorFsm">角色状态机</param>
public ActorState(Actor actor,ActorFSM actorFsm)
{
this.actor = actor;
this.actorFsm = actorFsm;
}
public virtual void OnEnter(){} //当进入该状态的执行函数
public virtual void OnExit(){} //当退出该状态的执行函数
public virtual void OnFrameUpdate(){} //在脚本框架中非物理更新的刷新执行函数
public virtual void OnPhysicsUpdate(){} //需要在FixedUpdate中更新的物理函数
}
这是所有角色状态的基类
其中提供了四个虚函数,作用如注释所写,根据需要可以通过override重写
然后来编辑ActorFSM脚本
public class ActorFSM
{
public ActorState currentState; //当前状态
/// <summary>
/// 注册初始状态
/// </summary>
/// <param name="startingState"></param>
public void Initialize(ActorState startingState)
{
currentState = startingState;
startingState.OnEnter();
}
/// <summary>
/// 切换状态
/// </summary>
/// <param name="newState"></param>
public void TransitionState(ActorState newState)
{
currentState.OnExit();
currentState = newState;
currentState.OnEnter();
}
}
可以看到,代码中使用了一个ActorState变量currentState来管理当前的状态,其中编写了两个方法,通过执行状态的退出进入函数实现了注册状态和切换状态
在编辑Actor脚本之前
先创建一个新的文件夹
在该文件夹下创建需要的状态脚本,为了演示笔者创建了如下脚本
先让该脚本继承ActorState基类
public class ActIdleState : ActorState
{
public ActIdleState(Actor actor, ActorFSM actorFsm) : base(actor, actorFsm)
{
}
}
public class ActWalkState : ActorState
{
public ActWalkState(Actor actor, ActorFSM actorFsm) : base(actor, actorFsm)
{
}
}
这样我们拥有了两个状态,虽然它们什么都没有执行
接下来来编辑actor脚本
public class Actor : MonoBehaviour
{
public float moveSpeed; //移动速度
public ActorFSM actorFsm; //管理状态的fsm
public Vector2 inputDirection; //输入的方向向量
public Rigidbody2D rb; //刚体组件
public ActIdleState actIdleState;
public ActWalkState acrWalkState;
protected void Awake()
{
actorFsm = new ActorFSM();
inputDirection = new Vector2(0.0f, 0.0f);
rb = GetComponent<Rigidbody2D>();
actIdleState = new ActIdleState(this, actorFsm);
acrWalkState = new ActWalkState(this, actorFsm);
}
protected void Start()
{
actorFsm.Initialize(actIdleState);
}
protected void Update()
{
actorFsm.currentState.OnFrameUpdate();
GetInputValue();
}
protected void FixedUpdate()
{
actorFsm.currentState.OnPhysicsUpdate();
}
/// <summary>
/// 获取输入的方向向量
/// </summary>
protected void GetInputValue()
{
var horizontal = Input.GetAxis("Horizontal");
var vertical = Input.GetAxis("Vertical");
inputDirection.x = horizontal;
inputDirection.y = vertical;
if (inputDirection.magnitude > 1)
inputDirection = inputDirection.normalized;
}
}
可以看到,在脚本内声明了一个状态机对象,在awake里去赋值
同样,需要使用的状态也被声明,同时在awake里赋值
在Start方法中注册了第一个状态
在Update和FixedUpdate中执行状态脚本中对应的方法
至此,一个简单的状态机已经实现,它可以让状态切换在状态脚本中自行执行,这样我们在编写一个状态脚本的时候就不用再去关心有别的状态会影响到正在编写的脚本了
在如上的代码中我们还不能实现任何效果,因为两个状态脚本还没有编写任何执行函数
接下来来编写两个脚本的执行函数
public class ActIdleState : ActorState
{
public ActIdleState(Actor actor, ActorFSM actorFsm) : base(actor, actorFsm)
{
}
public override void OnFrameUpdate()
{
if (actor.inputDirection.magnitude != 0)
actorFsm.TransitionState(actor.acrWalkState);
}
}
public class ActWalkState : ActorState
{
public ActWalkState(Actor actor, ActorFSM actorFsm) : base(actor, actorFsm)
{
}
public override void OnFrameUpdate()
{
if(actor.inputDirection.magnitude==0)
actorFsm.TransitionState(actor.actIdleState);
}
public override void OnPhysicsUpdate()
{
actor.rb.velocity = actor.inputDirection * actor.moveSpeed;
}
}
通过丰富如上脚本,被挂载上actor的对象就可以实现简单的移动到idle之间的切换了,如果您需要新的状态,也可以编写一个新的状态脚本继承ActorState,然后在Actor脚本中声明,在状态函数中自行编辑需要的执行函数
同样,如果有多个不同的操作角色,可以通过继承actor类来丰富新的功能
在如上的方法中其实有许多未解决的问题,比如角色转向,动画切换,音频播放等等
但同样,我们可以在状态基类,也就是ActorState中增加音频文件和动画机,通过需要的方式在不同执行时机去播放需要的音频或动画
笔者想要表达的是,我们可以在有限状态机上继续丰富来满足自己的需要,但这个结构也有它不太优雅的地方,笔者因为时间问题在下文阐述一些有限状态机的衍生的概念,如果有需要,笔者可以在日后更新对应的实战案例
首先,如果角色需要有一把武器可以操作,那么在原有的不同状态类中就会有不少重复的代码,比如在idle状态下可以发射子弹,在walk状态下也可以发射子弹,我们可能在很多状态下都可以执行同样的一段代码,这样重复的代码并不优雅,那我们有什么解决方案吗
答案是 并发状态机
它很简单,我们只需要再创建一个武器状态机来管理武器就可以了,让武器fsm自行管理其内部的状态,当然,我们可能在原有的状态中有一些状态时不可以使用武器,但这也只需要一些简单的if判断就可以实现了
接下来我们再来设想一个场景,我们的角色有Squat(下蹲),walk,idle,attack状态,现在从前三个任意一个状态进入attack状态,在attack状态退出后,我们应该回到哪一个状态呢,如果我们需要回到上一个状态,那么笔者接下来说的就解决了这个问题
答案是 下推状态机
首先我们需要一个栈结构,当执行新的状态的时候将新状态入栈,当状态退出时将状态出栈,这样我们实现了对过去状态的一个保存,可以让我们执行新的状态结束后回到过去的状态
在书籍《游戏编程模式》中,还提及到一种层次状态机,其概念是将基层的状态作为子类,每个状态有一个它的父类,当有一个事件传进来时,根据继承链来匹配可执行的状态,如果没有可执行的状态,那么就什么也不执行
状态机是AI管理的一种好方法,除开有限状态机,还有模糊状态机可以实现更加真实的敌人对象,如果有需要,笔者还可以在下次通过实战来讲解模糊状态机。