npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

遊資網發表於2020-03-25
在我們玩的大多遊戲中,總有這麼一些角色默默無聞的陪伴著我們,這就是遊戲中的npc。npc本意是non-player character,即遊戲中非玩家操作的角色都是npc,包括怪物、村民等等元素都是npc。而“操作”著這些npc來陪我們進行遊戲的,正是遊戲AI。因此遊戲AI的設計開發,是大多遊戲中極為重要的一環。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(王者榮耀中的小兵,就是由遊戲AI操作著的npc角色)

01 npc的AI是如何運作的?

通常我們都認為“行為樹”、“狀態機”組成了遊戲AI,也有把GOAP(目標導向型行動計劃)當做是遊戲AI設計的,其實這樣的概念是不對的。npc的AI是這樣執行的:

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

就像現實中的我們思考問題一樣,在遊戲的每一幀(遊戲執行的最小時間單位),npc們都會這麼思考——首先,“我”(這個npc)有什麼事情要做嗎?拿出“小本子”看看工作計劃,如果有事情要做,就會明確一個todoThing(現在要幹什麼)。有了todoThing就會仔細想一想,這個todoThing能做的成嗎?因為“計劃趕不上變化”,有些原本計劃好可以做的事情,現在可能因為環境(circumstance)發生了變化,以至於無法進行了:

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

當無法進行或者原本就沒有todoThing的時候,npc就會開始思考“我現在要幹啥”。這裡就是我們常見的“行為樹”發揮作用的地方,但是不論是“行為樹”、“藍圖”,實際上返回的都是一段資料,並且被記錄到“小本子”裡作為工作計劃,然後重新開始看是否可以執行這個計劃,最終開始執行具體行動。

所以“把想要做什麼寫進小本子”,才是AI的核心,而“行為樹”只是寫入“寫入小本子”的“寫法”之一,包括指令碼程式碼(如Lua)、UE4的藍圖等都是“寫小本子的寫法”。“寫小本子”這件事情得有思路,不管是藍圖還是行為樹都是把設計師的思路“寫進去”的過程,而GOAP提供的是一種設計遊戲AI內容的思路。行為樹和GOAP,只是遊戲AI中最核心的一環的資料錄入的思路和方式之一,因此行為樹和GOAP都不等於遊戲AI。

下面是程式碼時間

首先我們需要有一個關於角色狀態的東西,它也會返回當前角色是否可以執行AI(本文所有圖片的虛擬碼使用TypeScript):
  1. export class CharacterState{

  2.     public value : number = 0;



  3.     //這裡用於返回是否能執行AI

  4.     public CanRunAI():boolean{

  5.         return (1 & this.value) == 0; //可以設計更多狀態不能執行AI

  6.     }



  7.     public static STATE_STUN = 1;

  8.     public static STATE_POISONED = 1 << 1;

  9.     public static STATE_CONFUSED = 1 << 2;

  10.     //...這裡可以根據遊戲設計來定義更多狀態

  11. }
複製程式碼

接著就是角色物件,在角色物件中,有一些內容是設計師需要設計的:

  1. export class Character{

  2.     //npc必然是屬於角色類的,只是某些屬性的值和玩家的不同而已

  3.     //此處省略AI無關的其他資料



  4.     private todoThing:Object; //這就是“小本子”

  5.     private state:CharacterState;



  6.     private teamId:number = 0;



  7.     //這裡正是策劃設計的重要部分,也是AI的大腦

  8.     private WhatToDo():Object{

  9.         let res = {}    //Unity推薦的做法在這裡是行為樹



  10.         //這裡開始則是這個角色的AI執行內容

  11.         //如果你大多是if else,那就跟行為樹沒區別了,頂多執行效率高些



  12.         return res;

  13.     }



  14.     //這個函式是圖中的“3T.想一想執行細節”

  15.     private CanDoBehave():boolean{

  16.         if (!this.todoThing) return false;

  17.         return (

  18.             //這裡正是策劃需要設計規則的地方

  19.             //這裡的內容多和少都不壞,取決於遊戲規則的複雜度

  20.             //但是如果這裡依賴了其他物件,依賴的越多,設計越蹩腳。

  21.             false

  22.         );

  23.     }



  24.     //這正是AI的核心流程,也是每一幀執行的內容

  25.     public FixedUpdate(){

  26.         if (true == this.state.CanRunAI()){

  27.             if (

  28.                 !this.todoThing ||   //2F.如果“小本子”沒東西

  29.                 false == this.CanDoBehave() //3T.如果“小本子”的事情做不了

  30.             ){

  31.                 this.todoThing = this.WhatToDo();   //執行AI

  32.             }

  33.             //接下來當然是具體怎麼執行AI的問題了。

  34.         }

  35.     }

  36. }
複製程式碼

可見,“行為樹”在整個AI中,是完全可以被其他方法取代的內容。

02 “群體AI”是究竟怎麼回事兒?

