游戏编程模式--状态模式

状态模式

  GoF对状态模式的定义:允许一个对象在其内部状态改变时改变自身的行为,对象看起来就好像在修改自身类。

  GoF的定义都比较的抽象,我们需要结合一个实际的例子来帮助我们理解状态模式。

游戏女主角

  假设我们正在开发一款新游戏,其中一个任务就是实现女主角的动作图像。女主角的行为图像是受玩家的输入控制的,比如按下B键的时候跳跃,那我们最简单的实现方式是这样子的:

class Heroine
{
public:
    Heroine() {}
    void HandleInput(Input input)
    {
        if (input == PRESS_B)
        {
            //do jump
        }
    }
};

  很明显,这里有一个bug,就是如果我不停的按下B键,女主角将一直跳跃,哪怕在空中。为了避免这种状况出现,我们加一个标志位做一个判断,如果已经在跳跃就不再跳跃。

class Heroine
{
public:
    Heroine() {}
    void HandleInput(Input input)
    {
        if (input == PRESS_B)
        {
            if(!isJumping_)
            {
                isJumping_ = true;
                //do jump
            }
        }
    }
};

  接下来,给女主角实现闪避动作,使用下方向键触发。

class Heroine
{
public:
    Heroine() {}
    void HandleInput(Input input)
    {
        if (input == PRESS_B)
        {
            if(!isJumping_)
            {
                isJumping_ = true;
                //do jump
            }
        }

        if(input == PRESS_DOWN)
        {
            //do ducking
        }
    }
};

  让我们看看,这其中可能会有什么问题?很明显我们没有处理不同动作下发生另外一个动作时的情况,比如先按下方向键闪避,然后按B键跳跃,再松开方向键,会发生什么?女主角在空中站立了起来,非常的怪异。这个时候一个粗暴的解决方案就是再加一个标志位,标识是否再闪避。

class Heroine
{
public:
    Heroine() {}
    void HandleInput(Input input)
    {
        if (input == PRESS_B)
        {
            if(!isJumping_ && !isDucking_)
            {
                isJumping_ = true;
                //do jump
            }
        }

        if(input == PRESS_DOWN)
        {
            if(!isJumping_)
            {
                isDucking_ = true;
                //do ducking
            }
            
        }
        else if(input == RELEASE_DOWN)
        {
            if(isDucking_)
            {
                isDucking_ = false;
                //do stand
            }
        }
    }
};

  这种做法如果你舍得花精力,最终也能实现功能,但过程将会非常痛苦的。因为每加一个状态,我们就需要添加一个标志位,同时其它的所有状态都要添加相应的处理代码,而且一些bug会隐藏的很深,需要很多的时间才能调试出来。我们有没有办法避免这种情况了?

救星:有限状态机

  首先让我们来画一张流程图,其中矩形来表示一种状态,如果一个状态能变换到另一种状态,则在它们之间画一个箭头,从源状态指向目标状态,同时在箭头旁标注变换的触发条件,结果如图:

  这张图表示的就是一个有限状态机。有限状态机借鉴了计算机科学里自动机的理论中的一种数据结构的思想。有限状态机可以看作最简单的图灵机。它表示意思时:

  •   你拥有一组状态,并且可以在这组状态间进行切换;
  •   状态机同一时间只能处于一种状态;
  •   每一个状态有一组转换,每一个装欢都关联一个输入并指向另一个状态。

  简单的来见,有限状态机可以分为:状态、输入、转换。我们可以画图来表示状态机,但编译器并不认识,所以我们需要使用代码来实现它,GoF的状态模式是一种实现方式,但我们先用一种简单的方式开始。

枚举和分支

  之前我们使用isJumping_、isDucking这样的变量来表示角色的一个状态,其中只能有一个为true,于使我们可以把它们定义成一个枚举,每一个枚举表示有限状态机的一个状态。

enum State
{
    STATE_STANDING,
    STATE_JUMPING,
    STATE_DUCKING,
    STATE_DIVING
};

  在这里我们使用一个state_的成员变量来表示角色当前所处状态。之前的实现中我们针对每一个输入处理状态的切换,虽然可以让我们几种处理输入的逻辑,但同时也让状态处理代码变得很乱,现在我们改变一下,先判断状态,代码如下:

class Heroine
{
public:
    Heroine() {}
    void HandleInput(Input input)
    {
        switch (state_)
        {
            case STATE_STANDING:
                if(Input == PRESS_B)
                {
                    state_ = STATE_JUMPING;
                    //do jumping
                }
                else if(Input == PRESS_DOWN)
                {
                    state_ = STATE_DUCKING;
                    //do ducking
                }
                break;

                //handle other states...
        
        }
    }
};

  这里的改变很普通,只是把同一个状态的处理代码集中在一起,但却极大的简化了我们的状态处理过程。这是状态机最简单的实现方式。但这种实现方式对于某些问题的处理却很显得粗糙,比如,我们想在下蹲的同时蓄能,蓄能之后能释放一个特殊的技能,那这个时候我们就需要一个变量来记录蓄能的时间,比如chargetime_,然后在update(假设我们已经有)改变它。

