主題

【Unity / Editor / Graph View】自製圖形化Editor 與 使用Async讓背景執行的Manager不用實例化

%%鼠 | 2021-07-11 02:42:33 | 巴幣 308 | 人氣 210

前言:
預計會打很多,所以特別分一篇。
想直接拿工具可以看上一篇:Scene 圖形化流程管理工具

關於Unity editor 的Graph view,可以看舊筆記(看這頻道學的,不過他是做對話系統,我比較喜歡自己的那套XD)。

完整程式碼: 連結

Editor存取Scene的資料:
不應該直接存Scene類型,而是SceneAsset類型。 為了避免Scene檔案路徑改變導致連結失效,我是直接存場景檔的Guid。 存取只是AssetDatabase的Guid-ScenePath正反操作而已。
(存guid而非SceneAsset的原因下面會解釋)

Async取代Corotuine:
之前研究過一下Async,放棄的原因不外乎就是Unity不支援多線程,主thread以外是無法取得UnityEngine的值(例如transform等等...)。

本工具因為需要在場景載入完成後呼叫callback,所以需要在背景等待場景載入完成。
用Corotuine等待的方法:
執行corotuine的必要條件有:
(1.)必須由已實例化的物件去Start。
(2.)corotuine不能被靜態方法呼叫。

一個工具還要手動實例化Manager就覺得很low,所以無論如何都要把它弄成public static才行,於是我看向以前學的Async,困難點在於如何克服thread問題。

Async有個守則:呼叫Async方法的方法也得是Async方法。
整個像是病毒一樣往上感染,盡量控制在一個腳本內,別讓Async前綴跑出去別的腳本了。

舊的作法:
因為非主線程無法得知是否isDone資訊,只能用Unity OnSceneLoaded當回傳。

新的做法(第163行):
註解起來的是舊方法的呼叫方式。

有人可能會問:為什麼不直接傳入scene path就好,還得從資料裡面找?
上面說過非主線程是無法取得UnityEngine資訊的,Scene.path亦無法。

在Unity使用Async的原則:只使用基本變數型態(如:int / string / float...)傳遞參數。
場景資料不是存SceneAsset而是檔案guid的緣故就是如此。

最後,因為Async不像Corotuine需要實例化物件,所以Manager方法也都能順利設成靜態了。



圖形化編輯器基本筆記:

Graph view:
製作一個編輯視窗:

public class LevelFlowGraphView : GraphView
{
    private string styleSheetName = "GraphViewStyleSheet";
    private LevelEditorWindow editorWindow;
    private NodeSearchWindow searchWindow;

    public LevelFlowGraphView(LevelEditorWindow _editorWindow)
    {
        editorWindow = _editorWindow;

        //套用Style
        StyleSheet tmpStyleSheet = Resources.Load<StyleSheet>(styleSheetName);
        styleSheets.Add(tmpStyleSheet);

        //設定大小
        SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);

        //設定滑鼠操作
        this.AddManipulator(new ContentDragger());
        this.AddManipulator(new SelectionDragger());
        this.AddManipulator(new RectangleSelector());
        this.AddManipulator(new FreehandSelector());

        //填充網格
        GridBackground grid = new GridBackground();
        Insert(0, grid);
        grid.StretchToParentSize();
        AddSearchWindow();
    }
}

新增Search Tree:

private void AddSearchWindow()
    {
        searchWindow = ScriptableObject.CreateInstance<NodeSearchWindow>();
        searchWindow.Configure(editorWindow, this);
        nodeCreationRequest = context => SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), searchWindow);
    }

建立Search Window選單:

using System.Collections;
using System.Collections.Generic;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;