當一個遊戲AI進行“思考”,也就是準備小本子的時候,除了會參考一些自身狀態,還會參考一塊“小黑板”內的資訊。這塊“小黑板”的資訊,遊戲的其他系統也會寫入一些必要的資料,以幫助遊戲AI更好的明確自己該做什麼。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

這小黑板上的資料,包括兩種型別的:

  • 遊戲全域性的一些狀態:比如遊戲如果有天氣,那麼現在是什麼天氣?可能NPC會需要因為下雨天,所以找房子躲起來,或者撐起傘。這些資料都是隨著遊戲推進,資料發生變化的時候會改寫的資料,對於遊戲AI而言,是隻讀資料。當然儘管我們舉例只說了天氣,但是通常情況下,遊戲程式中的所有變數都應該被寫在小黑板裡,供NPC的AI獲取資訊。
  • 一些“命令”:在這個小黑板當中,也會記錄一些由其他系統,比如玩家作業系統帶來的命令。比如“1組去A區”就是一種命令,當NPC在思考AI的時候,會發現有一條“1組去A區”的命令,此時如果這個NPC發現自己是1組的,他就會去A區。當然,資訊只是用來參考的,“不聽話”的1組隊員,完全是可以無視這條指令的。


npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

我們注意到了,在“小黑板”上有一些“命令”,這個“命令”正是很多遊戲中“群體AI”的核心關鍵所在。比如在即時戰略中,玩家操作一個小隊的角色移動到某個地方,就是一個“群體AI”;

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(即時戰略中,玩家控制一個小隊保持隊形駛向某處,就是一個“群體AI”)

除了玩家操作的,還有遊戲中場景裡刷了多個敵人,敵人與敵人之間像小組一樣的協作作戰;還有足球類遊戲中球員之間的跑位、配合等,都是典型的“群體AI”。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(在FIFA20等足球遊戲中,球員有組織的行動也是一種“群體AI”)

而“群體AI”的發起者,未必來自於一個更高階的系統(通常被認為是“遊戲AI系統”),因為這個“命令”對於執行遊戲AI的npc來說不是隻讀的資料,所以也可以由一個npc的AI發起。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

但是不論是誰發起的,都應該是在列表裡新增一條,而不是輕易修改已經存在的資料,當遊戲執行了一定幀數之後,自動清除掉,因此每一條“命令”必不可少的資料是“持續時間”。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(即使已經有的條目,也不能“修改”而只能“新增”,因為採納不採納的判斷,是由遊戲AI根據這些資訊“思考”出來的,我們只提供資訊,不提供解決方案)

因此,當我們看到遊戲中角色有序的群體執行某件事情的時候,通常是通過這種方式來發起“群體AI”,然後每個npc獨自行動的時候恰好形成了步調一致的結果,實際上他們之間互相併沒有任何“溝通”。

下面是程式碼時間

先是本章的主角——小黑板:
  1. //這是小黑板上的command的結構

  2. export class BlackBoardCommand{

  3.     public executorKeys:Array<string> = []; //期望這個指令的執行者的key

  4.     public tick:number = 1; //持續多少幀



  5.     //這是要執行的內容的具體資料約定,所以是靈活的東西,但他必須是一個資料結構,而不能是any

  6.     public command:Object = {};  

  7. }



  8. //小黑板

  9. export class Blackboard{

  10.     //遊戲執行時候暴露給AI的一些資料,這些資料對於AI只讀,本文省去get set

  11.     private runtimeData:Object;



  12.     //這裡是接收到的命令,外部只能新增,所以也用private,本文省去get set

  13.     private commands:Array<BlackBoardCommand>;



  14.     //根據執行這關鍵字,過濾出所有相關的命令

  15.     public GetCommandsByExecutorKey(executorKey:string):Array<Object>{

  16.         let res = new Array<Object>();



  17.         if (this.commands){

  18.             this.commands.forEach((cmd, index)=>{

  19.                 if (cmd.executorKeys.indexOf(executorKey) >= 0){

  20.                     res.push(cmd);

  21.                 }

  22.             });

  23.         }



  24.         return res;

  25.     }



  26.     //每一幀都會執行這個,來管理commands

  27.     public FixedUpdate(){

  28.         if (this.commands && this.commands.length > 0){

  29.             let i = 0;

  30.             while (i < this.commands.length){

  31.                 if (

  32.                     --this.commands[i].tick < 0

  33.                 ){

  34.                     this.commands.splice(i, 1);

  35.                 }else{

  36.                     i++;

  37.                 }

  38.             }

  39.         }

  40.     }

  41. }



  42. //整個遊戲只有一個

  43. var GameBlackBoard:Blackboard = new Blackboard();

  44. 一個聽話的NPC會這麼執行“群體AI”,在Character物件中,我們進行了一些小小的變化:

  45. //首先我們加入了一個小隊id,這當然不是所有遊戲都需要的,看設計需求

  46.     private teamId:number = 0;



  47.     //這裡正是策劃設計的重要部分,也是AI的大腦

  48.     private WhatToDo():Object{

  49.         let res = {}    //Unity推薦的做法在這裡是行為樹



  50.         //這裡開始則是這個角色的AI執行內容

  51.         //如果你大多是if else,那就跟行為樹沒區別了,頂多執行效率高些



  52.         //我是絕對服從命令的好孩子,組織讓我去那兒我去哪兒

  53.         //所以在最後我會判斷是否有command要我移動

  54.         //按照約定,應該是帶有"teamX"的是要我做的事情

  55.         let commandMoves = GameBlackBoard.GetCommandsByExecutorKey("team" + this.teamId);

  56.         if (commandMoves.length > 0){

  57.             //這裡就是根據命令來重新定義結果了,這是需要設計師設計的,包括資料結構和選擇方式

  58.             //在這裡,我們假設選取第一條的command就直接可以做為結果

  59.             res = commandMoves[0].command;  

  60.         }



  61.         return res;

  62.     }
