Up to date

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

Организация сцены

Эта статья охватывает темы связанные с эффективной организацией содержания сцены. Какие узлы вам стоит использовать? Как их стоит располагать? Как они должны взаимодействовать?

Как эффективно строить зависимости

Когда пользователи Godot начинают создавать собственные сцены, они часто приходят к подобной проблеме:

Они создают свою первую сцену и заполняют её содержимым, чтобы в конечном итоге прийти к тому, чтобы сохранить ветки узлов в отдельные сцены, так как начинает накапливаться мучительное чувство, что нужно разделить их. Однако затем они замечают, что ссылок, по которым они могли обращаться к узлам, больше нет. Использование одной и той же сцены в разных местах проблематично, потому что пути узлов не находят своих целей, а сигналы, установленные в редакторе, разрываются.

Чтобы исправить эти проблемы, необходимо создать экземпляры под-сцены, не требуя подробностей об их окружении. Нужно быть уверенным в том, что под-сцена создаст сама себя, не придавая значения тому, как её использовать.

Одна из важнейших вещей, которую следует учитывать в ООП — это поддержка целевых классов единственного назначения со слабым зацеплением с другими частями кодовой базы. Это сохраняет размеры объектов небольшими (для удобства поддержки) и улучшает их переиспользование.

Этот наилучший метод ООП имеет некоторую причастность к наилучшему методу структурирования сцены и использования скриптов.

Если в целом это возможно, вам стоит создавать сцены так чтобы они не имели зависимостей. То есть, вам стоит создавать сцены, которые содержат всё, что им нужно, в пределах самих себя.

Если сцена должна взаимодействовать с внешним контекстом, опытные разработчики рекомендуют использовать Внедрение Зависимости. Эта техника включает в себя наличие высокоуровневого API, который предоставит зависимости для низкоуровневого API. Почему так стоит делать? Потому что классы которые полагаются на внешнее окружение могут ненароком вызвать баги и неожиданное поведение.

Для этого необходимо предоставить данные, а затем положиться на родительскую связь чтобы все это инициализировать:

  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. Инициализация свойства Callable. Безопаснее, чем метод, так как нет необходимости владеть методом. Используется для запуска поведения.

    # 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_warnings(). Возврат из него непустого массива PackedStringArray заставит Scene dock сгенерировать в качестве всплывающей подсказки у узла иконку предупреждения со строкой (строками). Такой же значок появляется для узлов, например, для узла Area2D, если у него не определены дочерние узлы CollisionShape2D. После этого редактор самостоятельно документирует сцену с помощью кода скрипта. Дублирование содержимого через документацию не требуется.

Подобный графический интерфейс может лучше информировать пользователей проекта о важной информации об узлах. Есть ли у него внешние зависимости? Удовлетворены ли эти зависимости? Другим программистам, особенно дизайнерам и писателям, потребуются чёткие инструкции в сообщениях, говорящие им, что делать, чтобы настроить его.

Так почему же вся эта сложная коммутация работает? Ну, потому что сцены работают лучше всего, когда они работают в одиночку. Если нет возможности работать в одиночку, то лучше всего работать с другими анонимно (с минимальными жесткими зависимостями, т.е. свободное сопряжение). Неизбежно может возникнуть необходимость внести изменения в класс, и если эти изменения приведут к непредвиденному взаимодействию с другими сценами, то все начнёт ломаться. Смысл всей этой непрямой связи заключается в том, чтобы избежать ситуации, когда изменение одного класса приводит к негативному влиянию на другие классы, зависящие от него.

Скрипты и сцены, как расширения классов движков, должны соответствовать всем принципам ООП. Примеры включают...

Выбор структуры дерева нод

Итак, разработчик начинает работу над игрой только для того, чтобы остановиться перед огромными возможностями. Они могут знать, что они хотят сделать, какие системы они хотят иметь, но где их разместить? То, как человек будет создавать свою игру, всегда зависит от него самого. Дерево узлов можно построить бесчисленными способами. Но для тех, кто не уверен, это полезное руководство может дать им образец достойной структуры для начала.

В игре всегда должна быть своего рода "точка входа"; где-то разработчик может окончательно отследить, где что-то начинается, чтобы он мог следовать логике, как она продолжается в другом месте. Это место также служит для обзора всех остальных данных и логики программы с высоты птичьего полета. Для традиционных приложений это будет "главная" функция. В данном случае это будет главный узел (Main).

  • Узел "Main" (main.gd)

В этом случае скрипт main.gd будет служить основным контроллером игры.

Затем есть свой настоящий игровой "Мир" (World) (2D или 3D). Это может быть дочерний элемент Main. Кроме того, для игры потребуется основной графический интерфейс, который управляет различными меню и виджетами, которые необходимы проекту.

  • Узел "Main" (main.gd)
    • Node2D/Node3D "World" (game_world.gd)

    • Управление "GUI" (gui.gd)

