DSL在項目中的應用:用DSL高效組織遊戲情節

最近工作當中,任務部分的代碼寫的耦合度非常高並且也不好擴展,由我接手重構之後我自己編寫了一個簡單的DSL用於幫助策劃配置情節,在這裡簡單記錄一下吧。

由於開發時間非常緊,重構時間整個任務以及NPC對話系統只有短短三天而已,實際將DSL完成並且接入也僅僅花了一天的時間而已,也不需要自己過分編寫語法解析之類的輪子。

當然如果希望精益求精的話當然可以自己開發一套完整的語言,但是夠用就行了,不是嗎~

為什麼要劇情腳本

相對於遊戲邏輯,劇情邏輯更像是傳媒學生在剪輯片子,邏輯更加線性,而不像其他邏輯呈現樹狀結構。

行為樹插件 Behavior Designer

同樣的,我們試著思考一下,如果用節點編輯器(類似於Behavior Designer)編寫劇情,會是多麼感人,完全是一條線!

使用劇本編寫的NPC對話

這個時候劇情腳本的優勢就凸顯了,完全線性,邏輯清晰,看劇本就像是讀一整個故事一樣,由劇作人員接手編寫,沒有複雜的邏輯,策劃能夠將心思全部放在故事上面,比如策劃提出了某些需求,程序只需要提供相應的介面就完全可以滿足(類似於節點編輯器中的節點)

這種做法事實上在gal引擎中非常常見,日本的知名gal引擎KRKR(中文譯名吉里吉里,Fate/stay night使用的引擎)中就使用了兩種不同的腳本語言來進行編寫,一種為線性語言(KAG3),用於描述故事,而另一種則是用於編寫引擎擴展(TJS),實現複雜功能,這就同我們當前項目中所採用的Lua+DSL如出一轍,不僅有極強的擴展性,對於策劃而言也更為友好(況且實現成本也不高)。

劇情腳本介紹

基本語法

事實上這個DSL的語法非常簡單:

函數名@參數1|參數2...

例如:

log@輸出信息

也加入了一些簡單的語法來減少配置量

例如「~」可以用來替代上一次使用的函數

例如:

第二句調用的函數與第一句相同。

或者也可以通過「*」來表示上一次使用的對應位置的參數。

與上一句的參數完全相同

宏與表達式

當然,因為策劃需要獲取我們的變數名,所以我們就需要宏的機制。

<<宏名稱>>可以代表被替換的宏,例如角色名、角色等級等等。

而且宏是支持表達式的。(事實上這只是簡單地將表達式轉換為了Lua語句而已,此處需要用到簡單的詞法解析,我直接用正則做了)

類似於 < <PlayerLv > 30 and PlayerLv < 60>>

上面的語句就會返回True或者false

而如果是以LUA<<Lua代碼>>出現的則是直接運行Lua代碼並且以結果替換內容。

例如:LUA<<1+1>>

返回2,在Lua中可以做的都可以直接嵌入腳本。

後面提到的If、Switch、Condition函數都是基於表達式來做到分支的。

跳轉

另外一個比較重要的概念就是Tag,我們可以在劇本中插入Tag,策劃可以根據自己的需要來跳轉劇本。

例如:tag@testtag

則是定義了一個tag。

當策劃希望進行跳轉的時候就會直接使用:

gototag@testtag

各類的分支都是基於這個來做的。

分支

目前使用了三種方式來進行邏輯分支

  • If語句

如果表達式為true,前往tag1,否則前往tag2

if@<<條件表達式>>|tag1|tag2

  • switch語句

求值,如果與某個值匹配則跳轉相應的tag,否則繼續執行

switch@<<條件表達式>>|值1,值2,ect…|tag1,tag2,ect…

  • condition語句

多路條件判斷

對多個條件進行判斷,如果滿足某一個則跳轉,否則繼續執行

condition@<<條件表達式>>|tag1|<<條件表達式2>>|tag2

