씬 조직

이 문서는 효과적인 씬 내용 조직에 관련된 주제를 다룹니다. 어떤 노드가 사용되어야 할까? 어디에 그들을 배치해야할까? 어떻게 그들을 상호작용할까?

효과적으로 관계를 짓는 방법

Godot 사용자는 그들만의 씬을 제작하려 했을 때, 종종 다음과 같은 문제점에 봉착했습니다:

사용자들은 먼저 첫 씬을 만들고 거기에 내용물을 넣습니다, 이후 씬을 재 사용할 수 있는 조각들로 나눠야 하는 섬뜩한 기운이 그들에게 엄습할 줄은 몰랐을 것입니다. 사용자들은 씬 분기들을 그들의 씬에 저장합니다. 하지만 그 뒤 그들은 이제까지 의지해온 어려운 참고가 더 이상 효과가 없다는 것을 알게됩니다. 씬을 여러 방면에서 재 사용할 수 있는 것이 문제를 일으켰는데, 노드 경로가 대상을 찾지 못하기 때문입니다. 에디터에서 설정된 시그널 연결이 끊어집니다.

이 문제를 해결하기 위해, 환경에 대한 세부 정보가 필요없는 하위 씬을 인스턴스화해야 합니다. 하위 씬은 쓰이는 방식에 따라 까다롭게 만들어지진 않는다는 신뢰를 가져야 합니다.

객체 기반 프로그래밍에서 고려해야할 가장 큰 점은 집중된 단일 목적의 클래스를 코드베이스의 다른 부분과의 느슨한 연결(loose coupling)을 유지하는 것입니다. 이것으로 객체의 크기를 (유지 보수성을 위해) 작게 유지하며 재사용성을 높여줍니다, 완전한 논리를 다시 짤 필요가 없어집니다.

이런 객체 지향 프로그래밍 모범 사례는 씬 구조와 스크립트 사용의 모범 사례에 있어 여러 가지 파급 효과가 있습니다.

가능하다면 의존성이 없는 씬을 설계해야합니다. 이는 즉, 외부로 의존하지 않아 모든 것을 유지하는 씬을 만들어야합니다.

씬이 외부 컨텍스트와 상호작용해야 한다면, 경험있는 개발자들은 의존성 주입사용을 권장합니다. 이 기술은 하이 레벨 API를 사용해 로우 레벨 API의 의존성을 제공하는 것과 관련이 있습니다. 왜 이럴까요? 외부 환경에 의존하는 클래스가 우연히 버그를 일으키거나 예측하지 못한 행동을 할 수 있기 때문입니다.

이러기 위해, 데이터를 드러낸 다음 초기화하기 위해 부모 컨텍스트에 의존하게 해야합니다:

  1. 시그널에 연결합니다. 매우 안전하지만 행동에 "응답"하는 데에만 사용해야하고, 시작에는 쓰면 안됩니다. 시그널 이름은 보통 과거 시제 동사를 사용합니다, "entered", "skill_activated", 혹은 "item_collected"처럼 말이죠.

    # Parent
    $Child.connect("signal_name", object_with_method, "method_on_the_object")
    
    # Child
    emit_signal("signal_name") # Triggers parent-defined behavior.
    
    // Parent
    GetNode("Child").Connect("SignalName", ObjectWithMethod, "MethodOnTheObject");
    
    // Child
    EmitSignal("SignalName"); // 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).
    
    // Parent
    GetNode("Child").Set("MethodName", "Do");
    
    // Child
    Call(MethodName); // Call parent-defined method (which child must own).
    
  3. FuncRef 속성을 초기화합니다. 소유권이 불필요한 메서드보다 안전합니다. 시작 동작에 사용됩니다.

    # Parent
    $Child.func_property = funcref(object_with_method, "method_on_the_object")
    
    # Child
    func_property.call_func() # Call parent-defined method (can come from anywhere).
    
    // Parent
    GetNode("Child").Set("FuncProperty", GD.FuncRef(ObjectWithMethod, "MethodOnTheObject"));
    
    // Child
    FuncProperty.CallFunc(); // Call parent-defined method (can come from anywhere).
    
  4. 노드나 다른 객체 참조를 초기화합니다.

    # 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.
    
  5. 노드 경로를 초기화합니다.

    # 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.
    

이런 설정은 자식 노드로부터의 액세스 소스를 숨깁니다. 이것은 차례차례로 자식을 환경에 느슨하게 결합합니다. 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 class Left : Node
{
    public Node Target = null;

    public void Execute()
    {
        // Do something with 'Target'.
    }
}