複製程式碼

“小黑板”的資料,“引導”著每一個npc做出了自己的行為,恰好能夠形成一種“有組織”的錯覺。

03 打斷事件,從“A過去”和“走過去”說起

在PC上的一些即時戰略(RTS)和即時制的對戰遊戲(MOBA)比如《星際爭霸2》、《英雄聯盟》中,玩家有一個“微操”,就是在“A過去”和“走過去”之間做一個選擇:

  • 所謂“A過去”:通常是玩家按鍵盤上的A鍵(通常是預設A鍵),然後點選某個目標地點,角色會移動過去,但是路上一旦發現敵人、一旦遭受攻擊等,角色將會暫時放棄移動,轉而和敵人交火。
  • 所謂“走過去”:通常是直接滑鼠右鍵點選某個地點,或者按M(大多遊戲預設)然後點選某個地點,此時角色會移動過去,但是路上無論有什麼情況發生,即便角色捱打,也會繼續向目標走去,直到走到為止。


npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(“A過去”和“走過去”是完全不同的操作,比如在魔獸爭霸3中,如果“走過去”打建築物,就會盯著建築物打,而不顧周圍的情況,即使捱打也不會停下手裡的事情;如果用“A過去”點地面,則會各自尋找要打的建築物,而當敵方有士兵出現的時候,也會優先攻擊士兵)

當然,這裡我們並不是要討論在玩遊戲的時候玩家如何選擇“A過去”還是“走過去”,我們的思考是——這兩種移動模式,其實在遊戲AI的設計中,都是可能被用到的模式。比如當我設計一個正在巡邏的士兵的時候,因為他是高度警惕的,所以這個士兵在多個點之間的移動,應該始終是“A過去”的,一旦移動中發現情況,就要做出行動;而如果我們在設計一個被正在被追殺的難民,他的逃跑過程應該是向逃離點“走過去”的;同樣的如果我們設計了一場在危險的山道上的戰鬥,山上隨時會有泥石流,這時候所有的敵人的移動是“A過去”的,一旦遇到敵情就能立即反應,而當預感到泥石流出現的時候(比如螢幕開始震動,地上出現泥石流的陰影表示泥石流的階段要到了),這些敵人都會找到最近的安全區域(泥石流無法擊中)“走過去”,這時候不應該會因為在這段移動中遭遇了玩家角色就不顧泥石流的危險去和玩家的角色戰鬥。

所以這裡我們引出了第一個問題:“A過去”和“走過去”的打斷問題,一定只針對移動嗎?仔細一想並不是,而是隻要符合:

  • 需要一定時間來完成。
  • 這事情可以隨時被打斷。


那麼這個事情就跟“移動”是一樣的,就有打斷問題。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(比如吃東西這個事情,對於大多人來說是“A過去”的,但是我們依然可以利用“走過去”塑造出淡定哥)

接著第二個問題是:如果是“A過去”的,什麼時候打斷行動?我們可以簡單的歸納出一些時間點,比如:

  • 有敵人進入以自身為半徑的圓形範圍內(可以稱之為“警戒範圍”)的時候會打斷。當然這個警戒範圍不一定是正圓的,根據遊戲還可以設計多個扇形範圍,正面的半徑大一些,背後的半徑小一些之類的。
  • 當受到了來自敵人的攻擊的時候會打斷,因為自身的“警戒範圍”未必比敵人的射程短,所以需要這樣打斷,想一下如果一個喝醉酒的士兵正在歪歪斜斜的走向休息處,他應該是迷迷糊糊的,所以“警戒範圍”非常小,而此時他突然被人毆打,就應該“酒醒了”。
  • 自身Buff發生變化時:因為有時候攻擊並不是直接的,他可能是給角色新增了一個buff,比如讓角色中毒了,沒有直接傷害,但是也算是有攻擊性的。當然對於Buff的理解也不該如此狹隘,比如我們做一個類似GTA這樣的開放世界遊戲,在一個平靜的小村莊裡,npc正在悠閒地戰鬥,而好事的玩家逮住了最近的npc就打,此時這個npc會通過建立一個“求救”的AoE(當然嗓門越大的npc這個AoE範圍就越大)給附近所有其他npc新增一個buff,這個buff是“打架了”,而其他原本在散步的npc,收到了這個“打架了”的buff,有些會轉變為驚慌地逃跑,有些路見不平的npc則會加入到戰鬥中來。


