給貓看的游戲AI實(shí)戰(zhàn)(五)忙碌的搬運(yùn)工與AI協(xié)作

上一節(jié)我們講解了AI行為中尋路的算法,比較特別的是我們是融合了算法可視化的理念,將尋路做出了有趣的動(dòng)態(tài)效果。
這一節(jié)我們?cè)俅无D(zhuǎn)向另一個(gè)問(wèn)題——多個(gè)AI協(xié)作的問(wèn)題。為了講清楚這個(gè)問(wèn)題,我特意做了這個(gè)例子:

上圖中,3個(gè)紅色的是物流機(jī)器人,綠色的是貨物。將貨物隨意地扔給他們,他們就能自發(fā)地將貨物依次擺放。如果覺(jué)得有趣的話,我們來(lái)試著實(shí)現(xiàn)一下。┌( ?_?)┘
1、實(shí)現(xiàn)一個(gè)單獨(dú)的物流機(jī)器人

對(duì)有一定基礎(chǔ)的讀者來(lái)說(shuō),這個(gè)例子已經(jīng)不需要細(xì)講了。
1、搭建場(chǎng)景。

如上圖,非常簡(jiǎn)單,場(chǎng)景包含地面和機(jī)器人,墻可要可不要。(為了開(kāi)發(fā)方便,一開(kāi)始可以把墻隱藏起來(lái))。
機(jī)器人自身非常精簡(jiǎn),就是一個(gè)不要碰撞體Collider、也不要Rigidbody的最普通的膠囊體即可。
另外做一個(gè)綠色方塊box代表貨物,box要有Rigidbody剛體組件。將box拖入工程目錄變成prefab以后用到,然后刪除方塊即可。

