游戏设计模式:黑板模式
黑板(Blackboard)
“黑板”(Blackboard)在人工智能领域已经是一个很古老的东西了。它基于一种很直观的概念,就是一群人为了解决一个问题,在黑板前聚集,
每个人都可以发表自己的意见,然后在黑板上写下自己的看法,当然你也可以基于别人记录在黑板上的看法,
来发表和更新自己的看法,在这样不断的意见交换,看法更新的过程中,越来越趋向于对于问题的最终解答。
一开始的黑板模式就是这样一个由多个子系统来共同协作的人工智能解决方案。
定义
基于上面的描述,我们可以看到黑板有几个功能:
- 记录:每个人可以写下自己的看法。
- 更新:调整已有的看法。
- 删除:删除对于过时的,或者错误的看法。
- 读取:黑板上的内容谁都能自由阅读。
所以从本质上来说,黑板就是这样一个共享数据的结构,它对于多个系统间通信是很有帮助的。
它提供一种数据传递的方式,有助于系统的封装和解耦合。
对于各个子系统而言,只需要把自己的运算的结果数据记录在黑板上,至于这个数据谁会去用,并不需要关心。
反过来也是一样,对于自己的运算时需要用到的数据,可以从黑板上去获取,至于这个数据是谁提供的,也不需要关心。
只要这个数据在黑板上,就够可以认为是合法数据,这就提供的了一种灵活性,各个子系统的设计也会相对独立。
好处
现在游戏中,也大量的使用黑板(或者类黑板)模式,因为游戏系统的模块间通信的需求也是很多的,AI,动画,物理,实体与实体间,等等,他们都需要彼此交换数据,我想,大家经常碰到的一个头疼的问题就是,这个数据应该存在哪里?存在这里也可以,存在那里也可以,或者索性做个Data类来存,所以在Player类里,变量会越来越多,变量列表越来越长。
针对这种情况黑板可以帮助解决一部分问题,特别是对于在多模块之间需要通信的数据,我们再来看一下它几个好处:
- 解耦合:黑板做为独立的数据模块,可以”超然”于所有的模块之外,提供一些额外的数据维护和管理的功能,这个让我想到了那些内存数据库,比如redis和memcached,从某种程度上,黑板就像程序内的数据库。
- 共享性:黑板的数据是共享的,比如我们要去拿一个数据,我们不需要先拿到它的实例(还需要考虑是否为null),然后再通过get方法去取数据,我们只需要存一个黑板的实例,然后通过黑板获取数据的方法来获取。这就类似设计模式中的Facade方法,黑板提供了这样一个facade层,使得RWD的接口保持统一。
- 数据的维护和管理:黑板提供数据的RWD,生命期,作用域等内容,让我们可以从管理数据的漩涡中解脱出来,让专业的人做专业的事。
缺点
-
RWD(读写删)操作相对随意,特别是WD操作,容易造成数据被破坏,或者产生子系统间的竞争:
比如,系统A和系统B都会去修改data1,那到底以谁的值为准呢? -
可能会产生非法数据:
一般认为,只要在黑板上的数据,就是合法的数据,在读取的时候,不需要判断它是否合法,
但如果一个子系统没有很好的维护它自己产生的数据(比如,该删除的时候没删除,或者赋值错误),
那别人读取该数据的系统时候,就会产生错误的运算结果。
额外功能
博客(指AI分享站的博客)上有一篇较早的文章就讨论过这样的问题,像黑板这样的共享数据结构,既是黄金屋,又是垃圾堆,用好不容易,所以在黑板原有的功能中,我们可以加一些额外的功能:
- 数据过期时间:对于写入黑板的数据,可以加一个过期时间的功能,比如3秒后,该数据过期,这很实用,可以提高数据维护的便利程度。
- 数据作用域:我们可以规定可以读写该数据子系统,默认情况下,黑板的数据都是全局可见的,就像程序中的全局变量一样,但如果我们希望某些数据只有对个别子系统开放,就可以通过作用域字段来指定。
一个游戏使用黑板模式的例子
需求:我们在游戏中有一个技能,可以给角色提供一种狂暴状态,持续10秒。
游戏中很多别的系统在计算中,需要检查该角色是否有这样的一个狂暴的状态,然后做一些后续的判断。
在这样一个例子中,常规的做法可能是,在角色上存一个变量,技能触发的时候,置成True,然后维护一个计时器,设为10秒,
每帧检查这个计时器,当时间到了,就把这个值再置成False,再提供一个get方法给外部系统调用。
这样的逻辑正确,但相对繁琐,不够优雅。如果我们换用黑板模式来维护这个数据应该怎么写呢?就一句话:
player.GetBB().SetValue(BBKEY_FURIOUS, true).SetExpiredTime(10);
我们先获取了黑板的实例(GetBB),然后设置了变量为True(SetValue),然后再设置了过期时间为10秒(SetExpiredTime),这样在10秒内如果访问这个变量,会返回True,但如果过了10秒,这个变量就会返回False,而所有对于数据的管理就被完整的封装在了黑板的实现中。
当然,黑板可以有很多块,像我上面的例子,我就是在角色身上建了一块黑板,用来存储与角色相关的数据,还可以建一块全局的黑板,用来存储整个游戏层面上的数据通信。不管建了几块这样的黑板,它的原理都是一样的,具体如何选择,还是取决于实际情况。
有人可能会说,我把变量一个一个具体定义,和存在黑板中用key-value的结构好像区别也不大,确实,用黑板确实能带来一些好处,但好处还不够多。
但黑板有一个另外的优势,那就是支持可视化编程和数据驱动,结合现在的引擎来看,这样的好处真是大大的。
现在主流的引擎,都会提供一个强大的可视化的编辑器,通过一些UI上的操作,就能完成一些复杂的游戏逻辑,像行为树和状态机在游戏行业的经久不衰,一方面是因为它的概念比较简单和直观,另一方面也是因为它在可视化编程和数据驱动方面的优势。黑板在这样的潮流中,也是一点不落后。
首先它采用的存储方式是key-value的字典结构,很通用,可以通过配置文件简单定义,通过范型和反射很容易去创建,修改和读取。其次它作为共享数据,可以很好的和类似行为树和状态机这样的系统协同工作。
其他使用黑板模式的例子
行为树通信
行为树的节点间也是存在通信的需求的,最常见的就是序列节点:
比如我们有一个简单的攻击序列节点,第一个节点是选择目标,第二个节点是攻击,这里就存在一个节点间通信的需求。
在”选择目标”的节点里会选择一个攻击目标,然后在攻击的节点里会对这个目标实施攻击。所以”攻击目标”这个数据就会在两个节点间进行通信,第一个节点输出,第二个节点输入,那这个数据应该存在哪里呢?
存在角色身上是一个选择,还有一个选择,就是存在与这个行为树绑定的黑板上面,
在Unity的Behaivor Design这个行为树插件里,这样的变量就叫共享变量。
它的概念其实就是和黑板类似的(它在两个节点中分别创建了一个指向这个共享变量的引用,
主要是方便编辑器操作和代码上的访问),在编辑器中,我们就可以创建这样一个变量,
然后把它拖到第一个和第二个节点的相应变量里。
状态机通信
状态机也是一样的,当各个状态跳转的时候,势必也会带来一些数据的通信。
这个时候,黑板就能很好的帮助这样的系统进行共享数据的管理。
关于状态机的例子,大家可以看Unity上一个状态机的插件PlayMaker。
(Unity里Animator状态机的黑板模式)
小结
黑板是一个很好的共享数据系统,我很推荐大家在自己的代码库中加一个黑板的库,并应用到你核心游戏部分的实现中,这个小小的东西,会带来很大的思维和代码质量的提升。如果还不是很熟悉的同学,可以去用用看我刚刚说到Unity的那两个插件,这样你就会对数据通信,共享数据,黑板等概念更为清楚。
黑板模式的C++简易实现
#pragma once
#include <map>
#include <any>
#include <list>
//黑板类
class BlackBoard
{
private:
//黑板计时器
struct BlackBoardTimer {
float timer;
std::string key;
std::any value;
};
protected:
std::map<std::string, std::any> mDatas;
std::list<BlackBoardTimer> mTimers;
public:
BlackBoard();
~BlackBoard();
//设置数据
void setValue(std::string key, bool value);
void setValue(std::string key, bool value, float expiredTime , bool expiredValue);
void setValue(std::string key, int value);
void setValue(std::string key, int value, float expiredTime, int expiredValue);
void setValue(std::string key, float value);
void setValue(std::string key, float value, float expiredTime, float expiredValue);
void setValue(std::string key, std::string value);
//访问数据
int getInt(std::string key);
float getFloat(std::string key);
bool getBool(std::string key);
std::string getString(std::string key);
//更新时间
void update(float dt);
};
#include "BlackBoard.h"
BlackBoard::BlackBoard()
{
}
BlackBoard::~BlackBoard()
{
}
void BlackBoard::setValue(std::string key, int value)
{
mDatas.emplace(key, value);
}
void BlackBoard::setValue(std::string key, int value, float expiredTime, int expiredValue)
{
setValue(key, value);
mTimers.emplace_back(BlackBoardTimer{ expiredTime,key,expiredValue });
}
void BlackBoard::setValue(std::string key, float value)
{
mDatas.emplace(key, value);
}
void BlackBoard::setValue(std::string key, float value, float expiredTime, float expiredValue)
{
setValue(key, value);
mTimers.emplace_back(BlackBoardTimer{ expiredTime,key,expiredValue });
}
void BlackBoard::setValue(std::string key, bool value)
{
mDatas.emplace(key, value);
}
void BlackBoard::setValue(std::string key, bool value, float expiredTime, bool expiredValue)
{
setValue(key, value);
mTimers.emplace_back(BlackBoardTimer{ expiredTime,key,expiredValue });
}
int BlackBoard::getInt(std::string key)
{
auto & value = mDatas.at(key);
return std::any_cast<int>(value);
}
void BlackBoard::setValue(std::string key, std::string value)
{
mDatas.emplace(key, value);
}
float BlackBoard::getFloat(std::string key)
{
auto& value = mDatas.at(key);
return std::any_cast<float>(value);
}
bool BlackBoard::getBool(std::string key)
{
auto& value = mDatas.at(key);
return std::any_cast<bool>(value);
}
std::string BlackBoard::getString(std::string key)
{
auto& value = mDatas.at(key);
return std::any_cast<std::string>(value);
}
void BlackBoard::update(float dt)
{
auto itr = mTimers.begin();
while(itr != mTimers.end()) {
itr->timer -= dt;
if (itr->timer <= 0.0f) {
mDatas[itr->key] = itr->value;
itr = mTimers.erase(itr);
}
else {
++itr;
}
}
}
黑板模式的C#实现
可参考AI分享站的C#AI工具库:https://github.com/FinneyTang/TsiU_AIToolkit_CSharp
参考
转载并修改自原文—AI分享站的博文:http://www.aisharing.com/archives/801
原文对黑板模式的讲解非常深刻易懂,因此我仅做了部分的排版整理工作就直接搬运过来作为笔记。
游戏架构&游戏设计模式系列-其他文章:https://www.cnblogs.com/KillerAery/category/1307176.html
作者:KillerAery
出处:http://www.cnblogs.com/KillerAery/
本文版权归作者和博客园共有,未经作者同意不可擅自转载,否则保留追究法律责任的权利。