公式計算機的另一種實現思路

在討論「公式計算機」之前,首先我們來看「公式」。

遊戲開發中,「公式」是一個很關鍵的元素,而且出現頻率相當高。

舉個簡單的例子,職業A的玩家釋放了B技能,打中了C類型的怪物,那麼,把這個傷害結算流程看作一個公式,公式的輸入項就至少有A、B、C三個輸入項,輸出是一個傷害值。

「數據驅動」一直是推動行業程序員提升自我修養的一個主要因素。目前,國內99%以上的團隊針對上面舉的公式例子的需求,一定都能實踐如下程度的數據驅動工作流程:

策劃和程序一起討論這個公式的結構,提取中公式中的可配置數值項,程序在代碼中把公式結構寫死,某些項讀配置。

這是數據驅動的最初級實踐方式,能解決大部分問題,但是也會帶來新的問題,比如說:

由於增加一個公式的成本比較高,策劃往往會盡其所能的提出一個高度通用的複雜公式。這樣一方面是對策劃的要求比較高,另一方面其實也降低了策劃的描述能力。

還是之前技能結算的例子,如果策劃提出了一個普適的公式,能應用在不同職業、不同技能、不同怪物身上,這種普適公式的描述能力顯然不如一個公式組的描述能力強大——特定職業、特定技能、特定怪物可以應用一個特定公式。

在一開始,還有的團隊嘗試過讓策劃直接寫靜態類型語言或者DSL描述公式,然後在編譯期就轉換為開發語言中的一個普通函數,程序直接調用函數即可。

但是這樣一來,就沒辦法做熱更新了。

不過cocos2dx和Unity3D流行以後,大家都開始用js,用lua,只要策劃能寫點腳本,這問題也就沒有了。

如果問題這麼簡單就能解決掉,那自然是天下太平,程序員可以關注更少的事情,策劃可以擁有更強的表達能力

但是,對於大部分團隊來說,遊戲邏輯,尤其是遊戲服務端的邏輯並不是以Lua/JS(下文不再討論js)為主體在跑。如果直接引入腳本讓策劃配置的話,會有難以忽視的性能問題:

  • 首先,既然主體邏輯並不是Lua,那就肯定是C/C++/C#/JAVA這種靜態語言,那即使上LuaJIT,腳本指令的執行速度也最多就是到主體邏輯的執行速度下限。

  • 其次,Lua與靜態語言的函數互調開銷不容忽視。以C#中常用的ulua為例,即使是用了靜態綁定,一次C#到Lua的函數調用,仍然需要C#到C的一次marshal以及C到Lua的一次marshal。marshal的成本可要比虛擬機執行lua位元組碼的成本高多了。

所以,我們需要換一種思路,既可以讓策劃配置腳本,比如直接用Lua配置公式,又可以兼顧性能,比如公式的計算仍然在主體邏輯語言框架中進行,不發生跨語言的函數調用。

回到標題,「公式計算機」就可以解決這個問題。

何謂「公式計算機」?

雲風幾年前寫過一篇博客,標題就叫「公式計算機」。「公式計算機」可以理解為一個函數,輸入為一些環境,比如攻擊單位的屬性、受擊單位的屬性,輸出為一個傷害值。

UnitAttr -> UnitAttr -> float

也就是說,如果我們可以用Lua構造出一個公式計算機,這個計算機實際上是我們的服務端主體邏輯中的一個實例,每次需要計算的時候,傳入參數,計算機就可以直接計算出一個值。

這樣,就可以做到既能讓策劃可以靈活配置公式,又能兼顧性能問題。

雲風在博客中介紹的方案比較簡單,流程簡單來說就是這樣:

策劃在Lua配置一些四則運算表達式,表達式中會涉及「項」和常量。「項」既有可能是外部輸入的,也有可能是由表達式定義的。

C和Lua的一部分會對這些表達式做個處理,為每個「項」分配「寄存器」。

這樣相當於構建出了一個「計算機」,只要為其每個外部輸入的「項」對應的「寄存器」賦值,「計算機」就能計算出每個「項」的值。

代碼也比較簡單直觀,如果需求類似,完全可以直接拿來用。

不過,雲風的這個方案針對的是策劃只能配置簡單的四則運算,小說君在一開始準備實現一套公式方案的時候也有考慮過參考雲風的這個實現。

但是小說君找了下邏輯中現有的一些公式,發現公式中還存在各種函數(比如隨機數、條件判斷、讀表等等),如此一來雲風的方案就沒辦法適用了。

另一方面,小說君其實也比較同意一個觀點,那就是在項目中盡量少引入DSL。更多的DSL意味著更高的學習成本,更多的DSL parser,更多的DSL runtime,自然而然的就是更高的複雜程度。因此能直接用Lua描述公式自然是最好的。

由於下面開始的話題都比較抽象,所以小說君在這裡直接上一個最終實現好的例子:

