游戏开发设计模式之状态模式 & 有限状态机 & c#委托事件(unity3d 示例实现)

命令模式:游戏开发设计模式之命令模式(unity3d 示例实现)

对象池模式:游戏开发设计模式之对象池模式(unity3d 示例实现)

原型模式:游戏开发设计模式之原型模式 & unity3d JSON的使用(unity3d 示例实现) 

说起状态模式游戏开发者们第一个想到的一定是AI的有限状态机FSMs,状态模式确实是实现有限状态机的一种方法。之后还会讲状态机的进阶分层状态机(hierarchical state machines),和pushdown自动机(pushdown automata), 本文就拿人物控制的有限状态机来讲讲状态机模式,本文例子其实是状态模式和观察者模式的组合,通过获取玩家按键消息来改变状态。

如果不使用有限状态机

如果想要你实现一个2d或3d游戏中的人物控制(本文拿2d游戏举例),可以让人物走、跑,相信不少人会这么写:

[csharp] view plaincopy

  1.    if (Input.GetKey(KeyCode.D))
  2.         {
  3. …设置方向向右..
  4.             if (Input.GetKey(KeyCode.LeftShift))
  5.             {
  6. ..移动..播放跑步动画..
  7.             }
  8.             else
  9.             {
  10. ..移动..播放走路动画..
  11.             }
  12.         }
  13.         else if (Input.GetKey(KeyCode.A))
  14.         {
  15.         …设置方向向左..
  16.             if (Input.GetKey(KeyCode.LeftShift))
  17.             {
  18. ..移动..播放跑步动画..
  19.             }
  20.             else
  21.             {
  22. ..移动..播放走路动画..
  23.             }
  24.         }

然后再加上跳跃功能,怎么办呢,跳跃时肯定不能执行走路的操作播放走路的动画啊,加一个bool判断吧,然后代码变成了这样:

[csharp] view plaincopy

  1. bool isJump = false;
  2.     if (Input.GetKeyDown(KeyCode.W))
  3. {
  4. isJump = true;
  5. }
  6.     if (Input.GetKey(KeyCode.D))
  7.         {
  8. …设置方向向右..
  9.             if (Input.GetKey(KeyCode.LeftShift))
  10.             {
  11. if(!isJump)
  12. ..移动..播放跑步动画..
  13. else
  14. ..移动..播放跳跃动画..
  15.             }
  16.             else
  17.             {
  18. if(!isJump)
  19. ..移动..播放走路动画..
  20. else
  21. ..移动..播放跳跃动画..
  22.             }
  23.         }
  24.         else if (Input.GetKey(KeyCode.A))
  25.         {
  26.         …设置方向向左..
  27. if(!isJump)
  28. ..移动..播放跑步动画..
  29. else
  30. ..移动..播放跳跃动画..
  31.             }
  32.             else
  33.             {
  34. if(!isJump)
  35. ..移动..播放走路动画..
  36. else
  37. ..移动..播放跳跃动画..
  38.             }
  39.         }

然后我们又希望人物按D键能够实现蹲走,怎么办,再加一个bool,再加判断!