void Heroine::update()
{
    if(state_ == STATE_DUCKING)
    {
        chargetime_++;
        if(chargetime_ > MAX_CHARGE)
        {
            superBomb();
        }
    }
}

  同时在角色下蹲的时候要重置这个时间,所以我们还需要修改handleInput的函数:

class Heroine
{
public:
    Heroine() {}
    void HandleInput(Input input)
    {
        switch (state_)
        {
            case STATE_STANDING:
                if(Input == PRESS_B)
                {
                    state_ = STATE_JUMPING;
                    //do jumping
                }
                else if(Input == PRESS_DOWN)
                {
                    state_ = STATE_DUCKING;
                    chargetime_ = 0;
                    //do ducking
                }
                break;

                //handle other states...
        
        }
    }
};

  也就是说为了添加蓄能攻击,我们需要修改两个方法,而且要添加要给chargetime_的变量给Heroine,而我们想要的其实是把状态和与之相关的数据和代码封装起来,那我们接下来看看GoF是如何解决这个问题的。

状态模式

  首先我们定义一个状态接口,其中每一个与状态相关的行为定义成虚函数,在这里我们定义两个方法:handleInput和update。代码如下:

class HeroineState
{
public:
    HeroineState() {}
    virtual ~HeroineState(){}
    virtual handleInput(Heroine &heroine,Input input) {};
    virtual update(Heroine &heroine) {};
};

  接着我们为每一个状态定义一个类,这个类继承上述的接口,接口表示角色在这个状态下的行为,换句话说,我们要把之前switch语句的每个状态分支代码放到对应的状态类中。比如闪避状态,代码如下:

class DuckingState:public HeroineState
{
public:
    DuckingState():chargetime_(0)
    {}

    virtual void handleInput(Heroine &heroine,Input input)
    {
        if(input == RELEASE_DOWN)
        {
            //do standing
        }
    }

    virtual void update(Heroine &heroine)
    {
        chargetime_++;
        if(chargetime_ > MAX_CHARGE)
        {
            heroine.superBomb();
        }
    }

private:
    int chargetime_;
};

  注意我们把chargetime_这个变量从Heroine类中移到了DuckingState类中,这样非常好,因为chargetime_只对这个状态有意义,把它定义到这里,正好显式的反应了我们的对象模型。

状态委托

  定义好这些状态之后,我们只需要在Heroine类中定义一个HeroineState类型的state_变量表示当前状态,然后使用state_变量调用对应的虚函数接口,它会自动的调用对应的子类实现。代码如下:

class Heroine
{
public:
    void handleInput(Input input)
    {
        state_->handleInput(*this,input);
    }

    void update()
    {
        state_->update(*this);
    }
private:
    HeroineState *state_;
};

  如果状态发生的变化,只要把state_指向新的状态即可。至此,状态模式的内容就讲完了。但我们还要解决一个问题——状态对象如何生成。

状态对象应该放在哪里

  我们把状态定义成了类,如果状态改变,我们赋值给state_的状态对象从哪里来了?通常来说,有两种方式:

  •   静态状态
    如果一个状态没有任何数据成员,那么它就可以只定义一个静态的对象,复用这个对象即可。
  •   实例化状态
    但对于有数据成员的状态,使用静态对象就行不通了(除非场景中就一个角色)。这样的话我们就需要动态的创建状态对象,而这就需要处理一个新的问题——旧状态对象如何清除。我们可以在HeroineState::handleInput中创建新的状态对象,然后在切换状态之前删除旧状态。代码如下:
class Heroine
{
public:
    void handleInput(Input input)
    {
        HeroineState *state = state_->handleInput(*this,input);
        if(state != null)
        {
            delete state_;
            state_ = state;
        }
    }

    void update()
    {
        state_->update(*this);
    }
private:
    HeroineState *state_;
};

  状态子类中handleInput返回新创建的状态对象。

HeroineState* StandingState::handleInput(Heroine& heroine,Input input)
{
    if(input == PRESS_DOWN)
    {
        return new DuckingState();
    }

    return nullptr;
}

 进入状态和退出状态的行为

  我们使用状态模式就是要把状态相关的处理逻辑整合到一起,按之前的实现,假设我们的角色闪避时需要播放一个下蹲的动画,那就需要在站立或者其它状态中播放下蹲的动画,然后切换到闪避状态。这样,我们会写很多的重复代码,一个简单的改进方案是给这个状态添加一个进入状态的方法entry,把相关的处理逻辑放入这个方法中,比如闪避时播放下蹲动画,这样当前状态就不需要额外处理相关的逻辑,只需要在角色切换到新状态后执行以下entry的方法即可。代码如下:

class HeroineState
{
public:
    HeroineState() {}
    virtual ~HeroineState(){}
    virtual handleInput(Heroine &heroine,Input input) {};
    virtual entry(Heroine &heroine);
    virtual update(Heroine &heroine) {};
};

class Heroine
{
public:
    void handleInput(Input input)
    {
        HeroineState *state = state_->handleInput(*this,input);
        if(state != null)
        {
            delete state_;
            state_ = state;
            state_->entry(*this);
        }
    }

