国产 无码 综合区,色欲AV无码国产永久播放,无码天堂亚洲国产AV,国产日韩欧美女同一区二区

Unity行為樹可視化編輯器開發(fā)

這篇具有很好參考價值的文章主要介紹了Unity行為樹可視化編輯器開發(fā)。希望對大家有所幫助。如果存在錯誤或未考慮完全的地方,請大家不吝賜教,您也可以點擊"舉報違法"按鈕提交疑問。

Unity行為樹插件開發(fā)心得


概述

在ARPG項目的開發(fā)過程當中,要涉及到NPC的AI系統(tǒng),一般來說,簡單的AI行為使用狀態(tài)機即可比較好的實現,但如果NPC的行為稍微一復雜,那么使用狀態(tài)機來實現就會比較難維護,并且后期工作量會隨著NPC狀態(tài)的增加而成倍增加。

這時就可以考慮使用行為樹來實現NPC的AI,行為樹相比于狀態(tài)機更利于維護,在NPC的AI比較復雜的時候,狀態(tài)機已經難以我們去閱讀,而行為樹得益于其樹形結構化的表現,也還能有不錯的可讀性,方便擴展和修改。

不過行為樹相比于狀態(tài)機也有不足,從運行速度的角度來說,行為樹還是要稍遜于狀態(tài)機。從實現上來說,行為樹的門檻也要高于狀態(tài)機,理解起來也要困難一些。這里不對二者的區(qū)別做過多的闡述。

那么在實現Unity行為樹插件時,會涉及到很多部分,C#語言部分、Unity編輯器部分、行為樹框架部分等等,接下來就開發(fā)過程當中涉及到的幾個重要部分的知識點和關鍵點做解釋說明。

項目github地址 Unity-RPGCore-BehaviorTree
Unity行為樹可視化編輯器開發(fā)

行為樹Editor部分截圖(亂連的樹,僅展示用)

行為樹框架

要做行為樹,先從理解什么是行為樹開始。

對于游戲開發(fā)和人工智能來說,行為樹是一種圖形化編程工具,它以樹狀結構來描述復雜的行為邏輯。行為樹由一系列節(jié)點組成,每個節(jié)點代表一種行為或條件,可以是選擇節(jié)點、序列節(jié)點、條件節(jié)點、動作節(jié)點、裝飾節(jié)點等等,這些節(jié)點通過連線連接起來,形成一顆樹狀結構。行為樹可以用來描述人物角色的行為、NPC的行為、游戲AI的行為等等,具有可拓展、易維護、易修改等優(yōu)點。

下面展示了一個簡單的行為樹
Unity行為樹可視化編輯器開發(fā)
上面的行為樹就是由多個不同的節(jié)點組成的。執(zhí)行一顆行為樹即是遍歷一遍行為樹,從root節(jié)點開始,優(yōu)先向子節(jié)點遍歷,也就是深度優(yōu)先,一直到最下層的子節(jié)點也就是葉子節(jié)點開始執(zhí)行。

以上圖的行為樹為例,首先執(zhí)行的應該是PlayerInView,這個節(jié)點是一個條件節(jié)點,負責執(zhí)行條件判斷,當條件滿足時,它把結果返回給它的父節(jié)點也就是FollowPlayer節(jié)點,這個節(jié)點是Sequence節(jié)點,屬于一個控制節(jié)點或者說是一個組合節(jié)點。Sequence節(jié)點的特點是,當子節(jié)點返回成功時執(zhí)行下一個子節(jié)點,當子節(jié)點返回失敗時,不執(zhí)行后序子節(jié)點并返回失敗??梢詫equence節(jié)點理解為編程語言當中的‘‘&&’’(AND),即全部成功算成功,有一個失敗都算失敗。

FollowPlayer執(zhí)行完成時,其運行的結果會返回給父節(jié)點也就是Root,Root節(jié)點是一個Selector節(jié)點,同樣也是屬于控制或組合節(jié)點中的一員,區(qū)別于Sequence,Selector節(jié)點在編程語言中對應的是“||(OR)”,即全部失敗都算失敗,有一個成功就算成功。更詳細的說,當Selector節(jié)點的某個子節(jié)點返回失敗時,Selector節(jié)點不會返回,而是繼續(xù)向下執(zhí)行其子節(jié)點直到某個子節(jié)點成功或當前子節(jié)點已經是最后一個子節(jié)點了。

此時我們假設FollowPlayer的返回值為成功,那么Root就不會再去執(zhí)行后續(xù)子節(jié)點GoToSomeWhere,如果返回值為失敗,那么Root就要繼續(xù)向下執(zhí)行GoToSomeWhere。這樣排列鏈接節(jié)點就可以實現,當Player在視野范圍內即PlayerInView為真時Follow跟隨;當Player不在視野范圍內即PlayerInView為假時GoToSomeWhere執(zhí)行,設置目的地SetDestination、等待幾秒Wait、然后移動到目的地MoveTo,這就是這課行為樹簡單的邏輯。

不理解上面說的也沒關系,接下來我們詳細說明各部分。上述提到了兩個控制節(jié)點,Sequence節(jié)點和Selector節(jié)點,下面給出所有的基本節(jié)點的描述,所有的節(jié)點基本上都由這些基本節(jié)點變化而來。

  1. 組合節(jié)點(Composite or Control)

    組合節(jié)點是行為樹的內部節(jié)點,它定義了遍歷其子節(jié)點的方式
    Sequence:序列節(jié)點,所有子節(jié)點成功返回成功,否則返回失?。?amp;&/AND)
    Selector:選擇節(jié)點,有一個子節(jié)點返回成功則返回成功,所有子節(jié)點失敗則返回失敗(||/OR)
    Parallel:并行節(jié)點,執(zhí)行所有子節(jié)點,當有N個(count>N>0)子
    節(jié)點返回成功則返回成功,所有子節(jié)點返回失敗則返回失敗。
    除了上述給出的幾個基本的組合節(jié)點之外,還有很多以此為基礎的變種節(jié)點,例如ReverseSequence,RandomSequence,ReverseSelector,RandomSelector等等,可以根據實際需求對其進行更改。

  2. 根節(jié)點(Root)

    Root根節(jié)點,行為樹的入口。從實現上來說,Root節(jié)點可以是特殊的組合節(jié)點,或者就是單獨的一個節(jié)點,這個節(jié)點不做任何處理,只能有一個子節(jié)點,僅僅作為行為樹的入口。

  3. 葉子節(jié)點(Leaf)

    葉子節(jié)點是整個行為樹最外圍的部分,沒有子節(jié)點,執(zhí)行具體的邏輯和判斷
    Action:動作節(jié)點,執(zhí)行具體的邏輯,通俗來說,動作節(jié)點就是專門干活的節(jié)點,輪到我了我就執(zhí)行,其余的事情與我毫不相干。
    Condition:條件節(jié)點,執(zhí)行判斷,是控制行為樹執(zhí)行流程的重要部分。(也有一些行為樹將condition節(jié)點作為修飾節(jié)點,我們這里不討論這種情況)

  4. 修飾節(jié)點(Decorator)

    Decorator修飾節(jié)點,自定義子節(jié)點的行為。例如Invert節(jié)點,反轉其子節(jié)點返回的狀態(tài)信息;Repeat節(jié)點,按照給定次數重復執(zhí)行子節(jié)點;TimeOut節(jié)點,規(guī)定子節(jié)點的執(zhí)行時間,超過某個閾值不論子節(jié)點狀態(tài)如何直接返回失敗。修飾節(jié)點有且只有一個子節(jié)點。

上述給出的關于各種節(jié)點的描述比較籠統(tǒng)且不唯一,不同的行為樹的定義略有差別,理解即可。