[csharp] view plaincopy

  1. bool isCrouch = false;
  2.     if (Input.GetKeyDown(KeyCode.S))
  3. {
  4. isCrouch = true;
  5. }
  6. bool isJump = false;
  7.     if (Input.GetKeyDown(KeyCode.W)&&! isCrouch)
  8. {
  9. isJump = true;
  10. }
  11.     if (Input.GetKey(KeyCode.D))
  12.         {
  13. …设置方向向右..
  14.             if (Input.GetKey(KeyCode.LeftShift))
  15.             {
  16. if(!isJump&&! isCrouch)
  17. ..移动..播放跑步动画..
  18. else if(!isCrouch)
  19. ..移动..播放跳跃动画..
  20. else
  21. ..移动..播放蹲走动画..
  22.             }
  23.             else
  24.             {
  25. if(!isJump&&! isCrouch)
  26. ..移动..播放跑步动画..
  27. else if(!isCrouch)
  28. ..移动..播放跳跃动画..
  29. else
  30. ..移动..播放蹲走动画..            }
  31.         }
  32.         else if (Input.GetKey(KeyCode.A))
  33.         {
  34.         …设置方向向左..
  35. if(!isJump&&! isCrouch)
  36. ..移动..播放跑步动画..
  37. else if(!isCrouch)
  38. ..移动..播放跳跃动画..
  39. else
  40. ..移动..播放蹲走动画..
  41.             }
  42.             else
  43.             {
  44. if(!isJump&&! isCrouch)
  45. ..移动..播放跑步动画..
  46. else if(!isCrouch)
  47. ..移动..播放跳跃动画..
  48. else
  49. ..移动..播放蹲走动画..
  50.             }
  51.         }

然后再加入攻击,跳劈,潜袭,站防,蹲防,还要再继续添加if else 和bool吗?我们究竟能容忍多少这样纠缠在一起的的ifelse? 稍有不慎会出多少错误?调试起来复杂不?。。。这种方法显然是错误的!有大量复杂的分支,极易出现bug。不过,救星来了,就是有限状态机

一个最简单的有限状态机

阿兰图灵提出的图灵机就是一种状态机,就是指一个抽象的机器,它有一条无限长的纸带TAPE,纸带分成了一个一个的小方格,每个方格有不同的颜色。有一个读写头HEAD在纸带上移来移去。机器头有 一组内部状态,还有一些固定的程序。在每个时刻,机器头都要从当前纸带上读入一个方格信息,然后结合自己的内部状态查找程序表,根据程序输出信息到纸带方格上,并转换自己的内部状态,然后进行移动。

准备工作

状态机需要满足的条件:
1.    一组固定的状态(空闲,行走,跳跃,攻击。。。)
2.    状态机一次只能处在一种状态,最简单的例子,我们不能跳的同时蹲下。
3.    一些玩家输入或者事件发送到状态机,来改变现有状态
4.    状态与状态之间的转换都有一个过渡,在这个过渡中不接受玩家的任何输入,比如从站立到蹲下的过渡中玩家在此时按下跳跃或行走等是没有响应的(被忽略)。

根据上面的条件,我们在写一个状态机之前必须要做的一件事就是—画状态图,这样既可以理清你的思路,方便添加状态与功能,又能使你编程的遗漏减少。
比如我们想实现一个人的走、跑、跳、攻击、防御,状态图可以这么画:

enum&switch

然后我们完成最精简的状态机,就是enum和switch的组合。
我们需要把一组状态放在enum里,命名就按你需要的状态的名字来命名,比如空闲-idle,还需要一个变量来储存当前控制人物的状态:

[csharp] view plaincopy

  1.     public enum CharacterState
  2.     {
  3.         Idling = 0,
  4.         Walking = 1,
  5.         Jumping = 2,
  6.         acting= 3,
  7.         defending= 4,
  8. }
  9. public CharacterState  heroState = CharacterState. Idling;

设置一个函数handleInput来专门处理判断玩家的输入与状态操作,把这个函数放在update中每帧轮询。

