遊戲的數值系統的實現和演化
在遊戲的戰鬥系統中,數值系統是很重要的模塊之一。對策劃來說,數值策劃是一個非常重要的分類,關於數值從策劃的角度介紹的比較多。但是對於程序來說,可能是這一塊和需求比較密切,實現起來也沒有特別複雜,關於數值模塊實現的介紹,網上的資料比較少。
最近我在對我們遊戲的數值系統進行重構,一方面希望提高性能,另一方面支持策劃配置更新和實時測試,減少溝通成本(主要是不想把自己變成實現策劃想法的工具)。
0 數值系統包括什麼?
我們可以把市面上絕大部分遊戲的戰鬥抽象成這種模式:攻擊者A使用技能I擊中了受擊者B,造成了傷害x。
那麼戰鬥其實就可以分為兩個模塊:技能流程模塊/數值模塊。
技能流程[1]描述攻擊者以什麼樣的表現攻擊了受擊者;
數值模塊負責根據攻擊者A的屬性/受擊者B的屬性以及技能I的信息,計算出來傷害值x;
由上可知,數值系統我們可以分成三塊:屬性、技能信息和傷害計算。
屬性模塊記錄單位的屬性值,包括血量上限、攻擊力等。
技能信息模塊記錄每個技能和傷害/治療計算相關的信息,比如一個技能的傷害為:基礎傷害+傷害比例*攻擊者攻擊力,基礎傷害和傷害比例就是這個技能的技能信息。
傷害計算模塊是由計算規則構成。一個傷害值由攻擊者屬性/受擊者屬性和技能信息決定,那麼使用這些信息,如何計算出來最終的傷害值,這個規則就是傷害計算模塊。
1. 數值系統的實現過程中會遇到什麼困難?
若遊戲數值需求比較簡單,比如遊戲只有攻擊力和血量,所有的技能都是對目標造成百分之幾攻擊力的傷害值,其實這一塊沒有什麼好說的,直接代碼硬編碼就行。
但對於有的遊戲,比如暗黑,玩家具有很多的屬性種類,而這些屬性對最終的傷害都可能產生影響。對於這種遊戲,屬性數量多,傷害計算流程複雜,往往會造成以下問題:
1.性能問題
屬性之間往往是有依賴關係的,比如在dota中,(力量型英雄)等級決定了力量,而力量又決定了攻擊力、血量等。當等級上升時,這些屬性都需要重新計算。
其次,每次傷害也需要實時計算傷害值,對於數值複雜的遊戲,每次計算要走一大坨流程邏輯,這可能是一個比較大的性能熱點。
在屬性數量比較多、傷害計算流程複雜的情況下,數值計算就會成為性能熱點。還有一個重要原因是我們使用的腳本語言python,計算成本相對C++更高。
2.維護更新困難
在遊戲的開發過程中,數值系統往往會快速迭代。策劃今天加了一個攻擊屬性,明天又要加一個防禦屬性,後天可能覺得傷害計算流程不夠牛逼,再改改傷害計算流程。
若策劃每次修改都需要程序參與,一方面溝通成本會很高,另一方面,策劃在他們文檔里改了一些細節但沒有告訴程序或者程序漏了,會導致代碼和策劃預期不同,且問題很難被發現。
3.測試困難
數值系統往往難以測試。比如QA在玩遊戲的過程中,發現某一次傷害值不合理,但是qa也不知道哪裡出了問題。只能去找策劃讓策劃看一下相關的配表有沒有問題,若策劃沒找到問題,只能再讓程序去debug。一方面,有些情況又往往難以重現,也就無法debug;另一方面,這個debug過程設計程序策劃qa三方,成本太高。
4.無法做到離線分析
從程序和qa的角度,保證遊戲的數值系統正確性是必要的事情,也是相對簡單的事情。但是,從策劃的角度如何確保策劃填的數值的合理性其實是一個很困難的事情。
換句話說,策劃如何知道他們填的數值、成長、傷害計算公式是符合他們預期的呢?這件事我們項目qa和我都考慮過,但是沒有想到比較好的離線分析方案。
我諮詢了一些其他項目實現,比較常見的是在線分析方案,就是策划去配置幾個玩家、怪物去互相攻擊,看他們的屬性、傷害、勝負關係和勝利時間是否符合預期。
我們系統由於數值模塊已經和遊戲解藕,其實是能做到支持離線分析的。但策劃對此的需求並不強烈也就沒有推動,我們也沒有做這塊內容。
2 整體解決方案
這裡,我先對我們遊戲遇到的問題,總體介紹下我們的解決方案。
2.1性能優化方案
對於性能問題, 下圖是暗黑3截圖,我們以下圖的情況為例分析。
在遊戲裡面,副本中往往是多隻相同類型的怪物聚集在一起,並且具有相同的等級。因此,這麼多怪物其實屬性都是相同的。因此,我們可以讓所有的屬性相同的怪物,只計算一次屬性值並且所有怪物共用。當玩家使用一個AOE同時打中多個相同屬性的怪物時,其實我們可以只計算一次傷害,然後把傷害緩存下來,其他所有的傷害都不用再重新計算了。
注意,由於遊戲中的傷害一般都會有隨機性,這裡緩存的是不隨機的部分,然後每次傷害結算根據緩存信息計算最終傷害。比如,我們緩存暴擊率,然後實時計算時確定是否暴擊
此外,在我們遊戲里策劃設計了大概100個屬性,並且絕大部分都會影響到最終的傷害計算。但是,這麼多屬性,大部分都是給玩家/BOSS設計的,而對於小怪,他們的屬性少很多,傷害計算公式也就簡單很多。因此,我們可以對小怪的屬性進行簡化,涉及小怪的傷害計算也就可以對應進行簡化。
對於性能問題,我們的方案總體來說用了兩種方案:
- 就是把能緩存的數值緩存起來,能共用的進行共用,以此減少計算量。
- 對遊戲中單位分類,分為小怪/非小怪。對於小怪,可以簡化他們的屬性和傷害計算。
使用緩存方面,我們做了以下工作:
- 引入屬性樹描述一類單位的屬性值間的依賴/更新關係和計算公式。遊戲中所有相同單位可以共用一棵屬性樹。
- 具有相同屬性的不同單位,可以使用共同的屬性值。
- 多個單位,若他們的技能信息相同,則他們共用相同的技能信息。
- 引入傷害圖描述傷害計算流程,遊戲中只需要管理固定數量的傷害圖。
- 根據攻擊者屬性值/受擊者屬性值和技能信息,計算並緩存傷害值。以後,相同攻擊者屬性、受擊者屬性、技能信息的傷害不需要計算,直接拿cache。
- 即使屬性值變化,也不一定需要從新計算所有的傷害流程。只需要重新計算屬性改變所影響到的傷害流程中的部分結果。
對單位分類方面,我們區分了小怪和非小怪,然後做了以下工作:
- 小怪和非小怪的屬性數量不一樣。
- 將傷害計算區分,分為非小怪攻擊非小怪、非小怪攻擊小怪、小怪攻擊非小怪和小怪攻擊小怪。對於治療數值的計算同樣處理。因此我們遊戲一共只管理了8個傷害計算圖。
此外,我們把數值系統和其他模塊解藕後,用C++重新實現了一遍,也獲得了較大的性能提升。
性能優化結果:
- 傷害計算所消耗的時間受cache命中率影響,python版本比較性能提高了6-10倍,C++版本比python版本又提升了3-5倍(由於數值公式計算還使用了callable python對象,C++實現的提升比預期差了點)。
- 相同單位共用屬性值的提升效果和策劃配置場景怪物的方式有關,在大量相同小怪聚集的地方,效率提升明顯。
目前,數值計算所造成的性能消耗不再是戰鬥邏輯的瓶頸,雖然還有一些優化空間,但是也不打算繼續了。
2.2 數值系統的解藕和可配置化
剛開始,我們的數值系統是和遊戲中的其他模塊耦合的。而且傷害值的計算是程序寫死的,不利於程序的維護和策劃對數值模塊進行修改。
為此,我對遊戲中的數值系統首先進行解藕。將遊戲的數值相關的內容都統一放到一個模塊中封裝起來,只留了有限的幾個介面供其他模塊使用。此外,為了性能優化,做的相關內容比如緩存等,其他模塊也是不知道的,只有數值模塊才了解內部邏輯。其他模塊只能通過有限的幾個介面,獲得或改變某單位的屬性值,或者根據攻擊者受擊者和技能,查詢傷害值信息。
另外,為了讓策劃可以自己修改屬性和傷害計算流程,我們將這些信息都做成了可配置的。策劃可以在單位表裡填寫單位的屬性樹,描述單位的屬性值更新依賴和計算公式。對與傷害值計算流程,我們引入了傷害值計算流程圖,策劃可以定義流程圖中的各個節點,以及各個節點所使用的計算公式,從而描述傷害值的計算流程。(後面會介紹配置文件什麼樣子)
2.3 數值系統的測試方案
剛才已經介紹我們把數值模塊從遊戲中抽離出來,那麼帶來的另一個附加的好處就是這個系統可以一定程度脫離遊戲獨自運行。
之前,數值系統無法測試就是因為無法獲得每次傷害計算流程的中間結果。若我們可以從遊戲中獲得單位的屬性值,然後在遊戲外面計算傷害值並且把傷害計算的流程和中間都展示出來,策劃和QA就能明確的了解數值系統的內部運行邏輯,哪裡有問題也就一目了然。
因此,我們的實習生做了一個數值系統的模擬工具。最核心的功能就是從遊戲中抽取單位的屬性值,然後把傷害計算的中間結果和最終結果都展示出來。
3 屬性樹和屬性值
下面介紹我們遊戲中關於屬性樹和屬性值的實現。在我們遊戲中,每類怪物/玩家控制單位都有一個自己的屬性樹(我們遊戲也支持不同類型單位使用同一棵屬性樹,這裡不擴展介紹)。
這個屬性樹可能如下所示:
一棵屬性樹
從結構上來看,有的不同類型單位可能屬性樹的結構相同,但是不同的是,下層節點被上層節點影響的公式不同。比如,有的單位攻擊力 = 4 * 力量,而有的單位攻擊力 = 10 * 力量。我們把公式也保存在屬性樹中。
每一個單位都持有自己的屬性值(當然我們之前介紹過,通過一些方式若能確定兩個單位的屬性值相同,可以共享一個屬性值。)
屬性值保存的就是當前單位的屬性信息,可以把它通過簡單的dict實現,key是一個屬性名,value就是值。當一個屬性發生了修改,比如等級升級或者裝備增加了力量,那麼就根據它的屬性樹結構,使用公式重新計算被它影響到的所有屬性。
為了性能考慮,我們遊戲中所有相同類型的單位共用一棵屬性樹。對於同一場景相同屬性的怪物,共用一份屬性值。這樣可以減少屬性值更新所帶來的計算量。
4 傷害計算重構方案
之前,我們傷害計算是夾雜在遊戲戰鬥邏輯中的,和其他模塊藕合在一起,程序維護苦難,策劃也沒法直接修改。
後來,我們引入傷害計算流程圖,策劃配置傷害流程圖,程序只需要讀取就可以了。同時,傷害流程圖的引入,也可以支持中間結果的緩存,對性能提升也有貢獻。
4.1 傷害計算流程圖
一次傷害結算,是由攻擊者屬性、受擊者屬性以及技能參數確定的。
我們引入傷害計算流程圖來描述傷害計算的流程,以下圖為例,下圖是一個簡單的傷害計算流程:傷害=技能傷害加成*攻擊者攻擊力-受擊者防禦力。
傷害計算流程圖舉例
該圖中,技能的傷害加成屬於技能信息,攻擊者的攻擊力屬於攻擊者屬性,受擊者防禦力屬於受擊者屬性。
傷害計算流程圖中存在兩個節點:1.中間節點:技能加成*攻擊力2.最終結點:最終傷害基於傷害計算流程圖,我們可以定義每個節點(中間節點和最終結點)的描述規則,那麼傷害計算流程就可以由各個節點的描述構成,傷害計算流程描述文件如下:
傷害流程圖配置文件
4.2 傷害計算性能優化
我們在進行傷害計算時,若每次都計算一次,計算量往往是比較大的。因此,在上文我們說過,當同一個單位,使用相同的技能,多次中相同的另一個單位時,我們可以只計算一次,然後把結果緩存起來下次使用。
可以再拓展一下,相同屬性值的單位(可能是不同單位),使用相同的技能,擊中相同屬性值的單位(也可以是不同單位),只計算一次,把結果緩存下來。這時,若場景中成堆的小怪,往往能大幅度提高cache命中率。
這時就出現了一個問題,若單位的屬性、技能的參數變化特別頻繁(而這是遊戲的常態),緩存的傷害信息就無效了,又大幅度降低了命中率。(我們遊戲大概能命中70%~80%,這個數字並不高)
因此,我們基於傷害計算流程圖結構,對中間結果進行cache。以之前的最終傷害 = 技能加成*攻擊者攻擊力-受擊者防禦力為例。我們第一次計算的時候,中間節點和最終結點都需要計算。當受擊者防禦力改變時,我們的中間節點「技能加成*攻擊力」並不需要重新加算,只需要計算最終結果。
對於真實遊戲中的傷害計算流程,會存在比較多的這種中間節點,而緩存這種中間結果的方式也可以減少計算量。
測試發現,過多的引入中間節點引入(尤其在python中)會由於引入較多的函數調用和管理成本導致效率降低。因此中間結果需要以一定的經驗設置,引入不常常變化的中間節點是比較好的方式,但是無節制的引入中間節點有可能造成最終結果計算效率的變低。
5 技能的數值信息
對於小怪使用的技能,一般不存在技能等級等動態信息,其實全遊戲所有的相同類型小怪都共用同一個技能信息,也是完全可以的。
而對於玩家,根據需求可以適當cache。比如我們遊戲的技能信息被技能等級影響,這種情況如果技能等級最高5~10級的話,使用全局cache也是可以的。不幸的是,我們遊戲技能等級最高100級,而且玩家技能信息可能會被各種符文影響修改,所以沒法全局cache,只能每個玩家保存自己的技能信息,只要保證每次釋放技能不要都去重新生成技能信息就好。
6 傷害模擬分析工具
前面介紹了我們把數值系統和遊戲邏輯進行了解耦,並且把傷害計算流程改為可配置文件,因此我們的傷害計算可以做到離線計算。
基於此,我們項目的實習生進一步開發了傷害模擬分析工具。此工具最重要的功能是從遊戲中實時的抽取單位的屬性信息和技能信息,通過工具可以選擇計算攻擊者單位使用某一個技能攻擊收集者單位的傷害計算流程信息,包括計算過程的中間結果和最終結果。
此外,我們還支持離線計算某類單位和另一類單位互相攻擊的傷害結果分析等。可以供策劃分析數值成長和數值平衡。
總結
總的來說,我們的數值系統的迭代主要是兩個方向,第一個是使用cache,解決性能問題。第二個是解耦和可配置,從而進一步支持策劃直接配置和實時測試。
預告:
下一篇我想寫一篇形而上的戰鬥系統開發總結,希望梳理一下戰鬥系統實現方案背後的設計思想和思路。敬請期待。推薦閱讀:
※電腦語言「0,1」的蘊涵的數理邏輯知識 到底是什麼樣的?如何後天將其與思維模式結合?
※極樂技術周報(第二十七期)
※FizzBuzz