一般來說,節(jié)點有三種基本狀態(tài),Success(成功)、Failure(失敗)、Running(運行中),行為樹中的節(jié)點每被觸發(fā)一次(Tick),就會返回當前的狀態(tài)信息給它的父節(jié)點。值得一提的是,當行為樹處于Running狀態(tài)時,這個在樹中傳遞的Running狀態(tài)一定是由某個Action節(jié)點返回的,極端點來說,只有Action節(jié)點才能真正處于Running狀態(tài),這很好理解,除了Action節(jié)點,其余節(jié)點都可以在一個Tick當中執(zhí)行完畢,但例如Wait這樣一個Action節(jié)點,就要跨越多個Tick執(zhí)行。當某個節(jié)點要跨越多個Tick執(zhí)行時,它就要返回Running狀態(tài),且行為樹的任意一個Tick,有且只能有一個Running狀態(tài)的節(jié)點。

現在讓我們再把這個示例行為樹拿出來看看。結合上面已經提供的信息和描述,我們現在能夠在腦中輕易的模擬一遍執(zhí)行過程,大家可以試試看。
Unity行為樹可視化編輯器開發(fā)

由此我們引出關于行為樹一個非常重要的部分,即中斷機制(Abort)。什么是中斷機制呢?我們還是看回這個行為樹,想象這樣一個情況,當此行為樹執(zhí)行到GoToSomeWhereWait節(jié)點時,Wait節(jié)點開始執(zhí)行并返回Running狀態(tài),假如說Wait節(jié)點設置的等待時間是兩秒鐘,那么不出意外的話,兩秒鐘過后,Wait節(jié)點執(zhí)行完畢向父節(jié)點也就是GoToSomeWhere返回成功,然后GoToSomeWhere執(zhí)行下一個子節(jié)點也就是MoveTo節(jié)點。

一切都很美好不是嗎?可是不出意外的話意外發(fā)生了,當Wait節(jié)點正在執(zhí)行的時候,如果PlayerInView節(jié)點的判斷為TRUE了,此時我們應該怎么辦?這可能會讓人有些疑惑,我們不是在說Wait節(jié)點嗎?這里我們要這樣想,要輪到Wait節(jié)點執(zhí)行,那前提一定是PlayerInView節(jié)點返回FALSE了才行,因為行為樹節(jié)點的執(zhí)行有優(yōu)先級,一般來說,父節(jié)點的優(yōu)先級比子節(jié)點高,兄弟節(jié)點之中,左邊節(jié)點的優(yōu)先級比右邊節(jié)點的優(yōu)先級高,如果只考慮葉子節(jié)點的話,可以籠統(tǒng)的概括為優(yōu)先級由左到右依次遞減。

所以在示例行為樹中,FollowPlayer的子節(jié)點的執(zhí)行優(yōu)先級要高于GoToSomeWhere的子節(jié)點,只有當FollowPlayer無法執(zhí)行了,才會輪到GoToSomeWhere,換句話說,我們要保證優(yōu)先執(zhí)行優(yōu)先級高的節(jié)點,放到示例行為樹上來說就是當PlayerInView為TRUE時,不論GoToSomeWhere的子節(jié)點執(zhí)行狀態(tài)如何,我們都要立刻執(zhí)行FollowPlayer下的子節(jié)點,即當NPC發(fā)現了Player,那么不論當前NPC是在Wait還是在MoveTo,都要立即執(zhí)行Follow,這樣才符合邏輯。

這里高優(yōu)先級節(jié)點打斷低優(yōu)先級節(jié)點執(zhí)行優(yōu)先執(zhí)行自己的機制就是中斷機制(Abort)。打斷是高優(yōu)先級打斷低優(yōu)先級,所以我們在設置中斷的時候一定是在相對高優(yōu)先級的節(jié)點上設置的,并且是在組合節(jié)點上設置的,話句話說,在打斷的時候,我們要把打斷后要執(zhí)行的那一組兄弟節(jié)點看為一個整體,那么在示例行為樹中,中斷的設置就應該放在FollowPlayer節(jié)點上。(這個地方可能說的有點繞,實際上手寫過之后就會好理解很多)

理解了中斷機制,我們就可以來詳細說明一下中斷的幾種類型。Self、LowPriority、Both、Noone。

  1. Noone

    Noone即是不打斷。

  2. LowPriority

    打斷低優(yōu)先級的節(jié)點,因為中斷是在組合節(jié)點上設置的,所以低優(yōu)先級是相對于此組合節(jié)點的右邊的兄弟節(jié)點及其子節(jié)點的。在示例中,FollowPlayer是高優(yōu)先級,GoToSomeWhere是低優(yōu)先級。

  3. Self

    打斷自身優(yōu)先級低的子節(jié)點的執(zhí)行,在示例中,如果FollowPlayer下的Follow正在執(zhí)行的時候,Player離開了NPC的視野范圍,也就是PlayerInView為FALSE,那么就打斷Follow的執(zhí)行。

  4. Both

    LowPriority與Self結合。在示例中,FollowPlayer如果設置為Both就很符合一個NPC的行為邏輯。

到這里,其實我們就可以嘗試實現一個簡單的行為樹了,不過這時行為樹的每個節(jié)點之間都還是孤立的,就比如如果一個節(jié)點的運行要根據上一個節(jié)點計算的值來執(zhí)行的話,我們就沒有辦法,這個時候我們就要想一個辦法,讓節(jié)點之間共享數據。

要在兩個相互獨立的節(jié)點之間傳遞數據,首先我們就不能讓其中一個節(jié)點直接持有數據,而是應該把數據存儲在一個任何節(jié)點都能夠訪問的地方,這樣節(jié)點就可以去找到想要的數據進行讀取或修改。這樣一種存儲共享數據的地方我們稱之為Blackboard(黑板),而我們的數據就像是寫在黑板上的東西,所有人都能看到并且修改,我們也稱之為Blackboard Value(黑板值)。

Blackboard具體到實現上其實也很簡單,只需要用一個字典來存儲黑板值,為了方便查找,一個key對應一個value,key就是黑板值得名稱,而value自然是對應的實際值,不過為了用一個字典存儲所有類型的黑板值,我們需要對數據做一層封裝。除此之外,為了方便保存黑板值,我們應該使用ScriptableObject,或者直接使用MonoBehavior來存儲,ScriptableObject能夠保存值很好理解,而MonoBehavior能夠保存值得原因也很好理解,因為MonoBehavior作為組件會掛載在GameObject對象上,這時就算停止運行了之后,寫在MonoBehavior里的字段值也會保留下來,這樣我們就達到了保存值得目的,而本項目中也是使用了MonoBehavior來保存值。

了解了這些內容之后我們就可以寫一個Runtime版的Blackboard了,至于為什么是Runtime版,我們后面做Blackboard Editor的時候再作說明。

行為樹講到這里其實就差不多了,具體實現都很靈活按照自己的想法實現即可,更進一步的話就是關于行為樹設計方面的東西,這我也是剛剛接觸不是很清楚就不做說明了。有了行為樹的基本知識,我們就可以著手開始寫一個簡單的行為樹了。這里我們不從頭開始寫一個行為樹了,更多關于行為樹的詳細介紹請移步 行為樹的理論與實踐入門


UIToolkit

前面我們已經了解了行為樹基本理論,假設當前你已經實現了一個行為樹,大概率是有至少兩個部分,一個是Node即節(jié)點部分,一個是BehaviorTree即行為樹部分,一個BehaviorTree持有一個RootNode,通過去調用RootNodeTick,從而遍歷整個由若干個Node構成的行為樹。如果沒有可視化編輯器的話,那么構造一個示例行為樹的部分代碼大概率長下面這樣。

{
    Node FollowPlayer = new Sequence();
        FollowPlayer.AddChildNode(new PlayerInView());
        FollowPlayer.AddChildNode(new Follow());
    Node GoToSomeWhere = new Sequience();
        GoToSomeWhere.AddChildNode(new SetDestination());
        GoToSomeWhere.AddChildNode(new Wait());
        GoToSomeWhere.AddChildNode(new MoveTo());
    Node Root = new Selector();
        Root.AddChildNode(FollowPlayer);
        Root.AddChildNode(GoToSomeWhere);
    BehaviorTree tree = new BehaviorTree(Root);
}