public class Right : Node
{
    public Node Receiver = null;

    public Right()
    {
        Receiver = ResourceLoader.Load<Script>("Receiver.cs").New();
        AddChild(Receiver);
    }
}

다른 객체에 종속하는 노드가 아닌 객체도 같은 원리가 적용됩니다. 어떤 객체든지 사실 객체들을 소유하며 그들 간의 관계를 관리해야 합니다.

경고

외부 컨텍스트에 종속성을 배치하듯이 데이터를, 심지어 느슨하게 결합된 것도 작업 내 (씬 내부)에 유지하는 것이 좋지만, 여전히 노드가 환경의 무언가가 참이라는 것을 생각하는 것입니다. 프로젝트의 디자인 철학은 문제가 발생하기 전에 이를 예방하는 것입니다. 그렇지 않으면, 코드의 고유 책임이 개발자에게 넘어가 아주 작은 규모에서 객체 관계를 추적하기 위해 문서를 사용하게 될 것입니다; 달리 말해 개발 지옥으로 알려져 있습니다. 외부 문서를 사용하여 코드를 안전하게 사용하는 코드 작성은 기본적으로 오류가 발생하기 쉽습니다.

그러한 문서를 작성하고 유지하는 것을 피하려면, 종속 노드 (위의 "child")를 tool 스크립트로 변환하여 _get_configuration_warning()을 구현해야 합니다. 여기서 비어있지 않은 문자열을 반환하는 것으로 씬 독은 노드에 의해 툴팁 문자열과 함께 경고 아이콘을 생성합니다. Area2D에서 자식 CollisionShape2D 노드가 없을 때 나타나는 그런 아이콘과 같습니다. 그런 다음 에디터는 씬을 스크립트 코드를 통해 자체 문서화합니다. 문서를 통한 콘텐츠 중복은 없습니다.

이런 GUI는 프로젝트 사용자에게 노드에 대한 중요한 정보를 더 잘 전달할 수 있습니다. 외부 종속성을 갖고 있나요? 그러한 종속성을 만족시켰나요? 다른 프로그래머들, 특히 디자이너와 작가들에게 노드에 무엇을 구성해야 하는 지를 메시지로 명확하게 설명해야 할 것입니다.

그럼 이런 밑장빼기 식의 작업을 왜 할까요? 음, 씬은 홀로 작동할 때 가장 잘 작동하기 때문입니다. 홀로 작동하지 않는 경우, 익명으로 다른 것들과 작동합니다 (최소한의 하드 종속성, 예: 느슨한 결합). 뜻밖의 방법으로 씬과 상호작용하기 위해 클래스가 씬에게 피할 수 없는 변경을 만든다면, 작업은 무너집니다. 한 클래스로의 변경은 다른 클래스에 영향을 끼칠 수 있습니다.

스크립트와 씬은, 엔진 클래스의 확장 프로그램으로 모든 객체 지향 프로그래밍 원칙을 준수해야 합니다. 예시는 다음을 포함합니다...

노드 트리 구조 선택하기

따라서 개발자는 게임을 작업하기 전에 게임에 있어서의 막대한 가능성을 막을 수 있습니다. 그들은 그들이 작업하고 싶은 것, 그들이 갖고 싶은 시스템을 알고 있을 것입니다, 하지만 어디에 그것들을 넣을 수 있을까요? 음, 게임을 만드는 것은 항상 그들에게 달려있습니다. 노드 트리는 무수한 방법으로 만들 수 있습니다. 하지만 확실하지 않은 사람들을 위해, 이 유용한 가이드가 시작하기에 알맞은 구조 샘플을 보여드리겠습니다.

게임은 항상 일종은 "진입 지점"을 갖습니다; 때로는 개발자들이 어디서 진입 지점이 시작하는지 명확하게 추적하고 그렇게 해서 다른 곳에서도 그 논리를 따르게 할 수 있습니다. 이 지점은 프로그램에서 조감 시점으로 모든 다른 데이터와 논리를 전달하는 곳이기도 합니다. 전통적인 애플리케이션의 경우, 이것은 "main" 함수가 될 것입니다. 이 경우에는, Main 노드가 될 것입니다.

  • 노드 "Main" (main.gd)

main.gd 스크립트는 게임의 주된 컨트롤러 역할을 할 것입니다.

