草庐IT

游戏开发中的状态机模式原理与应用

zshBlog2456 2023-03-28 原文

该文章总结自人民邮电出版社《游戏编程模式》一书

0、开篇

状态机,全称有限状态机,其灵感来源于图灵机。


将一系列数据输入输入图灵机中,输出数据会随着图灵机内部开关状态改变,使得同一份数据在不同图灵机中会获得不同的结果。


将这种思维抽象成代码,可以极大程度的提高代码可读性,但是会降低你在项目中的不可替代性(doge)。

1、没有状态机时

试想,我们正在开发一款横版动作游戏,需要为主角开发一系列的功能。

策划:角色不应该在防御的时候攻击(不考虑什么防御反击技能)
没有什么是一个if解决不了的。

策划:在防御时应该是个木桩,不能奔跑


哦,还有攻击、跳跃时候也是,蹲伏的话就慢慢移动。

策划:我觉得对于一个动作游戏来说,这些功能属实太单一了。来点热武器怎么样。

void Update()
{
    if(GetKey() == Key::Attack)
    {
        //防御时不可攻击
        if(!bDefense)
        {// 执行一次攻击
            Attack();
        }
    }else if(GetKey() == Key::Run)
    {
        bool canRun = true;
        canRun &= !bDefine;//防御中
        canRun &= !bAttacking;//挥刀中
        if(canRun))
        {
            if(bSquating)
            {
                run(RUN_SPEED);
            }else
            {
                run(SQUAT_SPEED);
            }
        }
    }else if(GetKey() == Key::OpenFire)
    {
        bool canOpenFire  = true;
        canOpenFire &= !bDefine;
        canOpenFire &= !bAttacking;
        canOpenFire &= !bReloading;
        canOpenFire &= (iAmmoCount != 0);
        if(canOpenFire)
        {
            openFile();
        }
    }else if(其他功能)
    {
        ...
    }
}

你骂骂咧咧的完成了任务


策划:唉,再来个可使用道具如何,比如烟雾弹或血药


你回头看了看代码说道:这尼玛谁写的

2、简单状态机

聪明的你开始思考,如何将这一个个的功能用面向对象的方式封装起来。

从分析问题开始,大量的标志位以极其抽象的方式表达出这个角色可以做的事与不能做的事。


而这些标志位只有处于某些特定组合时才会有意义,例如:


角色在受击时,不应该拥有奔跑对应的功能(别问,问就是策划需求)。


角色在奔跑时,应该能够射击,但是不能防御。
···
一旦功能代码中出现了这种情况,就应该考虑
使用一个枚举值来代替大量的标志位。

//255个状态,够用了
enum EState : uint8
{
    idle,//静止
    walk,//走动
    run,  //奔跑
    define,//防御
    ...
}

void Update()
{
    if(GetKey() == Key::Attack)
    {
        //防御时不可攻击
        if(State != define)
        {// 执行一次攻击
            Attack();
        }
    }else if(GetKey() == Key::OpenFire)
    {
        if(state != define
            || state != attack
            || state != reloading)
        {
            if(iAmmoCount != 0)
            {
                openFile();
            }
        }
    }else if(...)
    {
        ...
    }
}

这样做的好处有很多:

  • 省下了许多内存空间,如果状态多起来,每个状态标志位都会占用1个bool值空间(可以优化,但依旧不如一个枚举值来的简单)
  • 减少代码量 == 提高性能
  • 免去了出现无意义标志位的情况,减少了出bug的概率
  • 降低了代码阅读成本,原本5行甚至更多的标志位更换为仅1行的枚举值,理解成本大大降低

省空间省时间提高代码可读性的东西,有什么理由不用?

随着开发的深入,可以发现,各个状态间有些共同点,这不由得想到了面向对象的三大特性之一 多态

将各个状态间的共同点抽象出来,组成基类,然后由每个状态子类去实现自己的功能。

// 状态基类
class StateBase
{
    //获得这个状态
    virtual EState getState() = 0;
    //处理按键
    virtual void prossesKey() = 0;
}

class RunState : public StateBase
{
    virtual EState getState() overried
    {
        return EState::run;
    }
    virtual void prossesKey(Key key) overried
    {
        if(key == Key::Attack)
        {
            //奔跑状态下攻击可以变为特殊攻击
            character->RunAttack();
        }
    }else if(key == Key::)
    {
    }else ...//可以按照策划的脑洞整活
}

void Update()
{
    currentState->prossesKey(GetKey());
}

到此,状态的封装基本完成了,从判断状态实现事件转发,变为读取虚函数地址实现转发。
策划突然出现,说道:我们需要在站立和攻击之间添加一个过渡动作。
彳亍

// 状态基类
class StateBase
{
    //获得这个状态
    virtual EState getState() = 0;
    //处理按键
    virtual void prossesKey() = 0;
    //进入状态事件
    virtual void onEnter() = 0;
    //退出状态事件
    virtual void onExit() = 0;
}

void switchState(StateBase* newStat)
{
    currentState->onExit();
    // 伪代码,不考虑垃圾回收
    currentState = newStat;
    newStat->onEnter();
}