看起來其實也還好,但是別忘了示例行為樹還非常簡單,但凡行為樹的節(jié)點一多,那么手寫構造行為樹的代碼簡直就是噩夢。然而行為樹本身很重要的一點就是可視化編輯,所以當把行為樹的Runtime基本寫好之后,我們就可以開始考慮實現行為樹的Editor。

在Unity中實現這樣的節(jié)點編輯器其實有很多選擇,有很多第三方的插件可供選擇,不過在本項目中我選擇了Unity已經內置了的UIToolKit來實現整個行為樹的Editor UI。關于UIToolKit,下面給出一個官方的解釋

UI Toolkit是用于開發(fā)用戶界面(UI)的功能、資源和工具的集合。你可以使用UI Toolkit為Unity編輯器、運行時調試工具以及游戲和應用程序的運行時UI開發(fā)自定義UI和擴展。

UIToolKit受網頁開發(fā)技術的啟發(fā),通過類似于HTML+CSS的方法來構建UI,在Unity中對應的就是UXML和USS,簡單來說,UXML規(guī)定UI布局,USS規(guī)定UI樣式。在UI的實際開發(fā)過程當中,我們也主要是和這兩個部分打交道。

UIToolKit提供了一個UIBuilder工具來幫助我們完成UI的布局,具體的UIBuilder應該怎么使用這里就不做過多的解釋。
Unity行為樹可視化編輯器開發(fā)

UIBuilder

而在本項目中關于UIToolKit值得一提的是USS的編寫自定義控件。首先我們來說USS,編輯USS的辦法有兩種,第一種是直接在UIBulider中通過選中一個VisualElement,在其Inspector面板的Inlined Style下拉菜單中直接對當前的VisualElementStyleSheet進行編輯,因為是直接編輯,所以叫Inlined,或者將當前的Inlined Style導出為一個單獨的Class,即一個,這里的類說的是一類樣式,導出的好處就是可以在其他的VisualElement上應用這個Class,就不用每次都重新設置了。一個USS文件可以有很多類,這涉及到了后面要講的USS Selector(USS選擇器)。第二種就是直接編寫USS的代碼,例如:

.ClassName {
   PropertyName : PropertyValue
}

在一個USS文件當中,可以有很多個這樣的結構,每一個結構描述了對應選擇器的樣式,選擇器指定了哪些元素可以應用所描述的樣式,這里的ClassName就是選擇器的一種,在官方文檔中是這樣描述USS Selector

選擇器決定USS規(guī)則影響哪些元素。USS支持一組與CSS中的簡單選擇器類似但不完全相同的簡單選擇器。一個簡單的選擇器根據元素類型USS類、元素名稱通配符匹配元素。您可以將簡單的選擇器組合成復雜的選擇器,或者向它們附加偽類以瞄準處于特定狀態(tài)的元素。USS支持子代選擇器、子選擇器和多個復雜選擇器。

上面我們提到的.ClassName就是USS類選擇器,下面我們大概了解一下不同選擇器之間的區(qū)別。

  1. 元素類型

    例如Label、Button、ListView等等,使用元素的類型作為選擇器,只要元素類型相同就可以被選中。

  2. USS類

    類選擇器應該以英文句號"."開頭,類名由自己定義。當一個元素指定了多個類時,選擇器只需要匹配其中一個類來匹配元素。

  3. 元素名稱

    元素名稱選擇器以"#"開頭,后面接著的是元素的名稱,例如#DescriptionContainer就匹配了一個名稱為DescriptionContainer的元素。

  4. 通配符

    通配符只有一個"*", 顧名思義,就是可以匹配所有元素。

  5. 偽類

    偽類縮小了選擇器的范圍,因此它只匹配進入特定狀態(tài)的元素。例如:hover,光標位于元素上方時被選擇;:focus,元素具有焦點時被選則。將偽類附加到簡單的選擇器以匹配處于特定狀態(tài)的特定元素。

除了上面提到的基本的選擇器之外,我們還可以組合不同的選擇器,以達到精確指定元素的需求。

  1. 后代選擇器 Descendant Selector

    一個選擇器后面空一格跟著另一個選擇器
    selector1 selector2 {...}
    匹配的元素就是selector1下的任何滿足selector2的元素。

  2. 子代選擇器 Child Selector

    子選擇器由多個以>分隔的簡單選擇器組成。
    selector1 > selector2 {...}
    與后代選擇器相似,區(qū)別在于,selector2必須是selector1的子類。

  3. 多重選擇器 Multiple Selector

    多重選擇器由多個簡單選擇器組成,沒有任何東西將它們分開
    selector1selector2 {...}
    多重選擇器是多個簡單選擇器的組合。它選擇匹配所有簡單選擇器的任何元素。

了解USS的編寫之后,我們就可以針對不同的元素制定不同的樣式,讓我們的UI看起來美觀且好用。通過在代碼中調用AddClassToList方法來啟用一個Class,新加入的Class會覆蓋舊的Class。更多關于USS的內容請移步Unity Style Sheet (USS)

接下來就是關于自定義控件的部分,像UIToolKit內置的Label、Button、ListView等等就是控件,如下圖所示,就是UIToolKit內置的部分控件
Unity行為樹可視化編輯器開發(fā)

我們可以通過排列組合不同的控件以搭建起我們想要的UI,一般來說,簡單的UI使用內置的控件就足夠了,但如果內置的控件不能滿足我們的需求,我們也可以自定義控件。例如本項目所要用的到TwoPaneSplitView,它屬于一個Container控件,它被分成的兩個部分,兩個部分中間有滑條可以讓我們自由調整兩個部分相對比例大小
Unity行為樹可視化編輯器開發(fā)

TowPaneSplitView
我們在UIToolKit的Library中找不到這樣一個控件,這就需要我們自己寫出這個控件來,好在UIToolKit并不是沒有這樣一個控件,而只是沒有把這樣一個控件暴露給UIBulider,所以我們先從把自定義控件暴露給UIBulider以方便我們可視化拖拽編輯開始。
using UnityEngine.UIElements;
//繼承自默認的TwoPaneSplitView以定制自己的SplitView
public class TwoPaneSplitViewExposed : TwoPaneSplitView
{
	public new class UxmlFactory : UxmlFactory<TwoPaneSplitViewExposed, UxmlTraits>{ }
}

新建一個腳本,鍵入上述代碼,返回到UIBulider當中,在Library的Project一欄當中我們就可以看到已經有TwoPaneSplitViewExposed這一選項了。
Unity行為樹可視化編輯器開發(fā)
這個時候我們就可以把這個控件拖拽進Hierarchy中進行顯示了。而其中的關鍵就是這樣一句代碼

public new class UxmlFactory : UxmlFactory<TwoPaneSplitViewExposed, UxmlTraits>{ }

這句代碼的意思就是通過UxmlFactory這樣一個工廠類,來生成一個臨時的TwoPaneSplitViewExposed供UIBuilder顯示并使用。還有一個很重要的點就是,如果我們在Hierarchy中展開TwoPaneSplitViewExposed我們會發(fā)現這個控件下的元素都不能被我們編輯,但是我們可以通過拖拽將控件放入指定的元素下,又或者在腳本中用代碼來生成元素放入TwoPaneSplitViewExposed中。
Unity行為樹可視化編輯器開發(fā)
在上圖中,LeftPane和RightPane以及LeftPane下的Label都是由代碼生成的,而名稱為#Drag的Label則是拖拽放入的。但不論是代碼生成還是拖拽放入都只能是作為#unity-content-container的子級元素,這是由TwoPaneSplitViewExposed的內部代碼規(guī)定的,我們這里不做過多闡釋。更多關于自定義控件的內容請移步custom control
Unity行為樹可視化編輯器開發(fā)
關于UIToolKit還有很重要的一個部分就是數據綁定Data Binding,這個部分我們留到后面的部分在講一講,更多的細節(jié)請瀏覽官方文檔SerializedObject data binding


Unity GraphView

