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