[csharp] view plaincopy

  1. void handleInput()
  2.     {
  3. switch(heroState)
  4. {
  5. case CharacterState. Idling:
  6. …播放空闲动画..
  7. if…Input.GetKey –A,D.
  8. this. heroState = CharacterState. Walking;
  9. else if…Input.GetKey –w.
  10. this. heroState = CharacterState. Jumping;
  11. else if…Input.GetKey –J.
  12. this. heroState = CharacterState. acting;
  13. else if…Input.GetKey –I.
  14. this. heroState = CharacterState. defending;
  15. break;
  16. case CharacterState. Walking:
  17. if…Input.GetKey –A,D.
  18. …CharacterController移动操作..
  19. else…Input.GetKeyUp – A,D…
  20. this. heroState = CharacterState. Idling;
  21. break;
  22. case CharacterState. Jumping:
  23. if(Input.GetKeyUp(KeyCode.W))
  24. …CharacterController移动操作..
  25. if(CharacterController.isGrounded)
  26. {
  27. this. heroState = CharacterState. Idling;
  28. }
  29. break;
  30. case CharacterState. acting:
  31. …播放攻击动画.
  32. chargeTime += Time.timeScale / Time.deltaTime;
  33. if(chargeTime>maxTime)
  34. {
  35. this. heroState = CharacterState. Idling;
  36. chargeTime = 0;
  37. }
  38. break;
  39. case CharacterState. defending:
  40. …播放防御动画.
  41. if(Input.GetKeyUp(KeyCode.I))
  42. this. heroState = CharacterState. Idling;
  43. break;
  44. }
  45.     }

这里又需要通过时间来转换状态的,比如攻击状态,我们按下攻击键,执行一个攻击动画,当动画结束时,我们希望人物能回到空闲状态,此时就需要一个chargeTime变量来记录攻击状态持续了多长时间,在每一帧chargeTime加上这一帧的运行时间,也就是这个动画现在播放了多久,我们获取动画总时间长度来作为maxTime,当chargeTime达到maxTime也就是说明一个攻击动作做完了(一个动画播完了)就会转换到空闲状态,在转换到空间状态时再重置chargeTime为0。
这样可以实现一个简单的状态机,所有状态操作都被整合在一起,原理是轮询当前的状态,处理当前状态操作,接受玩家输入来转换状态。
此时这样一个状态机会出现一个问题,我们控制的人物不能跳起来时不能攻击,而且状态机规定一次只能处在一种状态,所以解决办法为拆分成几个状态机,比如负责移动的为一个状态机,攻击防御为另一个状态机,这两个状态机并发运行,就可以实现跳起攻击,或者潜袭等操作了。
当然,这样的状态机有很多缺点,比如不方便添加新状态,这个函数最终也会越写越长越乱。一种好的实现方式就是把每一个状态和它的操作还有在这个状态时对用户输入的判断,也就是对这一个状态的所有处理都封装在一个类中,这就是状态模式。
在实现状态模式之前,让我们先来了解一下c#的委托与事件。

c#的委托与事件

说起委托与事件,就肯定与观察者模式挂钩了。
delegate委托就是可以用这个委托调用别的类中的一个或多个函数
event事件通常与委托联用,就是事件发送者,通过委托调用事件接收者中的函数,来发送事件到事件接收者
在有限状态机中,我们的发送者是update中的按键,接收者就是等待处理按键的状态对象(后面会讲)。在update中判断玩家输入,再把输入的按键作为事件发送给状态对象处理。就是这么一个过程。
此处就拿攻击方面状态举例
首先我们先写一个EventArgs事件数据类的子类,是事件要传送的消息,也就是我们的接收者-状态对象想要接收的消息,这个消息可以自己定义,在此处,我们希望传送按键信息在接受者中处理

[csharp] view plaincopy

  1. using UnityEngine;
  2. using System.Collections;
  3. using System;
  4. public class InputEventArgs : EventArgs
  5. {
  6.     public string input;
  7.     public string addition1;
  8.     public string addition2;
  9.     public InputEventArgs(string _input, string _addition1, string _addition2)
  10.     {
  11.         this.input = _input;
  12.         this.addition1 = _addition1;
  13.         this.addition2 = _addition2;
  14.     }
  15. }

类中的参数用来存储信息此处的input为按键,addition1,addition2,留空,以便以后开发需要新的信息。作为攻击方面状态addition1,addition2可以是武器的种类(不同的攻击方式,不同的播放动画,不同的技能等等)也可以对组合键加以判断(有些游戏中组合键可以让人物发出某些特殊技能)

然后来看看事件发送者,新建一个hero类专门控制人物状态操作,heroStateActVer储存着当前状态,在update函数中

