天天看点

游戏编程模式-设计模式02(状态模式)状态模式状态类的对象进阶

什么是状态模式

在状态模式中,类的行为是根据状态发生改变的,所以他属于行为模式。当对象中有大量条件分支语句( if语句)和状态变换相关,这时就可以将这些分支语句通过另一种方法实现,例如枚举、继承等

在unity引擎中,直接使用状态模式的组件就是动画系统(虚幻引擎中应该有类似的组件)。动画的播放随着人物的状态的改变而改变,当人物处于行走状态时播放行走动画,跑步状态时播放跑步状态,所以为了直观介绍状态模式,将直接使用动画系统来介绍(反正跟着大佬的理解来,大佬万岁,好吧我大概就复述了一遍)

当你不使用状态模式

当你直接使用条件分支语句控制动画的播放,比如按下空格键播放跳跃动画

if(GetKeyDown(KeyCode.Space)){
	//播放跳跃动画
}
           

如果我再按下空格键会发生啥?跳两次,但是这游戏就是low,只能跳一次那咋办,凉拌。只能判断是否处于跳跃状态

if(GetKeyDown(KeyCode.Space)){
	if(isJumping == false){
		isJumping = true;
		//播放跳跃动画
	}
}
           

如果人物按下左ctrl键就能蹲下,松开又站起来呢?那不简单,判断人物是否按下左ctrl且人物是否在跳跃中播放蹲的动画,松开左ctrl播放站起来的动画不就行了?那问题是,人物他现在多了个蹲的状态,我能否在蹲的过程中直接跳跃(想象一下人物上一帧还蹲着,下一帧直接站立)?那显然不能,我就又得加一个bool变量来判断是否处在蹲的状态了

if(GetKeyDown(KeyCode.Space)){
	if(isJumping == false && isDown == false){
		isJumping = true;
		//播放跳跃动画
	}
}
           

那我又想在加一个攻击状态呢?继续加bool变量!继续加 if 分支!那我。。。加!那我再。。。停停停,代码已经写了一大堆了,每加一个状态都得更改处于其他状态时播放动画的代码,而且代码又混乱量又大,这可不是一件好事(除非你想混代码量,然后迷惑老师,但在实际开发项目中这么写你肯定会被毒打)

这时候你就要注意这个状态模式了

有限状态机

想要使用状态模式,你就绕不开状态机。如果你学过编译原理,或者生物(所以学生物的人咋做游戏来了)那些用到状态机的,你就会对这个东西十分了解。当然,当我放出实际的图你也会恍然大悟

游戏编程模式-设计模式02(状态模式)状态模式状态类的对象进阶

这图够不够明显,还是得来点更熟悉的?

游戏编程模式-设计模式02(状态模式)状态模式状态类的对象进阶

这不是动画状态机嘛,等等,状态机?哦(恍然大悟)。这就是状态机(当然上面那个状态机我没弄完,别干我)。

状态机来源于自动理论,就是离散数学的理论实现。它有以下要点:

  • 你拥有状态机是包含所有可能状态的集合。该例中的站立,蹲下,跳跃,跳斩
  • 状态机同时只能在一个状态。你人不会既在跳跃状态又在下蹲状态吧,就如同你不能说你自己又在食堂又在教室(某人:我会影分身。我:一边玩去)
  • 一连串的输入或事件被发送给状态机。该例中指按下跳跃键,下蹲键,攻击键
  • 每个状态都有一系列的转移,每个转移与输入和另一状态相关。箭头就是转移的目标,箭头上标示的就是转移所接受的输入。例如角色正处于站立状态,当按下空格键就进入跳跃状态,再按下攻击键就会进入跳斩状态,但如果开始按下p键,并没有以个箭头上面标识着p键,那该状态不会进行转移

所以状态模式的核心就是———状态,输入,转移

使用枚举

这是一种改善上述情况的方法,将不同的状态存在一个枚举类型中,这是实现状态机最简单的方法

enum State{
	STANDING,	//站立
	JUMPING,	//跳跃
	DOWNING,	//蹲下
	LEAPATTACKING	//跳斩
};
           

这时候就可以使用switch语句对不同状态分别判定输入从而完成状态转移

