1. 程式人生 > >遊戲開發筆記(九)——技能系統

遊戲開發筆記(九)——技能系統

      技能系統是一個對於遊戲來說,非常重要,實現起來又有些複雜的模組了。 在這個模組的設計上,很多程式都有不同見解,以及可以想到的是,在這個模組的製作上早期的許多團隊都或多或少的走過一段彎路。 這裡也不是說下面寫的這些就是康莊大道了,只算是這個行業發展到某個階段可能是比較經典的思路罷了,相信它還是可以繼續不斷演變進化的 :)       技能系統雖然在遊戲所有的子系統中算是一個比較龐大複雜的模組,但私下考慮的時候,我發現對於許多不同型別的遊戲也並非一定要作出不同的技能系統設計。把握住其核心部分後,即使更換遊戲型別,也能夠有較好的適用性。 在構建之前,和開發任何東西一樣,首先考慮有什麼樣的需求(弄清需求讓你不會在面對複雜情況時驚慌失措XD):
因為現實環境下,動手開發之前一般已經有專案計劃書——或者至少也已經有一個大致的對產品原型的考慮的——這個原型通常是過去的某款遊戲或者某幾款遊戲功能的排列組合... 所以通常來說,我們的最終的目標是要使技能系統能夠覆蓋到這個”產品原型“中的任意技能表現並留出一定擴充套件空間才行。因為現在沒有現成的遊戲規劃,所以只好先舉例說明。對於像魔獸世界、或者大菠蘿這樣的ARPG型別的遊戲原型,我們首先要分析一下其中技能部分會涉及到哪些要素,並進行簡單歸納。 具體的說,在這種型別遊戲裡,我們常常可以看到這幾種比較有代表性的技能效果: 位置相關的如:瞬移、衝撞、擊退、跳躍等 持續時間相關的如:眩暈、定身、臨時提高xx屬性等、魔法盾等
永久效果相關的如:永久加屬性上限、攻擊時永久附帶某種效果等 障礙效果的如:骨牆、還有像DOTA某牛的耕田技能 導彈系列如:追蹤箭、魔獸的遠端攻擊等 直線打擊類的如:菠蘿2的閃電、魔獸爭霸熊貓的噴射技能等 連鎖效果的如:治療鏈、閃電鏈等 地點持續類的:火牆一類的技能 還有吟唱類的:一下子想不起技能名...概括起來就是先念x秒咒語,然後出效果 還有立即生效類的:比如傳奇的閃電術、戰士的強力打擊等 還有一些多種型別複合的技能:比如菠蘿2的九頭蛇(既有地點持續,又放出火球攻擊) 以及其它一時沒想到的型別等等。       實際開發過程中,為每一個技能寫全套程式碼可行性不大,所以必須對技能進行分類處理,然後我們把一個個效果排列組合來完成豐富的技能設計。觀察角度不同我們會得到不同的分類結果,這裡的分類依據是對技能效果的主要功能特點的理解,結果並不是絕對化的。
好,既然已知本系統的設計要求能夠完成上述功能,那麼就可以嘗試性的開始構思程式了。 結構初步設計: 對於技能系統這個比較大的主題,通常要先拆小了來分析。也許不太恰當,但姑且先分為動作表現、技能邏輯和傷害判定這三塊吧。 從流程上看,從開始釋放一個技能到效果完全結束大致經歷這麼幾個步驟: 1、發出施放請求 2、驗證是否滿足使用技能條件 3、返回失敗結果或者開始執行技能同時開始動作、特效播放 4、執行該技能需要表現的各項效果 5、如需傷害判定則進行判斷並反饋結果 所以我們需要一種 單技能 => 多種表現的總分結構,解釋下為什麼是這樣的結構: 首先從玩家角度來說,他們所學習、使用的一定是一個個具體的技能,而我們要實現一些複雜效果的技能,又要努力避免重寫技能程式碼的情況,就需要組合多種效果,所以通常一個技能下面需要掛載多種特定的表現效果。多個效果之間可能還會有前後序影響,於是用序列結構加以管理(這樣設計的好處下面會看到)。 這樣我們就形成三個基本概念,其一是作為表象的技能(相當於包裝盒),其二是作為實際表現效果的技能效果(各種形狀的餅乾),其三是效果中的一系列操作(餅乾的組成元素)。 如此一來,對於一些表現比較簡單的功能,我們可以在一個技能下面掛一個效果,效果下面掛多個操作來實現。而對於複合效果的技能,我們也只需要增加操作或者想辦法掛多個效果來做到。只是順著這個思路下去我們會遇到一個不同效果銜接的問題,具體比如說某技能要求發出一個導彈,導彈命中目標後產生一個爆炸效果,同時產生一圈彈片飛向爆點周圍目標並造成傷害。因為我們除了配置該技能使它發出導彈之外,還需要配置導彈命中後的表現。 這樣的情況有兩個特徵:1、導彈的命中目標所需的飛行時間是不固定的,即我們沒辦法通過設定效果延時時間來配置命中後效果  2、到達導彈命中目標後所需要的表現效果是具有多樣性的(不是固定的產生某種型別的效果)。 所以,為了解決這種問題,我們除了對技能配置,還應該對特定的導彈進行配置,在它上面掛載另外的效果。所以上面提到的好處來了,把技能邏輯拆分成一個個效果有效降低了各個效果之間的耦合,同時提高了配置的靈活性。 我們把技能可能產生的所有具體效果(比如改變屬性值、轉換仇恨目標等各種特殊操作)都歸入到”操作“,一個效果就相當於一個特定的程式功能介面。這樣,我們可以按照 ”技能 => 效果 => 操作列表“ 的方式組合出各種各樣的表現。一些操作根據表現需要可能需要設定不同的引數,多的話可以考慮作單獨配置(如設定眩暈的持續時間、導彈的追蹤距離、跳躍的距離等)。這些具體的表現效果,和作為入口的技能一樣,最終也會折回到效果流程來完成自己特立獨行的操作。 順帶一提,播放動作時可能會遇到實際效果和動作播放進度的匹配問題,可以參考的解決辦法是由動作方面提供額外資訊描述需要在什麼時間點產生效果,這樣程式發現需要播放某個動作時可以做適當延時。 資料的組織: 結構清晰了安排資料的時候是需要對應一下把需要額外配置的資料彙總到一個個檔案就OK,通常是一些經常需要調整的數值會抽出來放到檔案中,對於不太變化的(比如重力加速度)就直接程式裡定義一個常量。 我們大致需要這麼幾份檔案: 1、技能資訊:描述技能名、技能介紹、有效距離、播放動作、技能效果等資訊 2、效果資訊:描述一個具名效果的執行操作列表、操作的作用目標等 3、各個具體型別的效果資訊:對導彈、連鎖、狀態、吟唱、地點之類的效果針對性進行描述其特徵以及產生的效果(返回到第2點) 擴充套件功能設計: 上面所做設計貌似已經能夠完成多數技能的製作,對於日後發現新型別技能效果,我們也只需要像原來那樣增加表現這個效果所需要的程式碼而已,配置方法上完全一樣。 但對於一些複雜的技能(通常也是別具特色的技能),這種配置能力仍是會很快暴露出它的問題的。比如某技能希望在技能目標處於A情況的時候進行一種效果,不處於A情況的時候進行另一種效果解決起來就比較麻煩。 執行一個技能的過程類似跑一個函式,技能名相當於函式名,技能效果相當於函式體,論靈活肯定是程式最靈活,一旦存在配死的東西,就產生無法實現的功能。而程式的核心在於(在我看來的)控制流,即順序、分支、迴圈,為了親愛的靈活性,我們最好把它暴露給外部檔案。 這樣我們在外部檔案進行配置的時候,就同時擁有了功能介面(效果)和控制流,就變成了用虛擬碼來寫程式了! 這是程式設計師最擅長的事,不過做起來卻不爽,”因為技能是策劃設計的!“。 如果我們想要從沒完沒了的和策劃溝通然後來調整技能各個細節的勞役中解脫出來的話,最好把這份差事丟給策劃來做(這樣他們也要相應的負責一部分的BUG)。但問題是幾乎所有的策劃都沒有程式基礎,所以讓他們來寫虛擬碼可能不是件容易的事。我的看法是,首先作為策劃,假設他們的邏輯能力假設是OK的,同時假定部分策劃對編寫程式碼具有一定心理負擔,作為程式對此可以做的是: 1、儘量讓編寫虛擬碼看起來不像是編寫虛擬碼(提供友好的、容易理解、對應具體遊戲邏輯的介面名)   2、可以考慮用表格的形式組織資料,這樣在策劃看來只是在填表   3、簡單的功能只需要簡單的配置,甚至感覺不到控制流的存在 那麼我們如何暴露控制流給外部檔案呢? 我們先來把一個個操作視為我們預期的要呼叫的子函式,然後: 1、首先對於”順序“邏輯,由於我們是用一個序列來儲存技能的各個效果,所以填寫檔案的時候各個操作的上下關係已經可以表達順序關係了。 2、對於分支,我們可以通過在配置操作列表的檔案中增加”執行條件“,其中內容為各個操作是否執行的需要滿足的各項條件。如”條件A,成立 | 條件B,不成立 | 條件C, 成立“,為此我們需要定義一系列條件(如”魔法值大於,引數“也是一種操作),而且各個操作需要反饋執行結果。程式在順序執行一條條操作的時候,我們同時給出一個序列結構報錯每個操作執行的結果。這樣我們在判斷一個操作條件是否滿足的時候就可以依次比較條件列表和結果列表,判斷是否所有條件都滿足。 3、而對於迴圈來說,表格結構描述起來不是很方便,遞迴(直接或者間接)實現起來會更加直觀方便一點,而且有了分支做基礎,我們也能方便的定義出遞迴的終止條件。實現遞迴的方法,比如說,我們可以定義一個操作叫做”執行效果“,操作引數給出和當前效果相同的效果名,然後遞迴就開始了... 控制流的加入一定會使得技能的多變性大大增強,足以應付大多數情況。但是,或許,偶爾可能還是有點不夠的,設計系統的時候我們要極力防止變態需求的出現,以至於後來疲於應付,最好的辦法就是一開始就把我們難以想到的特殊性考慮在內... 也許這時候已經很難想到這種流程還有什麼典型技能是不好做的,但是作為程式設計師會知道,只有程式流而沒有變數的程式設計是怎樣一回事(啊,多麼痛的領悟...=。=)。但是安全起見,為了防止策劃濫用或者過度依賴,提供給策劃用的變數最好是程式給出的,有限的幾個。而程式堅守陣地時只要牢牢盯著這幾個變數的生命週期就可以。 這一點上,我們可以參考暫存器的配置...給出幾個型別的變數,如設定用來儲存數值的P1-P3,儲存角色物件的T1-T3,和儲存其它稀奇古怪東西的一些變數(如果有這需要的話),如果是弱型別語言來實現,可以直接做幾個通用變量了事。另外還要定義一組用來儲存變數的操作(如mov指令一樣)。 一個技能相當於產生一個呼叫棧(本來執行技能也就相當於執行一個函式,很自然的想法),變數只在該層棧以上活動,一旦該層棧銷燬了變數亦隨之銷燬。有個流程在就可以了,不需要一開始就為策劃做太多的預留變數,可以後期根據需求逐漸加入。如果有全域性變數的需求,也可以考慮加入進來,不過要提醒策劃慎用吧(別說策劃了,程式也要慎用)。 到這裡,配技能已經徹底淪為寫虛擬碼了(策劃友好的版本)... 技能的配置中還有兩個要點。 其一是許多遊戲都有技能等級的概念,不同技能等級會帶來不同的數值影響。按照上面的設計,我們在操作名稱後面留出了一項引數,通過這個引數來實現相同操作不同結果的效果。加入技能等級時,我們需要描述技能等級和具體引數值之間的關係,這種關係通常是用表示式來做到的。為此我們還要在程式中實現一個解析表示式的模組,程式執行時通過向該模組傳遞技能等級獲得其具體值(這個功能靜態語言做起來略蛋疼,但在一些解釋型語言中做起來會十分容易,這大概是許多遊戲會把大量邏輯丟給指令碼去寫的原因之一)。 第二個要點是,技能的作用目標的問題這個分成幾種情況來說: 一種情況是地點、方向性的技能,這類技能通常是根據滑鼠提供的位置施放的,施放這種技能時並不能從施法請求中得到目標物件。這種情況應該從請求中獲取位置資訊並沿著呼叫棧傳遞上去。 另一種情況是現在比較新的所謂”戰鬥2.0(非鎖定目標攻擊)“的目標判斷方式,即不管是否選擇了目標都可以進行攻擊,擊中了哪些目標是根據實時位置關係計算得到的。這種情況多見於一些物理引擎應用比較深入的遊戲,邏輯上根據打擊部位和位置關係等資訊確定作用物件和結果。但這種情況另說吧,展開來又是比較複雜的內容,而且自己接觸的也十分有限。 還有一種則是比較傳統的鎖定目標攻擊,即許多技能需要先選中一個物件才能使用。這種情況下可以把作用物件和第一種位置的情況作類似的處理。 但通常像1、3這種簡單的目標機制並不能很好滿足實際需求,比如技能希望攻擊選中目標及其周圍一定範圍的物件等。依然需要一個獲取遊戲中目標的機制來完成。 這個可以借鑑Pipe的思想,顧名思義在一組Pipe執行過程中,上一個Pipe產生的結果給到下一個Pipe,最後一個Pipe輸出的結果即為最終結果。我們先實現許多個不同規則的Pipe(如”周圍,範圍引數“,”隊員“,”處於某狀態“等),然後通過組合這些Pipe來實現較為複雜的目標篩選。 具體實現: 至此已經基本把我們會用到的資料結構弄清了,圍繞這個結構來程式設計,我們只需要把資料和邏輯對應一下,實現起來是非常容易的事。我們可以把技能的入口邏輯封裝到一個管理器上,取名如SkillMgr,然後把這個管理器掛到需要用到的角色物件上,通過角色物件身上的一個轉調函式來進入技能流程。 然後封裝資料結構如下: 技能表[技能名] => 技能引數資訊 效果表[效果名] => 操作(條件作用目標、操作名、引數)列表 操作表[操作名] => 操作的具體實現程式碼 主要邏輯部分在於實現各個操作的邏輯,以後擴充套件主要也是擴充套件這個部分。 執行過程: 技能請求中得到技能名,查詢得到技能資訊 => 判斷技能是否可執行 => 播放動作,從效果表查詢 技能.效果名,得到效果 => 依次執行操作列表中操作,判斷是否滿足操作給出條件 => 根據操作名查詢操作表得到具體執行操作的程式碼 => 執行相應操作 技能的另一種實現思路: 拋棄任何格式的束縛,直接擁抱程式碼,最大限度的爭取配置技能的靈活性。 總體上還是延續上面提到的一部分思路——我們已經把技能的具體表現抽象成一個效果(操作列表),並且可知技能的邏輯重心也在這個地方。於是我們可以把代表效果的部分的配置直接用程式碼檔案去實現,一個效果對應一個檔案。如果是靜態語言來實現的話,這通常要通過提供介面編譯虛擬碼的形式來完成,可以在啟動遊戲程式的時候把所有程式碼文字解析一遍以執行效率更高的內部指令來儲存,根據檔名來呼叫。但如果用像Lua這樣的指令碼語言,做起來就異常簡單,可以直接把遊戲功能介面封裝成Lua函式,並讓通過適當培訓策劃直接參與技能邏輯編寫。 我們假定每個技能都有自己獨特的邏輯,所以一個單獨的控制流是必不可少的。但是如果策劃偷懶實在是有很多技能相似,也可以很容易做到封裝一些效果模板函式來快速實現效果。 這樣一來,流程上的固定操作還是由程式來開發,但著重點僅在於保證效率和穩定性上。要執行效果的地方採用Call(效果ID)來呼叫指定文字對應的指令。指令碼語言本來就比較容易上手,而如果只是使用其中最為簡單的邏輯控制,那學習成本就更低了。所以可以預見的是,策劃不需要花很長時間來提高程式碼能力,但已經可以獨立完成十分複雜的技能編寫(個別較難實現的大概得由程式來輔助完成,但對於實現一些很NB的技能效果來說,程式這點付出算得了什麼呢~)。 最後我接觸下來覺得許多策劃都有很好的邏輯能力,這方面未必不如程式,而快速掌握簡單的指令碼程式設計也不是一個多麼難的事情。另一方面來看,策劃掌握一些程式技巧後一定程度上也能拓寬自己的設計思路,時間長了會用的越來越得心應手(已經許多國外公司都要招會點指令碼的策劃啦!)。 關於除錯: 我的想法是,既然我們已經把開發技能邏輯的任務丟給了策劃(程式只在缺少必要元件的時候新增一下新元件),那麼不妨(嘿嘿...)好人做到底,把除錯的工作也交給策劃來完成(可憐的策劃...)。因為否則的話,當策劃配好技能來向反饋一個異常狀況的時候,程式不得不先理解一遍策劃配置的技能的邏輯(平時不需要關心策劃進度),然後才能開始診斷問題。反覆溝通的過程中經常會有相互打斷/阻礙相互工作的情況發生,久而久之其實策劃未必不願意自己解決。但畢竟程式過程對於策劃來說是一個黑箱子,不能瞭解各個步驟的執行狀況僅通過觀察配置有時候難以發現問題(何況程式提供的元件也不能完全確信是無BUG的)。 為了讓策劃參與除錯,有必要在一些關鍵的地方加上除錯資訊輸出,並不斷完善直到策劃可以自行診斷問題為止(如果是用指令碼的方法就更簡單啦)。為了不妨礙他人開發,我們還需要做一個輸出控制開關,把它交給策劃,非必要時不開啟。 開發中較常用的機制: 各種設計模式中比較適用於技能系統的是其中的觀察者模式。 技能的表現可以簡單的看作是對角色物件的一系列操作,和執行純粹的邏輯不同,這些操作有的需要計時,所以不總是連續執行的。 在這執行的過程中角色可能發生種種意外(主要是ARPG比較容易出現),常見的事件比如角色死亡、切換地圖、打斷技能等,如果此時還在執行某些效果,我們需要將其正確的處理掉。 一種直觀的辦法是將處理的操作封裝到一個角色物件上的一個函式中,通過呼叫這個函式來完成清理,各個模組需要清理的就把清理相關程式碼加到這個函式中來。但是這種做法並不理想,首先一堆需要清理的功能放到一個函式中肯定不好看,另外個人覺得寫起來彆扭也容易漏掉,對於多個事件我們還得封裝到不同的函式裡面去,壞處一堆堆誰試誰知道。 而通過觀察者模式來進行管理,把角色物件定義成一個Subject,而具體的效果流程定義為Observer,這樣一些持續性效果在開始之前,把自己加入到Subject上的某個事件的觀察者列表中,角色物件由於狀態改變而發出事件通知的時候,只需執行Notity(event),即可廣播給所有關心這個事件的Observer物件(呼叫該物件上的OnEvent),這樣每個效果流程內部只需要實現OnEvent並且新增處理程式碼就完成了整個過程,要比前者簡潔漂亮不少。