使用程式控制遊戲的 UI

簡介

在這篇教學中我們會將角色連接到血槽,並在生命值損失顯示動畫。

../../_images/lifebar_tutorial_final_result.gif

我們要做的:血槽與數字會在角色被攻擊時播放動畫,且會在角色死亡的時候淡出。

你將學習:

  • 如何通過訊號將角色 連接 到 GUI

  • 如何使用 GDScript 控制 GUI

  • 如何使用 Tween 節點來 動畫化 血條

如果你想學習有關製作界面,請參考按部就班 UI 教學:

在編寫遊戲程式碼時,我們通常會先製作遊戲過程的核型程式,如遊戲的主要機制、玩家輸入、判斷勝利與失敗的條件等。UI 通常都是比較後面才會做的。我們通常會希望將遊戲中的各個元素都儘可能拆開來,將每個角色都放在各自的場景中,有各自的腳本。UI 元素也是一樣。這樣一來可以防止 Bug,並讓專案好管理,且能夠讓不同的團隊成員都能各自處理遊戲中不同的部分。

一旦準備好核心的遊戲功能與 UI,我們就需要用某種方式來把遊戲功能與 UI 連接起來。舉例而言,若我們有敵人會定期攻擊玩家,則我們就要讓血槽會在玩家被攻擊的時候一起更新。

要做到這點,我們會使用 訊號

備註

訊號就是 Godot 版本的 Observer 設計模式(觀察者模式)。訊號允許我們能送出某種訊息,而其他的節點則可以連接物件來 送出 (Emit) 訊號與接收資訊。訊號對於使用者界面與成就系統來說是很強大的工具,但我們通常不會給所有地方都用上訊號。連接兩個節點會增加節點間的耦合度。當有大量的連接時,節點就變得難以管理。更多資訊請參考 GDquest 的 `影片訊號教學(英語)<https://youtu.be/l0BkQxF7X3E>`_

下載並摸索起始專案

下載這個 Godot 專案: ui_code_life_bar.zip 。這個專案包含了所有需要用到的資源與腳本。解壓縮 .zip 檔會有兩個資料夾: startend

在 Godot 中載入 start 專案。到 檔案系統 Dock 中點兩下來打開 LevelMockup.tscn。這個場景是一個 RPG 遊戲的打樣,裡面有兩個角色面對面。粉紅色的敵人會定期攻擊綠色方塊並造成傷害,直到綠色方塊死亡。你可以試玩一下這個已經有基本戰鬥機制的遊戲。但,角色還沒有與血槽關聯起來, GUI 沒有任何作用。

備註

這就是我們編寫遊戲程式的典型方法:先實作遊戲的核心,再處理玩家的死亡,然後才新增遊戲界面。這是因為 UI 是監聽遊戲裡發生的事的,所以若其他部分的系統還沒做好 UI 就無法運作。如果在做好遊戲原型與測試遊戲之前就先設計了 UI,那很有可能最後會沒辦法用這個 UI 而需從頭開始重新設計。

這個場景包含了一個背景 Sprite、GUI、以及兩個角色。

../../_images/lifebar_tutorial_life_bar_step_tut_LevelMockup_scene_tree.png

場景樹,設定了讓 GUI 場景顯示其子節點

GUI 場景封裝了所有遊戲的圖形界面,附帶的腳本預先寫好了取得節點路徑的程式:

onready var number_label = $Bars/LifeBar/Count/Background/Number
onready var bar = $Bars/LifeBar/TextureProgress
onready var tween = $Tween
public class Gui : MarginContainer
{
    private Tween _tween;
    private Label _numberLabel;
    private TextureProgress _bar;

    public override void _Ready()
    {
        // C# doesn't have an onready feature, this works just the same.
        _bar = (TextureProgress) GetNode("Bars/LifeBar/TextureProgress");
        _tween = (Tween) GetNode("Tween");
        _numberLabel = (Label) GetNode("Bars/LifeBar/Count/Background/Number");
    }
}
  • number_label 用來顯示剩餘的生命值。為一個 Label 節點

  • bar 是血槽本身。為一個 TextureProgress 節點

  • tween 是一個類似元件的節點,用來動畫化其他節點或控制其他節點的任何數值或方法