[csharp] view plaincopy

  1.   public delegate void InputEventHandler(object sender, InputEventArgs e);
  2. public event InputEventHandler InputTran;
  3.     InputEventArgs inputArgs = new InputEventArgs(“”,“”,“”);
  4. State heroStateActVer;
  5.     void Start()
  6.     {
  7.         personCtrl = this.GetComponent<HeroCtrl>();
  8.         heroStateActVer = new IdleStateActVer(this.gameObject);
  9.         InputTran += new InputEventHandler(heroStateActVer.handleInput);
  10.     }
  11.     void Input_events(InputEventArgs e)
  12.     {
  13.         if (e != null)
  14.         {
  15.             InputTran(this, e);
  16.         }
  17.     }
  18.     void Update()
  19. {
  20.         if (Input.anyKeyDown)
  21.         {
  22.             foreach (char c in Input.inputString)
  23.             {
  24.                 if (c != null)
  25.                 {
  26.                     inputArgs.input = c.ToString();
  27.                     Input_events(inputArgs);
  28.                 }
  29.             }
  30.         }
  31. heroStateActVer.UpDate();
  32. }

state的UpDate()存储了对该状态的实时操作,heroStateActVer.UpDate();为处理当前状态该有的操作,比如行走状态就是CharacterController.move,之后我们会讲解
我们在发送类中定义委托与事件,我们接收用户按键,以字符串的形式存储在事件信息类InputEventArgs中把它发给接受者state对象
我们之所以使用foreach,是因为Input.inputString可以接收多个按键,比如A键和D键同时按,Input.inputString就是“ad”,如下图所示,所以我们遍历一帧按下的所有键,来发送消息。

然后就是我们的接收类,也是订阅者state,state对象中有一个方法用来接收发送者发来的事件信息,也就是当事件发生时执行的函数

[csharp] view plaincopy

  1.   public void handleInput(object sender, InputEventArgs e)
  2.     {
  3.         input = e.input;
  4.         switch (input)
  5.         {
  6.             case “j”://攻击
  7. …转为攻击状态..
  8.                 break;
  9.             case “i”://防御
  10. …转为防御状态…
  11.                 break;
  12.         }
  13.     }

 

状态模式

四人帮说:
Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.
当一个对象的状态改变时来改变他的行为。对象将会改变它的类。
继承于父类状态的各个子状态其中的每一个把它的操作还有在这个状态时对用户输入和外部事件的判断,也就是对这一个状态的所有处理都封装在一个类中,对用户输入的判断和外部事件进行处理,如果需要则改变对象的状态。
在这个类中,我们需要,接受事件的方法handleInput(),实时处理当前状态操作的(如move状态就是处理人物移动)Update(),注意我们并没有继承于 MonoBehaviour,所以这里的Update()不是继承于MonoBehaviour的,是我们自己定义的,之后要放在人物发送类中的MonoBehaviour的Update()中执行 。进入状态时的操作Start(),退出状态时的操作Exit()(根据需要可加可不加)
首先我们需要定义一个父类State,让各种状态都继承于他,我们保存人物发送类和操作类的对象,便于在状态中update()里对人物的控制。chargeTime上面讲过,就是一个状态的持续时间。

[csharp] view plaincopy

  1. using UnityEngine;
  2. using System.Collections;
  3. public class State
  4. {
  5.     protected static string input;
  6.     protected GameObject Person;
  7.     protected Hero Person_ctrl;
  8.     protected float chargeTime;
  9.     protected float MaxTime;
  10.     public State(GameObject _Person)
  11.     {
  12.         this.Person = _Person;
  13.         this.Person_ctrl = _Person.GetComponent<Hero>();
  14.     }
  15.     public virtual void handleInput(object sender, InputEventArgs e)
  16.     {
  17.     }
  18.     public virtual void UpDate()
  19.     {
  20.     }
  21.     public virtual void UpDate()
  22.     {
  23.     }
  24.     public virtual void Start()
  25.     {
  26. }
  27. }

