Привет, Хабр!👋
В этой статье я расскажу, как на нашем проекте мы заставили xNode корректно работать внутри префабов в Unity. Возможно, вы сталкивались с похожей проблемой - когда все связи между узлами и компонентами теряются при сохранении. Мы нашли элегантное решение, которым хочу поделиться.
Немного про xNode
xNode - это open-source фреймворк для Unity, позволяющий создавать узловые графы (node graphs) с визуальным редактором прямо в инспекторе. Он прост в использовании, легко расширяется, поддерживает сериализацию пользовательских типов и отлично подходит для прототипирования или построения визуальной логики.
Однако у xNode есть важное ограничение: он построен на ScriptableObject и не предназначен для работы внутри префабов.
Контекст нашего проекта
В нашем проекте мы используем xNode для сборки всей логики (или скриптовки) игровых локаций - миссий. Все триггеры (условия) и действия описываются через узлы графа. В этих же графах геймдизайнеры настраивают цели миссий и последовательность событий.
Можно задать вопрос: «Зачем использовать сторонний фреймворк, если в Unity уже есть Visual Scripting?»
Мы пробовали Visual Scripting, но отказались от него в пользу xNode в первую очередь из-за читаемости графов, простоты кастомизации и быстроты интеграции.
Пример одной и той же логики в Visual Scripting и 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-проектах.