備註

這個專案使用了適合給 Game Jams 或小型遊戲的組織方法。

在專案的根目錄 res:// 資料夾內有一個 LabelMockup 。這個場景是我們要處理的遊戲的主要場景。所有構成遊戲的元件都放在 scenes/ 資料夾內。 assets/ 資料夾包含了遊戲的 Sprite 以及給 HP 計數器用的字體。 scripts/ 資料夾內有敵人、玩家、以及 GUI 控制器 (Controller) 腳本。

點擊場景樹中節點右邊的編輯場景圖示來在編輯器內打開場景。LifeBar 與 EnergyBar 本身就是子場景。

../../_images/lifebar_tutorial_Player_with_editable_children_on.png

場景樹,Player 場景設定為顯示其子節點

使用玩家的 max_health 來設定 Lifebar

我們必須用某種方式告訴 GUI 目前玩家的血量為何,並更新 Lifebar 的貼圖,然後在螢幕左上角的 HP 計數器上顯示剩餘的生命值。要做到這點我們必須在每次玩家受到傷害時將玩家的生命值傳送給 GUI。GUI 接著用收到的值來更新 LifebarNumber 節點。

我們可以直接來更新顯示的數字,但在此之前必須先初始化計量槽的 max_value 才能讓更新的比例正確。所以第一步應該是告訴 GUI 綠色角色的 max_health 值為何。

小訣竅

預設情況下,計量槽 TextureProgressmax_value100 。若不需要以數字方式顯示玩家的生命值的話則不需要更改 max_value 屬性。可以直接改為從 Player 傳送百分比給 GUIhealth / max_health * 100

../../_images/lifebar_tutorial_TextureProgress_default_max_value.png

點擊場景 Dock 中 GUI 右邊的腳本圖示來打開腳本。 我們要在 _ready 函式中將 PlayerMax_health 保存在一個新變數裡,然後用這個變數來設定 barmax_value

func _ready():
    var player_max_health = $"../Characters/Player".max_health
    bar.max_value = player_max_health
public override void _Ready()
{
    // Add this below _bar, _tween, and _numberLabel.
    var player = (Player) GetNode("../Characters/Player");
    _bar.MaxValue = player.MaxHealth;
}

讓我們把它拆開來看。 $"../Characters/Player" 是取得節點的簡寫,先從節點樹上的上一層開始搜尋,然後找到 Characters/Player 節點。陳述式的第二個部分 .max_health 表示在 Player 節點上存取 max_health 屬性。

第二行將剛才取得的值賦值給 bar.max_value 。雖然可以把這兩行簡寫成一行,但因為這個教學之後還會用到 player_max_health ,所以我們先這樣寫。

Player.gd 在遊戲開始時將 health 設定成了 max_health ,所以我們可以直接用 health 。但為什麼我們後面還要用到 max_health 呢?有兩個原因:

我們沒辦法保證 health 的值永遠與 max_health 相同,遊戲之後可能會出一個新版本是關卡載入時玩家的生命值就已經受損。

備註

當在遊戲內打開場景時,Godot 會以場景 Dock 內由上而下的順序來依次建立節點。 GUIPlayer 並不再相同的節點分支上,所以為了保證我們在存取這兩個節點時他們都存在,我們要用 _ready 函式。Godot 會在所有節點都載入後與遊戲開始之間呼叫 _ready ,所以這裡便是設定所有東西與準備遊戲流程的最佳時機。有關 _ready 的更多資訊請參考 撰寫程式 (續)

當玩家收到傷害時使用訊號來更新生命值

GUI 已經準備要來接收 Player 傳來更新過的 health 值了。我們要使用 訊號 來實作。

備註

Godot 內有許多實用的內建訊號,如 enter_treeexit_tree 這兩個訊號分別會在任何節點建立與銷毀時發出。我們也可以通過 signal 關鍵字來建立自定訊號。你可以在 Player 節點內看到兩個我們預先幫你做好的訊號: diedhealth_changed

