Godot入門到棄坑:再見敵人


3樓貓 發佈時間:2025-01-08 23:33:15 作者:cameLcAsE Language

之前在2D部分做過一個簡單的敵人。這次在3D中又來做一個簡單的敵人。

敵人的表示

我們先來實現一些基本的功能,暫時不關心它的具體視覺表現。所以我簡單地用一個膠囊表示就行了。當然你也可以導入一個3D模型什麼的。
當然,如果你像我一樣懶,只給了一個簡單的MeshInstance3D佔位的話,那麼記得再給它個什麼東西表示他的前方。不然我們不好調試。
根節點依然用方便的CharacterBody3D,但是和玩家不同,就目前來說不需要給它攝像機。添加腳本即可。
由於我們不需要控制敵人,所以腳本模板中只需要留下重力相關的代碼即可。

看向玩家

這裡說的行為其實算是敵人AI的一部分。要實現敵人的AI,其實沒那麼簡單。我們也沒法在一篇文章中說完(可能“永遠”也說不完)。我們先做一個看向玩家的功能。例如我們想要實現,“玩家離敵人只有5m的時候,就看向玩家”。
首先的問題是,如何判斷和玩家的距離?
如果一定要計算兩點之間的距離,我們應該使用Vector3distance_to
或者distance_squared_to來求得(毫無疑問Vector2也有對應的方法)。這是兩個實例方法。後者會比前者快一些,後者計算的是沒有開平方的距離。
兩點距離是如何求得的?不要忘了,我們把點也視作向量。“AB間的距離”就是兩點之間拉一個向量的長度。這個向量就是兩點座標之差。由於向量是有方向的,所以A-B得到的其實是從B指向A的向量。這裡可能會顯得反直覺,是需要注意的地方。當然,要計算的“距離”是一個標量,所以這裡其實也不那麼重要。
要推導出這個距離怎麼算其實視角有很多,但是這裡我肯定說中小學生都能理解的。在2D空間中,想象一條斜著的向量。這個向量在X軸上的投影(v),v末端和P的連線,構成了一個三角形。這個三角形斜邊的長度——勾股定理知道吧?那你就會求向量長度了。兩條直角邊的長度恰好就是向量的兩個分量。公式我就懶得打了,簡單用偽代碼表示就是sqrt(x*x + y*y),三維空間中也是一樣,只不過是三個分量的平方和。
這就是求準確距離需要開方的原因,如果你不需要那麼精確,那麼就直接用沒開方的結果即可。由於我們這裡明確知道是“5m以內”,那麼直接position.distance_squared_to(player.position) < 25即可。當然這裡你需要用一種辦法在場景中找到player,然後每幀做這個運算去檢查。
然而,這裡實際上不用真的去算這個距離。還記得Area嗎?一個可以用來檢查是否有可碰撞對象的沒有實體的區域。在3D中我們同樣有Area3D來做這個事情。為Enemy套上一個Area3D,給它一個shape然後調整到適當大小,連上body_entered即可檢查玩家是否進入!具體操作不贅述。
注意,實際上這裡需要調整碰撞層讓它不檢查enemy自己,因為CharacterBody3D本身也要參與碰撞的。這裡請自行調整或者偷懶。
接下來要實現“看向玩家”。直接說結論,需要使用Transform3Dlooking_at方法。名字已經很直白了,就是要讓這個transform“看向”某個點。第二個參數指定“上方”,用這個方向確定這個transform會繞哪個方向轉過去。默認就是Y軸正方向(通常意義的上方),所以調用時可以省略。
現在Area3Dbody_entered信號的處理函數大致是這樣:
func _on_area_3d_body_entered(body: Node3D) -> void: if body is not FpsCharacter: return transform = transform.looking_at(body.position)
為什麼需要給transform賦值?因為looking_at方法並不直接修改transform本身,它會返回一個新的Transform3D實例。
現在玩家進入Enemy的area時,敵人會一瞬間看向玩家:
有被嚇到。如果這是你想要的效果,那也行。但是你可能期望可以看到敵人轉身,哪怕轉身很快也要能看到這個過程感覺才真實。當然,這裡也不是bug,因為你代碼就是這麼寫的。我們就是在某一幀裡直接設置了transform,所以這個變換肯定也是一瞬間完成。
如何解決?說白了,要達到這種效果,我們需要讓這transform的這種變化,不在一幀中完成。但是我們只有起始值和最終值,如何獲得在中間這些幀的值呢?
話都說到這個份上了,肯定就是要插值。Transform3D是一個相對複雜的數據結構,所以一般情況下不可能我們自己去插值。它提供了一個interpolate_with方法來插值。第一個參數就是目標值,第二個參數為一個取值為0到1的值,就是說插值到哪裡了。
先定義一堆變量:
var turning = false var turn_alpha = 0 var turn_time = 1 var turned_time = 0 var target_transform: Transform3D
由於我們要在Area的信號響應函數中確定何時開始插值,因此目標transform要在這裡確定,但是要在其它函數中使用。在處理body_entered的函數中,我們不直接設置transform,只是確定目標並指出正在轉向:
func _on_area_3d_body_entered(body: Node3D) -> void: # ... if turning: return target_transform = transform.looking_at(body.position) turning = true
_physics_process中,我們只有在turning時才處理插值:
func _physics_process(delta: float) -> void: # ... if turning: turned_time += delta turn_alpha = clamp(turned_time/turn_time, 0, 1) transform = transform.interpolate_with(target_transform, turn_alpha) if transform.is_equal_approx(target_transform): turning = false turned_time = 0 turn_alpha = 0 move_and_slide()
實際上也不復雜。turn_time是一個我們自行設計的值,也就是整個轉身過程需要多長時間。turned_time指的是已經轉了多少時間了,每次進入_physics_process時就給它加上時間。turn_alpha就是兩者之比,也就是插值進度,它會作為interpolate_with的參數傳入。當然這個中間變量是沒有必要的,這裡為了清楚寫一下(題外話,如果編譯器足夠智能,可能就算這麼寫這個變量也會被優化掉)。
如果插值後發現當前transform已經和目標差不多了,那麼就重置這些狀態。
為什麼不用等號?因為由於浮點數精度等問題,很難確定兩者精確相等。如果在這裡用等號,你會發現幾乎不可能碰到兩者相等的時候!但是!這裡實際上不用比較transform,我們直接比較時間即可!簡化後的代碼:
if turning: turned_time += delta transform = transform.interpolate_with(target_transform, clamp(turned_time/turn_time, 0, 1)) if turned_time >= turn_time: turning = false turned_time = 0
為了這個簡單的插值操作我們定義了大量狀態變量,如果我們需要控制很多插值,可能還要定義很多變量。
學完手動擋,我們還是開自動擋吧。
別忘了,我們還有Tween可以用。如果不需要那麼精細地控制,我們可以直接用Tween來插值。用tween的話,我們就可以不用自己來計算時間和插值用的alpha(weight),很多代碼又可以省掉:
func _on_area_3d_body_entered(body: Node3D) -> void: # ... if turning: return target_transform = transform.looking_at(body.position) turning = true var tween = create_tween() tween.tween_property(self, "transform", target_transform, turn_time) tween.finished.connect(func(): turning = false)
當然這裡保留了turning用以判斷是否還在轉身過程中。這裡相關的行為可以根據遊戲設計調整。當然,手動擋已經告訴你怎麼開了,當你真的需要手動擋的時候,你應該知道怎麼做。