通過學習UIToolKit,假設我們已經通過UIBulider搭建了一個行為樹的布局界面
Unity行為樹可視化編輯器開發(fā)
這個界面用到了兩個TowPaneSplitView,一個橫向分割,分開了graphView部分和InspectorBlackboard組成的數據編輯部分;一個豎向分割,分割開了InspectorBlackboard?,F在我們還缺少一最重要的部分,就是右邊部分的GraphView,graphview的作用就是顯示節(jié)點視圖,并且可以讓我們添加、刪除、連接、復制節(jié)點等操作,所以說Graphview是行為樹可視化編輯最為重要的部分之一,接下來我們就詳細說一說Graphview

首先Unity已經為我們提供了一個功能完善的Graphview模塊,我們只需要繼承這個模塊,就能夠定義自己的Graphview。要用這個模塊我們就要引入此模塊的命名空間

using UnityEditor.Experimental.GraphView;

現在我們就可以繼承自GraphView這個類。

public class BehaviorTreeNodeGraphView : GraphView{}

根據我們前面了解到的關于UIToolKit的內容,我們可以讓我們的graphview暴露在UIBulider中,即加上下面一段代碼

public new class UxmlFactory : UxmlFactory<BehaviorTreeNodeGraphView, UxmlTraits>{ }

這樣我們就可以在UIBulider中通過拖拽將graphview加入UI中了。不過在這里我們不使用這樣的方式,而是直接通過代碼操作graphview或者其它元素的加入。在這之前,我們還是先講一講和Graphview本身相關的內容。

此時如果我們只是繼承了Graphview而不做任何改動的話,那么顯示出來的graphview就什么也沒有,一片空白,甚至沒有第一張圖展示出來的背景的格子,因為默認的Graphview就是一片空白,如果需要什么外觀和功能都要我們自己添加。

所以現在我就把所有此項目中用到的功能和外觀全都一一列舉出來做大概說明。首先我們來為graphview添加網格和一些拖拽框選的基本功能。在構造函數當中我們寫上以下代碼

public BTNodeGraphView()
{
   //加載背景網格的USS文件
   styleSheets.Add(Resources.Load<StyleSheet>("NodeGraphGridBackground"));
   //設置視圖滾輪縮放
   SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
   //添加拖拽、選擇、框選Manipulator 固定搭配
   this.AddManipulator(new ContentDragger());
   this.AddManipulator(new SelectionDragger());
   this.AddManipulator(new RectangleSelector());
   //為視圖添加背景網格
   var grid = new GridBackground();
   Insert(0, grid);
}

上述代碼都是常規(guī)操作,無腦粘貼復制即可,值得一提的是第一行加載USS文件,需要把USS資源文件放到Resource同名文件夾下才能使用Resources相關方法讀取。其余例如添加Manipulator的操作這里不做解釋,有興趣的自行百度。這里再放出背景網格的USS文件作為參考

GridBackground {
    --grid-background-color : #222222;
    --line-color: rgba(193,196,192,0.1);
    --thick-line-color: rgba(193,196,192,0.1);
    --spacing: 25;
}

有了上述代碼我們的graphview就像模像樣了,縮放、可拖拽、可框選、還有背景網格。接下來我們就要進行下一步,在graphview中添加節(jié)點Node。

我們先使用一種粗暴簡單的方法給Graphview中添加Node,因為實際的使用過程當中,添加Node這一操作還涉及到了其它很多方面,這里先按下不表,我們先來關注Node本身。

Unity同樣為我們提供了Node的模板,與Graphview在同一命名空間下,現在我們在Graphview中寫一個測試方法,以用來添加一個默認的Node

private void Test(){
   AddElement(new Node());
}

AddElement是添加GraphElement專用的方法。然后我們在構造函數中調用Test。

public BTNodeGraphView()
{
   ······
   //在構造函數中調用
   Test();
}

這里我們測試一下,效果就是下面這樣。在graphview視圖的左上角有一個小小的矩形,這個矩形就是我們添加的默認的Node,而這個Node的位置就是graphview的(0,0)點。
Unity行為樹可視化編輯器開發(fā)
現在我們就可以試著對這個小小的Node,進行選中拖拽刪除等操作了。不過很顯然,默認的Node根本不能達到我們的要求,所以我們就要試著自己定義一個Node。

GraphView相同,自定義Node同樣要繼承自Node

public class BehaviorTreeNodeGraph : Node{}

這時我們去

AddElement(new BehaviorTreeNodeGraph());

就與之前沒有區(qū)別,因為我們并沒有進行什么自定義操作。一個Node作為一個Node,首要的功能就是能夠和其它Node相連才有意義,而兩個Node相連需要的就是連接的端口Port,接下來我們就為Node添加Port。而這一系列操作都是在Node的構造函數中完成的。

Port inputPort;
Port outputPort;
public class BehaviorTreeNodeGraph : Node
{
   public BehaviorTreeNodeGraph(){
      //添加輸入端口
      inputPort = InstantiatePort(Orientation.Horizontal, Direction.Input,Port.Capacity.Single, typeof(Node));
      inputPort.PortName = "Input";
      inputContainer.Add(inputPort);
      //添加輸出端口
      outputPort = InstantiatePort(Orientation.Horizontal, Direction.Output,Port.Capacity.Multi, typeof(Node));
      outputPort.PortName = "Output";
      outputContainer.Add(outputPort);
      //刷新 不然會有顯示BUG
      RefreshExpandedState();
      RefreshPorts();
   }
}

其它的先不說,我們來看一下效果
Unity行為樹可視化編輯器開發(fā)
非常好,現在我們已經有了Port了,現在讓我來解釋一下是如何添加Port的,核心的部分就是InstantiatePort方法,這個方法是在Node下的,作用就是根據參數創(chuàng)建一個Port,第一個參數Orientation,指的是端口的方向,實際表現出來的就是,當我們連接兩個節(jié)點的時候是上下(Vertical)連接還是左右(Horizontal)連接;第二個參數Direction,這個很好理解,創(chuàng)建輸入端口就選Input,創(chuàng)建輸出端口就選Output;第三個參數指的是允許連接到此端口的節(jié)點數量,Single就是這個端口只能有一個連接,Multi就是這個端口可以有多個連接;最后一個參數不做解釋,默認傳入一個類型即可。除此之外,要有一步就是把創(chuàng)建的Port加入到節(jié)點中,這里直接把Port加入到對應的容器中即可。最后要刷新一下Node以免出現顯示BUG。

有了節(jié)點有了端口,我們就可以連接兩個端口啦!可事實上我們還不能連接兩個節(jié)點,問題出在連接兩個節(jié)點的時候我們還不知道這個連接是不是有效連接,也就是說,我們從一個端口拉一條連接線出來的時候,要知道有哪些端口是可以連接的。這個時候就需要在Graphview中覆蓋一個方法,這個方法就是GetCompatiblePorts,翻譯過來就是獲取到兼容的端口,返回值是一個List<Port>。所以我們就在Graphview中覆寫這樣一個方法

public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
   //存儲符合條件的兼容的端口
   List<Port> compatiblePorts = new List<Port>();
   //遍歷Graphview中所有的Port 從中尋找
   ports.ForEach(
      (port) =>
      {
         if (startPort.node != port.node && startPort.direction != port.direction)
         {
            compatiblePorts.Add(port);
         }
      }
   );
   return compatiblePorts;
}

覆寫此方法之后,我們再從某個端口拉一條連線出來的時候就會發(fā)現,不符合條件的端口就直接暗掉不能連接了,而符合條件的端口則可以正常連接。此時,我們就可以成功連接兩個節(jié)點了。

值得一提的是,當我們連接了兩個節(jié)點的時候,就是在Graphview上創(chuàng)建了一個Edge即一條邊,而這個Edge也屬于是一種GraphElement,邊上記錄了兩個連接的節(jié)點的信息。同時在Graphview中有三個字段分別保存了視圖上所有的節(jié)點和所有的端口以及所有的連接,分別是nodes,ports,edges,并且我們可以對其進行增刪改查,有很多操作也都需要用到這些字段,且看后面的內容。