При смене уровней можно извлечь из узла "World" дочерние элементы. Смена сцен вручную дает пользователям полный контроль над тем, как они меняют игровой мир.

Следующий шаг - обдумать, какие игровые системы требуются для вашего проекта. Если есть система, которая....

  1. отслеживает все свои данные изнутри

  2. должна быть доступна глобально

  3. может существовать изолированно

... то следует создать автозагрузочный узел 'singleton'.

Примечание

Для небольших игр более простой альтернативой с меньшим контролем может быть синглтон "Game", который просто вызывает метод SceneTree.change_scene_to_file() для смены содержимого основной сцены. Эта структура более или менее сохраняет "World" в качестве основного игрового узла.

Любой графический интерфейс также должен быть одноэлементным; быть преходящей частью "World"; или быть добавленным вручную как прямой потомок корня. В противном случае узлы GUI также удалялись бы при переходе между сценами.

Если у вас есть системы, которые изменяют данные других систем, следует определять их как свои собственные сценарии или сцены, а не помещать их автозагрузку. Дополнительные сведения о причинах см. в документации Автозагрузка против внутренних узлов.

Каждая подсистема в игре должна иметь свой собственный раздел в SceneTree (дерево сцены). Отношения родитель-потомок следует использовать только в тех случаях, когда узлы фактически являются элементами своих родителей. Означает ли удаление родителя разумно, что нужно удалить и детей? Если нет, то он должен иметь свое собственное место в иерархии в качестве родного брата или другого родственника.

Примечание

В некоторых случаях требуется, чтобы эти разделённые узлы еще и позиционировались относительно друг друга. Для этого можно использовать узлы RemoteTransform / RemoteTransform2D. Они позволят целевому узлу условно наследовать выбранные элементы трансформации от узла Remote*. Чтобы назначить цель NodePath, воспользуйтесь одним из следующих способов:

  1. Надёжная третья сторона, вероятно, родительский узел, для посредничества при назначении.

  2. Группа, чтобы легко получить ссылку на желаемый узел (при условии, что когда-либо будет только одна из целей).

Когда нужно это делать? Ну это субъективно. Дилемма возникает, когда нужно микроуправление, когда узел должен перемещаться по дереву сцены, чтобы сохранить себя. Например...

  • Добавьте узел "player" в "room".

  • Необходимо изменить комнаты, поэтому необходимо удалить текущую комнату.

  • Прежде чем комнату можно будет удалить, нужно сохранить и/или переместить игрока.

    Память - это проблема?

    • Если нет, можно просто создать две комнаты, переместить игрока и удалить старую. Нет проблем.

    Если да, то нужно...

    • Переместите игрока в другое место в дереве.

    • Удалить комнату.

    • Создать и добавить новую комнату.

    • Повторно добавьте игрока.

Проблема в том, что игрок здесь является "особым случаем"; тот, где разработчики должны знать, что им нужно обращаться с игроком таким образом для проекта. Таким образом, единственный способ надёжно поделиться этой информацией в команде - это задокументировать её. Однако хранить детали реализации в документации опасно. Это бремя обслуживания затрудняет читаемость кода и без надобности раздувает интеллектуальное содержание проекта.

В более сложной игре с более крупными ассетами может быть лучшей идеей просто полностью оставить игрока где-нибудь в другом месте в дереве сцены. Это приводит к:

  1. Большей согласованности.

  2. Нет "особых случаев", которые нужно где-то документировать и поддерживать.

  3. Нет возможности допустить ошибку, так как эти детали не учитываются.

Напротив, если кому-то когда-либо понадобится дочерний узел, который не наследует преобразование своего родителя, у него есть следующие варианты:

  1. Декларативное решение: поместите между ними Node. Как узлы без преобразования, узлы не будут передавать такую информацию своим потомкам.

  2. Императивное решение: Используйте свойство top_level для узла CanvasItem или Node3D. Это заставит узел игнорировать унаследованную трансформацию.

Примечание

При создании сетевой игры имейте в виду, какие узлы и системы игрового процесса относятся ко всем игрокам, а не только к авторитетному серверу. Например, не всем пользователям нужно иметь копию логики "PlayerController" каждого игрока. Вместо этого им нужны только свои. Таким образом, их хранение в отдельной ветке от "world" может помочь упростить управление игровыми подключениями и т.п.

Ключ к организации сцены - рассматривать дерево сцены в реляционных терминах, а не в пространственных терминах. Зависимы ли узлы от существования их родителей? Если нет, то они могут развиваться сами по себе где-нибудь ещё. Если они зависимы, то логично предположить, что они должны быть потомками этого родителя (и, вероятно, частью сцены этого родителя, если они ещё не являются таковыми).

Означает ли это, что сами узлы являются компонентами? Конечно, нет. Деревья узлов Godot формируют отношения агрегирования, а не композиции. Но несмотря на то, что у вас по прежнему есть возможность перемещать узлы, лучше всего, когда такие перемещения, по умолчанию, не нужны.