場景組織
本篇文章涵蓋了與有效組織場景內容相關的主題。您應該使用哪些節點?應該將它們放置在哪裡?它們應該如何互動?
如何有效地建立關係
當 Godot 使用者開始製作自己的場景時,通常會遇到下列問題:
建立好第一個場景並往場景中加上內容,最後會開始覺得場景逐漸變得複雜,應該要把東西都切割成小塊,所以就將場景中的各個分支都保存為獨立的場景。但是,接著就會發現之前在腳本裡直接引用其他節點的方法不能用了。在多個地方重複使用場景也會出問題,因為沒辦法用節點路徑找到目標,然後在編輯器中建的訊號連接也會斷開。
為了解決這些問題,您必須實例化子場景,使其不需要關於其環境的詳細資訊。您需要能夠信任子場景會自行建立,而不會挑剔其使用方式。
OOP 中要考慮的最重要的一點就是要維持一個專注且單一目的的類別,並與程式碼中其他部分 鬆散耦合 。這樣可以讓物件的大小維持得比較小 (為了可維護性) 並提高類別的可用性。
這些 OOP 最佳實踐在場景結構與腳本使用上實際是 數種 最佳實踐。
如果可以的話,您應該設計場景使其沒有依賴性。 也就是說,您應該創建將所需的一切都包含在自身之內的場景。
若場景必須要與外部的東西互動,則有經驗的工程師推薦使用 依賴注入 。這個技術是使用高等 API 來提動低等 API 的相依性。為什麼要這麼做?因為仰賴外部環境的類別可能會不小心觸發 Bug 或未預期的行為。
若要這麼做,您必須公開資料,然後依賴父層上下文來初始化它:
連接訊號。非常安全,但訊號應該只用來「回覆」一個行為,而不是執行行為。請注意,訊號名稱通常使用過去式動詞,如「entered」、「skill_activated」或「item_collected」。
# Parent $Child.signal_name.connect(method_on_the_object) # Child signal_name.emit() # Triggers parent-defined behavior.
// Parent GetNode("Child").Connect("SignalName", Callable.From(ObjectWithMethod.MethodOnTheObject)); // Child EmitSignal("SignalName"); // Triggers parent-defined behavior.
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { // Note that get_node may return a nullptr, which would make calling the connect method crash the engine if "Child" does not exist! // So unless you are 1000% sure get_node will never return a nullptr, it's a good idea to always do a nullptr check. node->connect("signal_name", callable_mp(this, &ObjectWithMethod::method_on_the_object)); } // Child emit_signal("signal_name"); // Triggers parent-defined behavior.
呼叫方法。用於開始一個行為。
# 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).
// Parent GetNode("Child").Set("MethodName", "Do"); // Child Call(MethodName); // Call parent-defined method (which child must own).
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("method_name", "do"); } // Child call(method_name); // Call parent-defined method (which child must own).
初始化 Callable 屬性。比起方法物件更安全,因為無需考慮方法的所有權。用於啟動行為。
# Parent $Child.func_property = object_with_method.method_on_the_object # Child func_property.call() # Call parent-defined method (can come from anywhere).
// Parent GetNode("Child").Set("FuncProperty", Callable.From(ObjectWithMethod.MethodOnTheObject)); // Child FuncProperty.Call(); // Call parent-defined method (can come from anywhere).
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("func_property", Callable(&ObjectWithMethod::method_on_the_object)); } // Child func_property.call(); // Call parent-defined method (can come from anywhere).
初始化 Node 或其他 Object 參照。
# Parent $Child.target = self # Child print(target) # Use parent-defined node.
// Parent GetNode("Child").Set("Target", this); // Child GD.Print(Target); // Use parent-defined node.
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("target", this); } // Child UtilityFunctions::print(target);
初始化 NodePath。
# Parent $Child.target_path = ".." # Child get_node(target_path) # Use parent-defined NodePath.
// Parent GetNode("Child").Set("TargetPath", NodePath("..")); // Child GetNode(TargetPath); // Use parent-defined NodePath.
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("target_path", NodePath("..")); } // Child get_node<Node>(target_path); // Use parent-defined NodePath.
這些選項會隱藏子節點的存取點。 這反過來使子節點與其環境保持**鬆散耦合**。 您可以在其他情境中重複使用它,而無需對其 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)
// Parent
GetNode<Left>("Left").Target = GetNode("Right/Receiver");
public partial class Left : Node
{
public Node Target = null;
public void Execute()
{
// Do something with 'Target'.
}
}
public partial class Right : Node
{
public Node Receiver = null;
public Right()
{
Receiver = ResourceLoader.Load<Script>("Receiver.cs").New();
AddChild(Receiver);
}
}
// Parent
get_node<Left>("Left")->target = get_node<Node>("Right/Receiver");
class Left : public Node {
GDCLASS(Left, Node)
protected:
static void _bind_methods() {}
public:
Node *target = nullptr;
Left() {}
void execute() {
// Do something with 'target'.
}
};
class Right : public Node {
GDCLASS(Right, Node)
protected:
static void _bind_methods() {}
public:
Node *receiver = nullptr;
Right() {
receiver = memnew(Node);
add_child(receiver);
}
};
相同的原則也適用於非節點物件,它們也可能維持著對其他物件的依賴關係。無論哪個物件擁有其他物件,都應該管理它們之間的關係。
警告
不過,你應該傾向於將資料保留在內部(場景內部),因為即使是鬆散耦合地依賴外部環境,仍然意味著節點會期望其環境中存在某些條件。專案的設計理念應該避免這種情況發生。如果沒有,程式碼固有的缺陷將迫使開發者使用文件來追蹤微觀層面的物件關係;這也就是俗稱的開發地獄。編寫依賴外部文件才能安全使用的程式碼,預設就容易出錯。
為了避免建立和維護這類文件,您可以將依賴的節點(上方的「子節點」)轉換為實作 _get_configuration_warnings() 的工具腳本。 從該函式回傳一個非空的 PackedStringArray 會使「場景」停靠視窗在該節點旁產生一個警告圖示,並將字串內容顯示為工具提示。這與 Area2D 節點在沒有定義子 CollisionShape2D 節點時所顯示的警告圖示相同。如此一來,編輯器便能透過腳本程式碼自我說明場景,無需再重複撰寫文件內容。
像這樣的 GUI 可以更好的通知專案使用者有關節點的重要訊息。節點有外部相依性嗎?相依性有滿足嗎?其他的程式設計師,尤其是設計師與作家,會需要更清楚的指示來瞭解如何正確設定這些節點。
那麼,為什麼這一切複雜的轉換手法會有效呢?嗯,因為場景在獨立運作時表現最好。如果無法獨立運作,那麼匿名地與其他場景協同工作(盡可能減少硬依賴,也就是鬆散耦合)就是次佳的選擇。不可避免地,類別可能需要進行變更,如果這些變更導致它以無法預見的方式與其他場景互動,那麼事情就會開始出錯。所有這些間接手段的重點在於避免最終陷入一種情況:變更一個類別會對依賴它的其他類別產生不利影響。
節點與場景,作為引擎類別的延伸,應該要能套用 所有 OOP 原則。這些原則的例子包含...
選擇一種節點樹架構
您可能剛開始製作遊戲,但卻被眼前廣泛的可能性弄得不知所措。您可能知道想做什麼、想要哪些系統,但*要將它們都放在哪裡*呢?如何著手製作您的遊戲始終取決於您。您可以用無數種方式建構節點樹。如果您不確定,本指南可以提供一個不錯的起始結構範例供您參考。
一個遊戲應該總是會有一個「進入點」;一個你可以明確追蹤事物從何開始的地方,這樣你才能夠在邏輯延續到其他地方時跟隨它。它也像是一個鳥瞰圖,呈現程式中所有其他的資料和邏輯。對於傳統應用程式來說,這通常是一個「main」函式。在 Godot 裡,它是一個主要的節點(Main node)。
Node「Main」(main.gd)
main.gd 這個腳本將會作為你遊戲的主要控制器。
接著您會有一個遊戲內的「世界」(2D 或 3D 的)。它可以是 Main 的子節點。此外,您的遊戲會需要一個主要的 GUI (圖形使用者介面),用來管理專案所需的各種選單和小工具。
- Node「Main」(main.gd)
Node2D/Node3D “世界”(game_world.gd)
Control「GUI」(gui.gd)
當切換關卡時,您可以替換 "World" 節點的子節點。手動切換場景 讓您完全掌控遊戲世界的轉換方式。
下一步是考量你的專案需要哪些遊戲機制。如果你有一個機制是...
能在內部追蹤所有的資料
可以在所有地方存取
獨立存在
... 那麼您應該建立一個 自動載入的「單例」節點。
備註
對於小型遊戲,一個較簡單但可控性較低的做法是建立一個「Game」單例,並單純呼叫 SceneTree.change_scene_to_file() 來切換主場景內容。這種架構會將「World」作為主要的遊戲節點保留。
任何 GUI 也需要是單例(singleton)、世界(World)的暫時性部分,或是手動新增為根節點的直接子節點。否則,GUI 節點也會在場景切換期間自行刪除。
如果您有會修改其他系統資料的系統,您應該將它們定義為獨立的腳本或場景,而不是自動載入。更多資訊請參閱:自動載入與一般節點的比較 。
您遊戲中的每個子系統都應該在場景樹中擁有自己的區塊。您應該只在節點實際上是其父節點的組成元素時,才使用父子關係。移除父節點是否合理地意味著子節點也應該被移除?如果不是,那麼它應該在層級結構中作為同層級節點或其他關係擁有自己的位置。
備註
在某些情況下,您會需要這些分離的節點彼此之間也能夠相對定位。您可以使用 RemoteTransform3D / RemoteTransform2D 節點來達成這個目的。它們能讓目標節點有條件地繼承來自 Remote* 節點所選取的變形元素。要指定 target NodePath,請使用下列其中一種方式:
可信的第三方,可能是母節點,來居中分配。
一個群組,用於拉取對目標節點的參照(假設目標只會有一個)。
何時應該這樣做是很主觀的。當你為了保存節點而必須細微地管理它在場景樹中的移動時,這個難題就產生了。例如...
將「玩家」節點加到「房間」中。
需要更換房間,因此您必須刪除目前的房間。
在刪除房間之前,您必須保留和/或移動玩家。
如果記憶體不是問題,你可以...
建立新的房間。
將玩家移動到新的房間。
刪除舊房間。
如果考量到記憶體用量,那麼你需要...
將玩家移動到樹狀結構中其他地方。
刪除房間。
實體化並建立新的房間。
將玩家重新加入新的房間。
問題在於此處的玩家是一個「特殊情況」,開發者必須「知道」專案需要以這種方式處理玩家。團隊可靠地共享此資訊的唯一方法是將其「文件化」。將實作細節保留在文件中是很危險的。這會增加維護負擔、降低程式碼可讀性,並不必要地膨脹專案的智力內容。
在擁有較大型素材的複雜遊戲中,將玩家物件完全放在場景樹的其他地方可能會是更好的做法。這會帶來:
一致性更高。
沒有必須要寫成文件並在其他地方維護的「特例」。
不會因為沒有注意到細節而有發生錯誤的機會。
相對地,如果您需要一個 不 繼承其父節點變形(transform)的子節點,您可以考慮以下選項:
宣告式 解決方案:在它們之間放置一個 節點。由於它沒有變換(transform),因此不會將此資訊傳遞給其子節點。
指令式 解法:在 CanvasItem 或 Node3D 節點上設定
top_level屬性。這樣該節點會忽略繼承來的變換。
備註
如果正在開發網路遊戲,請記住哪些節點和遊戲系統是所有玩家都相關的,哪些僅適用於權威伺服器。例如,使用者並不需要擁有每個玩家「PlayerController」邏輯的副本 - 他們只需要自己的。將這些邏輯放在與「世界」不同的分支中,有助於簡化遊戲連線等管理。
場景組織的關鍵就是要以關聯的方式來考量 SceneTree 而不是用空間性來考量。節點是否會仰賴母節點的存在?若不仰賴,則節點可以就完全可以在別的地方活得好好的。若有依賴母節點,則這種依賴關係就是子節點要是母節點的子節點的原因 (而且,顯然必須是母節點的場景中的一部分,如果還不是母節點的場景中的一部分的話)。
這表示節點本身就是組件嗎?一點也不是。Godot 的節點樹形成的是聚合關係,而不是組合關係。不過,雖然您仍然可以彈性地移動節點,但預設情況下最好還是避免這種移動。