那我們為什麼不直接在 _process 函式內取得 Player 節點然後再讀生命值呢?因為這樣直接存取節點會增加程式的耦合度。如果只是偶爾這樣寫的話或許沒什麼問題。但一旦遊戲變得更大,則代表需要建立更多的連接。若以這種方式取得節點則馬上就會讓遊戲變得很複雜。不僅如此,還必須在 _process 函式內定期追蹤狀態變化,而這個追蹤每秒都會執行 60 次,這樣很可能馬上就會因為程式執行的順序而讓遊戲壞掉。

對於特定的一幀上存取另一個節點 更新前 的屬性,則取得的值便是該節點上一個幀的狀態。這樣會讓 Bug 變得難以理解而且難修正。而且,訊號是在更新後才送出的,所以便能 保證 取得的資訊總是最新的,而且更新連接的節點的狀態時也是在改變發生後 馬上 進行的。

備註

Observer 設計模式——也就是原版的「訊號」功能——也還是會增加節點間的耦合度。但通常來說耦合度更低而且也比為了在兩個不同的類別間通訊而直接存取節點來得安全。如果是從母節點取得子節點的值的話還好,但如果是兩個不同的節點分支則最好還是使用訊號。你可以在 Game Programming Patterns 內瞭解更多有關 Observer 設計模式(英語) 的內容。 全書(英語) 也可以在線上免費閱讀。

瞭解到這點後,我們來將 GUI 連接到 Player 上。在場景 Dock 中點擊 Player 來選擇,然後到屬性面板點擊節點分頁。這裡就是用來連接節點以及監聽所選節點的地方。

第一個部分列出了 Player.gd 內定義的自定訊號:

  • died 會在角色死亡時送出。我們稍後會用這個訊號來隱藏 UI。

  • health_changed 會在角色受到攻擊時送出。

../../_images/lifebar_tutorial_health_changed_signal.png

我們現在要連接的是 health_changed 訊號

先選擇 health_changed 然後點擊右下角的連接按鈕便會打開連接訊號視窗。視窗的左側可以選擇要監聽訊號的節點,這裡選 GUI 節點。視窗的右邊則可以選擇訊號送出時要一併送出的可選值,但這個我們已經在 Player.gd 內處理了。通常來說我不建議在這個視窗內增加太多的引數,因為從程式碼內來做會比較方便。

../../_images/lifebar_tutorial_connect_signal_window_health_changed.png

選擇了 GUI 節點的連接訊號視窗

小訣竅

另外也可以使用程式碼來連接訊號,但通過編輯器來連接有兩個好處:

  1. Godot 在連接的腳本上幫你寫一個新的回呼函式

  2. 發送訊號的圖示會在場景 Dock 中顯示在送出訊號的節點旁邊

視窗的底部可以看到所選擇的節點的路徑。我們這裡可以注意到第二行為「節點中的方法」,這裡顯示的就是訊號送出時會在 GUI 節點中呼叫的方法,會接收訊號送出的值並讓你進行處理。右邊有一個預設勾選的「建立函式」單選框。點擊視窗底部的連接按鈕,Godot 會在 GUI 節點內建立方法,並打開腳本編輯器,將遊標移動到新建立的 _on_Player_health_changed 函式內。

備註

從編輯器上連接訊號時,Godot 會以 _on_發送者名稱_訊號名稱 這樣的格式來建立方法。若已經寫好這個函式的話,則「建立函式」仍然會保留已經寫好的函式。你可以任意替換掉預設的名字。

../../_images/lifebar_tutorial_godot_generates_signal_callback.png

Godot 會幫你建立好回呼方法並把打開

在函式名稱的括號後新增一個 player_health 引數。當 Player 送出了 health_changed 訊號,會將 Player 目前的 health 也一起送出。現在,你的程式碼應該長這樣:

func _on_Player_health_changed(player_health):
    pass
public void OnPlayerHealthChanged(int playerHealth)
{
}

備註

