Godot游戲開發(fā)實踐之四:搬運Unity的Pluggable AI教程

一、前言

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

Unity tutorial: Pluggable AI With Scriptable Objects

因為 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 ,供有興趣的同學參考。

Godot Pluggable AI

什么是可插撥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 文件的路徑:

使用路徑動態(tài)賦值

主要代碼如下:

# 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ā)生轉換的決定

他們之間的關系圖,以及主要的行為類:

AI關系圖

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)驗主要概括為這兩點:

  1. Pluggable AI 確實比較強大,使用非常方便,因為是可插撥,即使配置復雜的 AI 都只要輕輕一拖一拽一松手就完成了
  2. 但是這種方式也有令人不爽的地方,比如耦合還是比較厲害的,代碼中需要訪問、修改很多玩家相關數(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自學不成才(簡書屏蔽=垃圾操作)

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容