【构】游戏状态机的那些事儿

游戏中,很多模块或游戏个体都是处于一定状态中,并且他们的状态还会根据不同条件进行切换。例如:进局的时候,需要先进入Loading状态,加载相关资源,加载完毕后播放过场动画,之后进入战斗,战斗结束时还会进入结算状态。再例如NPC或者怪物的AI,都可以分为例如行走、警戒、攻击、死亡等状态。
这些状态设计和实现的好坏,直接关系到一个游戏的丰富程度。
本文就聊一聊这些状态的事儿。

1.我们的问题

玩过dota的都知道,玩家可以操作一个英雄进行各种战斗。英雄最常见的几种行为方式包括:

  1. 移动到某个地点
  2. 攻击某个敌军或建筑物
  3. 释放某个技能
  4. 回城
  5. 受到Buff或者Debuff,例如被眩晕

处于不同行为方式下,英雄的表现不一样。比如,回城的时候无法进行别的操作,如果有操作,则会打断回城,而眩晕时也无法移动,但不同的是,眩晕时,即使玩家存在操作,英雄也不会响应。
所以,很容易做出下面的设计。

  • 可以将这些行为定义成一个状态
  • 而把行为到行为之间的转变定义为状态间的切换条件
  • 需要让外界获取到英雄当前处于什么状态

一个dota英雄可能的行为状态及其切换

2.第一个设计

于是第一个Hero类就出来了。

public enum EHeroState
{
    Move,
    Attack,
    Skill,
    HearthStone,
    Dizzy,
    Num,
}
public class Hero : Actor
{
    protected EHeroState m_State;
    public void OnDizzy()
    {
        // Do Dizzy
        m_State = EHeroState.Dizzy;
    }
    public void Update()
    {
        switch (m_State)
        {
            case EHeroState.Move:
                _DoMove();
                break;
            case EHeroState.Attack:
                // Do Attack
                break;
            case EHeroState.Skill:
                // Do Skill
                break;
            case EHeroState.HearthStone:
                // Do HearthStone
                break;
            case EHeroState.Dizzy:
                // Do Dizzy
                break;
            case EHeroState.Num:
                break;
            default:
                break;
        }
    }
    protected void _DoMove()
    {
        // Do Move...
        if (/* some enemies nearby */)
        {
            m_State = EHeroState.Attack;
            return;
        }
    }
}

以上代码中,用一个枚举表明Hero所处的状态,用一个switch来根据不同的状态来控制Hero在不同状态下可以进行的行为。这里存在两种情况进行切换状态,第一种是外界调用Hero的接口进行切换,比如OnDizzy方法内,会将状态切换到Dizzy;另一种是内部自动切换,比如在Move状态下发现周围存在敌军,则自动切换到Attack状态。一般情况下,外界不会直接调用ChangeState这种接口,因为对于外界来说,这种接口没有太多实际意义,外界能看到的永远是攻击、回城这种明确含义的方法。
上面的设计,只要把每个状态内部要干的事情实现,再加上状态之间的切换,基本上可以满足需求中提到的问题。但是上面的设计也存在一些问题:

  1. 有些状态,只在进入和退出的时候 瞬时 做出一些行为,例如进入回城时播放一个循环的回城特效。上述设计中没有体现。
  2. 状态内要干的事情可能很复杂,如果都写在同一个文件中,那么久而久之,Hero.cs文件会变得非常臃肿。
  3. 状态之间的切换条件会很复杂,这就需要在每个状态行为方法中再加入一堆if-esle来判断需要跳转到哪个状态。
  4. Hero的状态不是一成不变的,如果新增一个状态,则需要修改原来的switch-case,并在每一个需要跳转到新增状态的状态中,增加跳转条件;而当去掉一个状态时,则需要删除之前状态中所有跳转的条件逻辑。上述设计对修改不封闭。
  5. 很多其他的角色,如树人或食尸鬼小兵,也会具有上面完全相同的一些状态(不是全部),例如移动时发现周围有敌军也会进入攻击状态,但小兵不会回城。上述的设计无法进行重用。

所以,我们需要一个更好的设计。

3.有限状态机

3.1定义

GoF里对有限状态机(FSM)定义如下。

Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.

即,允许一个对象在其内部状态发生变化时改变自身的行为。该对象看上去改变了自身的类。
使用有限状态机设计模式来修改之前的设计吧。

3.2封装状态

首先我们需要有一个状态类

public abstract class State
{
    protected StateMachine m_StateMachine;
    public AddStateMachine(StateMachine sm)
    {
        m_StateMachine = sm;
    }
    public abstract void Enter();
    public abstract void Update(float dt);
    public abstract void Exit();
}

上面定义了一个State的抽象基类,并提供了三个抽象方法,分别代表进入状态、更新状态、退出状态三种含义。

3.3封装状态机

下面定义状态机StateMachine。

public class StateMachine
{
    protected Dictionary<Type, State> m_DicState;
    protected State m_CurState;
    public void AddState(State state)
    {
        if(!m_DicState.Contains[state.GetType()])
        {
            m_DicState[state.GetType()] = state;
            state.AddStateMachine(this);
        }
    }
    public void ChangeState(Type type)
    {
        if(m_DicState.Contains[type])
        {
            m_CurState.Exit();
            m_CurState = m_DicState[type];
            m_CurState.Enter();
        }
    }
    public void UpdateState(float dt)
    {
        m_CurState.Update(dt);
    }
}

