平臺跳躍遊戲中從平臺上落入萬丈深淵通常都伴隨著死亡。有了前面積累的知識,實際上我們容易想到實現這種效果的做法。
最簡單的做法就是在畫面下方放一個足夠寬的Area2D。當玩家碰到它的時候,我們就執行和死亡相關的代碼。考慮到我們可能不止在有萬丈深淵的地方放上這樣的一個area——比如我們可以用它來實現其它的懲罰玩家的機制,我們把它作為一個單獨的場景來創建。
新建場景,比如叫它KillArea(你叫它KillZone我覺得也可以)。由於它沒有過多的花哨功能,一般來說也不會移動,所以我們可以直接把根節點改成Area2D。和之前一樣,我們需要給它添加一個CollisionShape2D,具體的形狀創建一個矩形就好。場景很簡單,就這樣:
把它直接放到我們的主場景中,調整它的大小,讓它的寬度足以覆蓋住場景——當然你也可以根據自己的需要來調整。簡要複習一下。調整大小就是調整它的scale。除了在檢視面板中的Transform欄中的Scale屬性中調整(記得解鎖X和Y同時調整),也可以在2D場景視口上方的工具按鈕中選擇縮放工具直接在場景中調整:
我的場景現在大概是這樣:
CollisionShape的形狀在啟動遊戲時不會顯示,如果想要在遊戲運行時也能看到它可以啟用Debug菜單中的這一選項:
現在需要為KillArea添加腳本實現相關功能。
腳本不會太複雜,主要是連接上Area2D的body_entered信號——畢竟我們的玩家角色使用CharacterBody2D製作的。
碰到KillArea之後玩家就會死,死的時候有些什麼要做的呢。首先我們想播放一個動畫,素材裡剛好有個可以用的素材叫player_hurt(雖然叫“受傷”但是不影響)。然後我們自然想讓這個節點死掉,或者說消失。
我們在前面已經添加了好幾段動畫,現在應當不是問題。除了添加動畫之外,我們再給表示玩家狀態的State枚舉類型再加上一個DEAD狀態,這樣在其他處理輸入和重力的相關方法中如果發現玩家已經死亡我們就不再進行相關處理。
動畫添加好後,修改播放動畫的方法:
注意我把處理跳躍和下落的代碼也修改了。由於我們會跌落到KillArea上然後修改狀態,所以跳躍和下落的動畫只有在狀態不為DEAD時才播放。
然後修改_physics_process。發現狀態為DEAD時直接返回。當然你也可以把if的條件反過來,發現狀態部位DEAD時才執行後面的代碼。但是像我這樣寫的話可以馬上return,不用多一級縮進。
隨後我們添加一個die方法,這樣在KillArea中我們就可以調用這個方法讓玩家死掉。目前來說就簡單在這個方法中把狀態設為DEAD即可。
然後在KillArea中調用——但是等等。body_entered信號連接的函數的參數是Node2D類型,但是我們的die方法定義在玩家——也就是Player類型(或者其他你給它取的名字)上。我們應當在試圖調用die方法之前,先檢查一下到底是誰進入了area。由於目前我們只考慮在這個area裡殺死玩家,所以我們只檢查它是不是Player就行。在GDScript精要部分我提到過,如果要把腳本作為一個類型讓它在其他腳本中也可以使用,需要額外寫一行代碼:
在玩家角色的腳本中,我添加了一行class_name Player。這樣在其他腳本中就可以通過Player這個類名來指代它了。
要檢查某個對象的類型,直接用is運算符加上類型名即可:
很遺憾的是目前GDScript沒法把它寫成body is not Player。順口提一句在Python和C#中是可以寫is not的,只不過Python沒法用它做類型檢查。is的優先級高於not,所以這裡不寫括號也可以,當然拿不準的時候加上括號準沒錯。
判斷類型之後,我們用as運算符將body轉換為Player,然後調用die方法。這裡為了方便我就寫到一行沒有單獨定義一個局部變量。由於前面有類型檢查作為保證,這裡不用as轉換也沒問題,但是在寫代碼時就沒有自動補全提示了。
另一方面,我們其實也可以設置Area的物理層,讓它只檢測玩家所在層。不過現在我們沒有那麼多不同的場景節點要處理,所以就簡單檢查一下是不是玩家就行了。
現在運行遊戲,玩家落下的時候碰到KillArea就會停止並播放死亡動畫。
現在播放可以正常播放,但是玩家場景節點依然存在。我們要調用一個方法讓它消失。queue_free方法可以銷燬節點。它定義在Object(絕大部分東西的基類,也是Node的基類)上,是一個方法,沒有參數,指的就是銷燬“這個”東西。queue_free不會立即銷燬,它會等到當前幀結束,並且一些函數調用完畢後才會銷燬對象,即使一幀多次調用它也是安全的。所以我們一般都會用它來執行銷燬節點的工作。
在die方法中加上queue_free的調用,運行遊戲。
嗯……玩家是沒了,但是我們看不到動畫了。
我們希望,在碰到KillArea,播放動畫,然後過一段時間再執行queue_free。聰明的你可能已經想到,可以用之前學習過的Timer節點做一個計時器在timeout信號連接的函數上銷燬自己。
這樣做可以,但是沒必要。因為我們並不會多次用到這個計時器,計時結束整個玩家節點都會沒,讓它一直揹著一個不怎麼用的節點有點浪費。因此我們學習一個新東西。
SceneTimer
SceneTimer如其名,也是一種計時器。但是它不是節點(不繼承Node)。場景樹會負責管理SceneTimer實例。正如文檔中所說的那樣,常用於創建一次性計時器。
要創建一個SceneTimer直接調用場景樹的`create_timer`方法。要獲得當前場景樹則需要調用get_tree。create_timer只有一個必填參數那就是時間。SceneTimer和Timer節點一樣,有一個timeout信號,我們只需要把計時結束時要執行的代碼連上即可:
SceneTimer不是節點,所以也就沒法在編輯器中連接信號了。所以這裡在代碼中用connect方法連接信號。我們目前的想法是“一段時間之後銷燬節點”,就一行代碼,所以沒必要單獨寫一個方法,這裡直接寫了一個lambda函數傳給connect。
現在運行遊戲,碰到KillArea之後3秒,玩家就會被銷燬。
await關鍵字
上面的代碼實際上可以用Godot 4+中引入的await關鍵字來簡化。首先來看簡化後的版本:
運行遊戲,效果和之前完全一樣。
await關鍵字的作用和它的字面意思類似,會“等待”某個東西。await後面只能是兩種東西,要麼是一個信號,要麼是另一個含有await的函數調用(注意是函數調用而不是函數本身)——即協程。含有await關鍵字的函數會成為一個協程(coroutine)。協程是一段可以被掛起(暫停),並在合適的時候可以繼續執行的程序。
代碼執行過程中遇到await時,會暫停(掛起)當前函數的執行,直到await後面的信號發出或者協程執行完畢,再繼續執行後面的代碼(如果有)。
調用協程的函數(不在協程前加上await關鍵字)在代碼執行到首個await時會立即返回執行自己接下來的代碼,直到協程中的代碼可以繼續執行時才會接著執行協程中的代碼。試看這個例子:
運行一下代碼看看效果。“吃吃吃”和“再來一個漢堡”毫無疑問首先按順序輸出。緊接著調用coroutine函數,此時“老闆:好”緊接著也正常輸出。按道理來說接下來需要等待計時器3秒倒計時,但由於調用coroutine時我們沒有使用await關鍵字“等待”它,因此遇到coroutine中的await時執行過程立即返回到ready中,然後接著執行後面的代碼輸出“吃吃吃喝喝喝”。range函數會構造一個含有5個數字的數組,我們這裡的循環會執行5次、每次間隔一秒,輸出“吃吃吃喝喝喝”。與此同時,三秒後(吃吃吃喝喝喝輸出三次)coroutine中的await等到了計時器的timeout信號發出,然後老闆才說漢堡好了。
這給人的感覺就好像是兩段代碼在同時運行、互不干擾一般。從某種程度上來說這種特性類似於各種編程語言中的異步編程(asynchronous programming)模型。並且一些編程語言同樣使用await(以及async)關鍵字來實現相關功能。異步編程旨在遇到耗時的方法調用時不阻塞(block,它可以是一個術語)當前的代碼執行。在遊戲開發中過程中,從直觀感受(和某些層面)上來說,我們的時間單位更像是“一幀”。在一幀中會有很多操作完成。但是,有時候我們不希望(比如剛才的死亡機制)或者說不能(比如說耗時的下載過程)讓某些操作在一幀內完成,但又希望不影響其他代碼的執行,此時就可以用協程來解決。
注意,真正的異步編程和協程可能和多線程、多任務、多進程有關,但並不能劃等號。
重生
各種遊戲在死了之後還得重生。怎麼死我們知道了,現在還得知道怎麼生。
現在的Player實際上是一個放在主場景中的一個場景實例。我們要學習如何從場景中構造一個場景實例。
先來新建一個Node2D場景作為一個spawner。目前來說我們只是需要一個位置來重生玩家,所以就不需要其他的節點了,創建好場景添加一個腳本就行。
Spawner只產生玩家可不夠,我們以後可能還需要用它來產生各種不同的東西,所以我們必然不能寫死讓它只產生玩家。那麼怎麼去引用一個場景呢?場景也是一種資源,它也有對應的類型。代表場景資源的類型是PackedScene。它就是對我們正在編輯的、可以保存為文件的各種場景的抽象。為了方便隨時編輯,我們直接定義一個變量並且用export暴露出來:
接下來,只需要編寫一個簡單的spawn方法來實例化這個場景就好了:
場景的instantiate方法顧名思義就是實例化的意思。它會直接根據場景造一個實例出來。但是光有這樣一個實例還不行,我們要把它添加到場景中。下一行代碼就是獲得spawner所在的場景樹。add_child顧名思義就是給某個節點添加子節點。我們這裡是在場景樹的root節點上調用的,它會把玩家加到場景根節點下面,而不是添加到spawner下面。
此外為了便於在其他腳本中引用Spawner,我還加上了class_name Spawner這行來暴露它的類名。
接下來我們給主場景添加一個腳本。在腳本中我們需要找到這個生成玩家的spawner,然後在合適的地方調用spawn。我們之前為了方便是直接把玩家場景丟到主場景中的,現在我們需要利用spawner來生成玩家,所以我們就在ready中調用spawn方法。現在刪除之前放在主場景中的Player場景節點,然後放入Spawner場景,調整到合適的地方,並把玩家場景放到它的Scene To Spawn屬性槽中,然後主場景的腳本如下:
我們希望在遊戲開始時生成玩家,就是這樣,很簡單。
啟動遊戲,啊?報錯了。錯誤信息說父節點忙於準備子節點,添加節點失敗了。讓我們考慮在add_child上使用call_deferred方法(要記得函數/方法也是對象,它們是Callable類型的,這個deferred是定義在Callable上的)。這個call_deferred啥意思呢,字面上來說是延遲調用的意思。從文檔中可知,在Callable上調用這個方法會把方法的調用延遲到這幀結束時。
_ready會在節點及其子節點進入場景樹時調用,並且此時子節點的ready都先於父節點調用完畢了。但是我們偏偏在這個時候加入新的子節點,雖然其他節點ready了,但是新加入的節點還沒有ready,這樣的操作不太合適。因此需要延遲一下它的執行。不過考慮到我們後面會編寫用於重生的代碼,因此不會總是在某個場景的ready中調用spawn,所以我們不延遲add_child而是直接延遲spawn:
注意回憶Callable的相關知識。加上括號是函數調用,不加括號就是在引用函數(Callable)本身。
再次運行遊戲,沒有報錯,有玩家角色成功生成,但是它位置不在spawner的位置!
位置默認是(0, 0),我們把生成的玩家場景節點加到了主場景的根節點中,所以它是在場景中的左上角生成的。所以我們在spawn方法中把實例化出來的玩家場景的位置設置為spawner的位置就好:
注意,我沒有給instance標註類型,而instantiate返回類型為Node。Node上面沒有定義position屬性,所以編寫這段代碼時不會有提示。但是,我們要生成的場景(不是場景資源本身)必然是Node2D的子類,所以我可以斷定這個position是存在的。如果還是不放心,可以在instantiate後面加上as Node2D來保險(不寫Player是因為我們可能會用來生成其他類型的場景)。
不過,我們還是沒有完整實現重生的功能。比如我們希望在玩家死亡(銷燬玩家場景節點)後,按任意鍵重生。我們知道怎麼死,也知道怎麼生,但是什麼時候讓誰去生呢?
考慮到玩家死亡時我們需要進行不止一項操作,比如除了重生玩家之外,我們還可能需要展示UI元素(後面會講)、播放聲音等等。這些操作不止涉及玩家場景自己,但是其他場景卻不一定知道玩傢什麼時候死亡。這種時候就是在暗示我們可以自定義一個信號。
發出死亡宣告
其實之前講信號的時候簡單提了一下如何自定義信號,忘了也沒關係,本身也很簡單。
我們希望玩家死亡時發出一個died信號表示玩家已經死亡。目前來說這個信號不會提供任何參數。定義信號時和定義一個“沒有主體的函數”類似,只不過func變成了signal。在玩家的腳本中加上:
任何連接到died信號且函數簽名兼容(和信號定義的參數類型相同)的函數,都會在died信號發出時得到通知(被調用)。
然後我們在處理玩家死亡的函數中發出該信號。信號的emit方法就是發射信號,如果信號有參數則需要傳入相應的參數:
和其他信號類似,我們需要連接它。我們在主場景的腳本中連接它。不過……
我們的spawn方法沒有返回值,所以我們拿不到對玩家場景節點的引用。當然你也有方法可以直接找到主場景中的Player場景,但是沒必要。我們直接修改Spawner的spawn方法,讓它返回spawn出來的東西:
方便起見我同時給它標註了返回值。然後修改主場景的代碼,但是……
我們在主場景中的代碼使用了call_deferred來延遲調用spawn。但是call_deferred不會返回Callable的返回值(其返回值類型為void),畢竟這個call_deferred立刻就返回了,真正的調用會發生在未來。我們拿不到生成的玩家角色。不過好辦,我們可以先拿到剛生成的玩家,但是我們自己延遲把它加入場景樹中。
這裡你可以選擇直接修改spwan方法,讓它不調用add_child,也可以像我這樣單獨寫一個不加入場景樹的方法,需要的時候調用它:
然後在主場景中:
嗯……看起來有點複雜。我們慢慢來看。首先我們調用spawner實例化場景後不會自行把新場景實例加入當前場景樹的spwan方法,這樣我們就能在主場景的代碼中拿到新產生的player。
隨後我們連接玩家的died信號。因為這個player是我們用代碼生成的所以沒辦法在編輯器中連接信號,這裡用signal的connect方法連接我們定義的一個方法來響應玩家死亡的信號。
接下來,我們希望延遲加入剛生成的玩家角色,因此需要call_deferred。但是add_child(player)是一條表達式而不是Callable,你沒法直接在它身上調用call_deferred,所以我們用一個lambda函數把它包起來獲得一個Callable然後延遲調用。
實際上`call_deferred`還有另一個定義在Object上的版本(節點也是Object),它需要傳入要調用的方法的名稱,然後傳入它的參數:
個人不太喜歡用函數名字符串傳函數,但是這樣寫也可以達成目的。
在這個信號響應方法中,我們等待半秒鐘——這是隨便寫的,然後調用spawner的spawn方法重新生成玩家。由於此時場景的ready早已調用完畢,我們也就沒必要延遲加入新生成的玩家。但是我們依然需要連接上新生成的玩家的died信號。
現在運行遊戲,死亡和自動重生的相關功能就能正常工作了!