黑板(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