單例(Autoload)

簡介

Godot 的場景系統雖然很強大又很彈性,但有個缺點:無法儲存多個場景間需要共享的資訊(如,玩家的分數或物品列)。

雖然有一些臨時解決方法可以處理這個問題,但這些方法都有各自的局限性:

  • 使用一個「master」場景作為主場景,並將所有其他場景都載入為子場景。但用這種方法表示將無法單獨正常執行這些場景。

  • 使用 res:// 將資訊保存在硬碟上,並在需要的場景中載入。但頻繁保存與讀取資料很麻煩,而且可能會拖慢速度。

單例模式 對於需要在不同場景間保存持續性資料這類常見問題來說是很實用的工具。在我們這個例子中就可以拿來在多個單例中重複使用相同的場景或類別,只要名稱不要衝突就好。

利用這個概念,我們可以做出像這樣的物件:

  • 隨時都保持載入狀態,不論現在執行的是哪個場景。

  • 可以保存全域變數,如玩家的資訊。

  • 可以處理場景的切換以及場景間的轉場效果。

  • 行為 類似單例模式,只是 GDScript 在設計上不支援全域變數。

通過讓節點與腳本自動載入便能實現這些特徵。

備註

Godot 不會依照單例設計模式將 AutoLoad 變成「真正」的單例。如果使用者需要的話,依然可以實體化多次 AutoLoad。

Autoload

可以通過建立 AutoLoad 來載入從 Node 繼承來的場景或腳本。

備註

當 Autoload 腳本時,會新增一個 Node 並將該腳本附加到 Node 上。這個節點會在其他場景載入前被新增到根檢視區上。

../../_images/singleton.png

要自動載入場景或腳本,請從選單中選擇 [專案] -> [專案設定] 然後切換到 [AutoLoad] 分頁。

../../_images/autoload_tab.png

這裡可以新增任意數量的場景或腳本。列表中的每一個項目都必須填寫名稱,這個名稱會被指派為節點的 name 屬性。列表上的順序就是載入進全域場景樹的順序,可以使用上下方向了來更改。

../../_images/autoload_example.png

這表示,所有的節點都可以通過下列方式來存取名為「PlayerVariables」的單例:

var player_vars = get_node("/root/PlayerVariables")
player_vars.health -= 10
var playerVariables = GetNode<PlayerVariables>("/root/PlayerVariables");
playerVariables.Health -= 10; // Instance field.

若有勾選「Enable」(啟用) 欄位 (預設就有勾選),則就可以直接這樣存取單例而不需使用 get_node()

PlayerVariables.health -= 10
// Static members can be accessed by using the class name.
PlayerVariables.Health -= 10;

請注意,存取 Autoload 的物件(腳本或場景)就像是存取場景樹中其他節點一樣。事實上,若仔細看執行中的場景樹,則會看到 Autoload 的節點出現在上面:

../../_images/autoload_runtime.png

自製場景切換器

這篇教學展示了如何使用 Autoload 來製作場景切換器。如果只需要做基本的場景切換,可以使用 SceneTree.change_scene() 方法 (詳細請參考 Using SceneTree )。但若在切換場景時需要更複雜的行為,則這個方法就有更多功能。

要開始本教學,請先下載這個樣板: autoload.zip 然後在 Godot 中打開。

這個專案包含了兩個場景: Scene1.tscnScene2.tscn 。兩個場景都包含了一個展示場景名稱的 Label ,自己一個連結了 pressed() 訊號的按鈕。執行專案時會從 Scene1.tscn 開始執行。但,現在按按鈕還不會發生任何事。

Global.gd

切換到 [腳本] 分頁並建立名為 Global.gd 的腳本。請確認該腳本有繼承 Node

../../_images/autoload_script.png

下一個步驟就是將這個腳本新增至 Autoload 列表中。從選單中打開 [專案] > [專案設定] ,然後切換到 [AutoLoad] 分頁,並點擊瀏覽按鈕來選擇腳本,也可以直接輸入路徑: res://Global.gd 。點擊 [新增] 來加入至 Autoload 列表:

../../_images/autoload_tutorial1.png

現在,無論正在執行專案中的哪個場景,這個腳本都會被載入。

回到腳本中,我們現在需要讓腳本在 _ready() 中取得目前的場景。目前的場景 (只有一個按鈕的那個) 以及 Global.gd 都是在根上的子節點,但 Autoload 的節點總是優先載入的。這表示根上的最後一個子節點永遠為目前載入的場景。

extends Node

var current_scene = null

func _ready():
    var root = get_tree().get_root()
    current_scene = root.get_child(root.get_child_count() - 1)
using Godot;
using System;

public class Global : Godot.Node
{
    public Node CurrentScene { get; set; }

    public override void _Ready()
    {
        Viewport root = GetTree().GetRoot();
        CurrentScene = root.GetChild(root.GetChildCount() - 1);
    }
}

現在,我們需要一個用來更改場景的函式。這點是需要釋放目前的場景並載入需要的場景。

func goto_scene(path):
    # This function will usually be called from a signal callback,
    # or some other function in the current scene.
    # Deleting the current scene at this point is
    # a bad idea, because it may still be executing code.
    # This will result in a crash or unexpected behavior.

    # The solution is to defer the load to a later time, when
    # we can be sure that no code from the current scene is running:

    call_deferred("_deferred_goto_scene", path)


func _deferred_goto_scene(path):
    # It is now safe to remove the current scene
    current_scene.free()

    # Load the new scene.
    var s = ResourceLoader.load(path)

    # Instance the new scene.
    current_scene = s.instance()

    # Add it to the active scene, as child of root.
    get_tree().get_root().add_child(current_scene)

    # Optionally, to make it compatible with the SceneTree.change_scene() API.
    get_tree().set_current_scene(current_scene)
public void GotoScene(string path)
{
    // This function will usually be called from a signal callback,
    // or some other function from the current scene.
    // Deleting the current scene at this point is
    // a bad idea, because it may still be executing code.
    // This will result in a crash or unexpected behavior.

    // The solution is to defer the load to a later time, when
    // we can be sure that no code from the current scene is running:

    CallDeferred(nameof(DeferredGotoScene), path);
}

public void DeferredGotoScene(string path)
{
    // It is now safe to remove the current scene
    CurrentScene.Free();

    // Load a new scene.
    var nextScene = (PackedScene)GD.Load(path);

    // Instance the new scene.
    CurrentScene = nextScene.Instance();

    // Add it to the active scene, as child of root.
    GetTree().GetRoot().AddChild(CurrentScene);

    // Optionally, to make it compatible with the SceneTree.change_scene() API.
    GetTree().SetCurrentScene(CurrentScene);
}

這裡我們用了 Object.call_deferred() ,這會讓第一個函式只會在目前場景的所有程式都執行完後執行一次。因此,目前的場景還在使用時就不會被移除(也就是指程式還在執行)。

最後,我們要在兩個場景中填好原本的空回呼函式:

# Add to 'Scene1.gd'.

func _on_Button_pressed():
    Global.goto_scene("res://Scene2.tscn")
// Add to 'Scene1.cs'.

public void OnButtonPressed()
{
    var global = GetNode<Global>("/root/Global");
    global.GotoScene("res://Scene2.tscn");
}

# Add to 'Scene2.gd'.

func _on_Button_pressed():
    Global.goto_scene("res://Scene1.tscn")
// Add to 'Scene2.cs'.

public void OnButtonPressed()
{
    var global = GetNode<Global>("/root/Global");
    global.GotoScene("res://Scene1.tscn");
}

執行專案並測試看看按按鈕來切換場景。

備註

請注意:當場景很小時,場景過場會馬上發生。但若場景很複雜,就可能會出現有體感的等待時間。要瞭解如何處理這個問題,請參考下一個教學: Background loading

另外,若載入時間沒有很長的話 (如小於 3 秒等),也可以在場景更改前顯示一些 2D 元素來製造出「載入磚 (Loading Plaque)」的效果。場景更改完後就可以將這些元素隱藏起來。這種做法可以用來讓玩家知道場景正在載入。