Godot 不會將 PascalCase 大駝峰法轉換成 snake_case 底線分隔。C# 的例子內的方法名稱我們會使用 PascaCase,方法參數則使用 camelCase,遵守 C# 命名慣例

../../_images/lifebar_tutorial_player_gd_emits_health_changed_code.png

Player.gd 中,當 Player 送出 health_changed 訊號時也會一併送出 health 值

讓我們在 _on_Player_health_changed 中呼叫第二個函式 update_health ,並代入 player_health 變數。

備註

我們大可以直接在 LifeBarNumber 上更新生命值,但我們在這裡使用這種方法有兩個原因:

  1. 函式的名稱可以讓未來的你與你的隊友們瞭解當玩家收到傷害時會更新 GUI 上的生命值

  2. 稍後會重複使用這個方法

_on_Player_health_changed 下方新增一個新的 ``update_health``方法,只接受一個 new_value 引數:

func update_health(new_value):
    pass
public void UpdateHealth(int health)
{
}

這個方法需要用來:

  • Numebr 節點的 text 設為轉換為字串的 new_value

  • TextureProgressvalue 設為 new_value

func update_health(new_value):
    number_label.text = str(new_value)
    bar.value = new_value
public void UpdateHealth(int health)
{
    _numberLabel.Text = health.ToString();
    _bar.Value = health;
}

小訣竅

str 是內建的函式,用來將任何數值轉換為字串。 Numbertext 屬性必須為一個字串,所以我們沒辦法直接將 new_value 指派給他

另外,在 _ready 的結尾呼叫 update_health 來在遊戲開始時將 Number 節點的 text 屬性初始化為正確的值。按 F5 來測試遊戲,血槽現在應該會在每次被攻擊時更新了!

../../_images/lifebar_tutorial_LifeBar_health_update_no_anim.gif

Number 與 TextureProgress 節點現在在 Player 被攻擊時都會更新了

使用 Tween 節點來動畫化生命受損

界面現在會動了,但我們還可以給他加點動畫。現在是介紹用來動畫化屬性的必要工具 Tween 節點登場的好時機。 Tween 可以將任何屬性動畫化,在某段時間內將某個起始點的值逐步增加到結束點。例如,可以使用 Tween 在角色受到攻擊時將 TextureProgress 上的生命值從目前的狀態動畫化到 Player 的新 health 值。

GUI 場景已經包含了一個儲存在 tween 變數上的 Tween 子節點了。讓我們來用用它。但我們必須先在 update_health 上做點修改。

我們要用到 Tween 節點的 interpolate_property 方法,這個方法有七個引數:

  1. 節點參照,要動畫化的屬性的節點

  2. 屬性的識別子,以字串傳入

  3. 起始值

  4. 終止值

  5. 動畫時間,以秒為單位

  6. 轉場類型

  7. 與方程結合使用的緩動 (Easing) 方式。

緩動方程 (Easing Equation) 是由最後兩個引數組合成的。即,用來控制數值如何從起始值變化至終止值。

再次點擊 GUI 節點旁邊的腳本圖示來打開腳本。 Number 節點需要使用字串來更新自己,而 Bar 需要的則是浮點數或整數。我們可以使用 interpolate_property 來動畫化數字,但無法直接動畫化字串。所以我們要用來動畫化 GUI 上的一個新變數 animated_health

在腳本開頭定義一個新變數,命名為 animated_health ,並將值設為 0。然後回到 update_health 方法並清空原本的內容。現在我們來動畫化 animated_health 的值。呼叫 Tween 節點的 interpolate_property 方法:

func update_health(new_value):
    tween.interpolate_property(self, "animated_health", animated_health, new_value, 0.6)
// Add this to the top of your class.
private float _animatedHealth = 0;

public void UpdateHealth(int health)
{
    _tween.InterpolateProperty(this, "_animatedHealth", _animatedHealth, health, 0.6f, Tween.TransitionType.Linear,
        Tween.EaseType.In);
}

讓我們把這段程式碼拆開來看:

tween.interpolate_property(self, "animated_health", ...

我們將目標設為 self ——也就使 GUI 節點——上的 animated_healthTween 的 interpolate_property 接受字串形式的屬性名。所以我們需要寫成 "animated_health"

... _health", animated_health, new_value, 0.6 ...

起始值為目前計量槽的值,這部分還需要寫,但總之這個值會是 animated_health 。動畫結束點則為 Playerhealth_changed 後的 health 值,也就是 new_value 。0.6 則為動畫時長,單位為秒。

動畫必須要使用 tween.start() 啟用 Tween 節點後才會開始播放。如果節點還沒啟用的話,只需要執行一次即可。在最後一行後面加上這段程式碼:

if not tween.is_active():
    tween.start()
if (!_tween.IsActive())
{
    _tween.Start();
}

備註

雖然我們大可直接動畫化 Player 上的 health 屬性,但不該這麼做。角色是在收到攻擊後馬上損失生命值的,這樣一來在如判斷角色死亡時機這類的狀態管理會更輕鬆。通常我們都會在另一個資料容器或節點上來做動畫化。 Tween 節點很適合用來做以程式碼控制的動畫。如果要手動製作動畫,請參考 AnimationPlayer

將 animated_health 指派給 LifeBar

現在 animated_health 變數已經動畫化了,但我們卻沒去更新真正的 BarNumber 節點了。讓我們來修正一下。

到目前為止,update_health 方法看起來應該像這樣:

func update_health(new_value):
    tween.interpolate_property(self, "animated_health", animated_health, new_value, 0.6)
    if not tween.is_active():
        tween.start()
public void UpdateHealth(int health)
{
    _tween.InterpolateProperty(this, "_animatedHealth", _animatedHealth, health, 0.6f, Tween.TransitionType.Linear,
        Tween.EaseType.In);

    if(!_tween.IsActive())
    {
        _tween.Start();
    }
}

在這個例子中,由於 numebr_label 只接受字串,所以我們需要使用 _process 方法來動畫化。來像之前一樣更新 NumberTextureProgress 節點,但這一次在 _process 中:

func _process(delta):
    number_label.text = str(animated_health)
    bar.value = animated_health
public override void _Process(float delta)
{
    _numberLabel.Text = _animatedHealth.ToString();
    _bar.Value = _animatedHealth;
}

備註

number_labelbar 是保存了 NumberTextureProgress 節點參照的變數。

執行遊戲就可以看到動畫很順暢地播放了,但 Text 顯示了十進位數字,而且看起來很亂。另外,考慮到遊戲風格,血槽的動畫效果如果能動得更粗糙一點會更好。

../../_images/lifebar_tutorial_number_animation_messed_up.gif

動畫很流暢,但數字壞了

這兩個問題都可以通過將 animated_health 四捨五入來修正。使用區域變數 round_value 來保存四捨五入後的 animated_health ,然後再指派給 number_label.textbar.value

func _process(delta):
    var round_value = round(animated_health)
    number_label.text = str(round_value)
    bar.value = round_value
public override void _Process(float delta)
{
    var roundValue = Mathf.Round(_animatedHealth);
    _numberLabel.Text = roundValue.ToString();
    _bar.Value = roundValue;
}

再執行一次遊戲看看,現在動畫變得有點塊狀,很好看。

../../_images/lifebar_tutorial_number_animation_working.gif

將 animated_health 四捨五入,一石二鳥修正了兩個問題

小訣竅

玩家每次受到攻擊時 GUI 都會呼叫 _on_Player_health_changed ,而裡面又會呼叫 update_health 並更新動畫,隨後在 _process 內也一併 number_labelbar 更新。將血條動畫化來讓生命值一點一點減少算是一種技巧,能讓 GUI 更生動。若 Player 一次受到 3 個傷害,則動畫會瞬間發生。

Player 死亡時淡出血槽

當綠色角色死亡時播放死亡動畫並淡出。這時,我們就不該再繼續顯示界面了。讓我們在角色死亡的時候淡出血槽。由於 Tween 節點可以同時平行處理多個動畫,所以我們會重複使用相同的 Tween 節點。

First, the GUI needs to connect to the Player's died signal to know when it died. Press Ctrl + F1 to jump back to the 2D Workspace. Select the Player node in the Scene dock and click on the Node tab next to the Inspector.

找到 died 訊號並選擇,然後點擊連接按鈕。

../../_images/lifebar_tutorial_player_died_signal_enemy_connected.png

訊號應該已經連接到 Enemy 上了

再次在連接訊號視窗中選擇 GUI 節點。節點路徑應該顯示為 ../../GUI ,而節點中方法應該顯示 _on_Player_died 。保持建立函式選項勾選,然後點擊視窗底部的連接按鈕。接著會在腳本工作區內打開 GUI.gd

../../_images/lifebar_tutorial_player_died_connecting_signal_window.png

連接訊號視窗內應該顯示這些值

備註

現在你應該看出模式了:每當 GUI 需要新的資訊時,我們就會發出新訊號。但請小心使用,因為建立越多連接則代表越難追蹤。

要讓 UI 元素動畫淡出,我們必須使用 modulate 屬性。 modulateColor 型別,用來將紋理貼圖與給定的顏色相乘。

備註

modulate 繼承自 CanvasItem 類別,所有的 2D 與 UI 節點都繼承自 CanvasItem。可以使用 modulate 來設定節點的可見性、指派著色器、或是更改顏色。

modulate 接受有四個通道的 Color 值:Red 紅色、Green 綠色、Blue 藍色、與 Alpha 透明度。若將前三個通道變暗,則界面會變暗。若降低 Alpha 通道,則界面會淡出。

我們需要在兩個色彩間 Tween,起始值為 Alpha 為 1 (即完全不透明)的白色,終止值則為 Alpha 值為 0 (即完全透明)的白色。來在 _on_Player_died 方法最上面新增兩個變數,名為 start_colorend_color 。使用 Color() 建置函式來建立兩個 Color 值。

func _on_Player_died():
    var start_color = Color(1.0, 1.0, 1.0, 1.0)
    var end_color = Color(1.0, 1.0, 1.0, 0.0)
public void OnPlayerDied()
{
    var startColor = new Color(1.0f, 1.0f, 1.0f);
    var endColor = new Color(1.0f, 1.0f, 1.0f, 0.0f);
}

Color(1.0, 1.0, 1.0) 對應為白色。第四個引數,在 start_colorend_color 中分別為 1.00.0 為 Alpha 通道。

接著我們需要再次呼叫 Tween 節點的 interpolate_property 方法:

tween.interpolate_property(self, "modulate", start_color, end_color, 1.0)
_tween.InterpolateProperty(this, "modulate", startColor, endColor, 1.0f, Tween.TransitionType.Linear,
  Tween.EaseType.In);

這次我們需要動畫化的是 modulate 屬性,從 start_color 漸變到 end_color 。持續時間為一秒鐘,並使用線性動畫。完整的 _on_Player_died 方法長這樣:

func _on_Player_died():
    var start_color = Color(1.0, 1.0, 1.0, 1.0)
    var end_color = Color(1.0, 1.0, 1.0, 0.0)
    tween.interpolate_property(self, "modulate", start_color, end_color, 1.0)
public void OnPlayerDied()
{
    var startColor = new Color(1.0f, 1.0f, 1.0f);
    var endColor = new Color(1.0f, 1.0f, 1.0f, 0.0f);

    _tween.InterpolateProperty(this, "modulate", startColor, endColor, 1.0f, Tween.TransitionType.Linear,
        Tween.EaseType.In);
}

就這樣。現在可以執行遊戲來看看最終成果了!

../../_images/lifebar_tutorial_final_result.gif

最終成果。恭喜你完成了!

備註

使用完全相同的基數,也可以在 Player 中毒時更改血槽的顏色、或是在殘血時將血槽改為紅色、更或是當受到致命一擊時晃動 UI……等。這些都是使用相同的概念:通過訊號從 Player 送出資訊給 GUI 並讓 GUI 處理這些資訊。