attP = Input.S.AP + Math.Random(Input.S.DamMin, Input.S.DamMax)test = 0.333*0.22*10test = Math.IfThen(Math.Equal(Input.S.Type, 2), EnemyConfig.GetConfig(Input.S.Id).SkillLevelRatio)attP = attP * testdam = Math.Random(HurtConfig.GetConfig(Input.HurtId).DamageMin, HurtConfig.GetConfig(Input.HurtId).DamageMax) + attPdam = 10 + attParmorFactor = 1 - Input.T.Armor / (Input.T.Armor + 15 * LevelExpConfig.GetConfig(Input.S.Level).LevelBattle)return armorFactor*dam

看起來就是一段非常常規的Lua代碼,其中Input、Math、XXConfig這些都是可以用ulua或者luabind這些工具從主體邏輯中導出的供Lua訪問的類型。

這段Lua腳本只會執行一次,執行完會自動構建出一個「計算機」,應用層每次需要計算的時候,傳入所有參數,即可得到一個結果值。

下面小說君介紹下這個方案的實現思路。

眾所周知,Lua是一門傳值調用(call-by-value)的語言,也就是說,在調用func(exp)的時候,exp會先求值,再傳給func作為參數,賦值語句同理:

attP = attP * test

這句賦值執行之前,Lua虛擬機會首先求值attP和test,但是很顯然,這時候的attP和test所依賴的外部輸入比如Input是不存在的值,也就無法進行求值。

那如何在傳值調用語言中實現傳名調用(call-by-name)?

很簡單,引入「thunk」。

thunk = function () return x + 5endfunction f(thunk) return thunk() * 2end--f(thunk)--f(x+5)

上面的代碼示例中,藉助thunk,實現了延遲求值。

繼續看公式計算機。

考慮一個最簡單的情況,當策劃配置了下面這樣一個公式,程序執行一遍腳本,應該拿到一個什麼樣的實例?

return Input.S.HP + 10

很顯然,拿到的應該是一個Input -> float的函數,每次給這個函數傳一個Input,這個函數就會輸出一個float。

先嘗試抽象一下概念,上面的公式中,Input.S.HP是一個概念,10是一個概念,而Input.S.HP和10還是同一個概念的subtype。

先來看下兩者共同概念的定義:

public interface Monad{ bool IsPure();}public abstract class Monad<T> : Monad{ protected bool pure = true; public abstract T GetValue(); public bool IsPure() { return pure; }}

此Monad非彼Monad,名字而已,無須產生過多聯想。

然後是Input.S.HP這種概念的定義:

