下面我想來談談關於伺服器上NPC的設計以及NPC智能等一些方面涉及到的問題。首先,我們需要知道什麼是NPC,NPC需要做什麼。NPC的全稱是(Non-Player Character),很顯然,他是一個character,但不是玩家,那麼從這點上可以知道,NPC的某些行為是和玩家類似的,他可以行走,可以戰鬥,可以呼吸(這點將在後面的NPC智能裡面提到),另外一點和玩家物件不同的是,NPC可以複生(即NPC被打死以後在一定時間內可以重新出來)。其實還有最重要的一點,就是玩家物件的所有決策都是玩家做出來的,而NPC的決策則是由電腦做出來的,所以在對NPC做何種決策的時候,需要所謂的NPC智能來進行決策。
下面我將分兩個部分來談談NPC,首先是NPC智能,其次是伺服器如何對NPC進行組織。之所以要先談NPC智能是因為只有當我們瞭解清楚我們需要NPC做什麼之後,才好開始設計伺服器來對NPC進行組織。
NPC智能
NPC智能分為兩種,一種是被動觸發的事件,一種是主動觸發的事件。對於被動觸發的事件,處理起來相對來說簡單一些,可以由事件本身來呼叫NPC身上的函數,比如說NPC的死亡,實際上是在NPC的HP小於一定值的時候,來主動呼叫NPC身上的OnDie() 函數,這種由事件來觸發NPC行為的NPC智能,我稱為被動觸發。這種類型的觸發往往分為兩種:
一種是由別的物件導致的NPC的屬性變化,然後屬性變化的同時會導致NPC產生一些行為。由此一來,NPC物件裡面至少包含以下幾種函數:
class NPC {
public:
// 是誰在什麼地方導致了我哪項屬性改變了多少。
OnChangeAttribute(object_t *who, int which, int how, int where);
Private:
OnDie();
OnEscape();
OnFollow();
OnSleep();
// 一系列的事件。
}
這是一個基本的NPC的結構,這種被動的觸發NPC的事件,我稱它為NPC的反射。但是,這樣的結構只能讓NPC被動的接收一些資訊來做出決策,這樣的NPC是愚蠢的。那麼,怎麼樣讓一個NPC能夠主動的做出一些決策呢?這裡有一種方法:呼吸。那麼怎麼樣讓NPC有呼吸呢?
一種很簡單的方法,用一個計時器,定時的觸發所有NPC的呼吸,這樣就可以讓一個NPC有呼吸起來。這樣的話會有一個問題,當NPC太多的時候,上一次NPC的呼吸還沒有呼吸完,下一次呼吸又來了,那麼怎麼解決這個問題呢。這裡有一種方法,讓NPC非同步進行呼吸,即每個NPC的呼吸周期是根據NPC出生的時間來定的,這個時候計時器需要做的就是隔一段時間檢查一下,哪些NPC到時間該呼吸了,就來觸發這些NPC的呼吸。
上面提到的是系統如何來觸發NPC的呼吸,那麼NPC本身的呼吸頻率該如何設定呢?這個就好象現實中的人一樣,睡覺的時候和進行激烈運動的時候,呼吸頻率是不一樣的。同樣,NPC在戰鬥的時候,和平常的時候,呼吸頻率也不一樣。那麼就需要一個Breath_Ticker來設定NPC當前的呼吸頻率。
那麼在NPC的呼吸事件裡面,我們怎麼樣來設定NPC的智能呢?大體可以概括為檢查環境和做出決策兩個部分。首先,需要對當前環境進行數字上的統計,比如說是否在戰鬥中,戰鬥有幾個敵人,自己的HP還剩多少,以及附近有沒有敵人等等之類的統計。統計出來的資料傳入本身的決策模組,決策模組則根據NPC自身的性格取向來做出一些決策,比如說野蠻型的NPC會在HP比較少的時候仍然猛撲猛打,又比如說智慧型的NPC則會在HP比較少的時候選擇逃跑。等等之類的。
至此,一個可以呼吸,反射的NPC的結構已經基本構成了,那麼接下來我們就來談談系統如何組織讓一個NPC出現在世界裡面。
NPC的組織
這裡有兩種方案可供選擇,其一:NPC的位置資訊儲存在情境裡面,載入情境的時候載入NPC。其二,NPC的位置資訊儲存在NPC身上,有專門的事件讓所有的NPC登陸情境。這兩種方法有什麼區別呢?又各有什麼好壞呢?
前一種方法好處在於情境載入的時候同時載入了NPC,情境就可以對NPC進行管理,不需要多餘的處理,而弊端則在於在重新整理的時候是同步重新整理的,也就是說一個情境裡面的NPC可能會在同一時間內長出來。而對於第二種方法呢,設計起來會稍微麻煩一些,需要一個統一的機制讓NPC登陸到情境,還需要一些比較麻煩的設計,但是這種方案可以實現NPC非同步重新整理,是目前網路遊戲普遍採用的方法,下面我們就來著重談談這種方法的實現:
首先我們要引入一個“靈魂”的概念,即一個NPC在死後,消失的只是他的肉體,他的靈魂仍然在世界中存在著,沒有呼吸,在死亡的附近漂浮,等著到時間投胎,投胎的時候把之前的所有屬性清零,重新在情境上構建其肉體。那麼,我們怎麼來設計這樣一個結構呢?首先把一個情境裡面要出現的NPC製作成圖量表,給每個NPC一個獨一無二的標識符,在載入情境之後,根據圖量表來載入屬於該情境的NPC。在NPC的OnDie() 事件裡面不直接把該物件destroy 掉,而是關閉NPC的呼吸,然後開啟一個重生的計時器,最後把該物件設定為invisable。這樣的設計,可以實現NPC的非同步重新整理,在節省伺服器資源的同時也讓玩家覺得更加的真實。
(這一章節已經牽扯到一些伺服器指令碼相關的東西,所以下一章節將談談伺服器指令碼相關的一些設計)
補充的談談啟發學習法搜尋(heuristic searching)在NPC智能中的應用。
其主要思路是在廣度優先搜尋的同時,將下一層的所有節點經過一個啟發函數進行過濾,一定範圍內縮小搜尋範圍。眾所周知的尋路A*演算法就是典型的啟發學習法搜尋的應用,其原理是一開始設計一個Judge(point_t* point)函數,來獲得point這個一點的代價,然後每次搜尋的時候把下一步可能到達的所有點都經過Judge()函數評價一下,擷取兩到三個代價比較小的點,繼續搜尋,那些沒被選上的點就不會在繼續搜尋下去了,這樣帶來的後果的是可能求出來的不是最優路徑,這也是為什麼A*演算法在尋路的時候會走到障礙物前面再繞過去,而不是預先就走斜線來繞過該障礙物。如果要尋出最佳化的路徑的話,是不能用A*演算法的,而是要用動態規劃的方法,其消耗是遠大於A*的。
那麼,除了在尋路之外,還有哪些地方可以應用到啟發學習法搜尋呢?其實說得大一點,NPC的任何決策都可以用啟發學習法搜尋來做,比如說逃跑吧,如果是一個2D的網路遊戲,有八個方向,NPC選擇哪個方向逃跑呢?就可以設定一個Judge(int direction)來給定每個點的代價,在Judge裡面算上該點的敵人的強弱,或者該敵人的敏捷如何等等,最後選擇代價最小的地方逃跑。下面,我們就來談談對於幾種NPC常見的智能的啟發學習法搜尋法的設計:
Target select (選擇目標):
首先獲得地圖上離該NPC附近的敵人列表。設計Judge() 函數,根據敵人的強弱,敵人的遠近,算出代價。然後選擇代價最小的敵人進行主動攻擊。
Escape(逃跑):
在呼吸事件裡面檢查自己的HP,如果HP低於某個值的時候,或者如果你是遠程兵種,而敵人近身的話,則觸發逃跑函數,在逃跑函數裡面也是對周圍的所有的敵人組織成列表,然後設計Judge() 函數,先選擇出對你構成威脅最大的敵人,該Judge() 函數需要判斷敵人的速度,戰鬥力強弱,最後得出一個主要敵人,然後針對該主要敵人進行路徑的Judge() 的函數的設計,搜尋的範圍只可能是和主要敵人相反的方向,然後再根據該幾個方向的敵人的強弱來計算代價,做出最後的選擇。
Random walk(隨機走路):
這個我並不推薦用A*演算法,因為NPC一旦多起來,那麼這個對CPU的消耗是很恐怖的,而且NPC大多不需要長距離的尋路,只需要在附近走走即可,那麼,就在附近隨機的給幾個點,然後讓NPC走過去,如果碰到障礙物就停下來,這樣幾乎無任何負擔。
Follow Target(追隨目標):
這裡有兩種方法,一種方法NPC看上去比較愚蠢,一種方法看上去NPC比較聰明,第一種方法就是讓NPC跟著目標的路點走即可,幾乎沒有資源消耗。而後一種則是讓NPC在跟隨的時候,在呼吸事件裡面判斷對方的當前位置,然後走直線,碰上障礙物了用A*繞過去,該種設計會消耗一定量的系統資源,所以不推薦NPC大量的追隨目標,如果需要大量的NPC追隨目標的話,還有一個比較簡單的方法:讓NPC和目標同步移動,即讓他們的速度統一,移動的時候走同樣的路點,當然,這種設計只適合NPC所跟隨的目標不是追殺的關係,只是跟隨著玩家走而已了。