Start()函数是在上一个状态结束,开始下一个状态时调用的函数,之调用一次,UpDate()是要放在人物发送类中的UpDate()中实时运行,handleInput在玩家输入按键时被调用处理,负责转换状态,我们把该状态中对应的所有按键的判断放在里面。
然后在来看看子类攻击状态

[csharp] view plaincopy

  1. using UnityEngine;
  2. using System.Collections;
  3. public class ActState : State
  4. {
  5.     public ActState(GameObject _Person)
  6.         : base(_Person)
  7.     {
  8.     }
  9.     public override void Start()
  10.     {
  11.         this.chargeTime = 0.0f;
  12.         this.MaxTime =..攻击动画时间..
  13. ..播放攻击动画..
  14.     }
  15.     public override void handleInput(object sender, InputEventArgs e)
  16.     {
  17.         input = e.input;
  18.         switch (input)
  19.         {
  20.             case “j”://连击
  21.                 if (chargeTime > MaxTime – 0.1f)
  22.                 {
  23.                     Person_ctrl.GetActState(1).Start();
  24.                 }
  25.                 break;
  26.             case “i”//转换为防御状态
  27.                 if (chargeTime > MaxTime – 0.1f)
  28.                 {
  29.                     Person_ctrl.SetActState(2);
  30.                     Person_ctrl.GetNowActState().Start();
  31.                 }
  32.                 break;
  33.         }
  34.     }
  35.     public override void UpDate()
  36.     {
  37.         if (chargeTime < MaxTime)
  38.             chargeTime += Time.timeScale / Time.deltaTime;
  39.         else
  40.         {
  41.             this.chargeTime = 0.0f;
  42.             Person_ctrl.SetActState(0);
  43.             Person_ctrl.GetNowActState().Start();
  44.         }
  45.     }
  46. }

可以看到,在start函数中也就是状态开始,刷新chargeTime,播放攻击动画,在update函数中,更新时间chargeTime,判断是否超过指定时间MaxTime(此处为一次攻击动画时间),如果超过则切换当前状态为空闲,handleInput接收事件输入,object sender是事件发送者,就是例子中的Hero类, InputEventArgs e是传入的按键信息,在该函数中判断是否需要转换状态,或者作出相应操作。所以,状态转换是在我们封装的状态对象中实现的。
我们有两种方法获取状态对象。一种是定义一个状态类,把状态声明为静态来获取, 不需要消耗内存来实例化,想要攻击状态就AllState. actState就可以,:

[csharp] view plaincopy

  1. using UnityEngine;
  2. using System.Collections;
  3. public class AllState
  4. {
  5. public static State actState = new ActState();
  6. public static State jumpState = new JumpState();
  7. …….
  8. }

但是这种方法并不能两个人物共用,状态时间chargeTime是无法公用的,这个问题很关键,所以,还有一种就是在Hero类里实例化状态对象,可以实现多任务共用状态,因为在每个人物类里都有状态的实例。

[csharp] view plaincopy

  1. private State[] hero_act_state = new State[3];
  2.     hero_act_state[0] = new IdleStateActVer(this.gameObject);
  3.     hero_act_state[1] = new ActState(this.gameObject);
  4.     hero_act_state[2] = new DefenseState(this.gameObject);

然后再写一个get,set方法,使状态变量更加安全,这里不做代码示范了。

总结

在有限状态机中,一般都是观察者模式与状态模式连用,状态模式把每个状态封装成类,来对每个输入消息(按键)处理,完全摆脱了大量if else的纠缠,减轻了大量逻辑错误出现的可能,但是本身也有很多缺点,因为状态毕竟是有限的,当状态少的时候可以运用自如,当状态多的时候10个以上就已经结构非常复杂,而且容易出错,之前在游戏人工智能开发之6种决策方法 提到如果用在AI上会产生一些问题,所以之后会发文讲状态机的进阶-分层有限状态机。