這里只是簡單講了講graphview最基礎的部分,目前為止我們可以創(chuàng)建節(jié)點,并手動連接節(jié)點了。既然說到了手動連接節(jié)點,那么是不是就可以自動連接節(jié)點呢?確實,在這個項目當中,當我們打開一個行為樹編輯器的時候,如果當前存在一個行為樹,那么在打開編輯器后就應該顯示出節(jié)點并且已經把所有的節(jié)點連接好了。說到底自動連接節(jié)點就是在GraphView中創(chuàng)建一個Edge然后指定這個Edge的輸入節(jié)點和輸出節(jié)點即可。

public Edge MakeEdge(Port oput, Port iput)
{
   var edge = new Edge { output = oput, input = iput };
   edge?.input.Connect(edge);
   edge?.output.Connect(edge);
   AddElement(edge);
   return edge;
}

上面的代碼是寫在Graphview中的,當需要自動連接兩個節(jié)點,比如編輯器剛打開時,就會調用這個方法,傳入兩個需要連接的節(jié)點,然后創(chuàng)建Edge,除了可以自動創(chuàng)建連線,自動創(chuàng)建節(jié)點也是必須的。

public BehaviorTreeGraphNode MakeNode(Vector2 position)
{
   BehaviorTreeGraphNode node = new BehaviorTreeGraphNode();
   node.SetPosition(new Rect(position, defaultNodeSize));
   AddElement(node);
   return node;
}

同樣的,需要自動創(chuàng)建節(jié)點的時,就可以調用上面的方法。兩個方法結合使用就可以自動根據已有的數據,創(chuàng)建出一顆可視化行為樹出來。
Unity行為樹可視化編輯器開發(fā)

最后一個關于Graphview比較重要的部分就是SearchWindow,SearchWindow是干什么的呢?簡單來說,SearchWindow就是提供一個搜索樹目錄,這個目錄中的各項在本項目中就是各種各樣的節(jié)點,選擇某個節(jié)點之后就創(chuàng)建選擇的節(jié)點在視圖上。具體長下面這樣:
Unity行為樹可視化編輯器開發(fā)
我們所有節(jié)點都被分類收錄到了一起然后由SearchWindow顯示出來,但SearchWindow只負責提供一個顯示的平臺,顯示什么,怎么顯示就是我們自己規(guī)定的。與graphview上其它元素不同的是,要創(chuàng)建一個SearchWindow,要繼承ScriptableObjectISearchWindowProvider。

public class BehaviorTreeSearchWindow : ScriptableObject, ISearchWindowProvider
{
   List<SearchTreeEntry> ISearchWindowProvider.CreateSearchTree(SearchWindowContext context){
      List<SearchTreeEntry> searchTreeEntries = new List<SearchTreeEntry>();
      //添加至少一項 否則不顯示
      searchTreeEntries.Add(new SearchTreeGroupEntry(new GUIContent("Behavior Tree Nodes"), 0));
      return searchTreeEntries;
   }
}

因為擁有了ISearchWindowProvider這個接口,所以我們要實現CreateSearchTree方法,這個方法的作用就是生成一組數據用以填充SearchWindow。而其中的SearchTreeEntry就代表了一個搜索樹條目,并且數據條目的類型有兩中,一種是SearchTreeGroupEntry即一個搜索樹條目組,另一種是SearchTreeEntry即一個實際的搜索數條目。其實在更底層,這兩種類型都是SearchTreeEntry

這里我們不討論如何構造一個符合實際需求的搜索樹目錄,我們先讓空白的SearchWindow顯示出來。我們在Graphview中添加如下字段與方法

private BehaviorTreeSearchWindow searchWindow;
private void AddSearchWindow()
{
   //創(chuàng)建一個searchWindow的實例
   searchWindow = ScriptableObject.CreateInstance<BehaviorTreeSearchWindow>();
   //添加一個回調 當按下空格的時候調用
   nodeCreationRequest = context =>
   {
      //打開一個searchWindow
      SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), searchWindow);
   };
}

然后在Graphview的構造函數中調用AddSearchWindow,那么當我們打開編輯器按下空格鍵的時候,就會跳出一個空白的SearchWindow。
Unity行為樹可視化編輯器開發(fā)
關于Graphview的內容暫且就講到這里,因為很多功能都是多個部分相互穿插配合才能夠實現,所以還有部分我們放到后面一一道來。


Unity Editor

現在行為樹可視化編輯器的UI我們有了,Graphview視圖我們也有了,接下來就是把這兩個放到一起,然后用一個EditorWindow把UI真正顯示出來。在Unity中,任何窗口,例如Inspector、Animator、TimeLine等等都是EditorWindow,我們現在也要把讓一個EditorWindow來顯示做好的UI。新建一個腳本,引入UnityEditor命名空間并繼承自EditorWindow。

public class BehaviorTreeEditorWindow : EditorWindow{}

現在我們就有了一個屬于自己的EditorWindow,其他的先不說,讓我們把這個窗口打開顯示出來。如果要打開一個自定義窗口,在Unity中有一個通用的方法。

[MenuItem("Window/AI/BehaviorTree Editor")]
public static void OpenEditorWindow()
{
   window = EditorWindow.GetWindow<BehaviorTreeEditorWindow>();
   window.titleContent = new GUIContent("BehaviorTree Editor");
   window.minSize = new Vector2(540, 480);
}

上面這段代碼就是我們能夠打開EditorWindow的關鍵,首先這個方法必須是靜態(tài)的,因為我們可以直接不持有窗口的實例直接調用此方法,在這個方法中,EditorWindow下的GetWindow就是打開一個窗口的核心方法,這也是一個靜態(tài)方法,方法內剩下的部分就是在設置這個窗口的基本參數,比如窗口的名稱和最小大小?,F在我們在來看方法上的那一個屬性MenuItem,這個屬性的作用就是告訴Unity,這個方法是一個菜單項,也就是說這個方法能夠被我們在菜單的某一項里調用,而這個調用的具體位置就由我們自己來設定,我設定的位置就是Window->AI->BehaviorTree Editor。下面我們來菜單欄中找一找看是不是有這樣一個選項。
Unity行為樹可視化編輯器開發(fā)
成功了,現在我們就能打開一個EditorWindow,并且這個窗口和名稱和我們設置的一樣,但是目前這個窗口一片空白,那是因為我們還沒有加載做好的UI。
Unity行為樹可視化編輯器開發(fā)
現在我們就來加載做好UI界面,我們在BehaviorTreeEditorWindowOnEnable方法中寫加載UI的功能,每次打開窗口的時候都會OnEnable被調用。添加以下代碼。

//加載主界面
VisualTreeAsset editorViewTree = Resources.Load<VisualTreeAsset>("BehaviorTreeEditorWindow");
TemplateContainer editorInstance = editorViewTree.CloneTree();
editorInstance.StretchToParentSize();
rootVisualElement.Add(editorInstance);

首先我們要把我們用UIBulider做好的UI界面的資源文件加載到內存中,這個資源文件其實就是UXML文件,加載進來的UXML文件會被一個VisualTreeAsset的實例保存為VisualElementAsset樹,UXML文件中的每一個節(jié)點都是一個VisualElementAsset。需要注意的是,我們需要把UXML文件放到Resources文件夾中,并且在讀取的時候,文件名后面不需要文件后綴,只需名稱即可。然后我們將加載進來的UXML文件調用CloneTree進行克隆,克隆后返回值是一個TemplateContainer類型的,這個類保存了當前克隆的VisualTreeRoot即保存了根。然后調用StretchToParentSize讓UI填滿整個父級窗口,這里的父級窗口自然就是整個窗口了,最后把獲取的UI的根元素,加入到窗口的根元素下,這樣我們就完成了主界面UI的加載。
Unity行為樹可視化編輯器開發(fā)
非常好,確實將我們做的UI界面加載了進來,不過似乎Graphview并沒有被加載,這很正常,因為我們之前并沒有在UIBulider中添加Graphview,況且我們也并沒有把Graphview暴露給UIBulider?,F在我們依舊用代碼將Graphview加載進來。

//加載節(jié)點視圖
nodeGraphView = new BTNodeGraphView(this);
nodeGraphView.StretchToParentSize();
rootVisualElement.Q<VisualElement>("RightPane").Add(nodeGraphView);

