Up to date

This page is up to date for Godot 4.2. If you still find outdated information, please open an issue.

場景組織

本文章討論與如何有效率地組織場景內容有關的主題。應該使用哪個節點?要將這些節點放到哪裡?各個節點要如何互動?

如何有效地建立關係

當 Godot 使用者開始製作自己的場景時,通常會遇到下列問題:

建立好第一個場景並往場景中加上內容,最後會開始覺得場景逐漸變得複雜,應該要把東西都切割成小塊,所以就將場景中的各個分支都保存為獨立的場景。但是,接著就會發現之前在腳本裡直接引用其他節點的方法不能用了。在多個地方重複使用場景也會出問題,因為沒辦法用節點路徑找到目標,然後在編輯器中建的訊號連接也會斷開。

要解決這些問題,就必須要讓子場景的實體化與子場景所在環境的詳細資訊無關。使用者必須要能確定子場景建立時不會因為使用子場景的方式不同而壞掉。

OOP 中要考慮的最重要的一點就是要維持一個專注且單一目的的類別,並與程式碼中其他部分 鬆散耦合 。這樣可以讓物件的大小維持得比較小 (為了可維護性) 並提高類別的可用性。

這些 OOP 最佳實踐在場景結構與腳本使用上實際是 數種 最佳實踐。

在設計場景時,應該儘可能地讓場景沒有其他相依性。 也就是說,建立的場景應該要將所有所需的東西都包含在場景內部。

若場景必須要與外部的東西互動,則有經驗的工程師推薦使用 依賴注入 。這個技術是使用高等 API 來提動低等 API 的相依性。為什麼要這麼做?因為仰賴外部環境的類別可能會不小心觸發 Bug 或未預期的行為。

為此,必須將資料公開並讓這些類別通過母級本文來初始化:

  1. 連接訊號。非常安全,但訊號應該只用來「回覆」一個行為,而不是執行行為。請注意,訊號名稱通常使用過去式動詞,如「entered」、「skill_activated」或「item_collected」。

    # Parent
    $Child.signal_name.connect(method_on_the_object)
    
    # Child
    signal_name.emit() # Triggers parent-defined behavior.
    
  2. 呼叫方法。用於開始一個行為。

    # Parent
    $Child.method_name = "do"
    
    # Child, assuming it has String property 'method_name' and method 'do'.
    call(method_name) # Call parent-defined method (which child must own).
    
  3. 初始化 FuncRef 屬性。比使用方法來得安全,因為不需在意方法的所有權。用來開始行為。

    # Parent
    $Child.func_property = object_with_method.method_on_the_object
    
    # Child
    func_property.call() # Call parent-defined method (can come from anywhere).
    
  4. 初始化 Node 或其他 Object 參照。

    # Parent
    $Child.target = self
    
    # Child
    print(target) # Use parent-defined node.
    
  5. 初始化 NodePath。

    # Parent
    $Child.target_path = ".."
    
    # Child
    get_node(target_path) # Use parent-defined NodePath.
    

These options hide the points of access from the child node. This in turn keeps the child loosely coupled to its environment. One can reuse it in another context without any extra changes to its API.

備註

雖然上述的例子中只表示了母子關係,但同樣的原則也可以用在所有物件關係上。有同級節點的節點應該只需要注意層級結構,因為祖先節點會居中協調這些節點間的通訊與參照。

# Parent
$Left.target = $Right.get_node("Receiver")

# Left
var target: Node
func execute():
    # Do something with 'target'.

# Right
func _init():
    var receiver = Receiver.new()
    add_child(receiver)

同樣的原則也可套用在與其他物件有相依性關係的非節點 (Node) 物件。無論那個節點實際上擁有這些節點,都應該負責管理這些節點間的關係。

警告

建議應儘量將資料保存在內部 (場景內部),雖然將相依性放在外部脈路上一樣代表這個節點需要依賴環境中的某些東西,就算依賴的這個外部脈路是鬆散耦合的也一樣。專案的設計理念應該避免這種狀況發生。若不避免的話,則程式碼的繼承關係也會強迫開發人員使用說明文件來追蹤物件在微觀尺度上的關係。這也可以稱為是開發地獄。撰寫需要依賴外部文件才能安全使用的程式碼很容易就會發生錯誤。

要避免建立與維護這種文件,則必須要將相依性節點 (上面稱的「節點」) 轉換為實作 _get_configuration_warning() 的工具腳本。從這個函式中回傳非空字串就能讓場景 Dock 產生一個警告圖示,並將該字串顯示為工具提示。這個圖示也是如 Area2D 節點沒有定義子級 CollisionShape2D 節點顯示的警告圖示。編輯器接著就通過腳本程式碼來讓場景自己說明自己。不需要通過文件來複製內容。

像這樣的 GUI 可以更好的通知專案使用者有關節點的重要訊息。節點有外部相依性嗎?相依性有滿足嗎?其他的程式設計師,尤其是設計師與作家,會需要更清楚的指示來瞭解如何正確設定這些節點。

那麼,為什麼還要搞得這麼複雜呢?這個嘛,因為場景在獨自運作的時候效果最好。若沒辦法獨自運作的話,則匿名地與其他場景運作則是次要最好的 (通過最小限度的硬參照,即鬆散耦合)。當然,當這些改動會導致類別與其他場景以無法預期的方式互動時就不可避免地會需要對類別做出修改,那麼接下來事情便開始當機。用這些拐彎抹角的方式的重點就是要避免造成這種狀況,也就是一個類別負面地影響了其他類別。

節點與場景,作為引擎類別的延伸,應該要能套用 所有 OOP 原則。這些原則的例子包含...

選擇一種節點樹架構

