12. NPC和怪物AI¶
並非遊戲中的每個實體都由玩家控制。 NPCs而敵人需要由電腦控制-也就是說,我們需要賦予他們人工智慧(AI)。
對於我們的遊戲,我們將實現一種稱為「狀態機」的 AI 型別。這意味著實體(如 NPC 或生物)始終處於給定的「狀態」。狀態的範例可以是「空閒」、「漫遊」或「攻擊」。 每隔一段時間,AI 實體將被「勾選」Evennia。這個「勾選」從一個評估開始,該評估確定實體是否應該切換到另一個狀態,或停留在目前狀態內並執行一個(或多個)操作。
例如,如果處於「漫遊」狀態的生物遇到玩家角色,它可能會切換到「攻擊」狀態。在戰鬥中,它可以在不同的戰鬥行動之間移動,如果它在戰鬥中倖存下來,它會回到「漫遊」狀態。
根據遊戲的運作方式,AI 可以在不同的時間尺度上「勾選」。例如,當生物移動時,它們可能每 20 秒自動從一個房間移動到另一個房間。但是一旦進入回合製戰鬥(如果你使用它),AI 只會在每個回合中「滴答」。
12.1. 我們的要求¶
對於本教學遊戲,我們需要 AI 實體才能處於以下狀態:
閒置 - 什麼都不做,只是站在旁邊。
漫遊 - 從一個房間移動到另一個房間。重要的是,我們增加了限制 AI 可以漫遊到的位置的功能。例如,如果我們有非戰鬥區域,我們希望能夠lock通往這些區域的所有出口,這樣侵略性的模組就不會走進它們。
戰鬥 - 啟動並與電腦進行戰鬥。此狀態將利用戰鬥教學隨機選擇戰鬥動作(回合製或滴答式)。
Flee - 這就像_Roam_,除了 AI 會移動以避免進入有電腦的房間(如果可能的話)。
我們將這樣組織 AI 程式碼:
AIHandler這將是在 AI 實體上儲存為.ai的處理程式。它負責儲存AI的狀態。為了「勾選」AI,我們執行.ai.run()。我們多久以這種方式轉動 AI 的輪子,我們就留給其他遊戲系統。NPC/Mob 類別上的
.ai_<state_name>方法 - 當呼叫ai.run()方法時,它負責尋找與其目前狀態類似的方法(e.g。.ai_combat如果我們處於 combat 狀態)。擁有這樣的方法可以輕鬆新增新狀態 - 只需新增一個適當命名的新方法,AI 現在就知道如何處理該狀態!
12.2. AIHandler¶
這是管理AI狀態的核心邏輯。建立一個新檔案evadventure/ai.py。
建立一個新檔案
evadventure/ai.py。
1# in evadventure/ai.py
2
3from evennia.logger import log_trace
4
5class AIHandler:
6 attribute_name = "ai_state"
7 attribute_category = "ai_state"
8
9 def __init__(self, obj):
10 self.obj = obj
11 self.ai_state = obj.attributes.get(self.attribute_name,
12 category=self.attribute_category,
13 default="idle")
14 def set_state(self, state):
15 self.ai_state = state
16 self.obj.attributes.add(self.attribute_name, state, category=self.attribute_category)
17
18 def get_state(self):
19 return self.ai_state
20
21 def run(self):
22 try:
23 state = self.get_state()
24 getattr(self.obj, f"ai_{state}")()
25 except Exception:
26 log_trace(f"AI error in {self.obj.name} (running state: {state})")
AIHandler 是物件處理程式 的範例。這是一種將所有功能組合在一起的設計風格。稍微向前看一下,這個處理程式將會被加入到物件中,如下所示:
# just an example, don't put this anywhere yet
from evennia.utils import lazy_property
from evadventure.ai import AIHandler
class MyMob(SomeParent):
@lazy_property
class ai(self):
return AIHandler(self)
簡而言之,存取 .ai 屬性將初始化 AIHandler 的例項,我們將 self (目前物件)傳遞給該例項。在 AIHandler.__init__ 中,我們取得此輸入並將其儲存為 self.obj(第 10-13 行)。這樣,處理程式始終可以透過存取 self.obj 對其「所在」的實體進行操作。 lazy_property 確保每次伺服器重新載入時此初始化僅發生一次。
更多關鍵功能:
第 11 行:我們透過造訪
self.obj.attributes.get()(重新)載入 AI 狀態。這將載入具有給定名稱和類別的資料庫 Attribute。如果尚未儲存,則傳回「idle」。請注意,我們必須訪問self.obj(NPC/mob),因為這是唯一可以存取資料庫的東西。第 16 行:在
set_state方法中,我們強制處理程式切換到給定狀態。當我們這樣做時,我們確保將其也儲存到資料庫中,以便其狀態在重新載入後仍然存在。但我們也將其儲存在self.ai_state中,因此我們不需要在每次獲取時都存取資料庫。第 23 行:
getattr函式是一個內建的 Python 函式,用於取得物件的命名屬性。這允許我們根據當前狀態呼叫NPC/mob 上定義的方法ai_<statename>。我們必須將此呼叫包裝在try...except區塊中才能正確處理 AI 方法中的錯誤。 Evennia 的log_trace將確保記錄錯誤,包括其偵錯回溯。
12.2.1. AI 處理程式上有更多幫助程式¶
在AIHandler上放幾個助手也很方便。這使得它們可以從 ai_<state> 方法內部輕鬆使用,可作為 e.g 呼叫。 self.ai.get_targets()。
1# in evadventure/ai.py
2
3# ...
4import random
5
6class AIHandler:
7
8 # ...
9
10 def get_targets(self):
11 """
12 Get a list of potential targets for the NPC to combat.
13
14 """
15 return [obj for obj in self.obj.location.contents if hasattr(obj, "is_pc") and obj.is_pc]
16
17 def get_traversable_exits(self, exclude_destination=None):
18 """
19 Get a list of exits that the NPC can traverse. Optionally exclude a destination.
20
21 Args:
22 exclude_destination (Object, optional): Exclude exits with this destination.
23
24 """
25 return [
26 exi
27 for exi in self.obj.location.exits
28 if exi.destination != exclude_destination and exi.access(self, "traverse")
29 ]
30
31 def random_probability(self, probabilities):
32 """
33 Given a dictionary of probabilities, return the key of the chosen probability.
34
35 Args:
36 probabilities (dict): A dictionary of probabilities, where the key is the action and the
37 value is the probability of that action.
38
39 """
40 # sort probabilities from higheest to lowest, making sure to normalize them 0..1
41 prob_total = sum(probabilities.values())
42 sorted_probs = sorted(
43 ((key, prob / prob_total) for key, prob in probabilities.items()),
44 key=lambda x: x[1],
45 reverse=True,
46 )
47 rand = random.random()
48 total = 0
49 for key, prob in sorted_probs:
50 total += prob
51 if rand <= total:
52 return key
get_targets檢查是否有任何其他物件與在其 typeclass 上設定的is_pc屬性位於相同位置。為簡單起見,我們假設怪物只會攻擊 PC(沒有怪物內鬥!)。get_traversable_exits從目前位置取得所有有效出口,不包括具有提供的目的地的出口或未透過「遍歷」訪問檢查的出口。get_random_probability採用字典{action: probability,...}。這將隨機選擇一個動作,但機率越高,它被選中的可能性就越大。稍後我們將在戰鬥狀態中使用它,以允許不同的戰鬥人員或多或少地執行不同的戰鬥動作。該演演算法使用了一些有用的 Python 工具:第 41 行:記住
probabilities是dict{key: value,...},其中值是機率。因此probabilities.values()為我們提供了僅包含機率的清單。對它們執行sum()可以得到這些機率的總和。我們需要它來標準化下面一行中 0 到 1.0 之間的所有機率。第 42-46 行:這裡我們建立一個新的元組可迭代
(key, prob/prob_total)。我們使用 Pythonsorted幫助器對它們進行排序。key=lambda x: x[1]意味著我們對每個元組的第二個元素(機率)進行排序。reverse=True意味著我們將從最高機率到最低機率排序。第 47 行:
random.random()呼叫產生一個 0 到 1 之間的隨機值。第 49 行:由於機率是從最高到最低排序的,因此我們迴圈遍歷它們,直到找到第一個適合隨機值的機率 - 這就是我們正在尋找的操作/鍵。
舉個例子,如果你有一個
{"attack": 0.5, "defend": 0.1, "idle": 0.4}的probability輸入,這將變成一個排序的可迭代(("attack", 0.5), ("idle", 0.4), ("defend": 0.1)),如果random.random()返回0.65,結果將是「空閒」。如果random.random()回傳0.90,那就是「防禦」。 也就是說,這個AI實體會在50%的時間攻擊,40%的時間閒置,10%的時間防禦。
12.3. 將 AI 新增至實體¶
我們需要向遊戲實體新增 AI- 支援,只需將 AI 處理程式和一堆 .ai_statename() 方法新增到該物件的 typeclass 上。
我們已經在 NPC 教學 中勾畫出了 NPCs 和 Mob typeclasses。開啟 evadventure/npcs.py 並展開迄今為止空的 EvAdventureMob 類。
# in evadventure/npcs.py
# ...
from evennia.utils import lazy_property
from .ai import AIHandler
# ...
class EvAdventureMob(EvAdventureNPC):
@lazy_property
def ai(self):
return AIHandler(self)
def ai_idle(self):
pass
def ai_roam(self):
pass
def ai_roam(self):
pass
def ai_combat(self):
pass
def ai_flee(self):
pass
所有剩餘的邏輯將進入每個狀態方法。
12.3.1. 空閒狀態¶
在空閒狀態下,生物不執行任何操作,因此我們將 ai_idle 方法保留原樣 - 其中只有一個空的 pass 。這意味著它也不會攻擊同一個房間內的PC - 但如果PC攻擊它,我們必須確保強制它進入戰鬥狀態(否則它將毫無防禦能力)。
12.3.2. 漫遊狀態¶
在這種狀態下,生物應該從一個房間移動到另一個房間,直到找到要攻擊的電腦。
# in evadventure/npcs.py
# ...
import random
class EvAdventureMob(EvAdventureNPC):
# ...
def ai_roam(self):
"""
roam, moving randomly to a new room. If a target is found, switch to combat state.
"""
if targets := self.ai.get_targets():
self.ai.set_state("combat")
self.execute_cmd(f"attack {random.choice(targets).key}")
else:
exits = self.ai.get_traversable_exits()
if exits:
exi = random.choice(exits)
self.execute_cmd(f"{exi.key}")
每次勾選AI時,都會呼叫該方法。它將首先檢查房間中是否有任何有效目標(使用我們在 AIHandler 上製作的 get_targets() 助手)。如果是這樣,我們切換到combat狀態並立即呼叫attack指令來發起/加入戰鬥(請參閱戰鬥教學)。
如果未找到目標,我們將獲得可遍歷出口的清單(未透過 traverse lock 檢查的出口已從該清單中排除)。使用 Python 的內建 random.choice 函式,我們從該列表中隨機取得一個出口,並按其名稱在其中移動。
12.3.3. 逃離狀態¶
逃跑與_Roam_類似,除了AI從不嘗試攻擊任何東西,並且會確保不按原路返回。
# in evadventure/npcs.py
# ...
class EvAdventureMob(EvAdventureNPC):
# ...
def ai_flee(self):
"""
Flee from the current room, avoiding going back to the room from which we came. If no exits
are found, switch to roam state.
"""
current_room = self.location
past_room = self.attributes.get("past_room", category="ai_state", default=None)
exits = self.ai.get_traversable_exits(exclude_destination=past_room)
if exits:
self.attributes.set("past_room", current_room, category="ai_state")
exi = random.choice(exits)
self.execute_cmd(f"{exi.key}")
else:
# if in a dead end, roam will allow for backing out
self.ai.set_state("roam")
我們將 past_room 儲存在我們自己的 Attribute「past_room」中,並確保在嘗試找到要遍歷的隨機出口時排除它。
如果我們最終陷入死衚衕,我們會切換到_漫遊_模式,以便它可以退出(並再次開始攻擊事物)。因此,這樣做的效果是,暴民會在「平靜下來」之前在恐懼中逃得盡可能遠。
12.3.4. 戰鬥狀態¶
在戰鬥狀態下,生物將使用我們設計的戰鬥系統之一(基於抽搐的戰鬥 或 回合製戰鬥)。這意味著每次 AI 滴答時,並且我們處於戰鬥狀態,該實體需要執行可用的戰鬥動作之一,hold、attack、do a 特技、use an item 或_flee_。
1# in evadventure/npcs.py
2
3# ...
4
5class EvAdventureMob(EvAdventureNPC):
6
7 combat_probabilities = {
8 "hold": 0.0,
9 "attack": 0.85,
10 "stunt": 0.05,
11 "item": 0.0,
12 "flee": 0.05,
13 }
14
15 # ...
16
17 def ai_combat(self):
18 """
19 Manage the combat/combat state of the mob.
20
21 """
22 if combathandler := self.nbd.combathandler:
23 # already in combat
24 allies, enemies = combathandler.get_sides(self)
25 action = self.ai.random_probability(self.combat_probabilities)
26
27 match action:
28 case "hold":
29 combathandler.queue_action({"key": "hold"})
30 case "combat":
31 combathandler.queue_action({"key": "attack", "target": random.choice(enemies)})
32 case "stunt":
33 # choose a random ally to help
34 combathandler.queue_action(
35 {
36 "key": "stunt",
37 "recipient": random.choice(allies),
38 "advantage": True,
39 "stunt": Ability.STR,
40 "defense": Ability.DEX,
41 }
42 )
43 case "item":
44 # use a random item on a random ally
45 target = random.choice(allies)
46 valid_items = [item for item in self.contents if item.at_pre_use(self, target)]
47 combathandler.queue_action(
48 {"key": "item", "item": random.choice(valid_items), "target": target}
49 )
50 case "flee":
51 self.ai.set_state("flee")
52
53 elif not (targets := self.ai.get_targets()):
54 self.ai.set_state("roam")
55 else:
56 target = random.choice(targets)
57 self.execute_cmd(f"attack {target.key}")
第 7-13 行:該指令描述了生物執行給定戰鬥動作的可能性。透過修改這個字典,我們可以輕鬆地建立行為非常不同的生物,例如更多地使用物品或更容易逃跑。您也可以完全關閉某些操作 - 預設情況下,他的暴徒從不「持有」或「使用物品」。
第 22 行:如果我們處於戰鬥狀態,則應在我們身上初始化
CombadHandler,可用作self.ndb.combathandler(請參閱 基礎戰鬥教學)。第 24 行:
combathandler.get_sides()為傳遞給它的人生成盟友和敵人。第 25 行:現在我們在本課前面建立的
random_probability方法變得很方便!
此方法的其餘部分僅採用隨機選擇的操作並執行所需的操作,以將其作為具有 CombatHandler 的新操作進行排隊。 為簡單起見,我們僅使用特技來增強我們的盟友,而不是阻礙我們的敵人。
最後,如果我們目前沒有處於戰鬥狀態並且附近沒有敵人,我們就會切換到漫遊 - 否則我們會開始另一場戰鬥!
12.4. 單元測試¶
建立一個新檔案
evadventure/tests/test_ai.py。
如果您遵循了先前的課程,那麼測試 AI 處理程式和生物會很簡單。建立一個 EvAdventureMob 並測試呼叫其上的各種與 ai 相關的方法和處理程式是否如預期運作。複雜之處在於模擬 random 的輸出,以便您始終獲得相同的隨機結果進行比較。我們將 AI 測驗的實作留給讀者作為額外的練習。
12.5. 結論¶
您可以輕鬆擴充套件這個簡單的系統,使生物變得更加「聰明」。例如,暴民不只是隨機決定在戰鬥中採取哪種行動,而是可以考慮更多因素 - 也許一些支援暴徒可以使用特技為他們的重擊者鋪平道路,或者在嚴重受傷時使用生命藥水。
新增“狩獵”狀態也很簡單,小怪在移動到那裡之前會檢查相鄰房間的目標。
雖然實現功能性遊戲 AI 系統不需要高階數學或機器學習技術,但如果您真的願意,您可以新增什麼樣的高階東西當然是沒有限制的!