NPC 行為:狀態機、行為樹、GOAP 與 AI 驅動的 NPC
打造你自己的 NPC 行為
NPC 如何決定要做什麼?狀態機、行為樹、GOAP、效用 AI 與 LLM 驅動的 NPC — 透過瀏覽器中的即時 3D 範例逐一解說。
NPC 行為是一種邏輯,它把一個模型、一條血條與一個碰撞膠囊,變成看起來有所渴求的東西。它以迴圈回答一個問題,通常是每一影格或固定的更新週期:根據我對世界所知的一切,我下一步該做什麼?本頁其餘的全部內容 — 有限狀態機、行為樹、GOAP、效用 AI、LLM 驅動的意圖 — 都是這個問題的不同答案,各有不同的成本與不同的失效模式。
我們打造的 NPC 在瀏覽器分頁中執行,位於 Neta Studio 的 3D 遊戲製作器內。這項限制形塑了以下的建議。這裡沒有專用伺服器、沒有獨立的物理執行緒,也沒有預算留給每次更新都要搜尋 40 毫秒的規劃器。若某項技術無法便宜到足以在中階筆電上與算繪並行執行,我們就會降級它,或把它移出熱路徑。接下來的章節描述我們實際出貨的內容,以及每種做法會在何處崩壞。
快速解答:NPC 如何做決定
NPC 透過依排程執行一個決策函式來做決定:讀取一小部分的世界狀態(與目標的距離、視線、目前生命值、最後已知位置),並回傳一個移動與動畫系統能執行的動作。排程與邏輯本身一樣重要。以 100 毫秒至 250 毫秒的週期做決定,並讓移動與碰撞逐影格執行,其成本只是逐影格決策邏輯的一小部分,而且在大多數情況下,玩家根本分辨不出差異。
這四種經典模型的不同,在於複雜度座落何處。狀態機把決策編碼為具名狀態之間的轉換。行為樹把它們編碼為選擇器與序列節點的階層。GOAP 把它們編碼為一個目標,加上一組帶有前置條件與效果的動作,並搜尋出一份計畫。效用 AI 把它們編碼為對每個候選動作的評分函式。LLM 驅動的 NPC 則以自然語言編碼高階意圖,再把結果交回較便宜的模型之一。
實務上,你會把它們組合起來。由效用評分器挑選目標,由行為樹或狀態機執行它,由導航網格移動身體,而語言模型只碰觸對話與意圖。本頁其餘部分會逐一探討每一層,並點名你會撞上的具體失效模式。
狀態機(FSM)
有限狀態機是最便宜且實用的模型,也是大多數 NPC 正確的第一選擇。你定義一組狀態 — 待機、巡邏、追擊、攻擊、逃跑、暈眩 — 以及它們之間的一組轉換條件。每次更新時,你評估離開目前狀態的轉換,若有其中一個觸發,就切換過去。成本與你檢查的轉換數量成正比,而這個數量很小。狀態是單一變數,這讓它易於序列化、除錯,並顯示在編輯器疊層中。
失效模式是震盪。一個距離玩家 5.1 公尺的 NPC,從巡邏轉換為追擊,移動到 4.9 公尺,接著一個對稱的轉換又把它送回巡邏。它就會在邊界上永遠振動。解法是滯後效應:在 5.0 公尺時進入追擊,並在 6.5 公尺時離開。同樣的問題也會出現在視線檢查上:當玩家橫向移動到柱子後方時,視線會閃爍不定;而同樣的解法也適用 — 要求該條件連續 N 次更新都成立,或加入一個記憶計時器,讓 NPC 在最後一次看見目標後仍維持追擊兩秒。
第二種失效模式是轉換糾結。五個狀態需要十條轉換;十個狀態則需要接近四十五條。每個新狀態都必須接到所有能抵達它的狀態,而條件的重疊方式又難以推理。當你的轉換表再也塞不進一個畫面時,那就是改用行為樹的訊號。
一個巡邏衛兵的具體 FSM:待機(等待 2 秒)→ 巡邏(走向下一個路徑點)→ 警戒(轉向聲源,1.5 秒)→ 追擊(以奔跑速度移向最後已知位置)→ 攻擊(若在 2 公尺內且冷卻結束)→ 搜尋(在最後已知位置附近徘徊 8 秒)→ 巡邏。七個狀態,大約十幾條轉換,而搜尋狀態中的記憶計時器,正是讓它感覺從容而非躁動的關鍵。
- 單一變數狀態目前的狀態就是一個列舉值。除錯時只需讀取一個值,而不必從堆疊中重建意圖。
- 每個門檻都要有滯後任何可能雙向轉換的距離或計時器,都需要不同的進入值與離開值,否則 NPC 就會在邊界上震盪。
- 轉換預算在表格變得難以維護之前,把狀態數維持在約十個以下。超過之後,就改用行為樹,或拆分成子狀態機。
行為樹
行為樹以一個階層取代轉換表。葉節點是動作(MoveTo、PlayAnimation、Wait)與條件(IsPlayerVisible、HealthBelow)。兩種複合節點類型負責大部分的工作。選擇器依序嘗試其子節點,一旦有一個成功就回傳成功 — 這就是你的優先順序清單。序列依序執行其子節點,一旦有一個失敗就失敗 — 這就是你按部就班的程序。
可擴充性上的勝點在於可組合性。新增一個行為,意味著在選擇器底下加一棵子樹,而不是把一個新狀態接到所有能抵達它的狀態。優先順序就明確地表現在子節點的順序中,這比一組彼此重疊的布林條件容易閱讀得多。子樹可以重複使用:同一條「調查聲響」分支,對衛兵、狗與平民都適用。
行為樹並不會自動變得更快。天真的實作會在每次更新時重新評估整棵樹,這就是為什麼你應把更新維持在 100–250 毫秒的決策排程上,並讓目前正在執行的動作逐影格驅動移動。長時間執行的動作需要一個能讓樹記住的執行中狀態,否則動作會在每次更新時從頭重新開始,NPC 就會滑回路徑的起點。多數內建行為樹的引擎 — Unity 的 Behavior package、Unreal 的內建樹,或 TypeScript 中的小型自製實作 — 都會以執行中的回傳值來處理這件事,但當 NPC 出現卡頓時,這正是第一個該檢查的地方。
一個戰鬥 NPC 的選擇器結構,依優先順序排列:(1)若生命值低於 25%,序列 [逃向掩體、治療];(2)若玩家可見且在 2 公尺內,序列 [面向玩家、攻擊];(3)若玩家可見,序列 [MoveTo 側移位置、攻擊];(4)若已設定最後已知位置,搜尋;(5)巡邏。選擇器會挑選第一個條件通過的分支。重新排列這些子節點,就是你不碰程式碼也能調整侵略性的方法。
- 選擇器節點在第一個成功的子節點上回傳成功。用它來安排備援優先順序:先試最具體的行為,再試下一個,最後才是預設行為。
- 序列節點在第一個失敗的子節點上失敗。用它來安排有順序的程序,因為若第一步沒有發生,第二步就毫無意義。
- 執行中狀態長時間的動作必須回傳 Running,並從停止的地方繼續。若它們立刻回傳 Success,樹就會在每次更新時重新啟動它們,移動便會瓦解。
- 黑板一個共享的鍵值儲存區,用來存放樹所讀寫的事實 — 目標位置、警戒等級、冷卻到期時間。讓它保持精簡且有型別。
GOAP(目標導向行動規劃)
GOAP 把模型翻轉過來。你不描述何時該轉換,而是描述動作會做什麼、需要什麼,接著由規劃器搜尋出一條能達成目標的序列。每個動作都有前置條件、效果與成本。「Attack」需要 HasWeapon 與 InRange,並設定 TargetDead。「GetWeapon」需要 WeaponAvailable,並設定 HasWeapon。「MoveToTarget」需要 TargetKnown,並設定 InRange。規劃器從目標出發,反向推導,找出效果能滿足目標的最便宜動作鏈。
當世界狀態豐富、且正確的序列並不明顯時,這就值得了。一個需要中斷視線、重新裝填、重新定位、然後交戰的 NPC,會產生一份設計師從未明確寫下的計畫,因為規劃器從前置條件圖中自行發現了順序。它在生產上也同樣值得,因為新增一個道具或能力,意味著新增一份動作定義,而不是去編輯每一個應該知道它的狀態機。
代價在於搜尋。天真的規劃器會探索動作組合,直到找到一份計畫,而隨著你新增動作與狀態變數,這可能會爆炸性成長。標準的緩解方式是啟發式 — 在狀態圖上以 A* 搭配到目標的距離估計 — 再加上嚴格的擴展上限。在我們自己的瀏覽器情境中,計畫會在決策週期上計算,並以數百個節點擴展為上限;任何超過上限的情況,都會退回使用快取的計畫或一條簡單的行為樹分支。誠實的限制是這樣的:當有意義的動作序列數量龐大時,GOAP 值得付出搜尋成本;而當 NPC 永遠只做三件事時,它就是浪費的額外負擔。
- 前置條件與效果每個動作都會宣告執行前必須為真的事,以及它會改變的事。規劃器是根據這些宣告來推理,而不是根據程式碼。
- 從目標反向搜尋從想要的狀態出發,找出效果能產生它的動作,再對它們的前置條件遞迴。通常比正向搜尋來得便宜。
- 擴展上限限制每次規劃呼叫的節點擴展數。超過上限就退回 — 絕不讓規劃器拖住一個影格。
- 計畫快取只在世界狀態有明顯變化時重新規劃。靜態的情境會以零成本重複使用先前的計畫。
效用 AI
效用 AI 為每個選項評分,並挑選最高分者。每個候選動作都有一個函式,把世界狀態對應到一個數字,通常正規化到 0 到 1 之間。「Flee」在生命值低且敵人接近時得高分。「Attack」在目標位於範圍內且彈藥大於零時得高分。「Wander」在其他事情都不急迫時得到中等分數。NPC 取最大值(argmax),可選擇性地加上一點隨機擾動,讓同分的狀況以不可預測的方式解決。
這是能產生最令人意外之角色的模型,因為行為是從曲線的交互作用中湧現,而非出自明確的規則。兩個評分權重略有不同的 NPC,在沒有任何分支程式碼的情況下,行為就明顯不同,這使它成為人格變化的自然選擇。它也是在執行階段最容易調校的模型:把權重暴露在除錯面板中,一邊執行遊戲一邊拖曳它們。
失效模式是效用太平或太雜。若每個動作都得分 0.4,NPC 看起來就優柔寡斷,並不斷切換。若最高分在兩個接近的數值之間每次更新都翻轉,你就會得到與糟糕狀態機相同的震盪。標準的解法是承諾視窗:一旦選定某個動作,就在固定時間內最少承諾它 — 500 毫秒是合理的預設值 — 除非最高分以有意義的幅度超越已承諾動作的分數。曲線也很重要。生命值的線性反應曲線,會產生一個在每場戰鬥都於完全相同時刻逃跑的 NPC。指數或邏輯曲線則會產生隨情境移動的門檻。
- 每個動作的評分函式每個動作都把世界狀態對應到 0 到 1 的效用。讓它們彼此獨立,這樣就能在不碰其他函式的情況下重新調校其中一個。
- 承諾視窗一旦選定,就把一個動作維持一段最短時間。避免在分數接近的選項之間逐次更新地反覆搖擺。
- 可調校的權重即時暴露權重。兩個只在權重上不同的 NPC,讀起來就是不同的角色,這是現有最便宜的人格系統。
- 反應曲線線性曲線會產生固定的門檻。邏輯曲線則讓 NPC 回應情境,而不是回應某個神奇數字。
導航與碰撞
決策邏輯挑選目的地,導航則把身體帶到那裡。導航網格是一個三角化的可行走表面;尋路會在其多邊形圖上執行 A*,結果是一串 NPC 會依循的多邊形邊。我們在載入時從關卡幾何建構導航網格並加以快取,因為逐影格重建,是在瀏覽器遊戲中拖垮影格率最簡單的方法。
轉向處理最後幾公尺以及每一刻的移動。Seek 與 Arrive 產生前向量,分離力把 NPC 推離附近的代理,障礙閃避則讓路徑繞過門或另一個角色等動態阻擋物。逐影格執行轉向;只在目的地改變或路徑失效時才執行尋路。為每個 NPC 逐影格重新計算路徑,是一種常見且可避免的成本。
碰撞則是把 NPC 擋下、不讓它穿牆的那一道處理。導航網格讓路徑保持在可行走的地面上,但轉向與物理仍可能把角色推進幾何之中,因此角色控制器會在移動之後,針對碰撞體解決穿透。在我們的等角世界中 — Mini World 與 The Cyclops' Island — 大多數肉眼可見的 NPC 錯誤都源自這裡:一條穿過比角色半徑還窄的縫隙的路徑,或一個在轉角把代理推進牆裡的轉向向量。在導航網格上正確設定代理半徑,並讓分離力低於控制器的推出強度,就能解決其中大多數問題。
01
在載入時從靜態幾何烘焙導航網格並加以快取。不要逐影格重建它。
02
設定代理半徑與高度,使其符合角色實際的碰撞膠囊,而不是模型的邊界框。
03
只在目的地改變、路徑受阻,或 NPC 偏離路徑超過某個門檻時,才執行尋路。
04
逐影格執行轉向、分離與障礙閃避,並為分離力設定上限。
05
在移動之後針對碰撞體解決碰撞,接著把角色吸附回導航網格上。
06
在除錯疊層中記錄並視覺化路徑與導航網格 — 這些錯誤通常是空間性的,而不是邏輯性的。
LLM 驅動的 NPC
語言模型改變的是意圖與對話層的樣貌,而不是導航層。尋路、轉向與碰撞依然如故,因為一個需要數百毫秒才回應的模型,無法驅動一個必須逐影格移動的身體。模型能做的是:根據玩家的話語判斷哪個目標合理、以角色身分生成回應文字,並在情境模糊、而手寫規則會產生平板結果時,在高階選項之間做選擇。
一個可行的架構會把兩種速度分開。立即反應由狀態機或效用評分器執行 — 攻擊、逃跑、尋找掩護 — 因為它們必須在一兩個影格內發生。深思熟慮的意圖則以慢得多的節奏穿過模型,也許每次互動一次,或每隔幾秒一次,而其輸出是快速層所執行的結構化命令:把目標設為 Follow、把對玩家的態度設為 Hostile、設定對話台詞。模型從不直接碰觸位置。
誠實的限制在於延遲、成本與確定性。回應時間長到 NPC 必須立刻以一個手勢或一句簡短的預錄台詞來回應輸入,否則讀起來就像是壞掉了。成本隨你呼叫的頻率而增加,所以要批次處理並快取。而由於輸出不具確定性,你必須在它抵達遊戲之前依結構描述驗證它 — 一段無法解析的自由文字回應,應該退回使用預設台詞,而不是讓對話系統崩潰。把模型當成一個位於具型別介面之後的不可靠服務,整合就能站得住腳。
- 具型別的命令輸出模型回傳的是結構化意圖,而不是由遊戲程式碼解析的散文。依結構描述驗證,並拒絕任何格式錯誤的內容。
- 雙速迴圈用快速的本地邏輯處理反應,用緩慢的模型呼叫進行深思。絕不要把網路來回放進移動路徑上。
- 立即回應一旦輸入抵達,就播放一個手勢或一句簡短的預錄台詞,接著在模型回應時替換掉它。藉此隱藏延遲。
- 優雅降級若呼叫失敗或逾時,就退回狀態機的預設回應。NPC 絕不能因為等待模型而停滯。
走進它所屬的世界
精靈圖(Sprite)是一種無處可站的角色。最後這一步,是沒有其他人能提供的:把你剛才建好的圖集,放進一個可以讓人走動的世界 — 一座小島、一顆行星、一個雨中的街角。本站的每一個世界,都是由一段文字描述製成,並以一個你可以遊玩的頁面發布。
回到 3D 遊戲製作器
全部 31 個完成品 3D 世界、遊戲與場景都在同一頁上 — 每一個都能遊玩。