这里,使用c#的反射获得状态类型,作为字典的key值,状态实例本身作为value值,存入字典。
到此为止,我们只需让Hero的不同状态继承State,实现三个方法,并加入到StateMachine中,在Hero的类里每帧调用StateMachine的Update方法即可。
那么这些状态该如何改变呢,只需在State的子类里实现类似以下的逻辑即可。

public class MoveState : State
{
    public override void Update(float dt)
    {
        base.Update(dt);
        if(/* some enemies nearby */)
        {
            m_StateMachine.ChangeState(typeof(AttackState));
        }
        _DoMove();
    }
}

现在,更新状态机,状态之间就可以自由转换了。

3.4封装转换条件

如果状态内的跳转特别复杂,在State的Update方法里就会出现一堆if条件,如果碰到条件本身就比较复杂,还需要在State中实现各种条件跳转的逻辑。这样State内部又会变得复杂起来,而这些复杂跟状态本身的行为并无相关。如,我们其实希望MoveState中只关心Hero如何行走,AttackState中只关心Hero如何攻击。
为了让State内部功能更加单纯(单一职能),可以封装转换条件类。

public interface IConditionBehaviour
{
    bool IsSatisfied();
}

public class StateTranstion
{
    protected StateMachine m_Machine;
    protected IConditionBehaviour m_Condition;
    protected State m_TargetState;
    public void AddTran(StateMachine machine, IConditionBehaviour cond, State target)
    {
        m_Machine = machine;
        m_Condition = cond;
        m_TargetState = target; 
    }
    public void TrySwitch()
    {
        if(m_Condition.IsSatisfied())
        {
            m_Machine.ChangeState(m_TargetState);
            return true;
        }
        return false;
    }
}

3.5绑定状态及跳转

上面的接口和类分别封装了条件行为以及根据条件跳转到什么地方这两件事情。
下面修改State类,加上统一的跳转判断。

public abstract class State
{
    ...
    protected List<StateTransition> m_TransList;
    public void AddTrans(StateTransition trans)
    {
        m_TransList.Add(trans);
    }
    public bool TrySwitch()
    {
        foreach(var item in m_TransList)
        {
            if(item.TrySwitch())
            {
                return true;
            }
        }
        return false;
    }
}

在状态机中,增加跳转关系的接口。

public class StateMachine
{
    ...
    public void AddTransition<TStateFrom, TStateTo>(IConditionBehaviour cond) where TStateFrom : State where TStateTo : State
    {
        StateTranstion trans = new StateTranstion();
        if(m_StateDic.Contains(typeof(TStateFrom)) && m_StateDic.Contains(typeof(TStateTo)))
        {
            trans.AddTrans(this, cond, mStateDic[typeof(TStateTo)]);
            m_StateDic[typeof(TStateFrom)].AddTrans(trans);
        }
    }
}

同时,还需要修改原来状态机内部的更新方法。

public class StateMachine
{
    ...
    public void UpdateState(float dt)
    {
        if(!m_CurState.TrySwitch())
        {
            m_CurState.Update(dt);
        }
    }
}

最后只需在初始化状态机和状态的时候,创建相应的跳转关系,进行绑定即可。
这里面的条件判断其实有一个问题,即,所有增加的跳转条件里,只要有一条满足,就认为可以跳转,可是如果是两个条件同时满足才可以跳转的条件呢。很容易想到可以将条件进行组合。
上面的状态机整体类图如下所示。
状态机类图

4.问题扩展

上面已经设计出了一个具有一定扩展性的状态机。但是让我们仔细想想,其实还有很多状态问题没有解决。
比如,我们可能希望在一个状态完成后,回到前一个状态(在攻击完后可以继续之前的寻路)。那么上述设计就无法在初始化时把这个跳转条件和要跳转到的状态进行绑定。
再比如,可能存在一种状态,这种状态是在任何时候都会运行(例如呼吸),例如呼吸,这时候需要一个全局状态。而有一些状态是在某些状态下才会进行(例如攻击时需要前摇、释放、后摇,有些前摇和后摇可以被操作打断,有些则不可以),这时候又需要子状态。
关于触发条件也是一样。有些条件可以内部收集游戏环境信息(周围有没有怪,蓝够不够),进行判断,有些条件则需要外界输入触发(鼠标、键盘响应事件)。
等等等等。
所以,关于状态机的文章,这篇只是起到抛砖引玉的作用。想要最佳设计,需要对自己的需求详细分析,从而设计出符合需求要求的框架结构。

5.参考文献

全局状态、跳转到之前状态等实现可以参考《游戏人工智能编程案例精粹》(Mat Buckland)
状态机其他概念和设计可参考《Head First设计模式》
以及Game Programming Patterns,该文提供在线免费连接:http://gameprogrammingpatterns.com/state.html
关于AI相关的设计,除了状态机,行为树也是不错的选择,关于行为树的文章可以参考知乎伍一峰的专栏:http://zhuanlan.zhihu.com/indiegamepixel/19890016

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值