
一、前言
在之前的幾篇文章里我簡單地介紹了 AI 尋路方式以及 Resource 的相關應用,那其實都是為這篇文章做鋪墊的,本篇的內容是基于油管上一個比較老的 Unity AI 系列教程: Unity tutorial: Pluggable AI With Scriptable Objects ,教程詳細介紹了 Unity 中如何實現(xiàn)可插撥式 AI 的功能,在我的一番苦苦研究下,硬生生地把它給搬運到了 Godot 中,搬運過程可謂是費了九牛二虎之力,這其中一部分原因是由于自己對 Godot API 的熟練程度不夠,另一方面則是 Godot 本身的一些缺陷,這些我都會在本文中提出來。

因為 Unity 中的 ScriptObject 在 Godot 中相當于 Resource ,如果不是很熟悉,推薦大家閱讀我的上一篇文章: Godot游戲開發(fā)實踐之三:容易被忽視的Resource 。另外,搬用并等于照抄,本 Demo 實現(xiàn)的部分 AI 功能使用的是我自己的方式,這也在我之前的文章里有詳細介紹: Godot游戲開發(fā)實踐之二:AI之尋路新方式。
說明:我不會很詳細的講述如何實現(xiàn)某些特定功能,所以推薦大家觀看原 Unity 視頻,如果上油管不方便,也請放心,視頻教程我已經(jīng)搬運到我的網(wǎng)盤,分享鏈接請關注我的公眾號,回復 AI教程 即可(友情提示:套路……),哈哈。
主要內容: 無
閱讀時間: 12 分鐘
永久鏈接: http://liuqingwen.me/2020/09/08/godot-game-devLog-4-translate-pluggable-AI-tutorial-from-unity-to-godot/
系列主頁: http://liuqingwen.me/introduction-of-godot-series/
二、正文
除了參考原視頻教程,也可以克隆本 Demo 的源碼,我已經(jīng)上傳到 Github ,供有興趣的同學參考。

什么是可插撥AI
所謂可插撥其實和安裝插件、熱插撥等概念類似,就是可以隨意添加或者刪除某個功能,通過直接拖拽就能組成復雜的 AI 體系而無須手動重復編寫代碼,在 Unity 中使用的是 ScriptableObject 而 Godot 中即 Resource :

其實在編輯器方面, Unity 使用起來比 Godot 舒服多了。 :joy:
先說Godot的問題
搬運這個 AI 教程的時候,我反反復復、仔仔細細研究了很多次,在按步照搬的過程中出現(xiàn)了一個非常奇怪且頭疼的問題:游戲無癥狀、無征兆地閃退!
代碼看上去沒問題,按下 <kbd>F5</kbd> 運行游戲,窗口還沒顯示就馬上停止運行,連錯誤提示都沒有。曾經(jīng)因為這個錯誤我一度想著放棄算了,但是轉念一想, Godot 開發(fā)者豈能低頭?! 所以我繼續(xù)嘗試,尋找錯誤原因,探索可行的解決方案,從至少能正常運行開始一步一步添加相關功能,最終發(fā)現(xiàn)了閃退的罪魁禍首: Circular reference to resource 即循環(huán)引用報錯,這在我之前的文章中已經(jīng)聊過,也有朋友遇到過類似的問題,錯誤信息大概是:
"scene/resources/resource_format_text.cpp:1387 - Circular reference to resource being saved found: 'res://src/Resources/States/???.tres' will be null next time it's loaded."
哪來的循環(huán)引用呢?熟悉游戲結構你就會感覺到這是很顯然的:在我的游戲中有很多 Resource 資源類,比如 Action/Decision/State/Transitions 等,而這些資源相互之間或多或少發(fā)生了一些引用,就像 PatrolChaser 中引用了 ChaseChaser ,反過來 ChaseChaser 又引用 PatrolChaser 從而造成循環(huán)引用鏈,甚至還有更加復雜的、難以發(fā)覺的、千絲萬縷的引用關系蘊含其中:
Alert Scanner -> Patrol Scanner -> Chase Scanner -> Alert Scanner -> Chase Scanner -> Alert Scanner -> ...
在編程語言里這些引用再正常不過,但是 Godot 3 還不能正常處理循環(huán)引用,這會在 4.0 中進行修復,我可不想等到明年春天了,最終解決方式是放棄部分插撥功能,對一些參數(shù)不采用推拽賦值的方式,取而代之的是在運行時判斷對應資源是否為 null 再決定動態(tài)加載進行賦值,這就造成了需要額外的一個變量用來指向對應 Resource 文件的路徑:

