Привет, Хабр! ?
В прошлой статье я рассказывал о том, как мы научили фреймворк 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 ноды и имена полей, но эту часть я оставлю за рамками данной статьи.