怎麼殺死敵人?

在2D部分的文章中,為了實現玩家的HP特性,我們直接在玩家的腳本中定義了一個屬性。同時由於那時的敵人不需要被擊殺,所以它也沒有HP。很多FPS中,玩家和敵人都會被擊殺,所以雙方一般都是有HP屬性的。當然,對於玩家和敵人來說,可能受到傷害的代碼都是類似的。
我們先設想一下如何實現對敵人進行攻擊。
我們已經實現了通過計算射線和物體是否相交來判斷能否擊中某物。如果可以擊中,我們就可以獲得一個代表被擊中物的collider,其類型為Node3D,我們知道,這是非常抽象的類型,它可以代表任意3D節點。
為了對被擊中物施加傷害,我們首先要知道它是否可以接受傷害
我們如何知道一個節點是否可以受到傷害呢?根據我們對GDScript的瞭解,我們可以想到可以對它進行類型檢查。假如我們在代表敵人的類上定義了一個hp屬性,如果這個東西是敵人,那麼肯定就可以操作它的hp,亦可以調用其它相關方法:
# 只作為例子展示 func fire(): # ... var result = get_world_3d().direct_space_state.intersect_ray(parameters) if not result: return var collider = result.collider if collider is Enemy: var e = collider as Enemy e.apply_damage(1) # ... # ...
隨著開發的進行,我們可能有多種不同的敵人,需要實現類似的處理傷害的功能,可以預見這些代碼是雷同的。為了減少這種不必要的複製粘貼,我們可以為各種敵人定義一個共有的基類。這樣在應用傷害時可以不用關心具體敵人。
這樣的通用敵人基類的基類可能是CharacterBody3D。但是,有沒有可能我們的敵人不需要、甚至不應該繼承CharacterBody3D呢?比如它可能是一個載具或者其它什麼東西,它們不利用CharacterBody3D實現。
還有一種可能,一個東西會對攻擊做出反應,但是它並不會被打死,因此它不需要一個hp什麼的,它怎麼能被當成敵人的子類呢?我們甚至不能操作它的hp!
你可能會想,我們用最抽象的Node來表示一種“可以被打”的東西,以此為基類,再構造一些略為具體的基類,最後再是真正直接使用的具體節點類。
想法不錯。但是如果我們需要抽象出不止一種行為呢?比如載具既可以被攻擊,也可以被“開”。如果它繼承了一個可以被攻擊的基類,那麼它就不能再去繼承另一個被開的基類了。
GDScript以及很多基於類的面向對象編程語言實際上都主動拋棄了多繼承(可以同時有多個基類)來避免多重繼承帶來的一些問題。所以上述設想在GDScript中是沒法實現的。