好的,所以開發人員開始開發遊戲,然後因為巨大的問題而止步不前。開發人員可能知道自己想做什麼,以及想要什麼系統,但要把這些東西都放在 哪裡 呢?呃... 人要怎麼做遊戲取決於自己。我們有無數種建置節點樹的方法,但,如果你不確定的話,這篇有用的教學提供了一個結構良好又簡單的架構來起手。

遊戲應該都要以某種「進入點」。進入點就是開發人員可以瞭若指掌地追蹤各種東西從哪裡來的地方,這樣便能追蹤邏輯跑去哪裡。這裡也是提供能俯瞰程式中所有其他資料與邏輯的地方。在傳統的應用程式中,進入點會是「main」函式。而在本例中,則是 Main 節點。

  • Node「Main」(main.gd)

main.gd 節點是遊戲的主要控制器。

接著我們會需要遊戲中真正的「世界」(2D 或 3D 世界)。可以是 Main 的子節點。另外,我們還需要遊戲的主 GUI 來管理專案所需的各種選單與小工具。

  • Node「Main」(main.gd)
    • Node2D/Node3D “世界”(game_world.gd)

    • Control「GUI」(gui.gd)

當改變關卡時,我們就可以把「World」節點的子節點換掉。通過 手動更改場景 就能提供能完整控制遊戲世界過場的方法。

下一步就是要想想專案需要的遊戲系統是怎麼樣的。如果需要這樣一個系統...

  1. 能在內部追蹤所有的資料

  2. 可以在所有地方存取

  3. 獨立存在

... 則應該要建立一個 Autoload「單例」節點

備註

對於比較小的遊戲,有另一種控制比較少東西的替代方案為用一個只呼叫 SceneTree.change_scene() 的「Game」單例來切換主要場景的內容。這種結構或多或少將「World」場景作為主要遊戲節點保留。

所有的 GUI 也都需要是單例。要嘛是會臨時放在「World」中的一部分,要嘛就是手動新增為根節點的子節點。否則,GUI 節點就必須在場景轉場時將自己刪除。

如果專案中的系統會修改其他系統資料,則必須要將這些系統定義為自己的腳本或場景,而不是使用 Autoload。更多有關這麼做的原因,請參考 《Autoload vs. 內部節點》 文件。

遊戲中的每個子系統也都應該在 SceneTree 中有自己的段落。開發人員應該使只在節點是對其母節點有效果時才使用子母關係。在移除母節點時就代表應該合理地移除子節點嗎?如果不應該的話,則應該在層級關係中使用同級節點或其他的關係。

備註

在某些情況下,我們會需要這些分離的節點來 同時 讓這些節點互相關聯。可以為此目的使用 RemoteTransform / RemoteTransform2D 節點。這兩個類別能讓目標節點通過指定條件來從 Remote* 節點中繼承選定的變換節點。要指派 目標 NodePath ,請使用下列其中一種方法:

  1. 可信的第三方,可能是母節點,來居中分配。

  2. 用群組,來輕鬆地取得到目標節點的參照 (假設只會有一個目標)。

那麼什麼時候該這麼做呢?嗯... 這就很主觀了。當節點必須在 SceneTree 場景中到處移動來保留自己時,出現必須要管理的細小部分就會遇到兩難。如...

  • 將「玩家」節點加到「房間」中。

  • 要換房間,所以必須要刪除目前的房間。

  • 在房間刪除之前,必須先保留與/或移動玩家。

    需要注意記憶體嗎?

    • 若不用的話,可以直接建立兩個房間,然後移動玩家,然後刪除舊房間。沒問題。

    如果要注意記憶體使用的話,則...

    • 將玩家移動到樹狀結構中其他地方。

    • 刪除房間。

    • 實體化並建立新的房間。

    • 重新加回玩家。

問題是,玩家在這裡是個「特例」,這個特例是開發人員必須要 知道 在專案中要這樣處理玩家。因此,要在團隊中可靠地分享這個情報的方法就只有 寫在文件 裡了。一直在文件裡寫細節是很危險的。這麼做會造成可維護性降低,並減少程式碼的可讀性,然後讓專案所需的知識不必要地成長。

在有更大型素材的更複雜的遊戲中,最好就一直將玩家放在 SceneTree 中一個完全不同的地方。這樣一來:

  1. 一致性更高。

  2. 沒有必須要寫成文件並在其他地方維護的「特例」。

  3. 不會因為沒有注意到細節而有發生錯誤的機會。

相較而來,若有需要一個 不繼承 轉換過的母節點的子節點,則有下列方法:

  1. 宣告型 解法:在這兩個節點間放一個 Node 。由於節點沒有變換,所以 Node 不會將這個資訊傳送給子節點。

  2. 指令式 解法:在 CanvasItemSpatial 上使用 set_as_toplover Setter。這樣一來便可讓節點忽略其繼承的變換。

備註

在製作網路遊戲時,請注意哪些節點與遊戲系統是關係到所有玩家的,以及哪些節點與遊戲系統只與權威伺服器有關。例如,使用者不需要有每個玩家的「PlayerController」邏輯副本,而只需要自己的。因此,將這些東西從「World」中分開來在獨立的分支中有助於簡化管理遊戲連線以及之類的東西。

場景組織的關鍵就是要以關聯的方式來考量 SceneTree 而不是用空間性來考量。節點是否會仰賴母節點的存在?若不仰賴,則節點可以就完全可以在別的地方活得好好的。若有依賴母節點,則這種依賴關係就是子節點要是母節點的子節點的原因 (而且,顯然必須是母節點的場景中的一部分,如果還不是母節點的場景中的一部分的話)。

這代表節點自己就是元件嗎?絕對不是。Godot 的節點樹構成了一種聚合關係,而不是組合關係。但,由於我們還是有能到處移動節點的靈活性,所以預設情況下這種移動是不必要的。