腳本的基石:行為隊列

說實話,要實現這個DSL非常簡單,也就是逐行解析,每一行代表的就是一個命令。當策劃對功能感到不夠用的時候就添加新的命令,就像KRKR中包含了包含音頻、視頻、對話等各種介面,我們根據自己遊戲的需要自行實現就可以了。

這裡需要講講的行為隊列。

行為隊列其實就是隊列,只不過裡面放著的是行為罷了。隊列最前端執行完成之後自動執行下一個行為,這就讓我們能夠做各類非同步的行為了。

行為隊列的用法

行為隊列使用類似於下面的代碼:

ActionQueue queue = new ActionQueue();queue.AddAction((callback)=>{ //任意方法也可以是非同步的 callback();}).AddAction((callback)=>{ //任意方法 callback();});

我們可以看到,我們傳入的行為必須調用callback,這個callback就意味著下一個行為出隊並且執行。

行為隊列非常有用,如果沒有行為對列的話我們可能會掉入callback hell——無盡的回調

DoSomething(()=>{ DoNext(()=>{ DoMoreNext(()=>{ //ect... }) })});

而在行為隊列中則可以做到線性思維:

ActionQueue queue = new AcitonQueue();queue.AddAction(DoSomething) .AddAction(DoNext) .Action(DoMoreNext);

隊列則會乖乖地為你依次執行。

行為隊列的陷阱:中斷處理

這個時候我們或許已經躍躍欲試想用行為隊列做很多事情,例如非同步載入場景時可以線性列出我們需要載入的東西,或者是劇情所需要的逐個行為。

但是這個時候我們必須引起警惕的是中斷處理。

例如:我們編寫了一個代碼

ActionQueue queue = new AcitonQueue()queue.AddAction((callback)=>{ //監聽完成事件 //當收到完成事件時調用callback,並且清理事件});

與普通的隊列一樣,我們的隊列中存在著Clear函數。

當我們調用Clear時我們以為我們做完了所有事情,因為隊列也停下來了,我們什麼都不用管了吧?

實際上,大家思路縝密的話就會發現我們需要卸載事件監聽,否則在事件觸發的時候則會嘗試對隊列出隊,造成意想不到的錯誤。(即使沒有被調用內存也泄漏了,事件沒有被釋放)

所以正確的方式應該是這樣

ActionQueue queue = new AcitonQueue();queue.AddAction((callback)=>{ //監聽完成事件 //當收到完成事件時調用callback,並且清理事件}, ()=>{ //卸載事件});

當中斷的時候調用當前行為的卸載方法。

更進一步的話我們可以將行為包裝成一個對象:

class SingleAction : IDisposable{ Action<Action> act;//行為 Action dispose;//卸載方法 void Dispose(){ dispose(); }}

當然,這裡只是簡單寫了一個樣例,如何封裝一百個人有一百種做法。

在我們的腳本系統當中,實際上就是進一步封裝的行為而已,裡面包含了上下文、參數等等,以幫助腳本系統更好地工作。

總結

就像眾多的可視化編輯器一樣,雖然腳本並非可視化編輯器,但是我認為其效率更高並且更加直觀,就像編劇編寫劇本,後期剪輯視頻一樣,策劃可以更輕易地編寫劇情、任務流程等等,並且實現成本不高,有效解放了程序的生產力,在這裡只是與大家做一個簡單的分享,說不定早就有公司在這樣做了吧!

我的資歷尚淺,如果有更好地任務、劇情解決方案的話還請大家多多分享,學習交流。


推薦閱讀:

[專題思考] 為什麼我們總愛討論技術與業務之間的那些是是非非?
PM06|乾貨:PMP基礎知識培訓課件PPT(上)
技術經理這種做法對嗎?
線性&沙盒遊戲與項目管理
不以用戶價值為目的的方案都是耍流氓

TAG:遊戲開發 | 領域特定語言 | 遊戲策劃 | 項目管理 |