這與加載主界面UI類似,先創(chuàng)建一個Graphview的實例,然后將這個界面也設置為填充父級的窗口,不過這最后一步稍有不同,此時我們的EditorWindow中已經有各種UI元素了,現在我要確定哪一個元素是Graphview的父級元素,在這里顯而易見是名稱為“RightPaned”的元素,所以我們使用Q方法找到這樣一個元素,Q的是Query的縮寫也就是查找,然后把Graphview加入RightPane中作為其子級元素。
Unity行為樹可視化編輯器開發(fā)
Graphview被正確的加載進來了,我們添加的功能也都可以正常執(zhí)行,不過我們現在還沒有做在編輯器界面添加節(jié)點的功能,并且左邊的InspectorBlackboard以及ToolBar上的各種按鈕也沒有利用起來,稍安勿躁,我們一步一步來。不過我不打算講的太細,因為內容確實很多,我也不想篇幅過長,所以我們挑一些很重要的地方說一下。

在我們的設想中,添加節(jié)點的時候我們是打開SearchWindow然后選擇要創(chuàng)建的節(jié)點,雖然現在SearchWindow我們已經可以打開了,不過并沒有任何東西,在這里我先跳過SearchWindow獲取所有節(jié)點并生成搜索樹目錄的部分,因為這部分更多的還是涉及到C#語言的部分,這個部分我們主要講解與UnityEditor相關的部分。

假設現在我們已經獲取到了所有節(jié)點,并且可以生成選擇的行為樹節(jié)點了。在生成行為樹節(jié)點的時候,我們也應該生成不一樣的節(jié)點圖,換句話來說,我們要做到每一個不同的節(jié)點要有一個唯一的對應的節(jié)點圖。這么說很容易把人搞混亂,我們先規(guī)定一下,能夠顯示在Graphview上的繼承自Node的節(jié)點,我們稱之為graphNode,實際參與行為樹運行,有具體數據和邏輯的繼承自MonoBehaviorScriptableObject的節(jié)點,我們稱之為monoNode。我們在Graphview上創(chuàng)建的節(jié)點是graphNode,那也就意味著要有一個monoNode與之對應,這就是我們所說的一個graphNode對應一個monoNode,也就是說我們在GraphView上創(chuàng)建graphNode的同時也要創(chuàng)建一個monoNode。

假設這里我們創(chuàng)建graphNode的時候也會自動添加一個monoNode,那么現在我們就可以在GraphView上用可視化的方式連出一顆行為樹了。
Unity行為樹可視化編輯器開發(fā)

最后的效果,僅做參考

現在讓我們把目光放到編輯器的左邊,InspectorBlackboard,選中一個graphNode時,Inspector會顯示對應的monoNode的序列化字段并且我們可以進行編輯。
Blackboard允許我們創(chuàng)建或刪除一個Blackboard Value。

先來講Inspector,假設我們可以獲取到當前選中的graphNode對應的monoNode,那么我們就把這個monoNode當中的序列化字段顯示到Inspector中。要顯示序列化字段,就像我們選中一個GameObject的時候在Unity編輯器的Inspector中顯示的掛載的組件上的各種字段一樣。關鍵在于一個在UnityEditor.UIElement命名空間下的類,PropertyField,和Label與TextField等相似,都是屬于一個UI控件。

PropertyField可以將序列化字段可視化出來,并且在綁定了數據源后,可以編輯并修改綁定的字段。除此之外,如何獲得一個腳本的序列化字段也很重要,其中發(fā)揮關鍵作用的是兩個類,SerializedObjectSerializedProperty?,F在我們就使用這三個類來將monoNode中的序列化字段顯示到Inspector上。

//獲取到當前節(jié)點中所有序列化數據
SerializedObject serializedNode = new SerializedObject(monoNode);
SerializedProperty nodeProperty = serializedNode.GetIterator();
nodeProperty.Next(true);
//遍歷所有序列化數據
while (nodeProperty.NextVisible(false))
{
   //構造一個PropertyField用于顯示
   PropertyField field = new PropertyField(nodeProperty);
   //與實際的節(jié)點數據綁定
   field.Bind(serializedNode);
   //加入到Inspector
   nodeInspector.Add(field);
}

上述代碼主要干的事情就是,先用SerializedObject拿到monoNode的所有序列化數據,然后再使用SerializedProperty一個個的遍歷這些序列化字段,之后用PropertyField綁定到字段對應的節(jié)點并加入Inspector面板中顯示此字段,此時我們當前選中的節(jié)點的可視化數據就顯示出來了。其實實際項目中的代碼不會這么簡單,這里只是核心部分。

說完Inspector,我們來說一說Blackboard,Blackboard的實現其實可以與Inspector相同,不過我在項目中還額外使用到了ListView,ListView中的每一項就是一個Blackboard Value,要往ListView中添加元素有三步,第一步makeItem,第二步bindItem,第三步指定itemsSource。makeItemListView下的一個Func<VisualElement>,可以理解為返回值為VisualElement的委托或者回調函數,而makeItem就是在ListView中生成Item時調用,作用就是以我們自己定義的方式生成一個Item,這里生成的Item是一個VisualElement。

makeItem之后,ListView中就多了一個空白的VisualElement,此時我們要使用bindItem來為這個空白的VisualElement填充內容。bindItem是一個Action<VisualElement,int>,即是一個有兩個參數的事件或回調函數,這里的參數VisualElement是我們使用makeItem生成的,而int參數則代表了,當前是ListView中的第幾項,這個時候我們就可以為空白的VisualElement中添加元素了,比如添加一個Label,再添加一個PropertyField

最后我們再將itemSource設置為我們在Blackboard中保存值的List即可。多說無益,上代碼。

private ListView variableList;
variableList.makeItem = () =>
{
   TemplateContainer variableViewInstance = variableViewTree.CloneTree();
   return variableViewInstance;
};
variableList.bindItem = (item, index) =>
{
   item.Q<Label>("variableName").text = treeBlackboard.variables[index].key;
   SerializedObject serializedObject = new SerializedObject(treeBlackboard.variables[index]);
   SerializedProperty property = serializedObject.FindProperty("val");
   item.Q<PropertyField>("field").label = "";
   item.Q<PropertyField>("field").BindProperty(property);
   item.Q<PropertyField>("field").Bind(serializedObject);
};
variableList.itemsSource = treeBlackboard.variables;

上面的代碼是項目中的代碼,makeItem中的variableViewTree是加載的UXML文件,然后用這個UXML文件生成的VisualTree作為一個Item。bindItem中干的事情其實就是,在item中填入并綁定第index個BlackboardValue的名稱為“val”的序列化字段。最后將這個ListView的數據源設置為Blackboard中存放實際值的List。這樣我們就能夠在Blackboard面板中顯示所有的Value
Unity行為樹可視化編輯器開發(fā)
可以對照一下代碼,看具體是怎么生成ListView中的每一項的。關于Blackboard其實還有很多很多可以講的,在實際的項目中Blackboard的實現相對來說也是很復雜的,而且在設計上也很有意思,不過這里我只是大概的講一講,如果有時間,我也許會寫一篇詳細的關于Blackboard的博客。

到這里關于Unity編輯器還有很重要的一個部分,就是自定義屬性繪制,CustomPropertyDrawer,在什么情況下我們需要自定義屬性繪制呢,就比如一個float值,默認在Inspector上是顯示為一個名稱和一個field,我們可以在這個field中輸入任何一個值,但是如果我們想讓這個float值限制在某個范圍,我們除了可以在OnValidate方法中限制之外,還可以用Range屬性來規(guī)定范圍,并且Range屬性在修飾float值后,還會在inspector中顯示一個滑條,方便我們靠拖拉滑條修改float的值。那么像這種效果我們使用PropertyDrawer覆蓋原有字段的繪制也可以實現,而在本項目中使用到PropertyDrawer的地方就是顯示node中的引用Blackboard Value的部分

