Привет, Хабр! 👋
В прошлой статье я рассказывал о том, как мы научили фреймворк xNode корректно работать внутри префабов - без потери ссылок и с сохранением целостности узловых графов при редактировании сцен. Это позволило нам использовать узловую систему для интерактивных объектов в игре: сундуков, оружия, кнопок, рычагов и всего, что требует небольшой автономной логики.
Сегодня хочу поделиться следующим этапом развития нашей системы - параметризацией графов, которая позволила нам создавать переиспользуемые интерактивные объекты с настраиваемой логикой, не плодя бесконечное множество почти одинаковых префабов.
Зачем понадобилась параметризация?
Представим, что у нас есть сундук. У него простой граф: проверка, что игрок вошёл в зону активации → проиграть анимацию → дать игроку золото → сменить состояние → готово.
Теперь нам нужен:
сундук на 20 золота
и точно такой же сундук на 50 золота
Логика одинаковая, меняется только одно число.
Но если просто сделать два разных префаба - граф внутри каждого начнёт жить своей жизнью, а любые правки логики придётся переносить вручную в каждый экземпляр.
Очевидно, что так жить нельзя.
Хочется иметь гибкость в нашей системе:
граф у всех объектов один и тот же;
но каждое поле, помеченное как «параметр», можно переопределить прямо в инспекторе.
Основа уже была: GUID у каждой ноды
Из предыдущей части у нас был важный бонус: каждая нода в графе имеет стабильный GUID, который не меняется после пересоздания графа.
А это значит, что мы можем хранить: GUID → имя поля → новое значение и спокойно восстанавливать параметры при старте сцены.
Помечаем параметры атрибутом
Не хочется переопределять всё подряд.
Гораздо приятнее явно сказать: «вот это поле я хочу менять через инспектор».
Мы сделали маленький атрибут:
[AttributeUsage(AttributeTargets.Field)] public class ExposeAsParameterAttribute : Attribute {}
И отмечаем только нужные поля:
[CreateNodeMenu("Conditions/Timer finished")] public class TimerFinishedConditionNode : BaseConditionNode { [SerializeField, ExposeAsParameter] private string _name; // Логика ноды... }
Всё: теперь редактор и рантайм знают, что _name — параметризуемое поле.
Где хранить переопределения?
Нужна простая структура, которая описывает:
GUID ноды;
имя поля;
тип значения;
само значение.
Вот так:
public enum NodeParamType { String, Int, Float, Bool, Enum } [Serializable] public struct NodeFieldOverride { public string Guid; public string FieldName; public NodeParamType ParamType; public string StringValue; public int IntValue; public float FloatValue; public bool BoolValue; }
Главный компонент - GraphDataOverride
Это обычный MonoBehaviour, который сидит на том же объекте, что и граф.
В нём мы прописываем переопределения значений, а затем применяем их при старте.
public class GraphDataOverride : MonoBehaviour { [SerializeField] private List<NodeFieldOverride> _overrides = new(); public void RestoreData() { var sceneGraph = GetComponent<SomeSceneGraph>(); var graph = sceneGraph.graph; foreach (var overrideData in _overrides) { if (string.IsNullOrEmpty(overrideData.Guid) || string.IsNullOrEmpty(overrideData.FieldName)) continue; if (!TryFindNodeByGuid(graph, overrideData.Guid, out var node)) continue; if (!TryFindField(node, overrideData.FieldName, out var field)) continue; var value = GetValueToSet(overrideData, field.FieldType); field.SetValue(node, value); } } private bool TryFindNodeByGuid(NodeGraph graph, string guid, out Node returnNode) { returnNode = null; foreach (var node in graph.nodes) { if (node is not ICacheableNode cachableNode) continue; if (!string.Equals(cachableNode.Guid, guid)) continue; returnNode = node; return true; } return false; } private bool TryFindField(Node node, string fieldName, out FieldInfo returnField) { returnField = node.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); if (returnField == null) return false; if (returnField.GetCustomAttribute<ExposeAsParameterAttribute>() == null) { returnField = null; return false; } return true; } private object GetValueToSet(NodeFieldOverride overrideData, Type fieldType) { switch (overrideData.ParamType) { case NodeParamType.String: if (fieldType == typeof(string)) return overrideData.StringValue; Log.Error($"Type mismatch: field '{overrideData.FieldName}' is {fieldType.Name}, not string", this); return string.Empty; case NodeParamType.Int: if (fieldType == typeof(int)) return overrideData.IntValue; Log.Error($"Type mismatch: field '{overrideData.FieldName}' is {fieldType.Name}, not int", this); return 0; case NodeParamType.Float: if (fieldType == typeof(float)) return overrideData.FloatValue; Log.Error($"Type mismatch: field '{overrideData.FieldName}' is {fieldType.Name}, not float", this); return 0f; case NodeParamType.Bool: if (fieldType == typeof(bool)) return overrideData.BoolValue; Log.Error($"Type mismatch: field '{overrideData.FieldName}' is {fieldType.Name}, not bool", this); return false; case NodeParamType.Enum: if (fieldType.IsEnum) return Enum.ToObject(fieldType, overrideData.IntValue); Log.Error($"Type mismatch: field '{overrideData.FieldName}' is {fieldType.Name}, not enum", this); return Enum.ToObject(fieldType, 0); default: Log.Error($"Unknown param type {overrideData.ParamType}", this); return null; } } } }
Применение параметров при старте
public class SomeSceneGraph : SceneGraph<SomeGraph> { private void Start() { var dataOverride = GetComponent<GraphDataOverride>(); dataOverride.RestoreData(); } }
Граф создался → параметры применились → готово.
Что получилось в итоге
Мы получили систему, которая:
позволяет использовать один и тот же граф для множества объектов;
не требует плодить одинаковые префабы;
не боится пересоздания графа Unity при открытии сцены;
сводит различия между объектами к простому списку параметров;
легко расширяется - хоть на десятки полей и типов.
А самое главное - теперь правки в логике делаются в одном месте и автоматически применяются для всех объектов, независимо от параметров.
Дальше для более удобной настройки стоит написать кастомный редактор для GraphDataOverride, что бы не пришлось вручную вписывать GUID ноды и имена полей, но эту часть я оставлю за рамками данной статьи.
