起因是在知乎上看到木七七工作室轉發的談隨機處理的一個內部視頻,視頻里面聊到Dota2的技能概率處理方式,比如大魚人(Sorry好久沒玩Dota了已經想不起來這位的真名)的被動技能升滿后:
- 每次攻擊有25%的幾率讓敵人眩暈。
一般做法用純隨機 True random distribution的方式,每次攻擊時計算概率,獨立判斷是否觸發眩暈。不過可能會出現一些體驗問題:
- 因為每次獨立計算概率,極限情況會導致一直觸發眩暈和一直不觸發,間接造成歐皇和非洲酋長之間的戰爭。
- 從玩家體驗上來說,感官上25%的幾率,超過5,6次不觸發,他就會開始懷疑幾率是否被策劃運營篡改,幕后是否有骯臟的PY交易,而不是回想下初中的數學課。
純隨機在數學上是無罪的,機器底層的隨機函數是清白的(其實也不是那么清白,畢竟純隨機是不存在的,不過這個就扯深了,先默認一般的random接口函數就是純隨機),但是有些時候并不是最佳解決方案。
用偽隨機分布Pseudo-random distribution處理概率
Dota2的偽隨機分布采用概率補償的方式,每次觸發概率從一個值開始遞增,第N次的觸發概率P(N) = C * N,比如25%的幾率,C值大概為8.5%,運算流程如下:
- 第一次觸發眩暈概率為8.5%
- 第二次為17%,以此類推遞增
- 如果觸發眩暈成功,則概率重新從8.5%開始遞增計算。
這種方式使得連續觸發或連續不觸發的幾率降低,避免了運氣成分過于影響戰斗結果(特別是競技游戲)。
一般幾率對應的C值可以參考下面這張圖。P(T)代表預期值,就是游戲中顯示的幾率值。P(A)是用了偽隨機后的實際概率。MaxN表示最壞情況下觸發概率的次數。
計算C值的方式和程序實現可以參考這個鏈接下的回答,有C#的實現代碼:
//CfromP是主函數,傳入理論概率P就可以求得遞增的C值
public decimal CfromP( decimal p )
{
decimal Cupper = p;
decimal Clower = 0m;
decimal Cmid;
decimal p1;
decimal p2 = 1m;
while(true)
{
Cmid = ( Cupper + Clower ) / 2m;
p1 = PfromC( Cmid );
if ( Math.Abs( p1 - p2 ) <= 0m ) break;
if ( p1 > p )
{
Cupper = Cmid;
}
else
{
Clower = Cmid;
}
p2 = p1;
}
return Cmid;
}
private decimal PfromC( decimal C )
{
decimal pProcOnN = 0m;
decimal pProcByN = 0m;
decimal sumNpProcOnN = 0m;
int maxFails = (int)Math.Ceiling( 1m / C );
for (int N = 1; N <= maxFails; ++N)
{
pProcOnN = Math.Min( 1m, N * C ) * (1m - pProcByN);
pProcByN += pProcOnN;
sumNpProcOnN += N * pProcOnN;
}
return ( 1m / sumNpProcOnN );
}
上面的偽隨機分布算是用概率補償的方式控制概率來改善玩家的體驗,詳細的可以參考Dota2的Wiki(打Dota2,向冰蛙學數學)。
當然也有其他方式控制隨機數和概率,正好前一陣子看了一個從D&D擲骰角度談控制隨機分布的文章,下面也算一個翻譯和整理。
我這把可是1d2有毒的飛刀
D&D里面NdS表示投擲S面的骰子N次,累加結果。比如1d12表示投擲一個12面骰子一次,3d4表示投擲一個4面骰子3次。
假設我們要獲取[0,24]之間的隨機值,可以先設置一個函數rollDice(N, S)來模擬骰子投擲:
public static int rollDice(int N, int S) {
int value = 0;
for (int i = 0; i < N; i++) {
//每次隨機結果為[0, S]
value += Random.Range(0, S + 1);
}
return value;
}
我們可以rollDice(1,24),也可以拆分成2次,變成rollDice(2,12),變成兩次[0,12]的和,以此類推rollDice(3,8)、rollDice(4,6),下面這張圖可以看到最終結果的分布變化:
可以看到投擲的次數越多,最終結果分布就越集中在[0,24]的平均值附近,所以4d6的武器比3d8的武器輸出更平穩,但3d8的武器造成高傷害的幾率也更高。
除了控制隨機取值的集中區域,我們還可以用簡單的方式控制隨機取值是大部分分散在平均值以下還是大部分分散在平均值以上。
兩次隨機取較大/較小值
還是以取[0,24]之間隨機值為例,每次rollDice(2,12)兩次,取較大值:
int roll1 = rollDice(2, 12);
int roll2 = rollDice(2, 12);
int result = Math.Max(roll1, roll2);
分布圖如下:
反過來,取較小值,可以獲得集中在平均值以下的分布:
int roll1 = rollDice(2, 12)
int roll2 = rollDice(2, 12)
int result = Math.Min(roll1, roll2);
取較小值在計算傷害值比較常見,比如一個角色的攻擊力在20到40之間,利用這種方法可以使得最后結果集中在較低的范圍,高傷害出現的幾率較低。
三次隨機取較大的兩個值
rollDice(1,12)三次,取較大的兩個值:
int roll1 = rollDice(1, 12);
int roll2 = rollDice(1, 12);
int roll3 = rollDice(1, 12);
int result = roll1 + roll2 + roll3;
result = result - Math.Min(roll1, roll2, roll3);
分布圖如下:
可以看出比兩次取較大/較小值分布更為平滑。
總結一下,可以看到在控制某個范圍內隨機數時,可以從下面幾個角度進行自定義以滿足需求:
- 范圍。確定隨機范圍的最大值和最小值,如果需要可以做一些偏移,比如[20, 30]可以分解為20 + rollDice(1, 10)。
- 方差。將一次隨機分解為多次隨機,可以使結果更靠近中間值。相反,次數越少,結果分布范圍越廣。
- 不對稱性。可以通過上面介紹的兩種方法,使隨機結果更多分布在平均值之前或者之后。
自定義概率分布
很多情況下,策劃過來找你的時候,情景有可能是:我這里有10種掉落物品,每種的掉率我都想單獨配置,比如A掉率10%,B掉率20%等等和一個Excel文件。
最終的配置文件可能是像這樣一個數組,前面是掉率(以100算100%),后面跟著物品ID。
local dropRate = {
{10, 100001},
{20, 100002},
{30, 100003},
{40, 100004},
}
掉率的總和不一定正好是100,畢竟要考慮些對配置文件的容錯性,所以先算出概率和sumRate,取random(sumRate)的值value,依次遍歷dropDate表,累加概率和weight,如果value小于等于weight,則算是落在當前區間,返回對應的物品ID。我用lua寫了一段測試代碼,畢竟lua的table實在是太方便了。
local dropRate = {
{10, 100001},
{20, 100002},
{30, 100003},
{40, 100004},
}
local distribute = {
[100001] = 0,
[100002] = 0,
[100003] = 0,
[100004] = 0,
}
local checkRate = function(t, value)
local weight = 0
for i=1,#t do
weight = weight + t[i][1]
if value <= weight then
return t[i][2]
end
end
return nil
end
local getDropItem = function(t)
local weightTotal = 0
for k,v in pairs(t) do
weightTotal = weightTotal + v[1]
end
local value = math.random(weightTotal)
return checkRate(t, value)
end
local main = function()
--用倒序時間設置random的seed,確保seed隨時間顯著變化
math.randomseed(tostring(os.time()):reverse():sub(1, 6))
for i=1,10000 do
local id = getDropItem(dropRate)
if id and distribute[id] then
distribute[id] = distribute[id] + 1
end
end
for index,dis in pairs(distribute) do
print("index:",index)
print("dis:",dis)
print("percent:",dis / 10000)
print("=================")
end
end
main()
測試結果和配置概率很接近,這樣就可以讓策劃盡情發揮他的奇怪掉率了。
總結
上面的部分只是最近看到的一些有意思的隨機數討論整理,真正在實際項目中,隨機數的處理是跟隨不同的需求做變化的,隨機可以增加游戲過程的樂趣,可以給游戲增加賣點,也可以變成各種“坑”,對于開發來說,只要這個坑是可控制的,不要坑到自己就行了~