Привет, Хабр! ?

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

Комментарии (0)