switch(_state){
	case STADING:
	if(GetKeyDown(KeyCode.Space){
		_state = State.JUMPING;
		//播放跳跃动画
	}else if(GetKeyDown(KeyCode.LCtrl){
		_state=State.DOWNING;
		//播放下蹲动画
	}
	break;
	case DOWNING:
	....	
}
           

下面的各种状态直接可以照着状态转化的图来进行,处于哪个状态就只用注意他自己状态变换的输入。

这也用到了分支语句,但比较与上面纯粹的 if 语句而言,处理状态的代码变的更加集中,也不需要在输入中判断是否处于哪个状态(这会白白浪费很多时间,你只用关注和你状态图上相邻的状态,而不是像 if 语句那样去关注每一个状态),你的侧重点从等待输入去找状态变成了且已知状态等输入。

用枚举很简单(对你的脑子来说不用做想太多复杂的工作),但简单就得有简单的缺点:

  • 当一个状态中的逻辑很多(比如我不仅要播放动画,我还放音乐,我又放几个特效。),状态的数量多的时候,会让整个switch语句块变的很庞大,导致代码混乱。还记的刚学编程的时候为啥不要把所有的代码都放在一个main函数中嘛
  • 扩展仍然不便利,当我们需要增加一个新的状态时,首先在枚举类型中加一个状态
enum State{
   //前面省略
   FLYING		//飞
}
           

然后在switch语句中找到跟新状态相连的状态的代码,加上一个

case 和FLYING相连状态:
	//省略
	if (获得输入) { 
		_state = State.FLYING; 
		//播放飞行动画;
	}
	break;
           

最后加上

case FLYING: 
	巴拉巴拉;
	break;
           

加一个状态要做三步,太难了

  • 当你状态中还和其他函数进行交互,如果状态的需求进行变化,还需要更改交互函数,这又增加了工作量和复杂度

开始的简单,换来了之后的复杂

状态模式

开始正题了

GOF提出的状态模式:允许一个对象在其内部状态发生变化时改变自己的行为,该对象看起来好像修改了它的类型

这句话感觉和上面使用枚举所做的事情差不多(当_state发生变化时,下面所做的行为也发生了变化,例如站立状态播放站立动画,跳跃状态播放跳跃动画),只要将枚举类型和switch封装在类中就行了。

那真正的状态模式是什么?下面就一一阐述

状态接口

定义一个状态接口,将switch的处理部分变成接口中的虚方法

interface PlayerState{
	void handle(Player player, Input input);
}
           

状态类

将 switch 中的每一个 case 写成一个类,也就是每一个状态都变成一个状态类,例如站立状态

public class StandingState : IPlyerState
{
    public void handle(Player player, Input input)
    {
        if(input == GetKeyDown(KeyCode.Space)){
        	//改变状态
            //播放跳跃动画
        }else if(input == GetKeyDown(KeyCode.LCtrl)){
        	//改变状态
            //播放下蹲动画
        }
    }
}
           

如果一些数据只在该状态有用,比如站立状态可以蓄力攻击,这时候需要一个计时器,就可以在Standing类中声明一个 time 属性进行计时

状态指针

这时候在状态的主体,也就是本例的 Player 类中声明一个状态的指针(当然现在在很多语言都不在使用指针的概念,选择将次概念隐藏,但知道大概是怎么回事都行)

public class Player
{
    private IPlyerState plyerState;
	
	public void setState(IPlayerState state){
		playerState = state;
	}
	
    public void handleInput(Input input)
    {
        plyerState.handle(this, input);
    }
}
           

这基本就是状态模式的全部,现在只需要控制 playerState 属性指向角色所在的状态类的对象即可。

状态类的对象

现在需要思考将状态类的对象存放在哪个地方,通常有两种方法

静态状态

首先需要思考,状态类需要声明多少个实例?当状态类中的成员只有来自父类的虚方法时,状态类就十分单纯,不管声明多少实例他们都一样———他们没有可以相互区别的东西(字段或者属性)。当然,如果字段和属性他们也都是保持一致,同样适用

这种情况只需要声明一个状态类只需要声明一个实例来代表状态即可,静态实例就是很好的选择。静态实例放在哪里取决于你自己,没有什么很特殊的限制,这里就随便写在一个类吧。

public class State{
	public static StandingState standingState;
	//省略
}
           

静态实例对性能消耗低(每一个实例的创建和释放都会消耗性能),在尽可能的条件下使用它

实例化状态

如果两个状态的实例有差别时,比如有两个玩家,两个角色都有蓄力攻击的状态,站立状态类中有个属性 time 默认值为0,当 time 增加到大于 2 就可以进行一次重击了,当第一个角色蓄力了 1 s,站立状态的实例 time 就成了 1,另一个角色还没蓄力呢,他的 time 应为 0 ,那能用同一个实例吗?

显然不能,那就得在使用时进行实例化。这个地方就要注意了,对有垃圾回收机制的语言来说,你不用自己去释放内存,但对于c++来说,他需要自己控制释放。

先说有垃圾回收机制的语言,可以直接创建新的状态实例

对于c++,首先让状态类的Handle方法中返回新的状态

class StandingState : public IState{
	public IState handle(Player player, Input input){
		if(input == GetKeyDown(KeyCode.Space)){
			//状态处理
			return new JumpingState();
		}
		//省略
	}
}
           

将返回值存在Player类的Handle方法的临时变量中,再释放内存(因为Player类中Handle函数将this指针传了过去,直接释放),最后将实体类的接口指向新的状态

Player::handleInput(Input input){
	IState state = _state->handle(*this, input);
	if(state != nullptr){
		delete _state;
		_state = state;
	}
}
           

用c++写的都是大佬.jpg

入口行为和出口行为

入口行为指进入状态所做的行为,比如我从站立状态转换到跳跃状态,要播放跳跃动画,这个播放动画就是入口行为

出口行为指离开状态所做的行为,比如蓄力攻击,我蓄力完成进行重击就是离开蓄力攻击状态所做的行为

但是,在上述代码中,跳跃动画的播放居然在站立状态的类中,让张三去完成李四的事,这怎么能行呢。所以将跳跃动画的播放移动到跳跃状态的入口行为中来。

给IState接口添加一个enter方法

interface PlayerState{
	void handle(Player player, Input input);
	void enter(Player player)
}
           

在JumpingState类中实现

void enter(Player player){
	player.playAudio(跳跃动画);
}
           

然后StandingState类中就不用再去管理其他类所需要的工作了,专注自己的事情就好。

这样也帮助减少代码,比如从蹲下状态和跳跃状态回到站立状态后我都需要播放站立动画,如果将播放站立动画放在其他两个类中就需要重复两次相同的代码

这只是以动画系统为例子所写的,当然状态系统不止运用于此,只要实体的行为会随着状态的改变都可以使用状态模式。在游戏中,状态机因AI而闻名,但是它也常用于其他领域, 比如处理玩家输入,导航菜单界面,分析文字,网络协议以及其他异步行为。

还有一件事再提一遍,不能单纯的为了装B而使用设计模式,如果你的状态自始至终都只有几个,每个状态的工作也不复杂,那为什么不使用更简单的方法呢?

进阶

并发状态机

当角色可以拥有一把枪时,他的之前的状态仍存在,现在所拥有的状态为:站立,拿枪站立,跳跃,拿枪跳跃,蹲下,拿枪蹲下。如果我再加几种武器,每个状态又会翻一番,这种情况下再为每个状态创建类,类的数量会直接爆炸。

发生这种情况的原因是我们没把他做的动作和携带的东西分离开。如果看成两个集合,一个是动作集={站立,蹲下,跳跃},一个是武器集={无,枪},那么上述的无脑方法就是这两个集合的笛卡尔积,需要6个类(两个集合大小的乘积 2*3)。如果分别按两个集合进行创建,只需要5个类(两个集合大小相加 2+3)。两个集合的元素越多,乘法和加法的差距就越大

再定义一个接口

interface IEquipment{
	void handle(Player player, Input input);
}
           

再如同上面状态模式一样每个武器创建一个类继承接口,实现接口的方法。然后在Player类中再加一个字段

class Player{
	private IState _state;
	private IEquipment _equipment;
	
	void handleInput(Input input){
		_state.handle(this, input);
		_equipment.handle(this, input);
	}
}
           

当两个集合几乎没有联系时,这是一个很好的实现方式

分层状态机

角色在站立,行走,跑步过程中都可以进行跳跃,这种情况下,处理转到跳跃状态的代码是否要被重复使用 3 次呢。要达成重用代码减少冗余又要有自己独特的行为,“继承”这两个是不是浮现在你的脑海。

这种就是分层状态机的通用结构,一个状态拥有父状态,当该状态没有处理一些输入时,将其移交到父状态进行处理。这时候可以给站立,行走,跑步,定义一个统一的基类,然后让基类去处理跳跃,子类处理自己独立的逻辑

class OnGroundState : IState{
	//处理跳跃
}

class StandingState : OnGroundState{
	//处理自己的逻辑
	//当遇到其他输入转给父类
}
           

这只是一种实现方式,也可以使用栈来解决。子类栈的下面是父类栈,当子类无法解决时出栈,交给下一个栈顶的类解决,直到有类能够解决这个输入,如果到栈低都无法解决就无视该输入。

下推状态机

这个也利用栈来实现,两者有所不同。分层状态栈是高层栈拥有相同的特性,该特性被存在底层栈中。而下推状态栈中的每个状态都不在意有无相同特性,只是为了记录上一个状态。

比如想让一个角色跳跃前处于什么状态,跳跃后,仍处于什么状态。这种情况就可以使用下推状态机(其实就是用栈来存储上一次的信息)。

参考

游戏设计模式:https://gpp.tkchu.me/state.html

继续阅读