Организация сцены
Эта статья охватывает темы связанные с эффективной организацией содержания сцены. Какие узлы вам стоит использовать? Где их стоит располагать? Как они должны взаимодействовать?
Как эффективно строить зависимости
Когда пользователи Godot начинают создавать собственные сцены, они часто приходят к подобной проблеме:
Они создают свою первую сцену и заполняют её содержимым, чтобы в конечном итоге прийти к тому, чтобы сохранить ветки узлов в отдельные сцены, так как начинает накапливаться мучительное чувство, что нужно разделить их. Однако затем они замечают, что ссылок, по которым они могли обращаться к узлам, больше нет. Использование одной и той же сцены в разных местах проблематично, потому что пути узлов не находят своих целей, а сигналы, установленные в редакторе, разрываются.
Чтобы исправить эти проблемы, необходимо создавать экземпляры под-сцены без подробностей об их окружении. Нужно быть уверенным в том, что под-сцена создаст сама себя, не придавая значения тому, как её будут использовать.
Одна из важнейших вещей, которую следует учитывать в ООП — это поддержка целевых классов единственного назначения со слабым зацеплением с другими частями кодовой базы. Это сохраняет размеры объектов небольшими (для удобства поддержки) и улучшает их переиспользование.
Этот наилучшие практики ООП имеет некоторую причастность к наилучшему практикам в структурировании сцены и использования скриптов.
Если в целом это возможно, вам стоит создавать сцены так, чтобы они не имели зависимостей. То есть, вам стоит создавать сцены, которые содержат в себе всё, что им нужно.
Если сцена должна взаимодействовать с внешним контекстом, то опытные разработчики рекомендуют использовать Внедрение Зависимости. Эта техника включает в себя наличие высокоуровневого API, который предоставит зависимости для низкоуровневого API. Почему так стоит делать? Потому что классы, которые полагаются на внешнее окружение могут ненароком вызвать баги и неожиданное поведение.
Для этого необходимо предоставить данные, а затем положиться на родительскую связь чтобы все это инициализировать:
Подключение к сигналу. Очень безопасно, но должно использоваться только для "реагирования" на поведение, а не для его запуска. По традиции, именами сигналов обычно служат глаголы прошедшего времени типа "вошел" (entered), "навык_активирован" (skill_activated) или "предмет_собран" (item_collected).
# Parent $Child.signal_name.connect(method_on_the_object) # Child signal_name.emit() # Triggers parent-specified behavior.
// Parent GetNode("Child").Connect("SignalName", Callable.From(ObjectWithMethod.MethodOnTheObject)); // Child EmitSignal("SignalName"); // Triggers parent-specified 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-specified behavior.
Вызов метода. Используется для запуска поведения.
# Parent $Child.method_name = "do" # Child, assuming it has String property 'method_name' and method 'do'. call(method_name) # Call parent-specified method (which child must own).
// Parent GetNode("Child").Set("MethodName", "Do"); // Child Call(MethodName); // Call parent-specified 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-specified method (which child must own).
Инициализация свойства Callable. Безопаснее, чем метод, так как нет необходимости владеть методом. Используется для запуска поведения.
# Parent $Child.func_property = object_with_method.method_on_the_object # Child func_property.call() # Call parent-specified method (can come from anywhere).
// Parent GetNode("Child").Set("FuncProperty", Callable.From(ObjectWithMethod.MethodOnTheObject)); // Child FuncProperty.Call(); // Call parent-specified 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-specified method (can come from anywhere).
Инициализация ссылки на Node или другой Object.
# Parent $Child.target = self # Child print(target) # Use parent-specified node.
// Parent GetNode("Child").Set("Target", this); // Child GD.Print(Target); // Use parent-specified 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-specified NodePath.
// Parent GetNode("Child").Set("TargetPath", NodePath("..")); // Child GetNode(TargetPath); // Use parent-specified NodePath.
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("target_path", NodePath("..")); } // Child get_node<Node>(target_path); // Use parent-specified 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. Затем редактор самостоятельно документирует сцену через код скрипта. Дублирование содержимого через документацию не требуется.
Подобный графический интерфейс может лучше информировать пользователей проекта о важной информации об узлах. Есть ли у него внешние зависимости? Удовлетворены ли эти зависимости? Другим программистам, особенно дизайнерам и писателям, потребуются чёткие инструкции в сообщениях, говорящие им, что делать, чтобы настроить его.
Итак, почему все эти сложные переключения работают? Ну, потому что сцены работают лучше всего, когда они работают в одиночку. Если не могут работать в одиночку, то работа с другими анонимно (с минимальными жесткими зависимостями, т. е. слабой связанностью) — это следующий лучший вариант. Неизбежно может потребоваться внести изменения в класс, и если эти изменения заставят его взаимодействовать с другими сценами непредвиденным образом, то все начнет ломаться. Весь смысл всей этой косвенности в том, чтобы избежать ситуации, когда изменение одного класса приводит к неблагоприятному влиянию на другие классы, зависящие от него.
Скрипты и сцены, как расширения классов движков, должны соответствовать всем принципам ООП. Примеры включают...
Выбор структуры дерева узлов
Вы можете начать работать над игрой, но быть ошеломленными огромными возможностями, которые перед вами открываются. Вы можете знать, что вы хотите сделать, какие системы вы хотите иметь, но где вы все это разместите? То, как вы будете делать свою игру, всегда зависит от вас. Вы можете построить деревья узлов бесчисленным количеством способов. Если вы не уверены, это руководство может дать вам пример приличной структуры для начала.
Игра всегда должна иметь «точку входа»; где-то вы можете определенно отследить, где все начинается, чтобы вы могли проследить логику, поскольку она продолжается в другом месте. Это также служит видом сверху на все остальные данные и логику в программе. Для традиционных приложений это обычно «главная» функция. В Godot это узел Main.
Узел "Main" (main.gd)
Скрипт main.gd будет служить основным контроллером вашей игры.
Затем у вас есть внутриигровой "World" (2D или 3D). Он может быть потомком Main. Кроме того, вам понадобится основной GUI для вашей игры, который управляет различными меню и виджетами, необходимыми проекту.
- Узел "Main" (main.gd)
Node2D/Node3D "Мир" (game_world.gd)
Управление "GUI" (gui.gd)
При смене уровней вы можете поменять местами дочерние элементы узла «World». Смена сцен вручную дает вам полный контроль над тем, как меняется игровой мир.
Следующий шаг - обдумать, какие игровые системы требуются для вашего проекта. Если есть система, которая....
отслеживает все свои данные изнутри
должна быть доступна глобально
может существовать изолированно
... тогда вам следует создать вышеуказанный autoload 'singleton'.
Примечание
Для небольших игр более простой альтернативой с меньшим контролем может быть синглтон "Game", который просто вызывает метод SceneTree.change_scene_to_file() для смены содержимого основной сцены. Эта структура более или менее сохраняет "World" в качестве основного игрового узла.
Любой GUI также должен быть либо синглтоном, временной частью "World", либо вручную добавленным как прямой потомок корня. В противном случае узлы GUI также будут удалять себя во время переходов между сценами.
Если у вас есть системы, которые изменяют данные других систем, вы должны определить их как собственные скрипты или сцены, а не как автозагрузки. Для получения дополнительной информации см. Автозагрузки и обычные узлы.
Каждая подсистема в вашей игре должна иметь свой собственный раздел в SceneTree. Вы должны использовать родительско-дочерние отношения только в тех случаях, когда узлы фактически являются элементами своих родителей. Означает ли удаление родителя, что дочерние элементы также должны быть удалены? Если нет, то он должен иметь свое собственное место в иерархии как родственный элемент или какое-то другое отношение.
Примечание
В некоторых случаях вам нужно, чтобы эти разделенные узлы также позиционировали себя относительно друг друга. Для этой цели вы можете использовать узлы RemoteTransform / RemoteTransform2D. Они позволят целевому узлу условно наследовать выбранные элементы преобразования из узла Remote*. Чтобы назначить target NodePath, используйте одно из следующих:
Надёжная третья сторона, вероятно, родительский узел, для посредничества при назначении.
Группа, для извлечения ссылки на нужный узел (предполагается, что будет только одна из целей).
Когда это делать — вопрос субъективный. Дилемма возникает, когда необходимо микроуправлять, когда узел должен перемещаться по SceneTree, чтобы сохранить себя. Например...
Добавьте узел "player" в "room".
Необходимо сменить комнату, поэтому вам придется удалить текущую комнату.
Прежде чем удалить комнату, необходимо сохранить и/или переместить игрока.
Если память не бескоит, вы можете...
Создать новую комнату.
Переместите игрока в новую комнату..
Удалить старую комнату.
Если у вас проблемы с памятью (компьютерной, а не в вашей), вам нужно будет...
Переместите игрока в другое место в дереве.
Удалить комнату.
Создать и добавить новую комнату.
Повторно добавьте игрока в новую комнату.
Проблема в том, что проигрыватель здесь — это «особый случай», когда разработчики должны знать, что им нужно обращаться с проигрывателем таким образом для проекта. Единственный способ надежно поделиться этой информацией в команде — это документировать её. Хранить детали реализации в документации опасно. Это обременительно для обслуживания, затрудняет читаемость кода и неоправданно раздувает интеллектуальное содержание проекта.
В более сложной игре с большими активами может быть лучшей идеей полностью оставить игрока в другом месте SceneTree. Это приводит к:
Большей согласованности.
Отсутствию "особых случаев", которые нужно где-то документировать и поддерживать.
Отсутствию возможности допустить ошибку, так как эти детали не учитываются.
Напротив, если вам когда-нибудь понадобится дочерний узел, который не наследует преобразование своего родителя, у вас есть следующие варианты:
Declarative (Декларативное) решение: поместить Node между ними. Поскольку у него нет преобразования, они не будут передавать эту информацию своим потомкам.
Императивное решение: Используйте свойство
top_levelдля узла CanvasItem или Node3D. Это заставит узел игнорировать унаследованную трансформацию.
Примечание
При создании сетевой игры помните, какие узлы и игровые системы актуальны для всех игроков, а какие — только для авторитетного сервера. Например, не всем пользователям нужна копия логики "PlayerController" каждого игрока — им нужна только их собственная. Размещение их в отдельной ветке от "мира" может помочь упростить управление игровыми соединениями и т. п.
Ключ к организации сцены - рассматривать дерево сцены в терминах отношений, а не в пространственных терминах. Зависимы ли узлы от существования их родителей? Если нет, то они могут развиваться сами по себе где-нибудь ещё. Если они зависимы, то логично предположить, что они должны быть потомками этого родителя (и, вероятно, частью сцены родителя, если они ещё не являются таковыми).
Означает ли это, что узлы сами по себе являются компонентами? Вовсе нет. Деревья узлов Godot образуют отношение агрегации, а не композиции. Но хотя у вас все еще есть гибкость для перемещения узлов, все равно лучше, когда такие перемещения, по умолчанию, не нужны.