public class NodeSearchWindow : ScriptableObject, ISearchWindowProvider
{
    private LevelEditorWindow editorWindow;
    private LevelFlowGraphView graphView;

    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
    {
        List<SearchTreeEntry> tree = new List<SearchTreeEntry>() {
            new SearchTreeGroupEntry(new GUIContent("Level Flow"),0),
            new SearchTreeGroupEntry(new GUIContent("Level Node"),1),

            AddNodeSearch("Start Node",new StartNode()),
            AddNodeSearch("Level Node",new LevelNode())
        };

        return tree;
    }
    private SearchTreeEntry AddNodeSearch(string _name, BaseNode _baseNode)
    {
        SearchTreeEntry tmp = new SearchTreeEntry(new GUIContent(_name))
        {
            level = 2,
            userData = _baseNode
        };
        return tmp;
    }

    public void Configure(LevelEditorWindow _editorWindow, LevelFlowGraphView _graphView)
    {
        editorWindow = _editorWindow;
        graphView = _graphView;
    }


    public bool OnSelectEntry(SearchTreeEntry _SearchTreeEntry, SearchWindowContext _context)
    {
        Vector2 mousePosition = editorWindow.rootVisualElement.ChangeCoordinatesTo(
                 editorWindow.rootVisualElement.parent, _context.screenMousePosition - editorWindow.position.position
             );

        Vector2 grphviewMousePosition = graphView.contentViewContainer.WorldToLocal(mousePosition);
        return CheckForNodeType(_SearchTreeEntry, grphviewMousePosition);
    }

    private bool CheckForNodeType(SearchTreeEntry _searchTreeEntry, Vector2 _pos)
    {
        switch (_searchTreeEntry.userData)
        {
            case LevelNode node:
                graphView.AddElement(graphView.CreateLevelNode(_pos));
                return true;

            case StartNode node:
                graphView.AddElement(graphView.CreateStartNode(_pos));
                return true;

            default:
                break;
        }

        return false;
    }
}

各種資料欄位創建範例:
Node:
public class BaseNode : UnityEditor.Experimental.GraphView.Node
{
    public BaseNode(Vector2 _position ) {
        //CSS樣式
        StyleSheet styleSheet = Resources.Load<StyleSheet>("NodeStyleSheet");
        styleSheets.Add(styleSheet);

        //Node title文字
        title = "Start";

        //Node 位置與大小
        SetPosition(new Rect(_position , defaultNodeSide));

        nodeGuid = Guid.NewGuid().ToString();  //Guid能產生獨特的id

        //新增節點須refresh
        RefreshExpandedState();
        RefreshPorts();
    }
}

Enum Field:
private EnumField enumField;
private EndNodeType endNodeType = EndNodeType.End;
public EndNodeType EndNodeType { get => endNodeType; set => endNodeType = value; }

//******in constructer:*******
enumField = new EnumField()
        {
            value = endNodeType
        };

enumField.Init(endNodeType);
        
enumField.RegisterValueChangedCallback((value) =>
{
       //賦予新value
       endNodeType = (EndNodeType)value.newValue;
});

enumField.SetValueWithoutNotify(endNodeType);

mainContainer.Add(enumField);

Image Field:
private Sprite Image;
public Sprite image { get => Image; set => Image= value; }
private ObjectField image_Field ;

//*****in constructer:******
image_Field = new ObjectField
        {
            objectType = typeof(Sprite),
            allowSceneObjects = false,
            value = image
        };
image_Field .RegisterValueChangedCallback(ValueTuple =>
        {
            image = ValueTuple.newValue as Sprite;
        });
mainContainer.Add(image_Field );

Text Field:
private string name = "";
public string Name { get => name; set => name = value; }

Label label_name = new Label("title");
mainContainer.Add(label_name);

TextField textField = new TextField("title"); //傳入label標籤,會跟對應到的label同行,否則獨立一行
textField.RegisterValueChangedCallback(value =>
{
        name = value.newValue;
});

name_Field.SetValueWithoutNotify(name);

//Apply style
name_Field.AddToClassList("css");

mainContainer.Add(textField );

Label:
Label label_texts = new Label("Texts Box");
label_texts.AddToClassList("Label");
mainContainer.Add(label_texts);

