using UnityEngine; using UnityEditor; using System.Collections.Generic; using UnityEditor.IMGUI.Controls; using MBT; namespace MBTEditor { public class BehaviourTreeWindow : EditorWindow, IHasCustomMenu { private MonoBehaviourTree currentMBT; private Editor currentMBTEditor; private Node[] currentNodes; private Node selectedNode; private bool nodeMoved = false; private Vector2 workspaceOffset; private NodeHandle currentHandle; private NodeHandle dropdownHandleCache; private bool snapNodesToGrid; private bool locked = false; private Rect nodeFinderActivatorRect; private NodeDropdown nodeDropdown; private Vector2 nodeDropdownTargetPosition; private readonly float _handleDetectionDistance = 8f; private readonly Color _editorBackgroundColor = new Color(0.16f, 0.19f, 0.25f, 1); private GUIStyle _lockButtonStyle; private GUIStyle _defaultNodeStyle; private GUIStyle _selectedNodeStyle; private GUIStyle _successNodeStyle; private GUIStyle _failureNodeStyle; private GUIStyle _runningNodeStyle; private GUIStyle _nodeContentBoxStyle; private GUIStyle _nodeLabelStyle; private GUIStyle _nodeBreakpointLabelStyle; private void OnEnable() { // Read snap option snapNodesToGrid = EditorPrefs.GetBool("snapNodesToGrid", true); // Setup events EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; EditorApplication.playModeStateChanged += OnPlayModeStateChanged; Undo.undoRedoPerformed -= UpdateSelection; Undo.undoRedoPerformed += UpdateSelection; // Node finder nodeDropdown = new NodeDropdown(new AdvancedDropdownState(), AddNode); // Standard node _defaultNodeStyle = new GUIStyle(); _defaultNodeStyle.border = new RectOffset(10,10,10,10); _defaultNodeStyle.normal.background = Resources.Load("mbt_node_default", typeof(Texture2D)) as Texture2D; // Selected node _selectedNodeStyle = new GUIStyle(); _selectedNodeStyle.border = new RectOffset(10,10,10,10); _selectedNodeStyle.normal.background = Resources.Load("mbt_node_selected", typeof(Texture2D)) as Texture2D; // Success node _successNodeStyle = new GUIStyle(); _successNodeStyle.border = new RectOffset(10,10,10,10); _successNodeStyle.normal.background = Resources.Load("mbt_node_success", typeof(Texture2D)) as Texture2D; // Failure node _failureNodeStyle = new GUIStyle(); _failureNodeStyle.border = new RectOffset(10,10,10,10); _failureNodeStyle.normal.background = Resources.Load("mbt_node_failure", typeof(Texture2D)) as Texture2D; // Running node _runningNodeStyle = new GUIStyle(); _runningNodeStyle.border = new RectOffset(10,10,10,10); _runningNodeStyle.normal.background = Resources.Load("mbt_node_running", typeof(Texture2D)) as Texture2D; // Node content box _nodeContentBoxStyle = new GUIStyle(); _nodeContentBoxStyle.padding = new RectOffset(0,0,15,15); // Node label _nodeLabelStyle = new GUIStyle(); _nodeLabelStyle.normal.textColor = Color.white; _nodeLabelStyle.alignment = TextAnchor.MiddleCenter; _nodeLabelStyle.wordWrap = true; _nodeLabelStyle.margin = new RectOffset(10,10,10,10); _nodeLabelStyle.font = Resources.Load("mbt_Lato-Regular", typeof(Font)) as Font; _nodeLabelStyle.fontSize = 12; // Node label when breakpoint is set to true _nodeBreakpointLabelStyle = new GUIStyle(_nodeLabelStyle); _nodeBreakpointLabelStyle.normal.textColor = new Color(1f, 0.35f, 0.18f); } private void OnDisable() { EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; Undo.undoRedoPerformed -= UpdateSelection; } [MenuItem("Window/Mono Behaviour Tree")] public static void OpenEditor() { BehaviourTreeWindow window = GetWindow(); window.titleContent = new GUIContent( "Behaviour Tree", Resources.Load("mbt_window_icon", typeof(Texture2D)) as Texture2D ); } void OnGUI() { // Draw grid in background first PaintBackground(); // If there is no tree to render just skip rest if (currentMBT == null) { // Keep toolbar rendered PaintWindowToolbar(); return; } PaintConnections(Event.current); // Repaint nodes PaintNodes(); PaintWindowToolbar(); // Update selection and drag ProcessEvents(Event.current); if (GUI.changed) Repaint(); } private void PaintConnections(Event e) { // Paint line when dragging connection if (currentHandle != null) { Handles.BeginGUI(); Vector3 p1 = new Vector3(currentHandle.position.x, currentHandle.position.y, 0f); Vector3 p2 = new Vector3(e.mousePosition.x, e.mousePosition.y, 0f); Handles.DrawBezier(p1, p2, p1, p2, new Color(0.3f, 0.36f, 0.5f), null, 4f); Handles.EndGUI(); } // Paint all current connections for (int i = 0; i < currentNodes.Length; i++) { Node n = currentNodes[i]; Vector3 p1 = GetBottomHandlePosition(n.rect) + workspaceOffset; for (int j = 0; j < n.children.Count; j++) { Handles.BeginGUI(); Vector3 p2 = GetTopHandlePosition(n.children[j].rect) + workspaceOffset; Handles.DrawBezier(p1, p2, p1, p2, new Color(0.3f, 0.36f, 0.5f), null, 4f); Handles.EndGUI(); } } } private void PaintWindowToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); EditorGUI.BeginDisabledGroup(currentMBT == null); if (GUILayout.Toggle(snapNodesToGrid, "Snap Nodes", EditorStyles.toolbarButton) != snapNodesToGrid) { snapNodesToGrid = !snapNodesToGrid; // Store this setting EditorPrefs.SetBool("snapNodesToGrid", snapNodesToGrid); } // TODO: Autolayout // if (GUILayout.Button("Auto Layout", EditorStyles.toolbarButton)){ // Debug.Log("Auto layout is not implemented."); // } EditorGUILayout.Space(); if (GUILayout.Button("Focus Root", EditorStyles.toolbarButton)){ FocusRoot(); } if (GUILayout.Button("Select In Hierarchy", EditorStyles.toolbarButton)){ if (currentMBT != null) { Selection.activeGameObject = currentMBT.gameObject; EditorGUIUtility.PingObject(currentMBT.gameObject); } } if (GUILayout.Button("Add Node", EditorStyles.toolbarDropDown)){ OpenNodeFinder(nodeFinderActivatorRect, false); } if (Event.current.type == EventType.Repaint) nodeFinderActivatorRect = GUILayoutUtility.GetLastRect(); EditorGUI.EndDisabledGroup(); GUILayout.FlexibleSpace(); if (currentMBT != null) { GUILayout.Label(string.Format("{0} {1}", currentMBT.name, -workspaceOffset)); } EditorGUILayout.EndHorizontal(); } void FocusRoot() { Root rootNode = null; for (int i = 0; i < currentNodes.Length; i++) { if (currentNodes[i] is Root) { rootNode = currentNodes[i] as Root; break; } } if (rootNode != null) { workspaceOffset = -rootNode.rect.center + new Vector2(this.position.width/2, this.position.height/2); } } private void OnPlayModeStateChanged(PlayModeStateChange state) { // Disable lock when changing state this.locked = false; UpdateSelection(); Repaint(); } void OnInspectorUpdate() { // OPTIMIZE: This can be optimized to call repaint once per second Repaint(); } void OnSelectionChange() { MonoBehaviourTree previous = currentMBT; UpdateSelection(); // Reset workspace position only when selection changed if (previous != currentMBT) { workspaceOffset = Vector2.zero; } Repaint(); } void OnFocus() { UpdateSelection(); Repaint(); } void IHasCustomMenu.AddItemsToMenu(GenericMenu menu) { menu.AddItem(new GUIContent("Lock"), this.locked, () => { this.locked = !this.locked; UpdateSelection(); }); } // http://leahayes.co.uk/2013/04/30/adding-the-little-padlock-button-to-your-editorwindow.html private void ShowButton(Rect position) { // Cache style if (_lockButtonStyle == null) { _lockButtonStyle = "IN LockButton"; } // Generic menu button GUI.enabled = currentMBT != null; this.locked = GUI.Toggle(position, this.locked, GUIContent.none, _lockButtonStyle); GUI.enabled = true; } // DeselectNode cannot be called here // void OnLostFocus() // { // // DeselectNode(); // } private void UpdateSelection() { MonoBehaviourTree prevMBT = currentMBT; if (!this.locked && Selection.activeGameObject != null) { currentMBT = Selection.activeGameObject.GetComponent(); // If new selection is null then restore previous one if (currentMBT == null) { currentMBT = prevMBT; } } if (currentMBT != prevMBT) { // Get new editor for new MBT Editor.CreateCachedEditor(currentMBT, null, ref currentMBTEditor); } if (currentMBT != null) { currentNodes = currentMBT.GetComponents(); // Ensure there is no error when node script is missing for (int i = 0; i < currentNodes.Length; i++) { currentNodes[i].children.RemoveAll(item => item == null); } } else { currentNodes = new Node[0]; // Unlock when there is nothing to display this.locked = false; } } private void ProcessEvents(Event e) { switch (e.type) { case EventType.MouseDown: if (e.button == 0) { // Reset flag nodeMoved = false; // Frist check if any node handle was clicked NodeHandle handle = FindHandle(e.mousePosition); if (handle != null) { currentHandle = handle; e.Use(); break; } Node node = FindNode(e.mousePosition); // Select node if contains point if (node != null) { DeselectNode(); SelectNode(node); if (e.clickCount == 2 && node is SubTree) { SubTree subTree = node as SubTree; if (subTree.tree != null) { Selection.activeGameObject = subTree.tree.gameObject; } } } else { DeselectNode(); } e.Use(); } else if (e.button == 1) { Node node = FindNode(e.mousePosition); // Open proper context menu if (node != null) { OpenNodeMenu(e.mousePosition, node); } else { DeselectNode(); OpenNodeFinder(new Rect(e.mousePosition.x, e.mousePosition.y, 1, 1)); } e.Use(); } break; case EventType.MouseDrag: // Drag node, workspace or connection if (e.button == 0) { if (currentHandle != null) { // Let PaintConnections draw lines } else if (selectedNode != null) { Undo.RecordObject(selectedNode, "Move Node"); selectedNode.rect.position += Event.current.delta; // Move whole branch when Ctrl is pressed if (e.control) { List movedNodes = selectedNode.GetAllSuccessors(); for (int i = 0; i < movedNodes.Count; i++) { Undo.RecordObject(movedNodes[i], "Move Node"); movedNodes[i].rect.position += Event.current.delta; } } nodeMoved = true; } else { workspaceOffset += Event.current.delta; } GUI.changed = true; e.Use(); } break; case EventType.MouseUp: if (currentHandle != null) { TryConnectNodes(currentHandle, e.mousePosition); } // Reorder or snap nodes in case any of them was moved if (nodeMoved && selectedNode != null) { // Snap nodes if option is enabled if (snapNodesToGrid) { Undo.RecordObject(selectedNode, "Move Node"); selectedNode.rect.position = SnapPositionToGrid(selectedNode.rect.position); // When control is pressed snap successors too if (e.control) { List movedNodes = selectedNode.GetAllSuccessors(); for (int i = 0; i < movedNodes.Count; i++) { Undo.RecordObject(movedNodes[i], "Move Node"); movedNodes[i].rect.position = SnapPositionToGrid(movedNodes[i].rect.position); } } } // Reorder siblings if selected node has parent if (selectedNode.parent != null) { Undo.RecordObject(selectedNode.parent, "Move Node"); selectedNode.parent.SortChildren(); } } nodeMoved = false; currentHandle = null; GUI.changed = true; break; } } Vector2 SnapPositionToGrid(Vector2 position) { return new Vector2( Mathf.Round(position.x / 20f) * 20f, Mathf.Round(position.y / 20f) * 20f ); } private void TryConnectNodes(NodeHandle handle, Vector2 mousePosition) { // Find hovered node and connect or open dropdown Node targetNode = FindNode(mousePosition); if (targetNode == null) { OpenNodeFinder(new Rect(mousePosition.x, mousePosition.y, 1, 1), true, handle); return; } // Check if they are not the same node if (targetNode == handle.node) { return; } Undo.RecordObject(targetNode, "Connect Nodes"); Undo.RecordObject(handle.node, "Connect Nodes"); // There is node, try to connect if this is possible if (handle.type == HandleType.Input && targetNode is IParentNode) { // Do not allow connecting descendants as parents if (targetNode.IsDescendantOf(handle.node)) { return; } // Then add as child to new parent targetNode.AddChild(handle.node); // Update order of nodes targetNode.SortChildren(); } else if (handle.type == HandleType.Output && targetNode is IChildrenNode) { // Do not allow connecting descendants as parents if (handle.node.IsDescendantOf(targetNode)) { return; } // Then add as child to new parent handle.node.AddChild(targetNode); // Update order of nodes handle.node.SortChildren(); } } private void SelectNode(Node node) { currentMBT.selectedEditorNode = node; currentMBTEditor.Repaint(); node.selected = true; selectedNode = node; GUI.changed = true; } private void DeselectNode(Node node) { currentMBT.selectedEditorNode = null; currentMBTEditor.Repaint(); node.selected = false; selectedNode = null; GUI.changed = true; } private void DeselectNode() { currentMBT.selectedEditorNode = null; currentMBTEditor.Repaint(); for (int i = 0; i < currentNodes.Length; i++) { currentNodes[i].selected = false; } selectedNode = null; GUI.changed = true; } private Node FindNode(Vector2 mousePosition) { for (int i = 0; i < currentNodes.Length; i++) { // Get correct position of node with offset Rect target = currentNodes[i].rect; target.position += workspaceOffset; if (target.Contains(mousePosition)) { return currentNodes[i]; } } return null; } private NodeHandle FindHandle(Vector2 mousePosition) { for (int i = 0; i < currentNodes.Length; i++) { Node node = currentNodes[i]; // Get correct position of node with offset Rect targetRect = node.rect; targetRect.position += workspaceOffset; if (node is IChildrenNode) { Vector2 handlePoint = GetTopHandlePosition(targetRect); if (Vector2.Distance(handlePoint, mousePosition) < _handleDetectionDistance) { return new NodeHandle(node, handlePoint, HandleType.Input); } } if (node is IParentNode) { Vector2 handlePoint = GetBottomHandlePosition(targetRect); if (Vector2.Distance(handlePoint, mousePosition) < _handleDetectionDistance) { return new NodeHandle(node, handlePoint, HandleType.Output); } } } return null; } private void PaintNodes() { for (int i = currentNodes.Length - 1; i >= 0 ; i--) { Node node = currentNodes[i]; Rect targetRect = node.rect; targetRect.position += workspaceOffset; // Draw node content GUILayout.BeginArea(targetRect, GetNodeStyle(node)); GUILayout.BeginVertical(_nodeContentBoxStyle); if (node.breakpoint) { GUILayout.Label(node.title, _nodeBreakpointLabelStyle); } else { GUILayout.Label(node.title, _nodeLabelStyle); } GUILayout.EndVertical(); if (Event.current.type == EventType.Repaint) { node.rect.height = GUILayoutUtility.GetLastRect().height; } GUILayout.EndArea(); // Paint warning icon if (!Application.isPlaying && !node.IsValid()) { GUI.Label(GetWarningIconRect(targetRect), EditorGUIUtility.IconContent("CollabConflict Icon")); } // Draw connection handles if needed if (node is IChildrenNode) { Vector2 top = GetTopHandlePosition(targetRect); GUI.DrawTexture( new Rect(top.x-8, top.y-5, 16, 16), Resources.Load("mbt_node_handle", typeof(Texture2D)) as Texture2D ); } if (node is IParentNode) { Vector2 bottom = GetBottomHandlePosition(targetRect); GUI.DrawTexture( new Rect(bottom.x-8, bottom.y-11, 16, 16), Resources.Load("mbt_node_handle", typeof(Texture2D)) as Texture2D ); } } } private GUIStyle GetNodeStyle(Node node) { if (node.selected) { return _selectedNodeStyle; } switch (node.status) { case Status.Success: return _successNodeStyle; case Status.Failure: return _failureNodeStyle; case Status.Running: return _runningNodeStyle; } return _defaultNodeStyle; } private Vector2 GetTopHandlePosition(Rect rect) { return new Vector2(rect.x + rect.width/2, rect.y); } private Vector2 GetBottomHandlePosition(Rect rect) { return new Vector2(rect.x + rect.width/2, rect.y + rect.height); } private Rect GetWarningIconRect(Rect rect) { // return new Rect(rect.x - 10, rect.y + rect.height/2 - 10 , 20, 20); return new Rect(rect.x + rect.width - 2, rect.y - 1, 20, 20); } private void OpenNodeFinder(Rect rect, bool useRectPosition = true, NodeHandle handle = null) { // Store handle to connect later (null by default) dropdownHandleCache = handle; // Store real clicked position including workspace offset if (useRectPosition) { nodeDropdownTargetPosition = rect.position - workspaceOffset; } else { nodeDropdownTargetPosition = new Vector2(this.position.width/2, this.position.height/2) - workspaceOffset; } // Open dropdown nodeDropdown.Show(rect); } private void OpenNodeMenu(Vector2 mousePosition, Node node) { GenericMenu genericMenu = new GenericMenu(); genericMenu.AddItem(new GUIContent("Breakpoint"), node.breakpoint, () => ToggleNodeBreakpoint(node)); genericMenu.AddItem(new GUIContent("Duplicate"), false, () => DuplicateNode(node)); genericMenu.AddItem(new GUIContent("Disconnect Children"), false, () => DisconnectNodeChildren(node)); genericMenu.AddItem(new GUIContent("Disconnect Parent"), false, () => DisconnectNodeParent(node)); genericMenu.AddItem(new GUIContent("Delete Node"), false, () => DeleteNode(node)); genericMenu.ShowAsContext(); } void AddNode(ClassTypeDropdownItem item) { // In case there is nothing to add if (currentMBT == null || item.classType == null) { return; } // Allow only one root if (item.classType.IsAssignableFrom(typeof(Root)) && currentMBT.GetComponent() != null) { Debug.LogWarning("You can not add more than one Root node."); return; } Undo.SetCurrentGroupName("Create Node"); Node node = (Node)Undo.AddComponent(currentMBT.gameObject, item.classType); node.title = item.name; node.hideFlags = HideFlags.HideInInspector; node.rect.position = nodeDropdownTargetPosition - new Vector2(node.rect.width/2, 0); UpdateSelection(); if (dropdownHandleCache != null) { // Add additonal offset (3,3) to be sure that point is inside rect TryConnectNodes(dropdownHandleCache, nodeDropdownTargetPosition + workspaceOffset + new Vector2(3,3)); } } private void ToggleNodeBreakpoint(Node node) { // Toggle breakpoint flag // Undo.RecordObject(node, "Toggle Breakpoint"); node.breakpoint = !node.breakpoint; } private void DeleteNode(Node node) { if (currentMBT == null) { return; } DeselectNode(); // Disconnect all children and parent Undo.SetCurrentGroupName("Delete Node"); DisconnectNodeChildren(node); DisconnectNodeParent(node); Undo.DestroyObjectImmediate(node); // DestroyImmediate(node, true); UpdateSelection(); } private void DisconnectNodeParent(Node node) { if (node.parent != null) { Undo.RecordObject(node, "Disconnect Parent"); Undo.RecordObject(node.parent, "Disconnect Parent"); node.parent.RemoveChild(node); } } private void DisconnectNodeChildren(Node node) { Undo.RecordObject(node, "Disconnect Children"); for (int i = node.children.Count - 1; i >= 0 ; i--) { Undo.RecordObject(node.children[i], "Disconnect Children"); node.RemoveChild(node.children[i]); } } private void DuplicateNode(Node contextNode) { // NOTE: This code is mostly copied from AddNode() // Check if there is MBT if (currentMBT == null) { return; } System.Type classType = contextNode.GetType(); // Allow only one root if (classType.IsAssignableFrom(typeof(Root)) && currentMBT.GetComponent() != null) { Debug.LogWarning("You can not add more than one Root node."); return; } Undo.SetCurrentGroupName("Duplicate Node"); Node node = (Node)Undo.AddComponent(currentMBT.gameObject, classType); // Copy values EditorUtility.CopySerialized(contextNode, node); // Set flags anyway to ensure it is not visible in inspector node.hideFlags = HideFlags.HideInInspector; node.rect.position = contextNode.rect.position + new Vector2(20, 20); // Remove all connections or graph gonna break node.parent = null; node.children.Clear(); UpdateSelection(); } /// It is quite unique, but https://stackoverflow.com/questions/2920696/how-generate-unique-integers-based-on-guids private int GenerateId() { return System.Guid.NewGuid().GetHashCode(); } private void PaintBackground() { // Background Handles.BeginGUI(); Handles.DrawSolidRectangleWithOutline(new Rect(0, 0, position.width, position.height), _editorBackgroundColor, Color.gray); Handles.EndGUI(); // Grid lines DrawBackgroundGrid(20, 0.1f, new Color(0.3f, 0.36f, 0.5f)); DrawBackgroundGrid(100, 0.2f, new Color(0.3f, 0.36f, 0.5f)); } /// Method copied from https://gram.gs/gramlog/creating-node-based-editor-unity/ private void DrawBackgroundGrid(float gridSpacing, float gridOpacity, Color gridColor) { int widthDivs = Mathf.CeilToInt(position.width / gridSpacing); int heightDivs = Mathf.CeilToInt(position.height / gridSpacing); Handles.BeginGUI(); Handles.color = new Color(gridColor.r, gridColor.g, gridColor.b, gridOpacity); Vector3 newOffset = new Vector3(workspaceOffset.x % gridSpacing, workspaceOffset.y % gridSpacing, 0); for (int i = 0; i <= widthDivs; i++) { Handles.DrawLine(new Vector3(gridSpacing * i, -gridSpacing, 0) + newOffset, new Vector3(gridSpacing * i, position.height+gridSpacing, 0f) + newOffset); } for (int j = 0; j <= heightDivs; j++) { Handles.DrawLine(new Vector3(-gridSpacing, gridSpacing * j, 0) + newOffset, new Vector3(position.width+gridSpacing, gridSpacing * j, 0f) + newOffset); } Handles.color = Color.white; Handles.EndGUI(); } private class NodeHandle { public Node node; public Vector2 position; public HandleType type; public NodeHandle(Node node, Vector2 position, HandleType type) { this.node = node; this.position = position; this.type = type; } } private enum HandleType { Input, Output } } }