主要代碼如下:
# trueState 和 falseState 可以為 null
# 如果為 null 則使用對應的文件路徑進行動態(tài)加載
func _checkTransitions(controller : StateController) -> void:
for transition in transitions:
var decisionSucceeded : bool = transition.decision.decide(controller)
if decisionSucceeded:
var trueState = transition.trueState
if trueState == null: # 如果置空則動態(tài)加載一次
trueState = load(transition.trueStateResource)
transition.trueState = trueState
controller.transitionToState(trueState)
else:
var falseState = transition.falseState
if falseState == null: # 如果置空則動態(tài)加載一次
falseState = load(transition.falseStateResource)
transition.falseState = falseState
controller.transitionToState(falseState)
除此之外,還有一個不忍直視的問題是在編輯器中顯示資源值的視圖,一旦涉及多個參數(shù)、多種類型、多個級別的資源混合在一起,那么他們之間的層級關系在屬性面板中變得極其難以辨別,感同身受一下這張慢動圖所帶來的崩潰心情吧:

嗯,此刻的我心中萬馬奔騰,無限次奔潰閃退并自動重啟中……
AI結構分析
如果你看完了整個視頻教程,你會發(fā)現(xiàn)這個 AI 系統(tǒng)的幾個重要部件:
- Action 表示動作,比如巡邏、射擊等動作的控制實現(xiàn)
- Decision 表示策略行為的決定,即狀態(tài)之間進行切換的依據(jù)
- State 表示狀態(tài),一個狀態(tài)即一種 AI 行為,不同狀態(tài)之間根據(jù)決定進行切換
- Transition 包裝了兩個狀態(tài)(正反狀態(tài)),以及狀態(tài)發(fā)生轉換的決定
他們之間的關系圖,以及主要的行為類:

Action 父類代碼:
extends Resource
class_name AbstractAction, 'res://assets/icons/action-icon.svg'
export var debugDrawColor := Color.black # 顏色顯示,Debug用
export var resourceName := 'Action' # 名字,Debug用
# 動作的行為方法,每幀都會調用
func act(controller : StateController) -> void:
pass
Decision 父類代碼:
extends Resource
class_name AbstractDecision, 'res://assets/icons/decision-icon.svg'
export var debugDrawColor := Color.white # 顏色顯示,Debug用
export var resourceName := 'Decision' # 名字,Debug用
# 決定的方法,包裝在 Transition 中,每幀都會調用
# 返回結果決定了切換到的狀態(tài)
func decide(controller : StateController) -> bool:
return false
State 狀態(tài)類代碼:
extends Resource
class_name State, 'res://assets/icons/state-icon.svg'
export(Array, Resource) var actions = [] # 當前狀態(tài)下所有動作集合
export(Array, Resource) var transitions = [] # 所有的狀態(tài)轉換機制集合
export var debugStateColor := Color.green # 顏色顯示,Debug用
# 每幀執(zhí)行狀態(tài)更新
func updateState(controller : StateController) -> void:
_doActions(controller)
_checkTransitions(controller)
# 循環(huán)執(zhí)行所有動作
func _doActions(controller : StateController) -> void:
for action in actions:
action.act(controller)
# 檢查每一個轉換機制,是否可以進行狀態(tài)轉換
func _checkTransitions(controller : StateController) -> void:
# 代碼參考上文
# 省略……
控制器 Controller 和過渡機制 Transition 的代碼就不貼了,控制器中代碼都是一些基本狀態(tài)和控制操作的實現(xiàn)。這里我把視頻中介紹的所有 AI 類型例舉如下:
Chase Chaser: {
Actions: [ChaseAction, AttackAction],
Transitions: {Decision: ActiveStateDecision, TrueState: Remain State, FalseState: Patrol Chaser}
}
Patrol Chaser: {
Actions: [PatrolAction],
Transitions: {Decision: LookDecision, TrueState: Chase Chaser, FalseState: Remain State}
}
Chase Scanner: {
Actions: [ChaseAction, AttackAction],
Transitions: {Decision: LookDecision, TrueState: Remain State, FalseState: Alert Scanner}
}
Patrol Scanner: {
Actions: [PatrolAction],
Transitions: {Decision: LookDecision, TrueState: Chase Scanner, FalseState: Remain State}
}
Alert Scanner: {
Actions: [],
Transitions: [{Decision: ScanDecision, TrueState: Patrol Scanner, FalseState: Remain State}, {Decision: LookDecision, TrueState: Chase Scanner, FalseState: Remain State}]
}
當然,這個 AI 系統(tǒng)絕不局限于此,你完全可以組合出更多 AI 狀態(tài),也可以添加你心目中所要實現(xiàn)的其他動作、決定、過渡和狀態(tài)類,豐富這個強大的 AI 系統(tǒng)。
其他小功能簡介
最后,游戲中使用的一些小技巧我也在本篇中簡單介紹一下,包括:炸彈的范圍傷害、相機自動跟蹤、子彈高度模擬等。
炸彈范圍傷害