[CustomPropertyDrawer(typeof(targetProperty), true)]
public class targetPropertyDrawer : PropertyDrawer
{
   public override void OnGUI(Rect position, SerializedProperty property, GUIContent label){}
}

上述代碼就是修改某個屬性(targetProperty)的繪制方式的基礎代碼,首先就是CustomPropertyDrawer屬性,這個屬性就是告訴Unity,我們覆蓋繪制的目標是誰,屬性中的第二個參數的意思就是當前這個類的子級的字段繪制也要覆蓋掉。然后我們需要繼承自PropertyDrawer,最后在OnGUI中實現我們自己的繪制代碼,如果就拿上面的代碼去覆蓋某一個屬性的繪制的話,那么最后什么都不會顯示,因為我們根本沒有在OnGUI中寫任何的代碼啊。
Unity行為樹可視化編輯器開發(fā)
上圖紅色方框內就是使用PropertyDrawer實現的自定義屬性繪制,繪制的是Node中聲明的Blackboard VariableReference,現在我們就可以通過下拉選項框來選擇Blackboard中同一類型的值了,方便又直觀。如果要在Node中訪問Blackboard值就要使用對應類型的VariableReference,這是blackboard系統(tǒng)設計上的內容,這里就不做過多說明??傊?,如果要深入定制自己的編輯器,PropertyDrawer是必不可少的。更多內容移步官方文檔PropertyDrawer

講到這里Unity Editor部分的內容就差不多了,其實還有很多本項目中涉及到的關于Unity Editor的內容沒有講到,但是這部分的內容多且雜,全部講完也不現實,我們繼續(xù)講解后面的部分。


C#語言

終于要開始講C#語言部分了,上面講的大多數功能其實都離不來C#語言的一些很好用的特性,比如反射、屬性、Linq等等,C#語言也是非常優(yōu)秀的一門編程語言,相比于C++來說犧牲了部分性能,但是換來的確是非常舒適的編寫體驗。接下來我就講一講本項目中各個部分在實現過程中應用到的C#的語言特性。

  1. 屬性(Attribute)
    以下是我對Attribute的一些粗淺的理解

    應該如何理解C#中的特性(Attribute)?先用一句話總結一下,特性就是用來對C#中的類、字段、屬性、方法、甚至是特性自身添加額外的信息和行為的一種方式。為什么說是添加“額外”的信息和行為呢?因為一個元素使用Attribute后實際上會在編譯后的元素內部產生IL語言,并且在Metadata中會有記錄。

    與我們的代碼注釋有點相似,只不過代碼注釋是給開發(fā)人員看的,而Attribute可以理解為是給編譯器看的注釋,即注釋不能直接影響程序的運行,但是特性Attribute可以。

    實際上,特性也是一個類,即不論是框架自帶的特性還是我們自定義的特性,都需要繼承自Attribute類,值得注意的是,我們可以在自定義的Attribute中進行任何對于一般類的操作,寫屬性寫字段寫方法等等,因為Attribute就是一個類,我們不要因為他的特殊性就設定很多的條條框框。Attribute與一般的類不同的點就在于,我們在代碼階段我們就要確定我們寫的Attribute中所含所有信息,列如字段屬性的類型、值、方法的參數、返回值等等,因為在編譯后Attribute就不能再被動態(tài)的修改了,就像泛型那樣。

    總之特性可以在不破壞類型封裝的前提下,添加額外的信息和行為。

    回到項目當中,我們?yōu)楣?jié)點添加屬性,讓編寫的節(jié)點能夠被識別到。下面我們來看一個簡單的行為樹節(jié)點。

    [BTNode("Example/PrintLog", "打印日志信息")]
    public class PrintLog : BTNodeAction
    {
       public string logMsg;
       public override NodeResult Execute()
       {
          Debug.Log(logMsg);
          return NodeResult.success;
       }
    }
    

    上述代碼中的首行代碼就是本項目中用來標記一個節(jié)點的屬性,當節(jié)點被這個BTNode屬性標記之后,我們就可以在SearchWIndow中通過反射來獲取到這個節(jié)點上屬性中的信息,從而幫助我們構造搜索樹目錄,具體實現我們放到后面,現在我們先來看看這個Attribute的實現

    [AttributeUsage(AttributeTargets.Class)]
     public class BTNodeAttribute : Attribute
     {
     	//節(jié)點分類路徑
     	public string NodePath { get; set; }
     	//節(jié)點描述
     	public string NodeDescription { get; set; } = "";
     	public BTNodeAttribute(string nodepath)
     	{
     		NodePath = nodepath;
     	}
     	public BTNodeAttribute(string nodepath, string description)
     	{
     		NodePath = nodepath;
     		NodeDescription = description;
     	}
     }
    

    上述代碼就是整個BTNodeAttribute的實現,很簡短,但很有用,我們來具體看看。首先C#中的Attribute本質上也是一個類,只不過我們自定義的Attribute要繼承自Attribute基類,然后我們看第一行,同樣是個Attribute,只不過這個Attribute是修飾AttributeAttribute,這個名叫AttributeUsage的作用就是規(guī)定當前我們自定義
    Attribute的修飾范圍,是只能修飾類,還是只能修飾方法,還是只能修飾字段,或者都可以修飾。在這里我們規(guī)定自定義的Attribute只能修飾類。

    在這個BTNodeAttribute中我們聲明了兩個字段,一個字段記錄一個路徑,這個路徑規(guī)定了被修飾節(jié)點在SearchWindow中搜索樹目錄的路徑以及名稱,而第二個字段就是記錄了一些被修飾節(jié)點的描述?,F在再來看示例節(jié)點上的BTNoddAttitude就一目了然了。

    [BTNode("Example/PrintLog", "打印日志信息")]
    

    "Example/PrintLog"是路徑與節(jié)點名稱,"打印日志信息"是節(jié)點描述。這個時候我們有了屬性,屬性里面也有了各種信息,但其實如果我們不進行進一步的操作的話,就什么用也沒有,也就是說Attribute不會對類產生任何影響,除非我們主動去訪問里面的信息或方法,是的,Attribute也能夠實現方法,只不過這個方法也需要我們主動調用。

    那么我們怎么去訪問屬性中的字段與方法呢?這個時候就要引出下一個部分的內容了,反射Reflection

  2. 反射(Reflection)
    關于反射,以下是一些大概的解釋

    反射是.Net中獲取運行時類型信息的方式,.Net應用程序由幾個部分組成:程序集(Assembly)、模塊(Module)、類型(class)組成,而反射提供一種編程方式,讓程序員可以在運行時期獲取這幾個組成部分的相關信息。

    Assembly類可以獲得正在運行的裝配件信息,也可以動態(tài)的加載裝配件,以及在裝配件中查找類型信息,并且創(chuàng)建該類型的實例。Type類可以獲得對象的類型信息,此信息包含對象的所有要素:方法、構造器、屬性、字段等等,通過Type類可以得到這些要素的信息,并且調用。除此之外,還有列如FieldInfo、EventInfo等等,這些類都包含在System.Reflection命名空間下。

    其實關于反射,理論方面的東西比較復雜,不過我們現在主要關心應用,總的來說,反射就是讓程序自己了解自己的一種方法,就像我們化的妝要靠鏡子反射才能看到一樣,雖然這么比喻不怎么正確。廢話不多說,我們直接上代碼,下面這段代碼就是我們實現SearchWindow構造搜索樹目錄反射部分的代碼。

    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
    {
       ······
       //通過反射程序集找到所有繼承自BTNodeBase的類 也就是找到所有節(jié)點類
       List<Type> types = new List<Type>();
       foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
       {
          List<Type> result = assembly.GetTypes().Where(type =>
          {
             return type.IsClass && !type.IsAbstract && type.IsSubclassOf(typeof(BTNodeBase));
          }).ToList();
          types.AddRange(result);
       }
       ······
    }
    

    現在我們一行一行代碼來看,首先我們聲明并初始化了一個List,這個List存儲的值是Type,這個Type類型又是什么類型呢。

    Type類,用來包含類型的特性。對于程序中的每一個類型,都會有一個包含這個類型的信息的Type類的對象,類型信息包含數據,屬性和方法等信息。

    根據上述描述我們可以知道,Type記錄了類的各種信息,那我們接著看下面的foreach循環(huán),首先選擇的循環(huán)對象是當前程序域AppDomain中所有的程序集Assembly,Assembly是什么呢,在前面我們提到Assembly類可以獲得正在運行的裝配件信息,那裝配件又是什么呢?似乎這里涉及到的東西一層套一層難以理解,那么我們干脆就從C#程序的構成講起。

    C#應用程序結構包含 應用程序域(AppDomain),程序集(Assembly),模塊(Module),類型(Type),成員(EventInfo、FieldInfo、MethodInfo、PropertyInfo) 幾個層次

    他們之間是一種從屬關系,也就是說,一個AppDomain能夠包括N個Assembly,一個Assembly能夠包括N個Module,一個Module能夠包括N個Type,一個Type能夠包括N個成員。他們都在System.Reflection命名空間下。

    Unity行為樹可視化編輯器開發(fā)

    從上圖中我們不難看出程序集Assembly的組成。

    綜合上述描述,我們現在就應該能夠理解循環(huán)中我們在干什么了。我們先從AppDomain中獲取一個個Assembly,獲取到某一個Assembly后我們調用GetTypes獲取到當前程序集中所有的Type也就是獲取到所有的類,此方法的返回值是一個數組,之后我們使用Where查找返回所有符合要求的類,這個屬于C# Linq的內容我們之后在講,總之Where的作用就是構造并返回符合給定要求的可枚舉列表,簡單來說就是起到了一個篩選的作用,在這里我們給定的要求是,首先要是一個類,其次,這個類不能是抽象類,最后,這個類要是節(jié)點基類的子類。這樣一來我們就獲取到了所有的非抽象類的節(jié)點類。最后這個Assembly的節(jié)點類獲取完后我們就去找下一個Assembly的節(jié)點類,直到我們找到了所有的節(jié)點類。

    看,反射就是如此神奇,我們不用手動去找所有的節(jié)點類,只需要依靠反射就能夠找到項目中所有我們定義的節(jié)點。找到所有的節(jié)點之后我們就可以進行下一步操作了,即去獲取我們在節(jié)點聲明的屬性BTNodeAttribute中的內容了。

    foreach (Type type in types)
    {
       //獲取節(jié)點屬性的NodePath
       string nodePath = type.GetCustomAttribute<BTNodeAttribute>()?.NodePath;
       if (nodePath == null) continue;
       //將路徑中每一項分割
       string[] menus = nodePath.Split('/');
       ······
    }
    

    首先我們遍歷所有我們找到的節(jié)點類,然后通過調用GetCustomAttribute方法來獲取我們指定的自定義屬性,獲取到屬性之后我們就可以放心大膽的去拿到我們在屬性中定義的字段后者方法了,在這里我們就拿到了BTNodeAttribute中的NodePath字段,至此我們就可以對拿到的字段做進一步處理方便我們構造SearchWindow搜索樹目錄了。

    具體怎么構造SearchWindow搜索樹目錄這里不做過多闡述,大概思路就是把獲取到的路徑分割為一個個目錄項進行遍歷,當前目錄項存在就前往下一層,如果當前目錄項不存在就判斷,如果當前項是目錄項就構造一個新的目錄項,如果不是說明是實際的節(jié)點名稱,就構造一個節(jié)點項。

    至此,我們通過設置屬性和使用反射就把所有的節(jié)點類獲取到并且以此構造了我們的SearchWindow搜索樹目錄了。

  3. Linq(Linq語法)
    在項目中我們還用到了C#語法中比較牛叉的一個部分,那就是Linq,那啥是Linq呢?

    Linq 全稱 Language Integrated Query,語言集成查詢,是一種使用類似SQL語句操作多種數據源的功能。

    這么解釋比較抽象,如果你了解過SQL的話那應該就比較好理解,如果沒有了解使用過SQL的話,比如我,就這么理解Linq,

    Linq就是C#中為我們封裝好的一堆查詢方法,查詢的對象就是各種可以遍歷的數據源,比如List、Array、IEnumerable等等。

    我們只需要提供核心的比較、修改等方法,就可以對整個數據源進行自定義的遍歷。話不多說,我們上代碼。

    public void ClearNodeGraph()
    {
       foreach (var node in nodes)
       {
          //Remove Edges
          edges.ToList()
                .Where(x => x.input.node == node)
                .ToList()
                .ForEach(edge => RemoveElement(edge));
    
          //Remove Node
          RemoveElement(node);
       }
    }
    

    上面這個方法的作用是清除GraphView上所有的元素,其中就運用到了Linq,我們來看清除Edges的代碼,首先將Graphview上的edges轉換為List,然后使用Where遍歷找到與當前節(jié)點的輸入端口連接相同的節(jié)點,找到后再ToList,最后使用ForEach遍歷獲取到的edges,然后從Graphview上移出。整個過程非常的絲滑,一步步的就將我們需要的部分查詢了出來,并且中途直接使用返回值來進行下一步操作,如果這部分我們自己寫循環(huán)實現的話,差不多三十行代碼起步,但是使用Linq就能夠節(jié)約大量時間,并且讓我們專注于篩選查找的邏輯以便我們更精確的查找到我們想要的部分。

    Linq的魅力還遠不止于此,我們上述說的只是其中很小一部分,Linq作為C#中的一大特性,不經可以操縱程序中的數據源,還可以訪問并操縱非程序的內容比如,對外部數據庫進行查詢,或者是XML等等,這里給出一個比較專業(yè)的解釋

    Linq to Object。提供程序查詢內存中的集合和數組。
    Linq to DataSet。提供程序查詢http://ADO.NET數據集中的數據。
    Linq to SQL。提供程序查詢和修改Sql Server數據庫中的數據,將應用程序中的對象模型映射到數據庫表。
    Linq to Entities。使用linq to entities時,會在后臺將linq語句轉換為sql語句與數據庫交互,并能提供數據變化追蹤。
    Linq to XML。提供程序查詢和修改XML,既能修改內存中的xml,也可以修改從文件中加載的。

    所以說Linq是很強大的,有興趣的朋友可以自行搜索相關教程進行學習。

    那么在本項目中所使用到的C#相關的內容就這么多,或許還有一些零零星星的小點沒有講到,這里就不花篇幅一一講解了。其實C#還有很多有意思的地方,協程、泛型、委托等等,這部分的內容大家就自行了解吧。