我們可以看到,1、2兩個點的歸納是系統級的——即是由遊戲的其他系統來決定的,並不是所有遊戲都有“敵人”的概念,也並不是所有遊戲都有“攻擊”“傷害”之類的概念,因此1和2並不適合所有的遊戲,比如我們現在來做一個類似《開羅拉麵店》的遊戲,這是一個“和平時代”的遊戲,所以根本不存在“敵人”“攻擊”的說法;

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(開羅拉麵店中的僱員、客人都是由遊戲AI操作著的npc,但是遊戲中並不存在“進入警戒範圍”、“受到攻擊”等情況,和平年代不需要戰鬥)

由此我們進行重新抽象,但是遊戲的型別各式各樣,並且他們都需要遊戲AI,所以我們沒法很好的歸納出“遊戲AI需要被打斷當前執行的事件的時間點”來。此時我們需要把思維逆轉一下——AI的行動從來不是被外部打斷的,也就是外部從來不打斷在執行“小本子”裡內容的npc,而是npc經常會清空小本子。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

在遊戲AI每一幀運作的開端,我們都會判斷“小本子”裡是否有內容,利用的是這個特性——用移動來舉例子:

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

在這樣一張地圖下,我們的角色要移動到右上方的學校裡,清晰可見的是:學校和角色的距離並不是一下就能走到的,角色需要幾十上百幀不斷地移動,才能到達學校。即當如果“小本子”裡的事情是“走到學校”的話,沒有其他因素打斷的時候,這個行為要執行很久。具體要多久,也是沒法計算的,因為我們不能保證過程中角色不會因為“摔倒”而暫停幾個回合、因為“崴腳”而減速幾個回合……有非常多的動態原因會影響這個行為的執行,但是隻要最終不發生比如“學校沒了”、“角色昏迷了”之類的特殊情況,“小本子”裡的任務就始終是這個,這就實現了“走過去”的效果。

而如何做到“A過去”呢?事實上我們真正的需求是一個“在小本子的事情沒做完的時候清除掉小本子事情的方案”,而這個方案可以簡單到——就給這件事情限個時:
  1. //原本的“小本子”內的資料

  2. {"type":"move", "x":30, "y": 10}



  3. //現在的小本子內的資料

  4. {"type":"move", "x":30, "y":10, "tick":1}
複製程式碼

我們主要到現在的資料中多了一個"tick",這個"tick"就是執行多少幀邏輯後,如果事情還在,就把它從“小本子”裡抹掉。而上面的例子裡,就是“在向(30,10)移動了1幀之後或者到達目的地後(注意這是或關係的兩個條件),清除掉這個事情”,由此當npc第一幀向著目標移動之後,事情就沒有了,就會重新思考一次“todoThing=???”的問題,在“3F.思考要做什麼”一環裡,會重新根據當前情況去看是否“該去戰鬥”了。由此實現了“A過去”的“警惕性”,而且這還是一個帶“警惕程度”的方案,即“tick”值越大,npc越不警惕。當我們用多了"tick"這個條件以後,會發現一個現象——為了確保角色的靈活性,我們總是會去設計這個“tick”,那為什麼不預設就是有每1幀運算一次呢?是因為擔心效率嗎?其實並不是,還是為一些特殊的、需要堅持的事情留餘地。

而從程式設計的角度出發,我們更不應該選擇其他的物件或者事件,來打斷正在執行的AI事件,因為這意味著需要執行打斷AI事件的物件,將依賴於有AI的物件,這是一個依賴關係錯誤問題。

所以,關於AI的行為被打斷這件事,並不應該有任何特殊處理去打斷一個執行中的AI(即主動抹除“小本子”上的事情),而應該由AI的設計師通過設定“tick”的方式來自行決定某個AI的“敏感度”。

04 好的AI設計,是“可拼裝”的

在實際的遊戲AI製作工作過程中,可維護性和可執行性的問題就會冒出來。假如我們現在做一個餐館經營類遊戲,現在來設計裡面的服務員的AI,那他大致應該是這樣的:

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

當我們初次完成這個行為樹的時候,乍一看他非常美好,只要這麼迴圈,就能做出一個送餐服務員所有的工作了。但是,假如這時候製作組引入了新的設計:

  • 偶爾會有英雄級顧客來訪,比如馬拉多納、巴菲特、約翰尼德普等。服務員本身是有傾向性的,比如球迷服務員會優先服務馬拉多納;財經謎會優先服務巴菲特……而服務員本身還有崇拜人,比如同樣是球星,服務員可能是貝克漢姆的粉絲,所以當貝克漢姆、馬拉多納和貝利同時呼叫的時候,這個服務員會優先去貝克漢姆這裡。
  • 普通的服務員只能一次端一份菜送給顧客,而SR以上的服務員可以一次端2份菜送給顧客,並且會優先從準備好的菜裡選擇兩份目的地更接近的;更有SSR的服務員可以一次送3份,並且在前往服務檯之前,可以記錄2位顧客的需求。
  • 當服務員“待機中”的時候,會有小概率做一下小動作,而不是始終呆板的站在那裡發呆。如果有超過2個服務員在“待機中”,他們可能會“聊天”。


