在诸如街霸、拳皇等格斗游戏中,搓招指的是玩家通过在短时间内连续输入特定的指令来释放角色的招式(比如右下右拳释放升龙拳)
那么如何通过状态机来实现搓招呢?
我们可以让每个招式都持有一个状态机,把这个招式要求的输入指令作为状态机的状态而存在。依然以升龙拳为例,我们可以把升龙拳的4个指令视为4个状态,每个状态都对应一种输入指令,在玩家输入指定指令后,切换到下一个指令的状态(如果超时或输入的指令不是特定指令则切换回初始状态),在最后一个指令状态中来处理招式释放的逻辑,并切换回初始状态
首先从一个十分简单的状态机开始,新建一个Fsm文件夹,在其中新建2个类文件
代码非常简单:
/// <summary> /// 状态基类 /// </summary> public abstract class FsmState { /// <summary> /// 状态ID /// </summary> public int stateID; protected FsmState(int stateID) { this.stateID = stateID; } public abstract void OnEnter(Fsm fsm); public abstract void OnUpdate(Fsm fsm,float deltaTime); public abstract void OnLeave(Fsm fsm); /// <summary> /// 切换状态 /// </summary> public void ChangeState<TState>(int stateID,Fsm fsm) where TState : FsmState { fsm.ChangeState(stateID); } }
/// <summary> /// 状态机 /// </summary> public class Fsm{ /// <summary> /// 状态字典 /// </summary> private Dictionary<int, FsmState> states = new Dictionary<int, FsmState>(); /// <summary> /// 当前状态 /// </summary> private FsmState currentState; /// <summary> /// 添加状态 /// </summary> public void AddState(FsmState state) { if (!states.ContainsKey(state.stateID)) { states.Add(state.stateID, state); } } /// <summary> /// 切换状态 /// </summary> public void ChangeState(int stateID) { if (states.ContainsKey(stateID)) { FsmState state = states[stateID]; currentState.OnLeave(this); currentState = state; currentState.OnEnter(this); } } /// <summary> /// 开始状态机 /// </summary> public void Start(int stateID) { if (states.ContainsKey(stateID)) { FsmState state = states[stateID]; currentState = state; currentState.OnEnter(this); } } /// <summary> /// 轮询状态机 /// </summary> public void Update(float deltaTime) { currentState.OnUpdate(this, deltaTime); } }
在这里,状态机的轮询交给持有该状态机的招式进行,而招式的轮询最终会由持有该招式的Player类(继承MonoBehaviour)进行
接下来新建一个Skill文件夹,在其中新建一个代表招式指令状态的InstructionsState文件,继承FsmState类
为其添加要用到的字段,并在构造方法里进行初始化
/// <summary> /// 招式指令状态 /// </summary> public class InstructionsState : FsmState { /// <summary> /// 输入等待时间 /// </summary> private float inputWaitTime; /// <summary> /// 计时器 /// </summary> private float timer; /// <summary> /// 招式指令状态对应的按键指令 /// </summary> private KeyCode keyCode; /// <summary> /// 指令状态要执行的方法 /// </summary> private UnityAction action; /// <summary> /// 最大招式指令状态ID /// </summary> private int maxStateID; public InstructionsState(int stateID, float inputWaitTime, KeyCode keyCode, UnityAction action, int maxStateID) : base(stateID) { this.inputWaitTime = inputWaitTime; timer = 0; this.keyCode = keyCode; this.action = action; this.maxStateID = maxStateID; } }
这个类的重点在于状态的轮询方法
public override void OnUpdate(Fsm fsm, float deltaTime) { timer += deltaTime; if (timer >= inputWaitTime) { //指令输入等待时间耗尽,重置指令状态 timer = 0; fsm.ChangeState(1); } //按下任意按键 if (Input.anyKeyDown) { //重置计时器 timer = 0; //按下了指令状态的对应按键 if (Input.GetKeyDown(keyCode)) { Debug.Log("玩家按下了" + keyCode.ToString()); //执行该指令要执行的方法 if (action != null) { action(); } //最后一个指令状态 if (stateID == maxStateID) { //重置指令状态 fsm.ChangeState(1); } else { //不是最后一个指令状态,切换到下一个指令状态 fsm.ChangeState(stateID + 1); } } //未按下指令状态的对应指令,重置指令状态 else { fsm.ChangeState(1); } } }
在指令状态轮询时,有3种可能的情况需要切换回初始状态
1.玩家在限定时间内未输入
2.玩家的输入不是该指令状态对应的指令
3.该指令状态是最后一个指令状态
在指令状态编写完毕后,我们需要编写一个招式数据类,在其中使用一个招式ID与指令数组的字典保存数据
/// <summary> /// 招式数据 /// </summary> public static class SkillData{ /// <summary> /// 招式ID与指令的字典 /// </summary> public static Dictionary<int, int[]> instructions = new Dictionary<int, int[]>(); static SkillData() { //100-D 115-S 106-J instructions.Add(1, new int[] { 100, 115 ,100 ,106}); instructions.Add(2, new int[] { 115, 100, 106 }); } }
指令数组中存储的是指令对应按键的KeyCode枚举所对应的整数
这里的字典数据可以根据需求从文件中读取,为了方便就先直接写到静态构造方法里
接着同样是在Skill文件夹下,新建一个代表招式基类的Skill类,为其添加需要的字段与方法,并在构造方法里进行初始化
/// <summary> /// 招式基类 /// </summary> public class Skill { /// <summary> /// 招式ID /// </summary> protected int skillID; /// <summary> /// 招式指令的状态机 /// </summary> protected Fsm fsm = new Fsm(); /// <summary> /// 招式持有者 /// </summary> protected Player player; /// <summary> /// 最大指令状态ID /// </summary> protected int maxSkillStateID; /// <summary> /// 指令状态ID /// </summary> protected int stateID = 0; public Skill(Player player) { this.player = player; maxSkillStateID = GetSkillID(); Init(); fsm.Start(1); } /// <summary> /// 初始化 /// </summary> protected void Init() { //从字典中读取到招式指令数据,然后根据数据添加指令状态 if (SkillData.instructions.ContainsKey(skillID)) { int[] instructions = null; SkillData.instructions.TryGetValue(skillID, out instructions); maxSkillStateID = instructions.Length; for (int i = 0; i < instructions.Length; i++) { if (i == instructions.Length - 1) { //最后一个指令需要执行招式的出招处理 AddInstructionsState((KeyCode)instructions[i], SkillFight); } else { AddInstructionsState((KeyCode)instructions[i]); } } } } /// <summary> /// 招式的出招处理(使用模板方法模式,延迟给子类实现) /// </summary> protected virtual void SkillFight() { player.ResetSkill(); } /// <summary> /// 获取招式ID(使用模板方法模式,延迟给子类实现) /// </summary> protected virtual int GetSkillID() { return -1; } /// <summary> /// 添加指令状态 /// </summary> /// <param name="keyCode">指令对应的按键</param> /// <param name="action">指令对应的方法</param> /// <param name="inputWaitTime">指令的输入等待时间</param> protected void AddInstructionsState(KeyCode keyCode, UnityAction action = null, float inputWaitTime = 0.5f) { fsm.AddState(new InstructionsState(++stateID, inputWaitTime, keyCode, action, maxSkillStateID)); } }
在上面的代码里调用了两个虚方法GetSkillID与SkillFight,这两个方法都需要由子类进行重写
接下来为该类添加状态机的重置与轮询方法
/// <summary> /// 指令状态机重置 /// </summary> public void Reset() { fsm.ChangeState(1); } /// <summary> /// 轮询指令状态机 /// </summary> public void Update(float deltaTime) { fsm.Update(deltaTime); }
这里重置状态机的方法是由持有该招式的Player类来调用的,其意义在于,我们在通过输入指令成功出招后,需要让其他可能也响应了部分指令的招式回到初始状态,以免出现玩家在释放了一个招式后继续操作却无法释放或意外释放另一个招式的情况
现在招式类已经完成,可以开始编写Player脚本了
新建Player脚本,在其中使用一个列表维护该Player的所有招式,并添加招式的轮询与重置
/// <summary> /// 可以搓招的玩家类 /// </summary> public class Player : MonoBehaviour { /// <summary> /// 所有招式的列表 /// </summary> private List<Skill> skills = new List<Skill>(); void Update() { //轮询招式列表 foreach (Skill skill in skills) { skill.Update(Time.deltaTime); } } /// <summary> /// 重置所有招式 /// </summary> public void ResetSkill() { foreach (Skill skill in skills) { skill.Reset(); } Debug.Log("所有招式都被重置了"); } }先到这里,想要往Skills里添加招式需要等到实现了具体的招式类以后
以升龙拳和气功波为例,在Skill文件夹下新建FirePunch类,继承Skill类,重写GetSkillID与出招逻辑的方法
/// <summary> /// 升龙拳 /// </summary> public class FirePunch : Skill{ public FirePunch(Player player) : base(player) { } public override int GetSkillID() { return 1; } public override void SkillFight() { base.SkillFight(); Debug.Log("升龙拳!"); } }
整个类都非常简单,我们以同样简单的方式来编写KiBlast类
/// <summary> /// 气功波 /// </summary> public class KiBlast : Skill { public KiBlast(Player player) : base(player) { } public override int GetSkillID() { return 2; } public override void SkillFight() { base.SkillFight(); Debug.Log("气功波!"); } }
OK,现在让我们回到Player类里,在Start方法中为Player添加招式
void Start() { //添加招式 添加顺序决定搓招优先级 skills.Add(new FirePunch(this)); skills.Add(new KiBlast(this)); }
这里优先级的意义在于,如果两个招式中,指令较短的那个招式重合了指令较长的招式(比如在上面编写的两个招式指令升龙拳-DSDJ与气功波-SDJ),那么Player将优先释放在列表中靠前的那个招式
现在可以开始测试我们的搓招系统了,在场景中新建一个游戏物体,将Player脚本挂载上去,运行Unity,快速输入DSDJ
在测试中可以看到,第二次按键输入的D被Log了两次,这是因为我们在按下一次D以后再按S时,升龙拳与气功波同时响应了S的输入,进行了状态切换,升龙拳的第3个指令状态与气功波的第2个指令状态对应的指令都是D,所以第二次输入的D被Log了两次
因为招式优先级的关系,即使我们在输入S后输入过SDJ,搓出的也是升龙拳而不是气功波,如果在添加招式时让气功波排在升龙拳前面,那么我们搓出的就是气功波了(而且将无论如何都搓不出升龙拳,这点读者可以自行测试)