Button:
Button button = new Button()
        {
            text = "Add Choice"
        };
button.clicked += () =>
        {
            //TODO: callback           
        };
//另一種container也OK
titleButtonContainer.Add(button);

Port:
public Port AddOutputPort(string name, Port.Capacity capacity = Port.Capacity.Single)
    {
        Port outputPort = GetPortInstance(Direction.Output, capacity);
        outputPort.portName = name;
        outputPort.portColor = Color.green;
        outputContainer.Add(outputPort);

        outPorts.Add(outputPort);

        return outputPort;
    }

public Port AddInputPort(string name, Port.Capacity capacity = Port.Capacity.Multi)
    {
        Port inputPort = GetPortInstance(Direction.Input, capacity);
        inputPort.portName = name;
        inputContainer.Add(inputPort);

        inPorts.Add(inputPort);

        return inputPort;
    }
//返回的值一樣加進Container

連接Port:
連線規則要自己定義。
public class MyGraphView : GraphView
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
    {
        List<Port> compatiblePorts = new List<Port>();
        Port startPortView = startPort;

        //ports 會取得全部的port
        ports.ForEach((port) =>
        {
            Port _portView = port;
            if (startPortView != _portView &&  //不能自己連自己
                startPortView.node != _portView.node &&  //不能自己連自己的sub node
                startPortView.direction != port.direction  //out不能連out in不能連in
                )
            {
                //連接
                compatiblePorts.Add(port);
            }
        });
        return compatiblePorts;
    }
}


Editor:
雙擊自動開啟編輯器:
    [OnOpenAsset(1)] //當資料夾裡的檔案被點擊時的callback

    //每個project裡面的檔案都有自己的id
    public static bool ShowWindowInfo(int _instanceID, int line)
    {
        UnityEngine.Object item = EditorUtility.InstanceIDToObject(_instanceID);

        if (item is LevelMapSO) //點擊這類檔案的資料,開啟對應的編輯視窗
        {
            LevelEditorWindow window = (LevelEditorWindow)GetWindow(typeof(LevelEditorWindow));
            window.titleContent = new GUIContent("Level Flow Editor");
            window.flowData = item as LevelMapSO;
            window.minSize = new Vector2(500, 250);
            window.Load();
        }
        return false;
    }


建立Graph View:
*注意:順序應是先創建Graph view、再創建Tool bar。
//建立網格背景
    private void ConstructGraphView()
    {
        graphView = new LevelFlowGraphView(this);
        graphView.StretchToParentSize();
        rootVisualElement.Add(graphView);

        mapSaveLoad = new MapSaveLoad(graphView);
    }


連線Port:
private void LinkNodesTogether(Port _outputPort, Port _inputPort)
    {
        Edge tempEdge = new Edge()
        {
            output = _outputPort,
            input = _inputPort
        };
        tempEdge.input.Connect(tempEdge);
        tempEdge.output.Connect(tempEdge);
        graphView.Add(tempEdge);
    }




後記:
Unity graph view沒有想像中簡單,資料不夠完善、好多東西要自己設定(連port配對規則都要自己寫)...
反正製作一個RPG遊戲的要素:對話系統、場景管理都做好了!! 期許自己能專心做RPG了

別....別再分心亂學了啊啊啊啊!!!!

創作回應

is樂小呈
什麼都學的佬[e1]
2021-07-11 09:02:52
%%鼠
用到才學XD
2021-07-12 11:53:02
vakama
async/await 其實跟Thread無關 詳細可看 https://blog.opasschang.com/understand-csharp-asyn/
2021-07-19 10:03:00
vakama
用熟了真的比Corotuine易讀多了
2021-07-19 10:03:56
%%鼠
感謝提供! [e16] 感覺Async比Corotuine好用
2021-07-19 16:33:29
追蹤 創作集

作者相關創作

相關創作

更多創作