這樣的設計即合理又豐富了遊戲內容,並且完全不影響npc行為的和諧性,基於這兩個需求,我們可以得出服務員和顧客2個物件(本文只設計相關資料,無關資料全部省略):
  1. //服務員,Maid因為是二次元感更強烈些

  2. export class Maid extends Character{

  3.     //喜歡的顧客型別,因為只需要優先順序,所以越早的越喜歡就好

  4.     //喜歡的顧客型別甚至可以是遊戲中還沒設計的,所以用字串,可以預填寫資料

  5.     private favouriteCustomerTypes:Array<string>;



  6.     //喜歡的顧客,同樣是越早的越喜歡

  7.     //喜歡的顧客甚至可以是一個“不存在”的客戶,比如"mayun"而遊戲資料中並沒有id:"mayun"的顧客

  8.     private favouriteCustomerIds:Array<string>;



  9.     //稀有度,SR是4,SSR是5

  10.     private rank:number;



  11.     //同時可以端的盤子數,這是策劃設計的,應當可以隨時維護這個規則

  12.     private DishCarriage():number{

  13.         return Math.max(this.rank - 3, 0) + 1;

  14.     }



  15.     //同時可以服務的客戶數量

  16.     private MaxReception():number{

  17.         return Math.max(this.rank - 4, 0) + 1;

  18.     }

  19. }



  20. //顧客

  21. export class Customer extends Character{

  22.     //是否是一個英雄級

  23.     private isHero:boolean;



  24.     //客戶的id,比如巴菲特等都是因為這個id而是巴菲特的

  25.     //當然外觀等屬性會有所不同,但是外觀等屬性在本文中不敷述了

  26.     private id:string;



  27.     //客戶的型別

  28.     private customerType:string;

  29. }
複製程式碼

如果我們用常見的行為樹方式設計,要改變之前的行為樹以符合這個需求,可就十分困難了,假如原本這只是一個服務員的AI,不同服務員還有不同的AI,那就難上加難了。這根本的問題在於2點:

  • 行為樹的條件僅僅支援“如果是……否則……”(if (xxx()==true) {} else if (...))的結構,但實際上很多時候我們要判斷甚至要使用的並不是一個布林結果。比如上述的需求中,我們要求“優先接待貝克漢姆”,假如我們把它理解為“呼叫者為貝克漢姆”並且“我最喜歡的是貝克漢姆”,看起來只是2條布林判斷(if (xxx()==true))都滿足,並沒有問題,但是如果貝克漢姆已經被別人接待了,我要接待第二喜歡的,也許第二喜歡的也被別人接待了,我要優先接待第三喜歡的……因此這並不是一個“目標是誰”+“目標是我第幾喜歡的人”的問題,而是“通過排序我該找誰”,有了這個“誰”就有了我要去的目標,這個目標也包括其他客人,但是如果這個“誰”並不存在,那麼說明現在沒有客人召喚服務員。因此,在這樣精確判斷或者“狀態數量多到幾乎無限”的情況下,行為樹幾乎是無法支援的。
  • 好的AI應該是“可拼接”的,這具體表現在AI函式(即WhatToDo函式)本身應該可以被賦值,以及所賦的值可以是類似concate()拼接出來的。這個問題的導火索是追加的需求3,即待機中的服務員做小動作等。假如只有一種行為的服務員,即所有的服務員使用的都是上面的行為樹做的AI,那麼這個問題並不會被發現,但如果遊戲中有多種服務員,有些是隻負責接待顧客的、有些是隻負責上菜的、有些即負責上菜又負責接待顧客,按照傳統的行為樹的做法,就要有3種行為樹,滿足這3種不同服務員的AI,而這3種服務員的資料不同,僅僅只有外觀等本文不討論的“表現用屬性”以及AI不同。


npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(只要顏色不一樣,我們就能認可她們的行為不一樣。所以外觀屬性和所使用的AI不同,就足以形成多種服務員了)

而基於這兩個問題的思考,我們不得不重新去審視,怎樣設計AI的結構是好的。

第1個問題其實僅僅只是一個資料輸入的問題,我們只要不用行為樹而改用指令碼,就立即解決了。

而第2個問題的本質是:目前我們所使用的包括GOAP在內的幾乎所有的遊戲AI的架構思路都是“反人類”的——這些思路要求設計師先巨集觀的想好了一個NPC應該會做的一切事情,然後一條條細節追逐下去,正如本段開始的那個“行為樹”,我們必須規劃好了一個“服務員”所有的行為,然後把這些行為“進行中”的階段當做一種狀態,然後去“深入分析”這個狀態到底做了什麼。假如把這個思路用在傳統工業,比如服裝製造等擁有上百年流水線生產經驗、且今後幾百年製作流程和內容不會有變化(頂多製作手法和工具發生變化)的工作時,我們可以使用有限狀態機。但是,設計師的思維與此是相反的——設計師對於設計的思維正如他們的靈感一樣,是從一個點上迸發出來的。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(設計師的設計往往就像火山爆發一樣,從一個點突然就冒出許許多多有趣且非常有價值的設計來,如果我們的程式設計採用類似GOAP這樣的“向內包裹”的思路,就彷彿用一個袋子套住火山口,不讓它噴發——這並不符合好的設計的特點,好的設計,應該是讓噴發出的每一點子都能閃光)