從圖中可以看出,我使用了指數(shù)級的衰減函數(shù),也就是說距離炸彈爆炸中心越遠,傷害衰減的越厲害,個人認為要符合現(xiàn)實一些,當然你完全可以使用簡單的線性函數(shù),傷害和距離成反比,這取決于你自己以及游戲機制的設計:
# 傷害最大范圍
onready var damageRange : float = $CollisionShape2D.shape.radius
func _on_Explosion_body_entered(body: Node) -> void:
if body.has_method('damaged'):
var vector : Vector2 = body.global_position - self.global_position
# 指數(shù)系數(shù)
var ratio : float = 1.0 - pow(vector.length() / damageRange, 0.6)
# 傷害和沖擊力
var damage := ceil(maxDamage * ratio)
var force : Vector2 = maxForce * ratio * vector.normalized()
body.damaged(damage, force)
相機自動跟蹤
在本示例中我使用了相機自動跟蹤的效果。
因為類似于多人游戲,使用相機進行跟蹤是有必要的,這樣可以保證所有的坦克、玩家都在當前視野中。實現(xiàn)起來不難,根據(jù)當前玩家數(shù)量以及玩家的位置計算最大邊距以及中心點,然后移動并設置相機的縮放即可:
# 窗口大小
onready var _windowSize := self.get_viewport_rect().size
# 跟蹤的目標
var targets := []
func _process(delta: float) -> void:
if targets.size() <= 1:
_camera.zoom = lerp(_camera.zoom, Vector2.ONE, 2.0 * delta)
return
var minPos := _windowSize # 最小位置點
var maxPos := Vector2.ZERO # 最大位置點
for target in targets:
if ! is_instance_valid(target):
continue
if target.global_position.x < minPos.x:
minPos.x = target.global_position.x
if target.global_position.x > maxPos.x:
maxPos.x = target.global_position.x
if target.global_position.y < minPos.y:
minPos.y = target.global_position.y
if target.global_position.y > maxPos.y:
maxPos.y = target.global_position.y
# 移動到中心點
self.global_position = lerp(self.global_position, (maxPos + minPos) / 2, 2.0 * delta)
# 計算縮放比例,相對于游戲主窗口
var zoom = 2.0 * max((maxPos.x - minPos.x) / _windowSize.x, (maxPos.y - minPos.y) / _windowSize.y)
zoom = clamp(zoom, 0.5, 1.0)
_camera.zoom = lerp(_camera.zoom, Vector2.ONE * zoom, 2.0 * delta)
子彈高度模擬
原 Unity 視頻中的 Tank 是一個 3D 游戲,所以子彈也就有射程(落地)和高度之分,如果在 2D 場景中不設置高度,炸彈只要碰上其他炸彈或者靜態(tài)物體都會直接爆炸,那么游戲中的發(fā)射力(射程)也就毫無意義了,所以我使用代碼簡單地實現(xiàn)了子彈高度的模擬。

