在很多游戏中,敌人往往是必不可少的。因此,设计敌人的行动逻辑就显得十分重要。
但是,一类敌人往往会有很多状态,比如走路、奔跑、释放技能等等。Boss类的敌人还会有很多阶段,第一阶段是这个状态,第二阶段就变成了另一种状态,分别执行不同的行动逻辑。
可见,地方的行动逻辑是相当多样的,如果写在一个C#代码文件中难免会显得复杂,也增加了后期调整和维护的难度。那么有没有什么办法能够是这样的状态切换变得易于理解呢?
有限状态机的思想为我们提供了解决方案。
那么什么是有限状态机呢。有限状态机包含了一个游戏物体的各个状态的行动逻辑。以一个多阶段Boss为例。Boss具有三个阶段,每个阶段有不同的行动逻辑,那么我们就将这三个阶段不同的行动逻辑分别写在继承同一个抽象父类的C#代码中,一旦Boss转阶段,我们就切换执行的代码。这样我们就将不同的状态写在了不同的C#代码中,并能够以父类类型统一调用。不仅便于了编写,也易于维护和扩展。
光是文字会比较抽象。下面通过一个制作敌人的实际例子来加深理解。
敌人为右边的骑士,需要制作行走和奔跑两种状态。
在设计有限状态机之前,我们先将一些基本的行动逻辑写一下。
即:行动到墙壁前会转身,向另一方移动。
制作动画的过程和逻辑判断这边就省略了,详细可参考之前的笔记。
在实现该功能的过程中,运用了协程IEnumerator。
public IEnumerator onHurt(Vector2 dir)
{
rb.AddForce(dir * hurtForce);
yield return new WaitForSeconds(0.5f);
isHurt = false;
}
在协程中,我们可以通过yield return来控制语句执行的时间间隔,以上面的协程为例,第二条语句的意思就是执行完AddForce0.5秒后执行isHurt = false。
在一个协程中,可以有多个yield return,其含义和return完全不同,因此无需担心会有冲突。
yield return后面可以接很多不同的控制变量,详细可以参考其他文章。
通过StartCorountine(onHurt(dir))就可以执行上面的协程,StopCorountine则用于停止。
实现完基本逻辑功能后,我们可以正式制作有限状态机了。
首先分析在这个状态类中我们需要哪些方法。
要想进入某个状态,我们必须要有进入状态的函数方法,执行基本的变量赋值。
同理,在状态退出时,需要执行退出状态的函数方法。
以上两个方法共同构成的切换状态的基本模板。
同时我们需要控制状态逻辑和物理逻辑的两个函数方法。
分析结构:一个进入方法,一个退出方法,一个控制状态逻辑的方法和一个控制物理逻辑的方法。
接着将上面分析出来的4个方法抽象成一个基本的抽象状态类,通过这个抽象状态类我们能构建起其子类的联系。
代码如下:
public abstract class BaseState : MonoBehaviour
{
protected Enemy currentEnemy;
protected abstract void OnEnter();
protected abstract void LogicUpdate();
protected abstract void PhysicsUpdate();
protected abstract void OnExit();
}
因为我们需要在行走和奔跑两个状态之间切换,所有在Enemy基类中分别创建行走状态和奔跑状态。
private BaseState currentState;
protected BaseState walkState;
protected BaseState runState;
用currentState记录当前状态。
完成以后工作后,我们创建切换状态的方法。
基本逻辑:当敌人发现玩家,进入奔跑状态。首先执行当前状态的退出,接着执行新状态的进入。当地人丢失目标,突出奔跑状态,返回行走状态。
在创建切换状态的方法前,我们可以先做一些准备工作。
创建一个枚举类EnemyState
public enum EnemyState
{
walk,run
}
接着就可以创建切换函数SwitchState了。
这里使用一个新的c#的switch语法。
代码如下
public void SwithState(EnemyState state)
{
var newState = state switch
{
EnemyState.walk => walkState,
EnemyState.run => runState,
_ => null
};
currentState.OnExit();
currentState = newState;
currentState.OnEnter(this);
}
函数参数是一个枚举类里的一个枚举,表示我们需要切换成的状态。
我们用一个var类型记录新的状态。
当传入的state是walk,则newState赋值为walkState;当传入的state是run,则赋值为runState;其他情况赋值为null。
接着调用OnExit(),退出当前状态。然后将新的状态赋值给currentState,并执行新状态的OnEnter方法。
接下来只需要专注实现各个状态里的四个方法就行了。
首先是行走状态,该状态在之前已经实现了,我们可以将原来写好的代码剪切到行走状态里的方法里,之后在Enemy中调用。
public class WalkState : BaseState
{
public override void OnEnter(Enemy enemy)
{
currentEnemy = enemy;
currentEnemy.currentSpeed = currentEnemy.normalSpeed;
currentEnemy.anim.SetBool("walk", true);
}
public override void LogicUpdate()
{
if (currentEnemy.FindPlayer()) //发现玩家,切换成奔跑状态
currentEnemy.SwithState(EnemyState.run);
if ((currentEnemy.checkState.touchLeftWall && currentEnemy.faceDir.x < 0) || (currentEnemy.checkState.touchRightWall && currentEnemy.faceDir.x > 0) || !currentEnemy.checkState.isGround)
{
currentEnemy.isWait = true;
currentEnemy.anim.SetBool("walk", false);
}
else
{
currentEnemy.anim.SetBool("walk", true);
}
}
public override void PhysicsUpdate()
{
}
public override void OnExit()
{
currentEnemy.anim.SetBool("walk", false);
}
}
public class Enemy : MonoBehaviour
{
//........
public void OnEnable()
{
currentState = walkState;
currentState.OnEnter(this);
}
public void OnDisable()
{
currentState.OnExit();
}
public void Update()
{
faceDir = new Vector3(-transform.localScale.x, 0, 0);
currentState.LogicUpdate();
TimeCounter();
}
public void FixedUpdate()
{
currentState.PhysicsUpdate();
Move();
}
//........
}
当敌人发现了玩家,敌人就进入奔跑状态。此时只需要在RunState类中实现4个基本的状态方法即可。
public class RunState : BaseState
{
public override void OnEnter(Enemy enemy)
{
currentEnemy = enemy;
currentEnemy.currentSpeed = currentEnemy.chaseSpeed;
currentEnemy.anim.SetBool("run",true);
currentEnemy.isWait = false;
currentEnemy.lostTimeCounter = currentEnemy.lostTime;
Debug.Log("runEnter");
}
public override void LogicUpdate()
{
if (currentEnemy.lostTimeCounter <= 0)
currentEnemy.SwithState(EnemyState.walk);
if ((currentEnemy.checkState.touchLeftWall && currentEnemy.faceDir.x < 0) || (currentEnemy.checkState.touchRightWall && currentEnemy.faceDir.x > 0) || !currentEnemy.checkState.isGround)
{
currentEnemy.transform.localScale = new Vector3(currentEnemy.faceDir.x, 1, 1);
}
}
public override void PhysicsUpdate()
{
}
public override void OnExit()
{
currentEnemy.anim.SetBool("run",false);
Debug.Log("runExit");
}
}
下面是测试结果:
总结:有限状态机为我们提供一种十分便捷且易于理解维护的写脚本文件的方式,通过有限状态机,我们可以更加便利地实现多状态的游戏物体,而不仅限于敌人。有限状态机也可以无线扩展,当我们想为该游戏物体添加新的状态时,只需要声明一个新的子类并实现其内部的函数方法即可。有限状态机充分利用了类的继承,在之后的脚本编写中,也需要多多利用类给我们带来的便利,而不是仅仅注重过程逻辑的编写。