所以我們放下“你現在的需求我都能實現”的“自信”,來看一下我們如何為“將來”做出準備。先從一個設計師的正常思維出發,就拿我們在這一段的“服務員”設計來看這個AI的思維過程:

列出大綱:首先我們列出了一個簡單的大綱,即這個“服務員”到底會幹什麼,乍一看列出了幾乎所有的可能性,但事實上這隻能算是“頭腦風暴”,也許結果上看起來至少80%的內容都有了,但實際上這裡產生的設計,很可能是“誤導開發”的。行為樹和GOAP都在這裡為設計師的設計畫上了句號,即使能維護,也認為“今後會加的東西不多了,目前結構已經非常清晰了”——但事實上,目前結構幾乎不清晰,正如我所說:設計師在這裡僅僅只是做了“頭腦風暴”。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(列出大綱的步驟非常自然,根據生活經驗抽象出會幹些什麼,同時由此可以獲得最基礎的“行為樹”,但這個“行為樹”卻錯誤地被當做了大多遊戲的核心AI)

發散思考:這並不是一個“唯一”的過程,因為在遊戲開發過程中:每次交流中、美術參考資源收集整理中、實際生活再度仔細考察體驗中……等等各種對專案細節的有意識的、無意識的研究中,都可能刺激以產生突發靈感。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(設計師可能從細節出發開腦洞)

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(設計師還會從玩法功能角度大開腦洞)

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(還有基於“養成”等遊戲特性展開的腦洞)

深耕靈感:每一個靈感被深耕的時候,又可能萌發出很多好的設計,這些設計往往並不複雜,但是卻可以為遊戲帶來更豐富的內容,以及更突出遊戲主題的內容。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

在瞭解了設計師的設計過程、以及專案發展過程中的各種idea的變化、迭代、進化過程之後,我們可以對於AI的實現方法重新進行構思:

首先我們抽象一下游戲設計師的設計思路:在遊戲設計師的理解下,遊戲AI就是很多很多個“一件事”的組合,而這個“一件事”往往是一些列“事情”的順序過程或者組合。因為可能是一個過程,所以在“一件事”中,有些條件被滿足了,就會發生另一件事。比如:“服務生從A點走到B點(不管是否端著盤子),都可能因為顧客丟在地上的雜物摔倒”,這個“一件事”是指“服務生移動”,同時“端著盤子”或者“沒有端盤子”;而引發的另“一件事”,就是因為“顧客丟在地上的雜物(被服務生踩到)”(條件),所以產生了新的“一件事”:“摔倒”。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(設計師心中的AI行為的變化其實更接近於這樣一個模式)

我們之前在第3段的時候說過,由於每一個AI事件的運作,都是若干個tick以後重新思考的,所以實際上對於設計師來說,並不存在“什麼時間點打斷”的問題,而是隻要想清楚“什麼事情會打斷”。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(包括“一件事”結束,都是一個打斷,打斷的結果就是轉向另“一件事”)

而實際上在這個“一件事”的整個過程中,所有的情況都可以是“條件”,包括“一件事”走完以後。因此,我們完全可以把每一個“一件事”都看成一個“管理器函式”——這個函式決定了是否這一幀要跳轉到另一個函式,如果不要就照計劃行事。

在程式碼上利用好角色思考函式本身可以被賦值的特性:既然這是個可以被賦值的屬性,那麼改變它的值就不是不可能的事,而在比如Unity的Behaviour Designer等外掛中,行為樹的運用本身也是可以被重新賦值的(BehaviorTree下就有ExternalBehavior可以被賦值),所以利用這個性質對於角色的WhatToDo函式重新賦值來實現AI的變化是好主意。

下面是程式碼時間

