Scene organization

Este artigo cobre tópicos relacionados à organização eficaz do conteúdo da cena. Quais nós devemos usar? Onde devemos colocá-los? Como eles devem interagir?

Como construir relações de forma eficaz

Quando os usuários do Godot começam a criar suas próprias cenas, eles geralmente se deparam com o seguinte problema:

Eles criam sua primeira cena e a preenchem com conteúdo apenas para, eventualmente, acabar salvando ramos de sua cena em cenas separadas, à medida que a sensação incômoda de que devem dividir as coisas começa a se acumular. No entanto, eles percebem que as referências concretas nas quais podiam confiar antes não são mais possíveis. Reutilizar a cena em vários lugares cria problemas porque os caminhos dos nós não encontram seus alvos e as conexões de sinal estabelecidas na quebra do editor.

Para resolver estes problemas, é preciso instanciar as subcenas sem que elas exijam detalhes sobre seu ambiente. É preciso ser capaz de confiar que a subcena criará a si mesma sem ser exigente quanto à forma como se a utiliza.

Uma das maiores coisas a considerar no POO é manter as classes focadas e de propósito único, com acoplamento fraco para outras partes da base do código. Isto mantém o tamanho dos objetos pequenos (para manutenção) e melhora a sua reusabilidade.

Essas melhores práticas de POO têm várias implicações para as melhores práticas na estrutura da cena e uso de script.

Se possível, deve-se projetar cenas para não ter dependências. Ou seja, deve-se criar cenas que mantenham tudo o que precisam dentro de si.

Se uma cena deve interagir com um contexto externo, desenvolvedores experientes recomendam o uso de Injeção de Dependência. Esta técnica envolve ter uma API de alto nível que fornece as dependências da API de baixo nível. Por que fazer isto? Porque classes que dependem de seu ambiente externo podem inadvertidamente acionar bugs e comportamentos inesperados.

Para isto, é necessário expor dados e, em seguida, confiar em um contexto pai para inicializá-los:

  1. Conecte a um sinal. Extremamente seguro, mas deve ser usado apenas para "responder" ao comportamento, não para o iniciar. Note que os nomes dos sinais são normalmente verbos no past-tense (passado) como "entered", "skill_activated" ou "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. Chame um método. Usado para iniciar o comportamento.

    # 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. Inicialize uma propriedade FuncRef. Mais seguro do que um método, pois a propriedade do método é desnecessária. Utilizado para iniciar o comportamento.

    # 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. Inicialize um Nó ou outra referência de Objeto.

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

Essas opções ocultam os pontos de acesso do nó filho. Isso, por sua vez, mantém o filho vagamente acoplado ao ambiente. Pode-se reutilizá-lo em outro contexto sem quaisquer alterações extras em sua API.

Nota

Embora os exemplos acima ilustrem as relações entre pai-filho, os mesmos princípios se aplicam a todas as relações de objetos. Nós que são irmãos só devem estar cientes de suas hierarquias enquanto um ancestral media suas comunicações e referências.

# 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);
    }
}

Os mesmos princípios também se aplicam a objetos não-Nó que mantêm dependências em outros objetos. Qualquer objeto que realmente possui os objetos deve gerenciar as relações entre eles.

Aviso

Deve-se favorecer a manutenção dos dados internamente (internos a uma cena), embora colocar uma dependência em um contexto externo, mesmo um vagamente acoplado, ainda signifique que o nó esperará que algo em seu ambiente seja verdadeiro. As filosofias de design do projeto devem evitar que isso aconteça. Do contrário, as responsabilidades inerentes ao código forçarão os desenvolvedores a usar a documentação para controlar as relações de objeto em uma escala microscópica; isso também é conhecido como inferno do desenvolvimento. Escrever código que depende de documentação externa para usá-lo com segurança está sujeito a erros por padrão.

Para evitar a criação e manutenção de tal documentação, converte-se o nó dependente ("filho" acima) em um script de ferramenta que implementa _get_configuration_warning(). Retornar uma string não vazia fará com que a dock Cena gere um ícone de aviso com a string como uma dica de ferramenta pelo nó. Este é o mesmo ícone que aparece para nós como o nó Area2D quando não tem nós filhos CollisionShape2D definidos. O editor então autodocumenta a cena por meio do código do script. Nenhuma duplicação de conteúdo por meio de documentação é necessária.

Uma GUI como essa pode informar melhor os usuários do projeto sobre informações críticas sobre um Nó.Ele possui dependências externas? Essas dependências foram satisfeitas? Outros programadores, e especialmente designers e escritores, precisarão de instruções claras nas mensagens dizendo-lhes o que fazer para configurá-lo.

Então, por que toda esta troca complexa funciona? Bem, porque as cenas funcionam melhor quando funcionam sozinhas. Se não conseguir trabalhar sozinho, trabalhar com outras pessoas anonimamente (com dependências rígidas mínimas, ou seja, acoplamento fraco) é a segunda melhor opção. Inevitavelmente, mudanças podem precisar ser feitas em uma classe e se essas mudanças fizerem com que ela interaja com outras cenas de maneiras imprevistas, então as coisas começarão a quebrar. O objetivo de toda essa indireção é evitar acabar em uma situação em que a mudança de uma classe resulte em efeitos adversos em outras classes.

Scripts e cenas, como extensões de classes do motor, devem obedecer a todos os princípios de POO. Exemplos incluem...

Escolhendo uma estrutura de árvore de nós

So, a developer starts work on a game only to stop at the vast possibilities before them. They might know what they want to do, what systems they want to have, but where to put them all? Well, how one goes about making their game is always up to them. One can construct node trees in countless ways. But, for those who are unsure, this helpful guide can give them a sample of a decent structure to start with.