結語

終于,關于如何從零開始開發(fā)Unity行為樹插件到這里就接近尾聲了,沒料到最后竟然寫了這么多,一開始打算的是就寫一寫開發(fā)過程當中的心得避免直接忘記,但是寫著寫著發(fā)現寫的有點詳細了,畢竟是第一次寫博客,在內容的規(guī)劃上還是沒有經驗。

本文中涉及到的幾個部分其實如果單獨拿出來講的話都可以寫一個系列博客了,有機會的話,我也會深入研究研究。

最后感謝您能夠看到這里,如果這篇博客能夠對你有所幫助,那是我莫大的榮幸。如果您還有任何的問題的話歡迎在評論區(qū)留言,我會竭盡所能回答,當然,如果您覺得本文有任何不妥之處也歡迎指出。(2023/08/15)文章來源地址http://www.zghlxwxcb.cn/news/detail-664770.html

到了這里,關于Unity行為樹可視化編輯器開發(fā)的文章就介紹完了。如果您還想了解更多內容,請在右上角搜索TOY模板網以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網!

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。如若轉載,請注明出處: 如若內容造成侵權/違法違規(guī)/事實不符,請點擊違法舉報進行投訴反饋,一經查實,立即刪除!

領支付寶紅包贊助服務器費用

相關文章

覺得文章有用就打賞一下文章作者

支付寶掃一掃打賞

博客贊助

微信掃一掃打賞

請作者喝杯咖啡吧~博客贊助

支付寶掃一掃領取紅包,優(yōu)惠每天領

二維碼1

領取紅包

二維碼2

領紅包