Visual Scripting Node Editor Help [C#]

I am making a node-based visual scripting editor. It is very simple at the moment, but I am trying to make it as expandable as possible with ease for adding new nodes.

I based it off of some code (the Finite State Machine code) on the Unify community wiki, and have expanded it greatly.

I posted this here because of this question:

What I need to know is how the heck to fix the DrawNodeWindow function in Scripty.cs so I can have EditorGUILayout.ObjectField for any field inside of a node. From there I can easily do the rest (hopefully XD).

To use the code, create a folder named “Scripty” putting scripts in there BUT put the 4
nodes in another folder inside of Scripty named “Nodes” also make a folder inside of Scripty
called “Saves” so the editor saves.

To see that the core works, create a sphere with a rigidbody, and name it “TestSphere” then add DoSystem.cs to it. Change the exposed variable “File Name” to “test.asset” through the inspector. Open the editor once, and errors will happen but a save is made nonetheless. If you run the game, the sphere should start to go upwards.

The code is all as follows:

Here is the core, in NodeSystem.cs:

using UnityEngine;
using System.Reflection;
using System.Collections;
using System.Collections.Generic;

[System.Serializable]
public abstract class Node : ScriptableObject {
    public static int NullTransition = 0;
    public static int NullNodeID = 0;
    // works like map[trans] = id
    public List<int> map = new List<int>();
    public int nodeID;

    // For input nodes
    public List<int> inputs = new List<int>();

    // used for editor
    public virtual string nodeName { get { return "nameHere"; } }
    public Rect window = new Rect (10, 10, 170, 100);

    public void SetTransition(int trans, int id) {
        if (trans == NullTransition) {
            Debug.LogError ("Scripty: Error: NullTransition is not allowed as a transition");
            return;
        }
       
        if (id == NullNodeID) {
            Debug.LogError ("Scripty: Error: NullNodeID is not allowed as an ID");
            return;
        }
       
        // make sure space exists
        if (map.Count < trans) {
            for (int i = map.Count; i <= trans; i++) {
                map.Add (0);
            }
        }
        map[trans] = id;
    }

    public void DeleteTransition(int trans) {
        if (trans == NullTransition) {
            Debug.LogError ("Scripty: Error: NullTransition is not allowed as a transition");
            return;
        }

        map[trans] = 0;
    }

    public int GetOutputNodeID(int trans) {
        return map[trans];
    }
   
    public virtual void OnEnter () { }
    public virtual void OnLeave () { }

    // Step (self) should return an int, commonly 1, which is the transition to call next
    public abstract int Step ();
    // For nodes that are for giving outputs (Vector3, float, GameObject, etc.)
    public abstract object Output ();

}

[System.Serializable]
public class NodeSystem {

    public List<Node> nodes;

    public NodeSystem () {
        nodes = new List<Node>();
    }

    public List<Node> getNodes() {
        return nodes;
    }

    /**
     * Sets node n's input of fieldName to node settingInput
     */
    public void SetNodeInput(Node n, Node input, string fieldName) {
        const BindingFlags flags = BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public;
        FieldInfo[] fields = n.GetType ().GetFields (flags);
        for (int i = 0; i < fields.Length; i++) {
            if (fields[i].Name == fieldName) {
                // this is the right spot to set.
                // make sure space exists
                if (n.inputs.Count < i) {
                    for (int j = n.inputs.Count; j <= i; j++) {
                        n.inputs.Add (0);
                    }
                }
                n.inputs[i] = input.nodeID;
                break;
            }
        }
    }

    public void UpdateNodeInputs(Node n) {
        const BindingFlags flags = BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public;
        FieldInfo[] fields = n.GetType ().GetFields (flags);
        for (int i = 0; i < fields.Length; i++) {
            if (n.inputs[i] > 0) {
                // there is a node assigned as an input
                foreach (Node possible in nodes) {
                    if (possible.nodeID == n.inputs[i]) {
                        // node found to get output from
                        // update it too
                        UpdateNodeInputs (possible);
                        // use its output
                        fields[i].SetValue (n, possible.Output ());
                        break;
                    }
                }
                break;
            }
        }
    }

    public void AddNode(Node n) {
        if (n == null) {
            Debug.LogError ("Scripty: Error: Null reference is not allowed");
            return;
        }

        foreach (Node node in nodes) {
            if (node.nodeID == n.nodeID) {
                Debug.LogError ("Scripty: Error: Impossible to add node " + n.nodeID.ToString() + " because is has already been added");
                return;
            }
        }

        nodes.Add (n);
    }

    public void DeleteNode(int id) {
        if (id == Node.NullNodeID) {
            Debug.LogError ("Scripty: Error: NullNodeID is not allowed as a transition");
            return;
        }

        foreach (Node node in nodes) {
            if (node.nodeID == id) {
                nodes.Remove (node);
                return;
            }
        }
        Debug.LogError ("Scripty: Error: Impossible to delete node " + id.ToString() + " because it hasn't been added or has already been deleted");
    }

    public void CallFunction (string funcName) {
        // Make sure the function is even acceptable to search for first
        if (funcName == null) {
            Debug.LogError ("Scripty: Error: CallFunction cannot call a function by a null name!");
            return;
        }
        if (funcName == "") {
            Debug.LogError ("Scripty: Error: CallFunction cannot call a function by a blank name!");
            return;
        }

        // search through the nodes for the function node of this function
        foreach (Node node in nodes) {
            if (node.GetType () == typeof (FunctionNode)) {
                // This node is a function node
                if (((FunctionNode) node).function == funcName) {
                    // right node. start then from this node
                    StartFromNode (node);
                }
            }
        }
    }

    public void StartFromNode (Node startingNode) {
        Node curNode = startingNode;
        UpdateNodeInputs (curNode);
        curNode.OnEnter ();
        bool functioning = true;
        while (functioning) {
            UpdateNodeInputs (curNode);
            int next = curNode.Step ();

            if (next < 0) {
                // given a negative, this means that the node wants to stop this chain.
                functioning = false;
            } else if (next == 0) {
                // the node doesn't quite want to leave yet.
            } else {
                // move on to the next node.

                // prepare
                UpdateNodeInputs (curNode);
                curNode.OnLeave ();

                int nextID = curNode.GetOutputNodeID (next);
                if (nextID < 1) {
                    // shouldn't happen at all.
                    Debug.LogError ("Scripty: Error: Null ID given on transition!");
                }

                // transition
                foreach (Node possible in nodes) {
                    if (possible.nodeID == nextID) {
                        curNode = possible;
                        UpdateNodeInputs (curNode);
                        curNode.OnEnter ();
                        break;
                    }
                }
            }
        }
    }

}

Here are the different nodes:

FunctionNode.cs:

using UnityEngine;
using System.Collections;

public class FunctionNode : Node {

    public string function = "Update";
   
    public override string nodeName { get { return "Function"; } }

    public FunctionNode (string funcName) {
        function = funcName;
    }

    public override int Step() {
        return 1;
    }
   
    public override void OnEnter() {
    }

    public override void OnLeave() {
    }

    public override object Output() {
        return null;
    }

}

StopNode.cs:

using UnityEngine;
using System.Collections;

public class StopNode : Node {
   
    public override string nodeName { get { return "Stop"; } }

    public StopNode () {
    }

    public override int Step() {
        return -1;
    }
   
    public override void OnEnter() {
    }

    public override void OnLeave() {
    }
   
    public override object Output() {
        return null;
    }

}

FindGameObjectNode.cs:

using UnityEngine;
using System.Collections;

public class FindGameObjectNode : Node {

    public string gameObjectName;

    public override string nodeName { get { return "Find GameObject"; } }

    public FindGameObjectNode (string gameObjectName) {
        this.gameObjectName = gameObjectName;
    }

    public override int Step() {
        return 1;
    }
   
    public override void OnEnter() {
    }

    public override void OnLeave() {
    }
   
    public override object Output() {
        return GameObject.Find  (gameObjectName);
    }

}

PushNode.cs:

using UnityEngine;
using System.Collections;

public class PushNode : Node {

    public Vector3 pushForce = Vector3.zero;
    // You can set this to 1 to just apply the same force every time instead of a delta affected force
    public float delta = 1.0f;
    public GameObject toPush;

    public override string nodeName { get { return "Push"; } }

    public PushNode (Vector3 pushForce) {
        this.pushForce = pushForce;
    }

    public override int Step() {
        if (toPush != null && toPush.GetComponent<Rigidbody>() != null && !toPush.GetComponent<Rigidbody>().isKinematic) {
            toPush.GetComponent<Rigidbody>().AddForce (pushForce * delta);
        }

        // do the first listed transition
        return 1;
    }
   
    public override void OnEnter() {
    }

    public override void OnLeave() {
    }
   
    public override object Output() {
        return null;
    }

}

Here is for saving, ScriptyFile.cs:

using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;

public class ScriptyFile : ScriptableObject {

    public NodeSystem system;

}

Here is the editor, which gives errors in DrawNodeWindow but still successfully creates the save file to load, Scripty.cs:

using UnityEngine;
using UnityEditor;
using System.Reflection;
using System.Collections;
using System.Collections.Generic;

public class Scripty : EditorWindow {

    private NodeSystem system;
   
    public const string savePath = "Assets/Scripty/Saves/";

    [MenuItem ("Window/Scripty/Scripty Editor")]
    static void OpenWindowFunc () {
        Scripty editor = EditorWindow.GetWindow<Scripty>();
        editor.title = "Scripty Editor";
        editor.Init ();
    }

    public void Init () {
        system = new NodeSystem ();
        FunctionNode updateNode = new FunctionNode ("FixedUpdate");
        updateNode.nodeID = 3;
        PushNode pushNode = new PushNode(new Vector3(0, 20, 0));
        pushNode.nodeID = 1;
        StopNode stopNode = new StopNode ();
        stopNode.nodeID = 2;
        updateNode.SetTransition (1, 1);
        pushNode.SetTransition (1, 2);
        FindGameObjectNode findNode = new FindGameObjectNode ("TestSphere");
        findNode.nodeID = 4;
        system.AddNode (pushNode);
        system.AddNode (updateNode);
        system.AddNode (stopNode);
        system.AddNode (findNode);
        system.SetNodeInput (pushNode, findNode, "toPush");

        saveScripty(savePath + "test.asset");
    }

    void saveScripty(string path) {
        ScriptyFile saveFile = (ScriptyFile) ScriptableObject.CreateInstance (typeof (ScriptyFile));
        saveFile.system = system;
        AssetDatabase.CreateAsset (saveFile, path);

        for (int n = 0; n < saveFile.system.nodes.Count; n++) {
            Node node = saveFile.system.nodes [n];
            AssetDatabase.AddObjectToAsset (node, saveFile);
        }

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

    void OnGUI () {
        BeginWindows ();

        foreach (Node n in system.getNodes ()) {
            DrawNode (n);
        }

        EndWindows ();
    }

    void DrawNode (Node n) {
        // draw window
        n.window = GUI.Window (n.nodeID, n.window, DrawNodeWindow, n.nodeName);

        // draw connections
        int offs = 2;
        foreach (int input in n.inputs) {
            // draw each input connection
            foreach (Node check in system.getNodes ()) {
                if (check.nodeID == input) {
                    // we've found our node to connect to
                    DrawNodeCurve (new Vector3(check.window.x + check.window.width / 2, check.window.y, 0), new Vector3 (n.window.x, n.window.y + offs));
                    break;
                }
            }
            offs += 16;
        }
    }

    void DrawNodeWindow(int id) {
        foreach (Node n in system.getNodes ()) {
            if (n.nodeID == id) {
                // we've found our node to draw

                int offs = 10;
                const BindingFlags flags = BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public;
                FieldInfo[] fields = n.GetType ().GetFields (flags);
                for (int i = 0; i < fields.Length; i++) {
                    offs += 16;
                    GUILayout.BeginHorizontal ();
                    GUILayout.Label (fields[i].Name);
                    // make sure space exists in the list, or else annoying out of range exceptions happen
                    if (n.inputs.Count < fields.Length) {
                        for (int add = 0; add <= fields.Length; add++) {
                            n.inputs.Add (0);
                        }
                    }
                    if (n.inputs[i] > 0) {
                        // has a connected node input
                        if (GUI.Button (new Rect (2, offs - 8, 64, 16), "Detach")) {
                            // detach this node from the right input
                            n.inputs[i] = 0;
                        }
                    } else {
                        // has no connected node input
                        fields[i].SetValue (n, (object) EditorGUILayout.ObjectField ((Object) fields[i].GetValue (n), fields[i].FieldType, true));
                    }
                    GUILayout.EndHorizontal ();
                }
                break;
            }
        }

        GUI.DragWindow ();
    }

    void DrawNodeCurve (Vector3 startPos, Vector3 endPos) {
        Vector3 startTan = startPos + Vector3.right * 50;
        Vector3 endTan = endPos + Vector3.left * 50;
        Color shadowCol = new Color(0, 0, 0, 0.06f);
        for (int i = 0; i < 3; i++) {
            Handles.DrawBezier(startPos, endPos, startTan, endTan, shadowCol, null, (i + 1) * 5);
        }
        Handles.DrawBezier(startPos, endPos, startTan, endTan, Color.black, null, 1);
    }

}

And lastly, here is the code that runs the save file, DoSystem.cs:

using UnityEngine;
using UnityEditor;
using System.Collections;

public class DoSystem : MonoBehaviour {
   
    private const string savePath = "Assets/Scripty/Saves/";

    public string fileName;

    public NodeSystem system;

    void Start () {
        LoadSystem ();
        system.CallFunction ("Start");
    }

    void Update () {
        system.CallFunction ("Update");
    }

    void FixedUpdate () {
        system.CallFunction ("FixedUpdate");
    }

    private void LoadSystem () {
        system = new NodeSystem();
        Object[] objects = AssetDatabase.LoadAllAssetsAtPath (savePath + fileName);
        if (objects.Length == 0) {
            Debug.LogError ("Scripty: Error loading assets for " + fileName + " !!!");
            return;
        }
        ScriptyFile theFile = null;
       
        for (int i = 0; i < objects.Length; i++) {
            object o = objects [i];
            if (o.GetType () == typeof (ScriptyFile))
                theFile = o as ScriptyFile;
        }
        if (theFile == null) {
            return;
            Debug.LogError ("Scripty: Error, loaded asset file loads but is null!");
        }
        system = theFile.system;

        AssetDatabase.Refresh ();
    }
}

Uhm you iterate over all fields of your class. You have int fields and generic List fields which all aren’t types compatible with the ObjectField. The ObjectField is only for reference types which are derived from UnityEngine.Object. If you want to create a complete generic inspector based on reflection it’s way more complicated. You have to analyse the actual type and provide a proper edit field for each field. For example if it’s a float value you might want to use EditorGUILayout.FloatField. Of course you have to cast to the proper type each time.

An ObjectField is just a drag & drop field where you can drop references to other Unity objects. The ObjectField is what is used be the default inspector for GameObject or Component fields.

Oh just saw that you have DeclaredOnly in your binding flags. However even in your concrete classes you have float, string, Vector3 values. The only field that would be compatible with ObjectField is the “toPush” variable in your PushNode. All other fields are either not reference types at all or of a type that is not derived from UnityEngine.Object.

Ok…I need some clarification. How is “toPush” allowed though? Is it because it is GameObject? I will try just casting these for each data type, then.

Yes, because it’s a GameObject. UnityEngine.Object is not the same as System.Object (or it’s alias “object”). It’s the base class for classes within the UnityEngine.

Take a look at the class hierarchy of Unity. Besides that you can’t “edit” something in an ObjectField. As i said it’s just for dropping references to other objects.

For all types you want to be able to “edit” you have to test for that type and use an appropriate gui element. Example:

    FieldInfo[] fields = n.GetType().GetFields(flags);
    // make sure space exists in the list, or else annoying out of range exceptions happen
    while (n.inputs.Count < fields.Length)
    {
        n.inputs.Add(0);
    }
    for (int i = 0; i < fields.Length; i++)
    {
        GUILayout.BeginHorizontal();
        if (n.inputs[i] > 0)
        {
            // has a connected node input
            GUILayout.Label(fields[i].Name);
            if (GUILayout.Button("Detach"))
            {
                // detach this node from the right input
                n.inputs[i] = 0;
            }
        }
        else
        // has no connected node input
        {
            var type = fields[i].FieldType;
            if (type == typeof(Vector3))
                fields[i].SetValue(n, EditorGUILayout.Vector3Field(fields[i].Name, (Vector3)fields[i].GetValue(n)));
            else if (type == typeof(float))
                fields[i].SetValue(n, EditorGUILayout.FloatField(fields[i].Name, (float)fields[i].GetValue(n)));
            else if (type == typeof(int))
                fields[i].SetValue(n, EditorGUILayout.IntField(fields[i].Name, (int)fields[i].GetValue(n)));
            else if (type == typeof(string))
                fields[i].SetValue(n, EditorGUILayout.TextField(fields[i].Name, (string)fields[i].GetValue(n)));
            else if (typeof(UnityEngine.Object).IsAssignableFrom(type))
                fields[i].SetValue(n, EditorGUILayout.ObjectField((UnityEngine.Object)fields[i].GetValue(n), fields[i].FieldType, true));
            else
                GUILayout.Label("Not supported Type: " + fields[i].Name + "("+fields[i].FieldType.Name+")");
        }
        GUILayout.EndHorizontal();
    }

Note: I put the “adjustment” of the List before the loop as it’s pointless to do it before every field. Also mixing GUILayout and GUI isn’t recommended. Most of the EditorGUILayout.XXXField methods have already a name field infront. The nice thing is that you get the same dragging feature you have in the normal inspector for free.

I just covered a few types here. For every type that is not supported it just shows a label with “Not supported” followed by the field name and it’s type.