2、下面概覽一下用到的腳本:
1、攝像機(jī)掛載腳本PlayerInput.cs,功能:鼠標(biāo)點(diǎn)擊地面時(shí)生成貨物。
2、機(jī)器人掛載腳本RobotController.cs,功能:AI的所有邏輯。
可以猜到,其實(shí)箱子并不是由機(jī)器人通過(guò)物理推動(dòng)的,那樣實(shí)現(xiàn)會(huì)非常困難,因?yàn)楹茈y瞄準(zhǔn)推動(dòng)的角度,箱子會(huì)發(fā)生偏移和旋轉(zhuǎn)。
3、實(shí)現(xiàn)點(diǎn)擊地面,生成箱子。
這個(gè)功能對(duì)于看了本文好幾節(jié)的同學(xué)來(lái)說(shuō)應(yīng)該很簡(jiǎn)單了。代碼如下:
public class PlayerInput : MonoBehaviour {
public GameObject box_prefeb;
void OnClickGround()
{
Camera cam = Camera.main; // 主攝像機(jī),這樣獲取很方便
// 老規(guī)矩,從鼠標(biāo)點(diǎn)擊的地方,向屏幕內(nèi)打射線
Ray ray = cam.ScreenPointToRay(Input.mousePosition);
// 處理這條射線打到的那個(gè)GameObject
RaycastHit hitt = new RaycastHit();
Physics.Raycast(ray, out hitt, 100);
Debug.DrawLine(cam.transform.position, ray.direction, Color.red);
// 如果打到地面,就生成box(也就是貨物)
if (hitt.transform!=null && hitt.transform.name=="Ground")
{
Vector3 p = new Vector3(hitt.point.x, 5, hitt.point.z);
Instantiate(box_prefeb, p, Quaternion.Euler(0, 0, 0));
}
}
void Update() {
// 每幀檢測(cè)鼠標(biāo)點(diǎn)擊
if (Input.GetMouseButtonDown(0))
{
OnClickGround();
}
}
}
4、實(shí)現(xiàn)貨物管理器。
由于我們要將散亂的貨物按順序碼好,這就需要給每個(gè)貨物編號(hào)。參考代碼如下:
public class RobotController : MonoBehaviour
{
Dictionary<GameObject, int> boxes = new Dictionary<GameObject, int>();
int id_counter = 1;
// 保存貨物到boxes容器中,會(huì)給貨物分配ID
void SaveNewBox(GameObject box)
{
if (box.transform.position.y > 0.251f)
{
return;
}
if (boxes.ContainsKey(box))
{
return;
}
boxes[box] = id_counter;
id_counter++;
}
void Update()
{
GameObject[] all = GameObject.FindGameObjectsWithTag("Box");
foreach (GameObject box in all)
{
// 保存貨物到boxes中,這里會(huì)給貨物分配ID
SaveNewBox(box);
}
}
}
管理貨物的方法很簡(jiǎn)單,每一幀都遍歷所有貨物,將沒(méi)有加入boxes字典的貨物加入字典,ID增加1。
5、實(shí)現(xiàn)機(jī)器人移動(dòng)和整理邏輯。
簡(jiǎn)單來(lái)說(shuō),機(jī)器人從boxes中找一個(gè)需要整理的貨物,然后將其設(shè)置為當(dāng)前工作的貨物,然后移動(dòng)它即可。
注意機(jī)器人搬運(yùn)時(shí),有兩種狀態(tài):1、正在跑向貨物。2、正在搬運(yùn)貨物。也就是說(shuō),要先跑到貨物旁邊才能搬運(yùn)它。用一個(gè)bool變量來(lái)標(biāo)識(shí)狀態(tài)。
// 當(dāng)前正在搬運(yùn)的貨物
GameObject working_box;
// going_back表示了機(jī)器人的兩種狀態(tài):
// true代表當(dāng)前箱子已處理完畢,可以去取下一個(gè)箱子
// false代表正在推當(dāng)前的箱子
bool going_back = true;
我一開(kāi)始做的例子也沒(méi)有g(shù)oing_back區(qū)分狀態(tài),機(jī)器人會(huì)瞬移到貨物旁邊直接開(kāi)始搬運(yùn)。我的例子代碼也是慢慢完善才得到的。
邏輯完善以后,代碼如下圖,加了幾個(gè)函數(shù),Update函數(shù)也要添加一些邏輯:
// 整理貨物,即搬運(yùn)貨物到目標(biāo)位置
bool CleanBox(GameObject box)
{
Vector3 clean_pos = BoxCleanPos(boxes[box]);
if (Vector3.Distance(box.transform.position, clean_pos) > 0.05f)
{
//MoveTowards(current: Vector3, target: Vector3, maxDistanceDelta: float) : Vector3
Vector3 to = clean_pos - box.transform.position;
box.transform.position += to.normalized * Mathf.Min(0.1f, Vector3.Distance(box.transform.position, clean_pos));
transform.position = box.transform.position + to.normalized * -0.5f;
return false;
}
return true;
}
// 根據(jù)ID計(jì)算貨物對(duì)應(yīng)的位置
Vector3 BoxCleanPos(int id)
{
int n = (id - 1) % 5;
int row = (id - 1) / 5;
Vector3 v = new Vector3(-5f + n * 1.0f, 0.25f, -5f + row * 1.0f);
return v;
}
// Update is called once per frame
void Update()
{
GameObject[] all = GameObject.FindGameObjectsWithTag("Box");
foreach (GameObject box in all)
{
// 保存貨物到boxes中,這里會(huì)給貨物分配ID
SaveNewBox(box);
}
// 如果當(dāng)前沒(méi)有正在搬運(yùn)的貨物,則從boxes中查找需要搬運(yùn)的貨物
if (working_box == null)
{
foreach (var pair in boxes)
{
Vector3 clean_pos = BoxCleanPos(boxes[pair.Key]);
if (Vector3.Distance(pair.Key.transform.position, clean_pos) > 0.05f)
{
// 找到一個(gè)需要搬運(yùn)的貨物,設(shè)置為當(dāng)前正在搬的
working_box = pair.Key;
break;
}
}
}
// 如果當(dāng)前正在搬運(yùn)貨物
if (working_box != null)
{
// 情況一:正在搬運(yùn)的狀態(tài)
if (going_back == false)
{
if (CleanBox(working_box))
{
working_box = null;
going_back = true;
}
}
else
{
// 情況二:正在跑向貨物的狀態(tài)
if (Vector3.Distance(working_box.transform.position, transform.position) > 0.05f)
{
//MoveTowards(current: Vector3, target: Vector3, maxDistanceDelta: float) : Vector3
Vector3 to = working_box.transform.position - transform.position;
float f = to.magnitude / 0.1f;
to /= f;
transform.position += to;
}
else
{
going_back = false;
}
}
}
}
到此為止,我們已經(jīng)實(shí)現(xiàn)了一個(gè)單獨(dú)的物流機(jī)器人了,試試看吧。效果見(jiàn)本段開(kāi)頭只有一個(gè)機(jī)器人的那個(gè)動(dòng)圖。
回想一下前幾節(jié)介紹的狀態(tài)機(jī)AI的例子,會(huì)發(fā)現(xiàn)AI邏輯基本都是這樣的形式,只要寫(xiě)過(guò)一個(gè)復(fù)雜一點(diǎn)的狀態(tài)機(jī),再寫(xiě)大部分小游戲AI都會(huì)比較有信心了 (? ??_??)? ~~
2、多機(jī)器人協(xié)作
可以試驗(yàn)一下,在場(chǎng)景里多復(fù)制幾個(gè)機(jī)器人,也不會(huì)報(bào)錯(cuò)哦~~機(jī)器人可以正常搬運(yùn),只不過(guò)多人同時(shí)搬運(yùn)一個(gè)貨物,移動(dòng)會(huì)加快。
這是因?yàn)槎鄠€(gè)機(jī)器人的邏輯是相同的,他們會(huì)同時(shí)奔向同一個(gè)貨物,然后一起搬運(yùn)。他們的這種行為就好像不知道隊(duì)友的存在一樣,毫無(wú)計(jì)劃性,純粹的個(gè)人主義 ?ω? ?ω? ?ω? ?ω?
要想讓多人協(xié)作起來(lái),他們之間就必須通過(guò)某種方式做信息的交流。
A:我要搬1號(hào)貨物哦,不要和我搶。
B:那我搬2號(hào)貨物。
過(guò)了一陣:
A:1號(hào)貨物已搬運(yùn)完畢。
這里,我們通過(guò)在貨物上面做標(biāo)記的方法實(shí)現(xiàn)消息通信,為貨物創(chuàng)建一個(gè)腳本BoxData.cs,并掛在貨物的prefab上面:
// BoxData.cs
public class BoxData : MonoBehaviour {
public GameObject working_robot = null;
}
我這里直接用機(jī)器人變量本身作為標(biāo)記,比較方便。
機(jī)器人打算搬某個(gè)貨物時(shí),要在貨物上面標(biāo)記好自己。別的機(jī)器人看到這個(gè)貨物已經(jīng)被人占用了,就不會(huì)處理這個(gè)貨物了。
// 修改RobotController.cs
// 給貨物加鎖,也就是打上自己的標(biāo)記
bool LockBox(GameObject box)
{
BoxData d = box.GetComponent<BoxData>();
if (d == null)
{
return false;
}
if (d.working_robot == null)
{
d.working_robot = gameObject;
}
if (d.working_robot != gameObject)
{
return false;
}
return true;
}
// 釋放鎖,也就是刪除貨物的標(biāo)記
bool FreeBoxLock(GameObject box)
{
BoxData d = box.GetComponent<BoxData>();
if (d == null)
{
return false;
}
if (d.working_robot == null)
{
return true;
}
if (d.working_robot != gameObject)
{
return false;
}
d.working_robot = null;
return true;
}
在機(jī)器人處理貨物時(shí)做一點(diǎn)改動(dòng),用到了面兩個(gè)函數(shù)。下面的代碼關(guān)鍵看LockBox和FreeBoxLock兩處:
void Update () {
GameObject[] all = GameObject.FindGameObjectsWithTag("Box");
foreach (GameObject box in all)
{
SaveNewBox(box);
}
if (working_box == null)
{
foreach (var pair in boxes)
{
// 如果鎖定失敗,就代表貨物已經(jīng)被別人占用了
if (!LockBox(pair.Key))
{
continue;
}
Vector3 clean_pos = BoxCleanPos(boxes[pair.Key]);
if (Vector3.Distance(pair.Key.transform.position, clean_pos) > 0.05f)
{
working_box = pair.Key;
break;
}
}
}
if (working_box != null)
{
if (going_back == false)
{
if(CleanBox(working_box))
{
// 運(yùn)送到位后即可釋放鎖
FreeBoxLock(working_box);
working_box = null;
going_back = true;
}
}
這樣就OK了。
什么???這么簡(jiǎn)單!?是的,無(wú)論多少機(jī)器人,都能井井有條的協(xié)作!ヽ(??ω?? )ゝ
復(fù)制10個(gè)試一試!

如螞蟻一樣一擁而上的效果,你也可以實(shí)現(xiàn)。ヽ(??ω?? )ゝ。看起來(lái)炫酷的效果卻是用一個(gè)非常簡(jiǎn)單的方法做到的,這就是算法的魅力啊~~~
注意,有一種特殊情況,也已經(jīng)被解決了,不需要更多考慮,可以想想是為什么:
A:1號(hào)貨物已搬運(yùn)完畢。
過(guò)了一會(huì)兒
C:1號(hào)貨物被擠到了其他位置,需要再搬運(yùn)一下
過(guò)了一會(huì)兒
C:1號(hào)貨物搬運(yùn)完畢
代碼就不貼了,工程地址會(huì)放在文末。下載即可。
3、總結(jié)
本節(jié)我們介紹了一種模擬整理箱子的Demo,有很大篇幅在制作這個(gè)Demo本身,但是重點(diǎn)是第2段。在第2段我們用一種非常簡(jiǎn)單的方法實(shí)現(xiàn)了一種自發(fā)性的任務(wù)規(guī)劃。
這有點(diǎn)像公司制度,在制度合理的情況下,每個(gè)人只要按制度干活,就能實(shí)現(xiàn)良好的協(xié)作,事情就能自動(dòng)處理好??墒翘斓紫虏欢际沁@么簡(jiǎn)單的事,比如現(xiàn)在IT、金融等知識(shí)密集型的領(lǐng)域,制度的作用就不像在工廠、車間里那么有效了。這時(shí)候需要更復(fù)雜的協(xié)作機(jī)制,將計(jì)劃和管理的工作獨(dú)立出來(lái),而且同時(shí)讓工作者們保持一定自主性,才能達(dá)到良好效果。
在很多重視AI的游戲中,上面說(shuō)的這些也都是可以做到的。比如一些MOBA或者RTS游戲里的高智能電腦,就既懂得自己發(fā)展,又懂得和友軍協(xié)作。
作為AI設(shè)計(jì)的入門級(jí)專欄,本文沒(méi)有把問(wèn)題講得很深入。但是只要引起讀者的興趣,就已經(jīng)達(dá)到本文的目的了。 (????)?
工程地址:
https://github.com/mayao11/PracticalGameAI/tree/master/AIBlock
————————————————————————————————————
對(duì)游戲開(kāi)發(fā)感興趣的同學(xué),歡迎圍觀我們:【皮皮關(guān)游戲開(kāi)發(fā)教育】 ,會(huì)定期更新各種教程干貨,更有別具一格的線下小班教育~
我們的官網(wǎng)地址:http://levelpp.com/
我們的游戲開(kāi)發(fā)技術(shù)交流群:610475807
我們的微信公眾號(hào):皮皮關(guān)