思路大概是這樣的:給子彈添加一個陰影,陰影大小和透明度隨子彈高度發(fā)生變化,飛行中的子彈在垂直方向上偏移一定位置表示高度,最后把碰撞體設置在陰影上。這里的變化都使用了線性比例,實現(xiàn)方式也相對簡單,從上圖也可以看出來:
export var missileBodyMaxOffset := 60.0 # 最高時子彈視覺偏移
export(float, 1.0, 10.0) var shadowMaxScale := 1.5 # 陰影最大縮放,即子彈離地最低點
export(float, 0.0, 1.0) var shadowMinScale := 0.5 # 陰影最小縮放,即子彈離地最高點
export(float, 0.0, 1.0) var shadowMinAlpha := 0.25 # 陰影最小透明度,最高點
export(float, 1.0, 2.0) var shadowMaxAlpha := 2.5 # 陰影最大透明度,最低點
func init(force : float, maxSpeed : int, resistance : int, dir : Vector2) -> void:
_direction = dir
_fullSpeed = maxSpeed
_moveResistance = resistance # 阻力,即重力加速度
# 計算能達到的最大高度
_maxFlyHeight = 0.5 * maxSpeed * maxSpeed / resistance
# 計算線性關系系數(shù)a和b:y=ax+b
_paramScaleA = (shadowMinScale - shadowMaxScale) / _maxFlyHeight
_paramScaleB = shadowMaxScale
_paramAlphaA = (shadowMaxAlpha - shadowMinAlpha) / (shadowMinScale - shadowMaxScale)
_paramAlphaB = shadowMaxAlpha - shadowMinScale * _paramAlphaA
# 保證炸彈總是往上方偏移,不然看起來奇怪
var angle = fmod(dir.angle(), 2 * PI)
if angle > PI * 0.5 || angle < - PI * 0.5:
missileBodyMaxOffset = -missileBodyMaxOffset
# 水平和垂直初始速度一樣,模擬45度發(fā)射導彈
_velocityX = force * maxSpeed
_velocityY = force * maxSpeed
# 計算水平和垂直位移
func _physics_process(delta: float) -> void:
self.position += _direction * _velocityX * delta
_velocityY -= _moveResistance * delta
currentHeight += _velocityY * delta
_adjustHeight(currentHeight)
if currentHeight <= 0.0:
explode()
# 根據(jù)高度調整陰影大小、透明度、子彈垂直偏移
func _adjustHeight(height : float) -> void:
_body.position.y = - height / _maxFlyHeight * missileBodyMaxOffset
var shadowScale = _paramScaleA * height + _paramScaleB
_shadow.scale = Vector2.ONE * shadowScale
var shadowAlpha = _paramAlphaA * shadowScale + _paramAlphaB
_shadow.modulate.a = shadowAlpha
嗯,我就想弱弱問一句:現(xiàn)實生活中物體越高其陰影是越大還是越小呢?……
三、總結
這種 AI 系統(tǒng)具有比較強的擴展性和易用性,有點復雜問題簡單模塊化的思維,用起來應該會相當爽,當然我也沒有具體項目案例,另外也有一些不足之,個人經(jīng)驗主要概括為這兩點:
- Pluggable AI 確實比較強大,使用非常方便,因為是可插撥,即使配置復雜的 AI 都只要輕輕一拖一拽一松手就完成了
- 但是這種方式也有令人不爽的地方,比如耦合還是比較厲害的,代碼中需要訪問、修改很多玩家相關數(shù)據(jù),依然需要一番精心的設計
好在 Unity 中具有更加成熟的碰撞檢測相關 API ,比如 SphereCast 還有 Navigator 都是極好用的 AI 輔助工具, Godot 中就只能手動實現(xiàn)了。 :joy:
最后,務必關注我的公眾號,回復 AI教程 我會送上本套視頻以及非常棒的一套 AStar 講解視頻(毫無疑問也是在 Unity 中實現(xiàn),但是原理通用)。本篇的 Demo 以及相關代碼已經(jīng)上傳到 Github ,地址: https://github.com/spkingr/Godot-Pluggable-AI , 后續(xù)繼續(xù)更新,原創(chuàng)不易,希望大家喜歡! :smile:
我的博客地址: http://liuqingwen.me ,歡迎關注我的VX-GZ號(第一時間更新+游戲開發(fā)資源+相關資訊):IT自學不成才(簡書屏蔽=垃圾操作)