Um jogo deve sempre ter uma espécie de "ponto de entrada"; em algum lugar, o desenvolvedor pode rastrear definitivamente onde as coisas começam para que possam seguir a lógica enquanto ela continua em outro lugar. Este local também serve como uma visão geral de todos os outros dados e lógicas do programa. Para aplicações tradicionais, esta seria a função "main". Nesse caso, seria um nó Main.

  • Node "Main" (main.gd)

O script main.gd então serviria como o controlador principal do jogo.

Então, se tem seu "Mundo" real no jogo (um 2D ou 3D). Este pode ser um filho de Main. Além disso, será necessária uma GUI principal para o jogo que gerencie os vários menus e widgets de que o projeto precisa.

  • Node "Main" (main.gd)
    • Node2D/Spatial "World" (game_world.gd)

    • Control "GUI" (gui.gd)

Ao mudar de fase, é possível trocar os filhos do nó "Mundo". Mudar as cenas manualmente dá aos usuários controle total sobre como suas transições de mundo de jogo.

O próximo passo é considerar quais sistemas de jogabilidade o projeto requer. Se houver um sistema que...

  1. rastreia todos os seus dados internamente

  2. deve ser globalmente acessível

  3. deve existir isoladamente

... então deve-se criar um nó autoload 'singleton'.

Nota

Para jogos menores, uma alternativa mais simples com menos controle seria ter um singleton "Game" que simplesmente chama o método SceneTree.change_scene() para trocar o conteúdo da cena principal. Esta estrutura mantém mais ou menos o "Mundo" como o nó principal do jogo.

Qualquer GUI também precisaria ser um singleton; ser uma parte transitória do "Mundo"; ou ser adicionado manualmente como filho direto da raiz. Caso contrário, os nós da GUI também se excluiriam durante as transições de cena.

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 versus regular nodes documentation.

Cada subsistema dentro do jogo deve ter sua própria seção dentro da SceneTree. Deve-se usar relacionamentos pai-filho apenas nos casos em que os nós são efetivamente elementos de seus pais. Remover o pai significa razoavelmente que também se deve remover os filhos? Do contrário, deve ter seu próprio lugar na hierarquia como irmão ou algum outro parente.

Nota

Em alguns casos, é necessário que esses nós separados também se posicionem uns em relação aos outros. É possível usar os nós RemoteTransform / RemoteTransform2D para este propósito. Eles permitirão que um nó de destino herde condicionalmente os elementos de transformação selecionados do nó Remoto*. Para atribuir o :ref: NodePath <class_NodePath> target, use um dos seguintes:

  1. Um terceiro confiável, provavelmente um nó pai, para mediar a atribuição.

  2. Um grupo, para puxar facilmente uma referência para o nó desejado (assumindo que haverá apenas um dos alvos).

Quando fazer isto? Bem, isto é subjetivo. O dilema surge quando é necessário microgerenciar quando um nó deve se mover ao redor da SceneTree para se preservar. Por exemplo...

  • Adicionar um nó "jogador" a uma "sala".

  • É necessário mudar de sala, portanto, deve-se excluir a sala atual.

  • Antes que a sala possa ser excluída, deve-se preservar e/ou mover o jogador.

    A memória é uma preocupação?

    • Caso não, pode-se simplesmente criar as duas salas, mover o jogador e deletar a antiga. Sem problemas.

    Se sim, será necessário...

    • Mover o jogador para outro lugar na árvore.

    • Deletar a sala.

    • Instanciar e adicionar a nova sala.

    • Adicionar novamente o jogador.

A questão é que o jogador aqui é um "caso especial"; aquele em que os desenvolvedores devem saber que precisam lidar com o jogador desta maneira para o projeto. Como tal, a única maneira confiável de compartilhar essas informações em equipe é documentá-las. Manter os detalhes da implementação na documentação, entretanto, é perigoso. É um fardo de manutenção, restringe a legibilidade do código e incha o conteúdo intelectual de um projeto desnecessariamente.

Em um jogo mais complexo com assets maiores, pode ser uma ideia melhor simplesmente manter o jogador inteiramente em outro lugar no SceneTree. Isto resulta em:

  1. Mais consistência.

  2. Nenhum "caso especial" que deva ser documentado e mantido em algum lugar.

  3. Nenhuma oportunidade para erros ocorrerem porque estes detalhes não são levados em consideração.

Em contraste, se alguém precisa ter um nó filho que não herda a transformação de seu pai, tem-se as seguintes opções:

  1. A solução declarativa: coloque um Node entre eles. Como nós sem transformação, Nodes não passarão essas informações para seus filhos.

  2. A solução imperativa: Use o setter set_as_toplevel para o nó CanvasItem ou Spatial. Isso fará com que o nó ignore sua transformação herdada.

Nota

Se estiver criando um jogo em rede, tenha em mente quais nós e sistemas de jogo são relevantes para todos os jogadores e aqueles apenas pertinentes ao servidor autorizado. Por exemplo, nem todos os usuários precisam ter uma cópia da lógica "PlayerController" de todos os jogadores. Em vez disso, eles precisam apenas dos seus próprios. Como tal, mantê-los em um ramo separado do "mundo" pode ajudar a simplificar o gerenciamento de conexões de jogos e similares.

A chave para a organização da cena é considerar a SceneTree em termos relacionais, em vez de termos espaciais. Os nós são dependentes da existência de seus pais? Do contrário, eles podem prosperar sozinhos em outro lugar. Se forem dependentes, é lógico que devam ser filhos desse pai (e provavelmente parte da cena desse pai, se ainda não o forem).

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.