組合(可能)優於繼承

那怎麼辦呢?既然我們知道可以把某種能力剝離出來,那麼我們也就可以把它包裝成一個單獨的節點。Godot的節點樹設計天然地讓我們可以讓一個節點作為根節點,讓它帶有若干子節點。這樣一來這個根節點就可以利用這些子節點的能力來實現各種功能。並且,HP相關的數據、狀態本身是不需要和視覺呈現交互的。把它(比如叫它HpComponent)作為一個Node的派生類來實現也是合情合理的:
# HpComponent示例 extends Node class_name HpComponent ​ @export var MAX_HP: int = 100 var hp: int = MAX_HP ​ signal damaged() signal died(node: Node3D) ​ func apply_damage(d: int) -> int: hp = clamp(hp - d, 0, MAX_HP) damaged.emit() if hp == 0: died.emit(get_parent()) return hp
當然,如此一來我們就無法直接從一個節點的類型上得到它關於這一能力的信息,因為現在和接受傷害相關的能力有關的屬性和方法都在這個單獨的子節點上。如果使用這種模式,為了檢測一個節點“是否可以接受傷害”,我們需要做的是檢查它身上是否有某個節點。
在Godot中可以使用get_node
get_node_or_null來獲得對節點樹中某個節點的引用。這兩個方法唯一的區別就是前者在找不到指定節點時會發生錯誤並返回null,後者不會報錯。這個節點本質上就等於用GDScript的語法糖來獲得節點,就像我們在腳本中做過的那樣。只不過,在我們需要檢查來自另一個場景或節點身上有沒有某個節點時,我們就要通過這個方法來操作:
func fire(): # ... if not result.collider: return var collider = result.collider var hp_component = collider.get_node_or_null("HpComponent") if hp_component: hp_component.apply_damage(1) # ...
由於我們需要通過一個類似於字符串的參數來獲得節點,所以說並不能保證這個過程一定成功。雖然說這種實現具體功能的節點我們可能一般就作為根節點的直接子節點(第一層),但是你必須要在開發過程中和自己以及所有開發參與者達成一致,不然的話可能你在某些能被傷害的節點上用HpComponent可以找到這樣一個HpComponent節點,有些東西上又找不到了。另外,你還有可能給它改了名字或者打錯字了什麼的。所以很多時候用字符串當參數的約束力是很弱的,類型系統相比之下要安全很多。
當然這種模式的好處是顯而易見的。我們可以在任意場景中加入這個HpComponent,不用管它們的繼承關係,也不用關心具體類型,並且可以組合其它任意的功能。
當然,還有個問題沒有回答,那就是萬一這個東西根本不需要一個HP屬性呢?

何必這麼麻煩?

不要忘了,GDScript是一個足夠動態的編程語言。我根本不需要關心你到底是哪種節點,甚至不需要關心你有沒有某個子節點——只要你有叫某某的屬性或者方法,我就可以調用之!
你可以在所有可以受傷害的節點上都定義一個apply_damage方法,並且使用統一的參數列表。這樣一來,不管被射線碰到的節點是什麼,也不管它如何處理傷害,都可以通過這個方法來處理。
但是,不要忘了,如果這個被射線碰到的東西沒有這個方法,那麼就會報錯。如果要利用動態語言的這一特性來實現,還是那句話,事先約定,保持一致。
針對這一問題,GDScript中可以通過Objecthas_method方法來檢查一個節點(提醒:Node是Object的子類)上是否有某個方法。但是,在遊戲運行時調用這種方法比直接調用會慢得多。
利用這種動態語言的特性來實現還有一個壞處就是,就算你的代碼是完全正確的,由於類型不確定,編輯器無法給你提供代碼補全的提示,出現打字錯誤的可能性更高。
因此,為了方便起見,我文章中的示例代碼就定義apply_damage方法,然後直接動態調用它就行了。
當然,如果我用C#來編寫腳本,我不會這樣做。但是如果你一定要問可不可以,那其實還是可以。但是那樣就白瞎了C#帶來的類型安全。