这样子类只需要重写一下进入和退出事件就可以实现事件间过渡。
到最后了,简单封装一下

class IStateBase
{
public:
    //获取对应状态机
    StateMechineBase* mechine = nullptr;
    //获得这个状态
    virtual EState getState() = 0;
    //更新
    virtual void update() = 0;
    //进入状态事件
    virtual void onEnter() = 0;
    //退出状态事件
    virtual void onExit() = 0;
}
class StateMechineBase
{
public:
    virtual void switchState(StateBase* newStat)
    {
        currentState->onExit();
        delete currentState;
        currentState = newStat;
        currentState->mechine = this;
        newStat->onEnter();
    }
    virtual void update()
    {
        currentState->update();
    }

    StateBase* currentState = nullptr;
}

一个简单的状态机模式完成了。

2、并发状态机

策划又来整活了:我希望玩家角色可以在奔跑或跳跃时射击,在挥剑时使用闪避。
我们已经有了奔跑和开枪状态,但是当两者组合时,我们应该是允许在奔跑时处理设计指令,还是在射击时处理奔跑指令,亦或者再写个移动设计状态。
本质上,这些方法都能实现功能,但是还是需要一个统一处理这些情况的方法。

没有什么是一个状态机解决不了的,如果有,那就用两个。

StateMechineBase* moveState;
StateMechineBase* actionState;

void update()
{
    moveState->update();
    actionState->update();
}


子状态机应该受到主状态机管理,形成组合模式。
其实这种方式需要更多的主次状态机间协调,是的主状态机接收到输入后子状态机不再处理。

3、层次状态机

不难发现,虽然我们状态众多,但是有一部分仍有相似点,如:
站立,设计,走动,奔跑都可以进行跳跃,此时,轮到了面向对象特性之一:继承出场了。

class OnGroundState : public StateBase
{
    virtual void update()
    {
        if(GetKey() == Key::Jump)
        {
            Jump();
        }
    }
}

class RunState : public OnGroundState
{
    virtual void update()
    {
        if(...)
        {
            ...
        }else
        {
            OnGroundState::update();
        }
    }
}

通过继承,实现了代码复用,继承了OnGroundState的类都拥有了跳跃能力,在子类能够处理时,父类会被覆盖掉,以满足一些特殊情况(如果这种情况过多,则需要重新组织设计结构)

3.5、责任栈状态机

即用栈的方式代替继承,自上而下的遍历栈,当有状态能够处理他时,停止遍历,若遍历完成后仍找不到则丢弃该事件。代码相对复杂且使用范围不多,不多做赘述

4、自动下推状态机

在写完角色功能后,策划表示有一个新的需求。
角色拥有一个背包,按下B键后打开背包面板,使用方向键在背包中选择道具,按下J键确认选择,弹出操作列表,选择合成按钮后会弹出下一个背包面板,在背包中选择一个道具与之合成。上述步骤的任意一步中点击取消时,都会回到上一个菜单状态(如,选择合成按钮后,按下K键取消选择,UI会回到道具操作列表。)
试着使用状态机实现这个功能。

class BagPannelState : public State
{
    void update()
    {
        if(GetKey() == Key::Confirm)
        {
            stateMechine->switchTo(ActionListState);
        }...
    }
}

class ActionListState : public State
{
    void update()
    {
        if(GetKey() == Key::Cancel)
        {
            stateMechine->switchTo(BagPannelState);
        }...
    }
}

这样做当然可以完成功能,但是日后维护他时,会非常痛苦,因为你需要手动的去控制它的上一层状态,当这个状态有了新的进入方式时,会引发一些奇怪的问题。
而这份痛苦的根源是来自当前的状态机不会记录上一个状态,为了解决这一问题,故引入下推状态机这一概念。
下推状态机,是指通过栈,记录状态的变化。当进入新的状态时,老的状态会保留在栈中,当新的状态退出时,只需要将新状态出栈。而每一次调用update,只需要对栈顶的状态进行更新即可。

class StateMechine
{
    Stack<State*> stack;

    void update()
    {
        //仅需要更新栈顶状态
        stack.top().update();
    }
    void switchToNewState(State* state)
    {
        stack.top()->onLeave();
        stack.push(state);
        state->onEnter();
    }
    void returnToLastState()
    {
        stack->onLeave();
        stack.pop();
        stack.top()->onEnter();
    }
}

最后

本篇文章从最基本的状态机开始,为了配合不同的使用场景,进行不同的优化。
相比于学会如何使用状态机模式,更重要的是学会利用他们的特长,在合适的场景中发光发热。