首先,我們要小小的改造一下Character中的WhatToDo,這樣他的值就可以是寫在其他指令碼里的函式:
  1. //這裡正是策劃設計的重要部分,也是AI的大腦

  2. private WhatToDo(character:Character):Object{

  3.     let res = {}    //Unity推薦的做法在這裡是行為樹



  4.     //首先我們從小黑板獲得一些跟我們相關的事情,省去指令碼端的麻煩

  5.     let commandMoves = GameBlackBoard.GetCommandsByExecutorKey("team" + this.teamId);

  6.     //然後我們呼叫約定好的指令碼

  7.     if (this.AIScript)

  8.         res = this.AIScript(character, this.sameAIScriptRunned++, commandMoves, this.runningAIParam);



  9.     return res;

  10. }



  11. //指令碼端通過指令碼介面改變這個值,實現了“一件事”之間的跳轉

  12. private AIScript : (character:Character, runned:number, commandMoves:Array<Object>, eventParam:Object)=>Object;



  13. //同一個指令碼已經執行了多少次,這是個很“甜”的東西

  14. private sameAIScriptRunned:number = 0;

  15. private runningAIParam:Object;



  16. //而實際上擴充套件性好的跳轉,跳轉到的“狀態”,不應該是固定的

  17. //比如當“跳舞完成”以後,“舞娘”應該繼續跳下一隻舞,而其他人可能就下臺繼續喝酒了。

  18. //儘管“跳舞完成”以後都會進入同一狀態,但是我們不一定非得用if else,可以用“afterDance”

  19. //即這個Object的結構是:key=跳轉的key“afterDance”等,而value是一個AIScript型別的函式

  20. //{"function key": (Character, number, Array<Object>, Object)=>Object}

  21. private aiWarpFunc = {

  22.     "default":StandStill    //設定一個預設值,以避免跳轉的函式並不存在

  23. };  



  24. public AIScriptWarp(scriptKey:string, eventParam:Object){

  25.     if (this.aiWarpFunc){

  26.         if (this.aiWarpFunc[scriptKey]){

  27.             this.AIScript = this.aiWarpFunc[scriptKey];

  28.             this.sameAIScriptRunned = 0;

  29.         }else if (this.aiWarpFunc["default"]){

  30.             this.AIScript = this.aiWarpFunc["default"];

  31.             this.sameAIScriptRunned = 0;

  32.         }

  33.     }

  34.     if (eventParam)

  35.         this.runningAIParam = eventParam;

  36.     //實在沒有就不跳轉,保持現在的

  37. }

  38. 而設計師則通過指令碼介面來寫指令碼完成整個AI的運作:

  39. //程式提供的指令碼介面

  40. var MaidAIWarp = function(maid:Character, scriptKey:string, eventParam:Object){

  41.     if (maid){

  42.         maid.AIScriptWarp(scriptKey, eventParam);

  43.     }

  44. }



  45. //判斷是否有客人呼叫,有就返回結構

  46. var CustomerCalls = function():Object{

  47.     if (true) { //這當然不能是true的,具體遊戲具體實現

  48.         //這個資料也是不對的,應該是返回一個呼叫的客戶的列表,當然這裡只是舉例,所以沒法實現

  49.         return {

  50.             customers:[

  51.                 {

  52.                     "x":10, //為指令碼選好合適的站位

  53.                     "y":10,

  54.                     "caller":new Customer() //這個客人是誰

  55.                 }

  56.             ]

  57.         }

  58.     }

  59. }



  60. //判斷是否到位了,這裡就假設是的

  61. var MaidArriveAtPosition = function(maid:Character, x:number, y:number):boolean{

  62.     //儘管這個函式在指令碼層也可以實現,但是提供一下也不壞

  63.     return true; //假設是true,本文中就不做詳細設計了,只是說明用

  64. }



  65. //往下都是設計師設計的指令碼

  66. var StandStill = function(character:Character, runned:number, commands:Array<Object>, eventParam:Object):Object{

  67.        return {"behave":"stay", "tick":1};

  68. }



  69. //服務員的待機

  70. var MaidWaiting = function(maid:Character, runned:number, commands:Array<Object>, eventParam:Object):Object{

  71.     let callers = CustomerCalls();

  72.     if (callers && callers["customers"] && callers["customers"].length > 0){

  73.         //假設就走向第一個客戶

  74.         let targetCustomer = callers["custmoers"][0];



  75.         //跳轉到角色的“move”下的函式,當然那個函式未必是MaidWalkTo,但我們按照約定做就好了

  76.         //第二和第三個引數也是設計師之間的約定,是根據遊戲設計來的

  77.         MaidAIWarp(maid, "move", {"x":targetCustomer["x"], "y":targetCustomer["y"], "event":"CustomerCalls"});



  78.         return; //不return是要出事的,這是個缺陷。

  79.     }



  80.     return {"behave":"stay", "tick":1}; //這是“小本子”的內容,由每個專案單獨設計規劃

  81. }



  82. //假如是走路怎麼辦,注意,

  83. var MaidWalkTo = function(maid:Character, runned:number, commands:Array<Object>, eventParam:Object):Object{

  84.     //甚至可以寫一個別的指令碼函式單獨處理,而那個指令碼函式也未必只有這個函式呼叫

  85.     if (DealWithMaidTouchThing(maid) == true){

  86.         return; //跳轉到別的事件了

  87.     }

  88.     //此處因篇幅省去異常判斷

  89.     if (MaidArriveAtPosition(maid, eventParam["x"], eventParam["y"]) == false){

  90.         return {"behave":"move", "x":10, "y":10, "tick":1}  //只為演示一下,所以座標的取法不對

  91.     }else{

  92.         if (eventParam["event"] == "CustomerCalls"){

  93.             MaidAIWarp(maid, "service", {}); //...

  94.             return;

  95.         }

  96.     }

  97. }



  98. var DealWithMaidTouchThing = function(maid:Character, thing?:any):boolean{

  99.     //假如角色移動中碰到了什麼東西,也可以單獨寫一個處理的函式

  100.     //返回boolean告訴呼叫者是否應該MaidAIWarp

  101.     return false;

  102. }