public class Closure<T0, TR> : Monad<Closure<T0, TR>>{ public delegate Monad<TR> MonadFunc<T0, TR>(Monad<T0> p0); class Apply_ : Monad<TR> {// ...} private bool isUserFunc = false; private MonadFunc<T0, TR> func; public Closure(MonadFunc<T0, TR> func) { this.func = func;} // 用一個C#普通函數(user func)構造一個closure public Closure(Func<T0, TR> func) { this.isUserFunc = true; // 根據一個user func構造一個func this.func = p0 => Help.MakePureThunk(func(p0.GetValue())); } public override Closure<T0, TR> GetValue() { return this; } public Monad<TR> Apply(Monad<T0> p0) { // 如果所有參數都是pure的,就直接把參數給過去 if (Help.IsAllPure(p0)) { return func(p0); } if (!isUserFunc) { return func(p0); } return new Apply_(this, p0); }}

雖然看起來寫了這麼多,實際上只是定義了一個函數,在Apply的時候會視情況直接求值還是包裹為一個thunk(Apply結果),在用戶顯式對結果GetValue的時候再做求值。

這樣,之前的腳本:

return Input.S.HP + 10

實際上執行完之後,C#這邊拿到的就是一個Closure<Input, float>實例。每次需要計算的時候,給這個實例Apply一個Input,就可以拿到一個結果值。

這個方案的核心思想就是這樣,不過,還需要做一些其他的處理。

首先我們需要在Lua和C#的粘合層中做一些處理。

這部分工作很簡單。對Lua熟悉的同學,肯定知道是用元表的方式hook住導出符號和Lua的literal value的一些常用數學操作。

導出工具一般會把宿主語言中的類型導出為userdata,那我們就需要用Lua API設置userdata的元表,接管四則運算以及其他的一些常用數學操作符即可。

而比如:

test = 0.333*0.22*10

這樣的表達式就無須接管,Lua會直接計算出來數值。

雖然大部分操作符都能hook住,但是判斷相等或者小於這些操作符就無能為力了。Lua標準規定「==」「<」只有在兩個操作數都是userdata的情況下才會去元表查元方法。

所以,也有了最開始示例中,用IfThen代替if語句的醜陋實現:

test = Math.IfThen(Math.Equal(Input.S.Type, 2), EnemyConfig.GetConfig(Input.S.Id).SkillLevelRatio)

至於說為什麼是IfThen而不是IfThenElse,還有其他原因,下文再介紹。

Lua的元表特性非常方便,我們不需要對策劃配置的Lua腳本做額外的預處理或者parse,就能保證最後拿到一棵Closure嵌套的樹。

再看一個稍微複雜些的例子:

srcAP = S.AP + 10dstAP = T.AP + 15return srcAP - dstAP

其中,S.AP表示SourceUnitAttr(一次攻擊的發起者的屬性)的攻擊力,T.AP表示TargetUnitAttr(一次攻擊的承受者的屬性)的攻擊力。

這樣,srcAP和dstAP的signature都是UnitAttr -> float。

那最後,srcAP - dstAP的signature會是什麼?

答案比較直觀,是UnitAttr -> UnitAttr -> float,也就是接受兩個UnitAttr作為參數,返回一個float的函數。

這個看起來比較像類型推導,但是實際上只是一種類型合併。原理很簡單,但是實現起來非常繁瑣,也是這個方案中的代碼量最多的部分。

之所以複雜,是因為這個表達式是運行時才能組合出來的,我們沒辦法藉助編譯器的類型推導特性來找到合適的組合函數。在運行時,需要多個參數才能確定一個組合函數,是一個多分派問題(multiple dispatch)。

我們處理單分派的時候,最自然的思路是查表。而如果想要處理任意維度的多分派,那不論是表結構還是查表邏輯的複雜度簡直無法想像。

不過好在由於公式的需求比較簡單,這樣一是我們導出的符號就非常有限,二是運算符有限,因此closure的組合方式是可以枚舉的。

下面依次介紹下。

最常見的是函數的apply。signature如下:

(T0 -> T1) -> T0 -> T1

意思是一個T0 -> T1類型的函數,接受一個T0類型的參數,返回一個T1類型的參數(下文舉例子就不再做額外解釋了)。

應用舉例:

EnemyConfig.GetConfig(1000001)

EnemyConfig.GetConfig的類型是uint-> EnemyConfig,那給一個常量,自然是應該返回1000001這個ID對應的EnemyConfig了。

然後是函數連續調用,signature如下:

(T0 -> T1) -> (T2 -> T1) -> (T2 -> T0)

應用舉例:

EnemyConfig.GetConfig(Input.S.Id)

EnemyConfig.GetConfig的類型前面說過了,uint -> EnemyConfig,Input.S.Id的類型是Input -> uint。如此一組合,是一個Input -> EnemyConfig類型的closure。

接下來是比較複雜,但是又最常用的。

在Lua中,策劃有可能用到的所有操作符實際上都是一個兩參數函數,操作符與兩個參數表達式的組合通常是用的最多的。

最簡單的例子,同樣是函數的apply:

(T0 -> T1 -> TR) -> T0 -> T1 -> TR

每個雙目運算符的signature都是T0 -> T1 -> TR,當然,如果特指數學運算符,那甚至可以更簡單:T0 -> T0 -> TR。

在Lua中,策劃也有可能配置出這樣的情況,比如我們導出了一個Random函數:

return Math.Random(10, 100)

當然,大部分應用都是比較複雜的,有下面三種情況:

(T0 -> T1 -> TR) -> (T2 -> T0) -> T1 -> (T2 -> TR)(T0 -> T1 -> TR) -> T0 -> (T2 -> T1) -> (T2 -> TR)(T0 -> T1 -> TR) -> (T2 -> T0 ) -> (T3 -> T1) -> (T2 -> T3 -> TR)

分別對應於下面三個例子:

a = Input.S.AP + 10b = 10 + Input.S.APc = Input.T.AP + Input.S.AP

當然,由於Input是特殊的,還可以做下化簡,組合為Input->float,省去了一個額外的Input輸入。

如果是有三參數函數,那情況就更複雜了,實現起來非常蛋疼。這也是為什麼前文提到的要改用IfThen來表達IfThenElse的原因。

原理性的東西大概就這麼多,其他的就是具體實現的細枝末節了,實在沒什麼好講的了。

組合函數的實現通常也比較簡單,每種組合方式寫一個泛型函數,藉助編譯器的類型推導來拿到不同的組合函數實例,在編譯期註冊在表中。運行時由於只能通過元信息拿到類型數據,所以只能查表找具體的組合函數。

舉個簡單的推導函數的例子:

// (T1->T2->TR) -> (T3->T1) -> (T3->T2)// => T3->TRpublic static Closure<T3, TR> InferredClosure<T1, T2, T3, TR>( Closure<T1, T2, TR> op, Closure<T3, T1> t1Generator, Closure<T3, T2> t2Generator){ return new Closure<T3, TR>(p3 => Apply( op, t1Generator.Apply(p3), t2Generator.Apply(p3)));}

對於小說君在最開始貼的Lua公式例子,最終實現好就是一個Closure<Input, float>,給Apply一個Input實例,直接就計算出值了。

個人訂閱號:gamedev101「說給開發遊戲的你」,聊聊服務端,聊聊遊戲開發。會選擇性搬運文章到知乎這邊,如果感興趣請關注。

看不到二維碼點這裡


推薦閱讀:

Lua5.2和5.1有哪些不同?相對與5.1有什麼進步?Lua5.2能用5.1的庫嗎?如果不能,有哪些可用的庫?
C、Scheme、Lua 和 Go 究竟哪個最簡單?
如何評價七牛雲存儲的 qnlang?

TAG:编程 | Lua | 游戏开发 |