有关游戏开发中的状态机模式原理与应用的更多相关文章

  1. ruby - 在 Ruby 程序执行时阻止 Windows 7 PC 进入休眠状态 - 2

    我需要在客户计算机上运行Ruby应用程序。通常需要几天才能完成(复制大备份文件)。问题是如果启用sleep,它会中断应用程序。否则,计算机将持续运行数周,直到我下次访问为止。有什么方法可以防止执行期间休眠并让Windows在执行后休眠吗?欢迎任何疯狂的想法;-) 最佳答案 Here建议使用SetThreadExecutionStateWinAPI函数,使应用程序能够通知系统它正在使用中,从而防止系统在应用程序运行时进入休眠状态或关闭显示。像这样的东西:require'Win32API'ES_AWAYMODE_REQUIRED=0x0

  2. ruby-on-rails - Rails - 子类化模型的设计模式是什么? - 2

    我有一个模型:classItem项目有一个属性“商店”基于存储的值,我希望Item对象对特定方法具有不同的行为。Rails中是否有针对此的通用设计模式?如果方法中没有大的if-else语句,这是如何干净利落地完成的? 最佳答案 通常通过Single-TableInheritance. 关于ruby-on-rails-Rails-子类化模型的设计模式是什么?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.co

  3. ruby - 将差异补丁应用于字符串/文件 - 2

    对于具有离线功能的智能手机应用程序,我正在为Xml文件创建单向文本同步。我希望我的服务器将增量/差异(例如GNU差异补丁)发送到目标设备。这是计划:Time=0Server:hasversion_1ofXmlfile(~800kiB)Client:hasversion_1ofXmlfile(~800kiB)Time=1Server:hasversion_1andversion_2ofXmlfile(each~800kiB)computesdeltaoftheseversions(=patch)(~10kiB)sendspatchtoClient(~10kiBtransferred)Cl

  4. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  5. ruby - 使用 C 扩展开发 ruby​​gem 时,如何使用 Rspec 在本地进行测试? - 2

    我正在编写一个包含C扩展的gem。通常当我写一个gem时,我会遵循TDD的过程,我会写一个失败的规范,然后处理代码直到它通过,等等......在“ext/mygem/mygem.c”中我的C扩展和在gemspec的“扩展”中配置的有效extconf.rb,如何运行我的规范并仍然加载我的C扩展?当我更改C代码时,我需要采取哪些步骤来重新编译代码?这可能是个愚蠢的问题,但是从我的gem的开发源代码树中输入“bundleinstall”不会构建任何native扩展。当我手动运行rubyext/mygem/extconf.rb时,我确实得到了一个Makefile(在整个项目的根目录中),然后当

  6. ruby-on-rails - Rails 应用程序之间的通信 - 2

    我构建了两个需要相互通信和发送文件的Rails应用程序。例如,一个Rails应用程序会发送请求以查看其他应用程序数据库中的表。然后另一个应用程序将呈现该表的json并将其发回。我还希望一个应用程序将存储在其公共(public)目录中的文本文件发送到另一个应用程序的公共(public)目录。我从来没有做过这样的事情,所以我什至不知道从哪里开始。任何帮助,将不胜感激。谢谢! 最佳答案 无论Rails是什么,几乎所有Web应用程序都有您的要求,大多数现代Web应用程序都需要相互通信。但是有一个小小的理解需要你坚持下去,网站不应直接访问彼此

  7. ruby - 无法运行 Rails 2.x 应用程序 - 2

    我尝试运行2.x应用程序。我使用rvm并为此应用程序设置其他版本的ruby​​:$rvmuseree-1.8.7-head我尝试运行服务器,然后出现很多错误:$script/serverNOTE:Gem.source_indexisdeprecated,useSpecification.Itwillberemovedonorafter2011-11-01.Gem.source_indexcalledfrom/Users/serg/rails_projects_terminal/work_proj/spohelp/config/../vendor/rails/railties/lib/r

  8. ruby - 如何在续集中重新加载表模式? - 2

    鉴于我有以下迁移:Sequel.migrationdoupdoalter_table:usersdoadd_column:is_admin,:default=>falseend#SequelrunsaDESCRIBEtablestatement,whenthemodelisloaded.#Atthispoint,itdoesnotknowthatusershaveais_adminflag.#Soitfails.@user=User.find(:email=>"admin@fancy-startup.example")@user.is_admin=true@user.save!ende

  9. ruby-on-rails - Rails 应用程序中的 Rails : How are you using application_controller. rb 是新手吗? - 2

    刚入门rails,开始慢慢理解。有人可以解释或给我一些关于在application_controller中编码的好处或时间和原因的想法吗?有哪些用例。您如何为Rails应用程序使用应用程序Controller?我不想在那里放太多代码,因为据我了解,每个请求都会调用此Controller。这是真的? 最佳答案 ApplicationController实际上是您应用程序中的每个其他Controller都将从中继承的类(尽管这不是强制性的)。我同意不要用太多代码弄乱它并保持干净整洁的态度,尽管在某些情况下ApplicationContr

  10. ruby-on-rails - 跳过状态机方法的所有验证 - 2

    当我的预订模型通过rake任务在状态机上转换时,我试图找出如何跳过对ActiveRecord对象的特定实例的验证。我想在reservation.close时跳过所有验证!叫做。希望调用reservation.close!(:validate=>false)之类的东西。仅供引用,我们正在使用https://github.com/pluginaweek/state_machine用于状态机。这是我的预订模型的示例。classReservation["requested","negotiating","approved"])}state_machine:initial=>'requested

随机推荐