그런 다음 실제 게임 내 "World" (2D나 3D)를 갖습니다. Main의 자식이 될 수 있습니다. 그리고 프로젝트에 필요한 다양한 메뉴와 위젯을 관리하는 게임용 기본 GUI가 필요합니다.

  • 노드 "Main" (main.gd)
    • Node2D/Spatial "World" (game_world.gd)
    • Control "GUI" (gui.gd)

레벨을 변경할 때, "World" 노드의 자식을 바꾸면 됩니다. 씬을 수동으로 바꾸는 것으로 사용자는 게임 월드 전환 방식을 완전히 제어할 수 있습니다.

다음 단계는 이 프로젝트에는 무슨 게임 플레이 시스템이 필요한 지 고려해야 합니다. 시스템은 다음을 갖고 있습니다...

  1. 모든 데이터를 내부적으로 추적하고
  2. 전역으로 액세스해야 하고
  3. 독립적으로 존재해야 하는 것

... 그러려면 오토로드 '싱글톤' 노드를 만들어야 합니다.

주석

더 작은 게임일수록, 더 적은 제어로 "Game" 싱글톤을 갖는 간단한 대안이 필요한데, SceneTree.change_scene() 이라고 하는 이 메서드는 메인 씬의 내용물을 바꿔 넣습니다. 이 구조는 "World"를 메인 게임 노드로 어느정도는 유지해줍니다.

모든 GUI는 "World"의 일시적인 일부로, 싱글톤이 되어야 합니다, 아니면 수동으로 루트의 자식으로 추가해야 합니다. 그렇지 않으면, GUI 노드는 씬을 전환하는 동안 자신 또한 삭제됩니다.

If one has systems that modify other systems' data, one should define those as their own scripts or scenes rather than autoloads. For more information on the reasons, please see the 'Autoloads vs. Internal Nodes' documentation.

Each subsystem within one's game should have its own section within the SceneTree. One should use parent-child relationships only in cases where nodes are effectively elements of their parents. Does removing the parent reasonably mean that one should also remove the children? If not, then it should have its own place in the hierarchy as a sibling or some other relation.

주석

In some cases, one needs these separated nodes to also position themselves relative to each other. One can use the RemoteTransform / RemoteTransform2D nodes for this purpose. They will allow a target node to conditionally inherit selected transform elements from the Remote* node. To assign the target NodePath, use one of the following:

  1. A reliable third party, likely a parent node, to mediate the assignment.
  2. A group, to easily pull a reference to the desired node (assuming there will only ever be one of the targets).

When should one do this? Well, it's up to them to decide. The dilemma arises when one must micro-manage when a node must move around the SceneTree to preserve itself. For example...

  • Add a "player" node to a "room".

  • Need to change rooms, so one must delete the current room.

  • Before the room can be deleted, one must preserve and/or move the player.

    Is memory a concern?

    • If not, one can just create the two rooms, move the player and delete the old one. No problem.

    If so, one will need to...

    • Move the player somewhere else in the tree.
    • Delete the room.
    • Instantiate and add the new room.
    • Re-add the player.

The issue is that the player here is a "special case", one where the developers must know that they need to handle the player this way for the project. As such, the only way to reliably share this information as a team is to document it. Keeping implementation details in documentation however is dangerous. It's a maintenance burden, strains code readability, and bloats the intellectual content of a project unnecessarily.

In a more complex game with larger assets, it can be a better idea to simply keep the player somewhere else in the SceneTree entirely. This involves...

  1. More consistency.
  2. No "special cases" that must be documented and maintained somewhere.
  3. No opportunity for errors to occur because these details are not accounted for.

In contrast, if one ever needs to have a child node that does not inherit the transform of their parent, one has the following options:

  1. The declarative solution: place a Node in between them. As nodes with no transform, Nodes will not pass along such information to their children.
  2. The imperative solution: Use the set_as_toplevel setter for the CanvasItem or Spatial node. This will make the node ignore its inherited transform.

주석

If building a networked game, keep in mind which nodes and gameplay systems are relevant to all players versus those just pertinent to the authoritative server. For example, users do not all need to have a copy of every players' "PlayerController" logic. Instead, they need only their own. As such, keeping these in a separate branch from the "world" can help simplify the management of game connections and the like.

The key to scene organization is to consider the SceneTree in relational terms rather than spatial terms. Do the nodes need to be dependent on their parent's existence? If not, then they can thrive all by themselves somewhere else. If so, then it stands to reason they should be children of that parent (and likely part of that parent's scene if they aren't already).

Does this mean nodes themselves are components? Not at all. Godot's node trees form an aggregation relationship, not one of composition. But while one still has the flexibility to move nodes around, it is still best when such moves are unnecessary by default.