    void update()
    {
        state_->update(*this);
    }
private:
    HeroineState *state_;
};

  同理,我们还可以再扩展一个推出的方法exit,让它处理状态退出时的行为。这样,我们就完全的把状态相关的行为封装到状态类中,实现状态间的解耦。

并发状态机

  虽然我们比较好的实现了有限状态机,你只需要维护一个当前状态,一些切换状态的代码即可。但如果你面对的是一个复杂的AI,那会面临这个模式的一些限制。比如我们给角色添加一个持枪的功能,角色持枪时也能闪避和跳跃,如果用之前的方式实现,那我们就需要添加一个持枪闪避的状态和一个持枪跳跃的状态,而且状态实现代码除了多了持枪动作,其它代码都一样,除此之外,如果我们继续添加武器种类,这些状态种类会急剧增加。实际上这些类的重复代码非常多,这种情况肯定不是我们想看到的,

  这里面的问题主要是我们把两个比较独立的状态硬塞到了一起,然后为每一种组合建模,也就是我们需要为每一种状态准备一组状态。解决的方法也比较直观,就是把两个状态分开,使用两个状态机,这种模式我们称之为并发状态机。代码如下:

class Heroine
{
public:
    void handleInput(Input input)
    {
        state_->handleInput(*this,input);
        equipment_->handleInput(*this,input);
    }

    //other code....

private:
    HeroineState *state_;
    HeroineState *equipment_;
};

层次状态机

  当我们把状态更细化之后,我们会发现大量相似的状态,这些相似状态的代码大部分都是一样的,比如站立、跑动、滑动等,在这些状态中,当我们按下B键时都是进入跳跃状态,按下下方向键时都是闪避。如果我们只是使用一个简单的状态机实现时,我们会写很多这样的重复代码,而好的实现是相同的代码我们只需要写一次,然后它就可以在所有的状态中使用。

  复用代码在面向对象中有一个很好的方式就是继承。比如在这里,我们定义一个“OnGround”的类,在这个类中实现B键和下方向键的逻辑处理,然后让站立、跑动等状态继承它,而在子状态中可以选择是否处理这些输入,如果子状态处理,则父状态就不用处理,否则事件就传给父状态处理。代码如下:

class OnGroundState:public HeroineState
{
public:
    virtual void handleInput(Heroine &heroine,Input input)
    {
        if(input == PRESS_B)
        {
            //
        }
        else if(input == PRESS_DOWN)
        {}
    }
};

class DuckingState:public OnGroundState
{
public:
    virtual void handleInput(Heroine &heroine,Input input)
    {
        if(input == RELEASE_DOWN)
        {
            //do something
        }
        else
        {
            OnGroundState::handleInput(heroine,input);
        }
    }
};

  像这样实现的状态机我们称之为层次状态机。当然,这不是实现继承的唯一方式,而且如果你不是用GoF的状态模式,这种方式还不奏效。我们也可以在基类中维护一个状态栈而不是一个状态的方法来更明确的表示父状态的状态链。我们当前的状态在栈顶,栈顶的下一个元素是它的父状态,再下一个则是父状态的父状态,以此类推。当一个输入进来时,我们先从栈顶状态开始,如果它不处理则传递到下一个状态(父状态),直到找到一个状态处理(如果找遍了整个栈都没处理,则忽略这个事件)。

下推自动机

  还有一种有限状态机的扩展,叫下推自动机。它也是使用一个状态栈,但与层次状态机中的状态栈不同,而且这个状态栈也是用于解决状态机无历史记录的问题。比如角色开火之后,应该回到什么状态了?答案是应该回到之前的状态,但之前的状态机实现方式并没有保存上一个状态,我们不得不定义一些对等的状态,比如跑步开火状态,站立开火状态,这样我们就知道开火之前是跑步状态还是站立状态,也就能切换回之前的状态了。

  很显然,这种方法不会是我们的优选。在这里我们需要的是一种能让我们保存之前状态的一种方法。数据结构中有这样的一种结构能实现我们的想法,即栈。我们使用一个栈来保存之前的状态,当前的状态存在栈顶,这样当切换到一个新状态时,让新状态入栈,同时我们也可以让当前状态出栈,这样栈顶状态就变成了上一个状态,状态机也就可以切换到上一个状态了。

结语

  虽然本节讲的时状态模式,但我们花了大量的篇幅来讲解有限状态机,因为我们很难绕开有限状态机单独讲解状态模式。但在当今的游戏AI领域,有限状态机并没有想的那么有用,现在的趋势是使用行为树和规划系统,如果你对复杂AI系统感兴趣的话,可以阅读专门的书籍来了解它们。但在某些情况下,有限状态机还是很有用的:

  •   你有一个游戏实体,他的行为基于它的内部状态而改变;
  •   这些状态被严格的划分为相对数目较少的小集合;
  •   游戏实体随着事件的变化会响应用户输入和一些游戏事件;

  同时,有限状态机也被应用于用户输入处理、浏览菜单屏幕、解析文件、网络协议和其它异步行为。

posted @ 2019-03-15 01:18  北冥有鱼其名为鲲  阅读(860)  评论(0编辑  收藏  举报