補充知識:這種仗,它們會怎麼打?

這種對具體能力的抽象,對實現和接口的剝離,在“正經的”編程語言中一般就稱為interface(接口),或者用蘋果更喜歡的說法protocol(協議)表示。
這種抽象的接口定義了一系列方法(某些語言也支持定義屬性),要求接口實現者(某個類),和接口使用者(通過接口操作某個對象的代碼)達成一種協議。以Godot和Unity等引擎支持的C#語言為例:
interface IDamageable { void ApplyDamage(int d); }
這樣一來,承諾實現該接口的類就必須提供一個相應的方法定義,且任意類型均可實現該接口。射線檢測到實現了這個接口的節點時,就必然能調用這個接口中定義的方法。這種確定性是C#作為一種(在絕大多數情況下都很)靜態的編程語言的類型系統能夠保證的。
很多現代的基於類的面向對象編程語言都是隻允許繼承一個類,但是可以實現多個接口。例如等價的Enemy可以去實現這個接口:
public partial class Enemy: CharacterBody3D, IDamageable { # ... [Export] public int MaxHp {set;get;} = 100; public int Hp {set; get;} = MaxHp; public void ApplyDamage(int d) { Hp -= d; } }
假設前面的fire方法被轉換成了等價的C#代碼:
void Fire() { // ... var collider = result["collider"]; if (collider is IDamageable d) { d.ApplyDamage(1); } // ... }
collider is IDamageable d是C#模式匹配語法的一種用法。如果發現collider實現了IDamageable接口,那麼就將其所指對象綁定到局部變量d上,便於在下方的代碼塊中使用。這裡通過d只能調用接口中定義的方法。一般有接口概念的編程語言都可以在需要類型的地方寫上接口,因此可以用接口類型的變量來引用對象。
如你所見,通過接口調用方法的代碼是不用關心具體類型是如何實現這個方法的。這些具體的類型上你可以背上一個類似於HpComponent的節點來實現具體功能,也可以完全不用——我根本不關心好吧。
Unity雖然也用C#,但是它更強調組合。一個GameObject身上的各個組件構成了這個東西的能力。不過也可以通過一些手段讓一個組件放到某個東西身上時自動給它加一個它依賴的組件,一定程度上可以避免找不到某個組件的問題。Unity的射線檢測結果可以拿到的也是一個GameObject(類似於Node2D和Node3D,因為GameObject有transform,但是Node沒有),可以通過調用獲得具體類型的組件。當然,還是因為有C#在,你可以定義一個類似的類層次結構,定義一個大的Enemy組件,讓它實現接口,也可以用GetComponent<IDamageable>來獲得具體的組件引用。
Unreal的編程語言是(被Epic加了很多黑魔法的)C++。C++其實是支持多重繼承的,但是Unreal的開發者選擇用黑魔法實現了一種類似於“現代面嚮對象語言”的接口。儘管Unreal不如Unity那麼“強調”組合,但是Unreal中也存在組件的概念,再加上接口,因此實際上也可以用上述的各種方法來解決問題。

殺敵(略)

基本的邏輯對於從頭看到這裡的讀者來說應該很簡單,建議先自己思考一下。
hp沒了直接queue_free就行了。

朝玩家方向移動

可是不動的敵人沒什麼意思。
我們這裡的場景和敵人邏輯都很簡單。事實上,這個功能涉及很複雜的情況。比如場景很複雜,敵人和玩家有高度差,簡單的距離計算是找不到路的。後續會介紹一種“更先進”的做法。
這裡的敵人是用CharacterBody實現的,玩家逼近後,我們直接修改速度。如果你的敵人響應玩家進入area的信號的函數里面,轉向是用Tween來實現的,那麼可以直接這樣寫:
# ... tween.finished.connect(func(): turning = false velocity = (body.position - position).normalized() * 0.5 # TODO: 這裡應該加上追逐玩家的代碼 )
正如前面提到的那樣,兩點座標相減就是一個向量。這裡用normalized方法得到規範化之後的向量,然後乘以我們設計的速度(speed)即可。
如果你的邏輯不復雜,這樣直接寫一個lambda表達式即可(快速回憶:形如func(): pass這樣的、匿名的簡單函數,這樣的表達式直接返回一個函數或者說Callable)。還要注意就是這樣的lambda表達式也可以有多行代碼,不需要限制自己只能寫一行代碼。當然,當這裡的代碼真的複雜起來之後,正確的做法是提取一個單獨的函數。

© 2022 3樓貓 下載APP 站點地圖 廣告合作:asmrly666@gmail.com