複製程式碼

從結構上來看,是不是感覺aiWarpFunc這個動繫有些多餘?如果單看流程的話,其實沒有這個東西,我們直接在指令碼里呼叫對應的介面是很好的。首先如我們上面說的,每個角色的“afterDance”不一定是一樣的事情,我們大可不必去(if (character...));其次是為了如果我們的設計師恰好都不會寫指令碼的時候,當我們要建立excel表去做一個靈活性很高的AI,這裡就可以是“角色屬性”的一環。

這樣,AI就實現了“可拼接”的靈活結構,並不是說“行為樹”做不到這樣的效果,畢竟行為樹本質是if else,沒有if else實現不了的功能,只是方便不方便的問題。

05 未來遊戲AI會都用Deeplearning?

事實上是不會的,也許人工智慧會在遊戲領域被運用,但是淘汰不了遊戲AI,因為他們本質就是不同的東西。遊戲AI和我們通常理解中的人工智慧最接近的一點功能是“陪玩”。因為在遊戲當中,有那麼一些元素(大多是角色),他們不屬於玩家可以控制的範圍——他們可能是玩家的對手、可能是拖累玩家的合作伙伴、也可能是玩家的強力隊友。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(即使是在FIFA這樣的足球類遊戲中,AI會做什麼,對於玩熟悉了的玩家依然是瞭然於心的)

對於這樣的一些角色來說,玩家不應該可以直接控制他們,因為對於玩家來說他們的行為應該是一個不那麼確定的因素,這樣才有策略性可言。假如我們十分了解這些“機器人”的行為,或者對於這些機器人會做什麼幾乎“一無所知”甚至他們的行為“出人意外”,那遊戲就會變得並不那麼好玩了。

AlphaGo生來是為了證明當有大量的資料,人們通過演算法分析就可以得出相對最好的解決方案。所以AlphaGo生來就不是為了成為“遊戲陪玩”的,而是為了擊敗圍棋達人才存在的。而“遊戲AI”與我們通常理解的“人工智慧”最大的區別也在於——“遊戲AI”是為了讓遊戲中的這些角色元素變得更有活力,而不是為了讓遊戲中的對手變得讓玩家無法擊敗。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(AlphaGo存在的意義就是證明,以大資料收集、分析為核心的Deeplearning可以為人類在解決問題的時候帶來極好的解決方案)

但是如果在遊戲中,我們因為對手的套路“太詭異”、“藏得太深”等因素,無法總結、或者分析出一些合適的對策,這並不有趣。能讓玩家憑藉判斷等技巧,結合經驗對抗得了的才是好的遊戲設計。設想一下,如果《怪物獵人》中一個非常厲害的AI操作要狩獵的龍,玩家幾乎戰勝不了,因為不知道龍會想出什麼鬼點子,做出什麼詭異的招式,這樣還能好玩嗎?

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI
(假如《怪物獵人》的AI是一個像AlphaGo一樣聰明的傢伙,那麼他會選擇讓飛龍在安全的地方睡覺,一旦有玩家進入場地,立即飛走,去另一個更遠的、安全的地方睡覺,以此拖滿50分鐘時間,這幾乎是必定可以戰勝玩家的方法,但如果是這樣的AI“陪玩”,玩家並不會開心,甚至會摔手柄)

“更聰明的AI”並不是遊戲設計需要的AI,遊戲設計需要的AI,至少是規則可以琢磨的,由此玩家才能想出對策來獲得遊戲的樂趣。比如在回合制遊戲中,某一種敵人的作戰方式就是“只攻擊血量數字最高的角色”,那麼當玩家找到一個攻擊力不高的角色,使勁給他堆血量的時候,就會發現自己做對了——因為那種怪物總是打那個攻擊力不高的角色,而那個角色只要犧牲不高的攻擊力來防禦,為其他高攻擊力角色提供輸出機會,就是很好的策略——玩家通過對AI的瞭解得出了一個合理的策略獲得了遊戲的優勢,從而非常快樂,這就是AI“陪玩”的意義。所以DeepLearning不會是“遊戲AI”的未來,因為“遊戲AI”要解決的問題和DeepLearning解決的問題不一樣。

總結

所以,當我們在說設計遊戲AI的時候,實際上是在設計:基於遊戲玩法規則而產生出的一套讓npc運作的規則,這套規則中npc會根據遊戲進行的情況來進行一些決策,做出“不那麼機械化”的行為。只要玩家足夠有分析能力、有足夠的遊戲經驗,總是可以摸清AI規律(能摸清但摸不透)來對抗的——這才是“遊戲AI”。

npc的AI是如何運作的? 從程式到策劃深入談遊戲AI

微信公眾號千猴馬的遊戲設計之道(ID:baima21th)授權轉載


作者:猴與花果山

相關文章