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

В этой статье я расскажу, как на нашем проекте мы заставили xNode корректно работать внутри префабов в Unity. Возможно, вы сталкивались с похожей проблемой - когда все связи между узлами и компонентами теряются при сохранении. Мы нашли элегантное решение, которым хочу поделиться.

Немного про xNode

xNode - это open-source фреймворк для Unity, позволяющий создавать узловые графы (node graphs) с визуальным редактором прямо в инспекторе. Он прост в использовании, легко расширяется, поддерживает сериализацию пользовательских типов и отлично подходит для прототипирования или построения визуальной логики.

Однако у xNode есть важное ограничение: он построен на ScriptableObject и не предназначен для работы внутри префабов.

Контекст нашего проекта

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

Можно задать вопрос: «Зачем использовать сторонний фреймворк, если в Unity уже есть Visual Scripting

Мы пробовали Visual Scripting, но отказались от него в пользу xNode в первую очередь из-за читаемости графов, простоты кастомизации и быстроты интеграции.

Пример одной и той же логики в Visual Scripting и xNode:

Переключение состояния массива объектов при попадании игрового персонажа в зону на Visual Scripting
Переключение состояния массива объектов при попадании игрового персонажа в зону на Visual Scripting
Переключение состояния массива объектов при попадании игрового персонажа в зону на xNode
Переключение состояния массива объектов при попадании игрового персонажа в зону на xNode

Проблема: префабы и ScriptableObject

Когда возникла необходимость собирать логику интерактивных объектов (например, сундуков или ловушек) прямо в префабах, стало ясно: ScriptableObject не может хранить ссылки на объекты сцены.

При сохранении префаба все такие ссылки теряются и поведение ломается.

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

Решение: кэширование ссылок

Мы создали интерфейс ICacheableNode, который реализуют узлы, нуждающиеся в сохранении ссылок. У каждого узла есть GUID, по которому мы сможем восстановить нужные данные.

public interface ICacheableNode
{
  string Guid { get; }

  Component[] Cache();
  void Restore(Component[] data);
}

Пример реализации узла

public class PlayerTriggerConditionNode : Node, ICacheableNode
{
  [SerializeField, HideInInspector] private string _guid;

  public string Guid => _guid;

  protected override void Init()
  {
    base.Init();
    if (Application.isPlaying || !string.IsNullOrEmpty(_guid))
      return;

    _guid = System.Guid.NewGuid().ToString();
  }

  public Component[] Cache() => new Component[] { _collider };

  public void Restore(Component[] data)
  {
    _collider = data[0] as Collider;
  }
}

Класс для хранения кэша

Далее мы реализовали компонент GraphDataCache, который хранит сериализованный кэш и умеет сохранять и восстанавливать данные:

public class GraphDataCache : MonoBehaviour
{
  [SerializeField, HideInInspector] private GraphData[] _data;

  public void CacheData()
  {
    var graph = GetComponent<SomeSceneGraph>().graph;
    var data = new List<GraphData>();

    foreach (var kvp in graph.Cache())
    {
      if (kvp.Item2 == null || kvp.Item2.Length == 0)
        continue;

      data.Add(new GraphData(kvp.Item1, kvp.Item2));
    }

    _data = data.ToArray();
  }

  public void RestoreData()
  {
    var graph = GetComponent<SomeSceneGraph>().graph;
    foreach (var data in _data)
      graph.Restore((data.Guid, data.NodeData));
  }
}

[Serializable]
public class GraphData
{
  public string Guid;
  public Component[] NodeData;
  
  public GraphData(string guid, Component[] nodeData)
  {
    Guid = guid;
    NodeData = nodeData;
  }
}

Редакторская кнопка для сохранения графа и префаба

Чтобы автоматизировать процесс, добавляем кастомный инспектор с кнопкой:

[CustomEditor(typeof(SomeSceneGraph))]
public class SomeSceneGraphEditor : SceneGraphEditor
{
  private const string GRAPH_PATH = "Assets/InteractiveGraph/Graphs";
  private const string PREFAB_PATH = "Assets/InteractiveGraph/Prefabs";

  private SomeSceneGraph SceneGraph => (SomeSceneGraph)target;

  public override void OnInspectorGUI()
  {
    base.OnInspectorGUI();

    if (SceneGraph.graph == null)
      return;

    if (GUILayout.Button("Save as prefab", GUILayout.Height(40)))
    {
      SaveGraphAssets();
      SavePrefab();
    }
  }

  private void SaveGraphAssets()
  {
    var graph = SceneGraph.graph;
    var path = Path.Combine(GRAPH_PATH, $"{SceneGraph.gameObject.name}.asset");

    if (AssetDatabase.AssetPathExists(path))
      AssetDatabase.DeleteAsset(path);

    AssetDatabase.CreateAsset(graph, path);
    foreach (var node in graph.nodes)
      AssetDatabase.AddObjectToAsset(node, graph);

    AssetDatabase.SaveAssets();
    AssetDatabase.Refresh();
  }

  private void SavePrefab()
  {
    var oldCache = SceneGraph.GetComponent<GraphDataCache>();
    if (oldCache != null)
      DestroyImmediate(oldCache);

    var cache = SceneGraph.gameObject.AddComponent<GraphDataCache>();
    cache.CacheData();

    var path = Path.Combine(PREFAB_PATH, $"{SceneGraph.gameObject.name}.prefab");
    var prefab = PrefabUtility.SaveAsPrefabAsset(SceneGraph.gameObject, path);

    PrefabUtility.InstantiatePrefab(prefab, SceneGraph.transform.parent);
    DestroyImmediate(SceneGraph.gameObject);
  }
}

Логика восстановления графа

[ExecuteAlways]
public class SomeSceneGraph : SceneGraph<SomeGraph>
{
#if UNITY_EDITOR
  private const string GRAPH_PATH = "Assets/InteractiveGraph/Graphs";

  private void OnEnable()
  {
    if (!Application.isPlaying)
      TryInitInEditor();
  }

  private void TryInitInEditor()
  {
    if (EditorUtility.IsPersistent(gameObject)) return;
    if (!PrefabUtility.IsPartOfPrefabInstance(gameObject)) return;
    if (PrefabStageUtility.GetPrefabStage(gameObject) != null) return;

    Undo.RecordObject(this, "Init prefab instance");
    InitializeDataInEditor();
    EditorUtility.SetDirty(this);
  }

  private void InitializeDataInEditor()
  {
    var graphName = gameObject.name.Replace("(Clone)", "").Split(' ')[0];
    var graphAsset = AssetDatabase.LoadAssetAtPath<SomeGraph>(Path.Combine(GRAPH_PATH, $"{graphName}.asset"));

    graph = Instantiate(graphAsset);

    var cache = GetComponent<GraphDataCache>();
    cache.RestoreData();
  }
#endif
}

public class SomeGraph : NodeGraph
{
  public IEnumerable<(string, Component[])> Cache()
  {
    foreach (var node in nodes)
    {
      if (node is not ICacheableNode cacheable)
        continue;

      yield return (cacheable.Guid, cacheable.Cache());
    }
  }

  public void Restore((string, Component[]) cachedNode)
  {
    var node = nodes.Find(x => x is ICacheableNode c && c.Guid == cachedNode.Item1);
    if (node is ICacheableNode cacheable)
      cacheable.Restore(cachedNode.Item2);
  }
}

Результат

Теперь графы на базе xNode можно безопасно использовать в префабах. Все ссылки на компоненты и объекты сцены корректно сохраняются и восстанавливаются при инстанцировании.

Вывод

Мы смогли расширить возможности xNode, не ломая его архитектуру, и сделали систему кэширования, которая:

  • сохраняет все связи графа в префабе;

  • восстанавливает их при инстанцировании;

  • не требует изменения ядра xNode;

  • полностью автоматизирована через редакторскую кнопку.

Надеюсь, наш подход будет полезен командам, которые активно используют xNode в своих Unity-проектах.

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