起因是在知乎上看到木七七工作室轉(zhuǎn)發(fā)的談隨機(jī)處理的一個(gè)內(nèi)部視頻谭溉,視頻里面聊到Dota2的技能概率處理方式社搅,比如大魚(yú)人(Sorry好久沒(méi)玩Dota了已經(jīng)想不起來(lái)這位的真名)的被動(dòng)技能升滿(mǎn)后:
- 每次攻擊有25%的幾率讓敵人眩暈号坡。
一般做法用純隨機(jī) True random distribution的方式买羞,每次攻擊時(shí)計(jì)算概率爷光,獨(dú)立判斷是否觸發(fā)眩暈。不過(guò)可能會(huì)出現(xiàn)一些體驗(yàn)問(wèn)題:
- 因?yàn)槊看为?dú)立計(jì)算概率席怪,極限情況會(huì)導(dǎo)致一直觸發(fā)眩暈和一直不觸發(fā)应闯,間接造成歐皇和非洲酋長(zhǎng)之間的戰(zhàn)爭(zhēng)。
- 從玩家體驗(yàn)上來(lái)說(shuō)挂捻,感官上25%的幾率碉纺,超過(guò)5,6次不觸發(fā)刻撒,他就會(huì)開(kāi)始懷疑幾率是否被策劃運(yùn)營(yíng)篡改骨田,幕后是否有骯臟的PY交易,而不是回想下初中的數(shù)學(xué)課疫赎。
純隨機(jī)在數(shù)學(xué)上是無(wú)罪的,機(jī)器底層的隨機(jī)函數(shù)是清白的(其實(shí)也不是那么清白碎节,畢竟純隨機(jī)是不存在的捧搞,不過(guò)這個(gè)就扯深了,先默認(rèn)一般的random接口函數(shù)就是純隨機(jī))狮荔,但是有些時(shí)候并不是最佳解決方案胎撇。
用偽隨機(jī)分布Pseudo-random distribution處理概率
Dota2的偽隨機(jī)分布采用概率補(bǔ)償?shù)姆绞剑看斡|發(fā)概率從一個(gè)值開(kāi)始遞增殖氏,第N次的觸發(fā)概率P(N) = C * N晚树,比如25%的幾率,C值大概為8.5%雅采,運(yùn)算流程如下:
- 第一次觸發(fā)眩暈概率為8.5%
- 第二次為17%爵憎,以此類(lèi)推遞增
- 如果觸發(fā)眩暈成功,則概率重新從8.5%開(kāi)始遞增計(jì)算婚瓜。
這種方式使得連續(xù)觸發(fā)或連續(xù)不觸發(fā)的幾率降低宝鼓,避免了運(yùn)氣成分過(guò)于影響戰(zhàn)斗結(jié)果(特別是競(jìng)技游戲)。
一般幾率對(duì)應(yīng)的C值可以參考下面這張圖巴刻。P(T)代表預(yù)期值愚铡,就是游戲中顯示的幾率值。P(A)是用了偽隨機(jī)后的實(shí)際概率胡陪。MaxN表示最壞情況下觸發(fā)概率的次數(shù)沥寥。
計(jì)算C值的方式和程序?qū)崿F(xiàn)可以參考這個(gè)鏈接下的回答,有C#的實(shí)現(xiàn)代碼:
//CfromP是主函數(shù)柠座,傳入理論概率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 );
}
上面的偽隨機(jī)分布算是用概率補(bǔ)償?shù)姆绞娇刂聘怕蕘?lái)改善玩家的體驗(yàn)邑雅,詳細(xì)的可以參考Dota2的Wiki(打Dota2,向冰蛙學(xué)數(shù)學(xué))妈经。
當(dāng)然也有其他方式控制隨機(jī)數(shù)和概率蒂阱,正好前一陣子看了一個(gè)從D&D擲骰角度談控制隨機(jī)分布的文章锻全,下面也算一個(gè)翻譯和整理。
我這把可是1d2有毒的飛刀
D&D里面NdS表示投擲S面的骰子N次录煤,累加結(jié)果鳄厌。比如1d12表示投擲一個(gè)12面骰子一次,3d4表示投擲一個(gè)4面骰子3次妈踊。
假設(shè)我們要獲取[0,24]之間的隨機(jī)值了嚎,可以先設(shè)置一個(gè)函數(shù)rollDice(N, S)來(lái)模擬骰子投擲:
public static int rollDice(int N, int S) {
int value = 0;
for (int i = 0; i < N; i++) {
//每次隨機(jī)結(jié)果為[0, S]
value += Random.Range(0, S + 1);
}
return value;
}
我們可以rollDice(1,24),也可以拆分成2次廊营,變成rollDice(2,12)歪泳,變成兩次[0,12]的和,以此類(lèi)推rollDice(3,8)露筒、rollDice(4,6)呐伞,下面這張圖可以看到最終結(jié)果的分布變化:
可以看到投擲的次數(shù)越多,最終結(jié)果分布就越集中在[0,24]的平均值附近慎式,所以4d6的武器比3d8的武器輸出更平穩(wěn)伶氢,但3d8的武器造成高傷害的幾率也更高。
除了控制隨機(jī)取值的集中區(qū)域瘪吏,我們還可以用簡(jiǎn)單的方式控制隨機(jī)取值是大部分分散在平均值以下還是大部分分散在平均值以上癣防。
兩次隨機(jī)取較大/較小值
還是以取[0,24]之間隨機(jī)值為例,每次rollDice(2,12)兩次掌眠,取較大值:
int roll1 = rollDice(2, 12);
int roll2 = rollDice(2, 12);
int result = Math.Max(roll1, roll2);
分布圖如下:
反過(guò)來(lái)蕾盯,取較小值,可以獲得集中在平均值以下的分布:
int roll1 = rollDice(2, 12)
int roll2 = rollDice(2, 12)
int result = Math.Min(roll1, roll2);
取較小值在計(jì)算傷害值比較常見(jiàn)蓝丙,比如一個(gè)角色的攻擊力在20到40之間级遭,利用這種方法可以使得最后結(jié)果集中在較低的范圍,高傷害出現(xiàn)的幾率較低渺尘。
三次隨機(jī)取較大的兩個(gè)值
rollDice(1,12)三次装畅,取較大的兩個(gè)值:
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);
分布圖如下:
可以看出比兩次取較大/較小值分布更為平滑。
總結(jié)一下沧烈,可以看到在控制某個(gè)范圍內(nèi)隨機(jī)數(shù)時(shí)掠兄,可以從下面幾個(gè)角度進(jìn)行自定義以滿(mǎn)足需求:
- 范圍。確定隨機(jī)范圍的最大值和最小值锌雀,如果需要可以做一些偏移蚂夕,比如[20, 30]可以分解為20 + rollDice(1, 10)。
- 方差腋逆。將一次隨機(jī)分解為多次隨機(jī)婿牍,可以使結(jié)果更靠近中間值。相反惩歉,次數(shù)越少等脂,結(jié)果分布范圍越廣俏蛮。
- 不對(duì)稱(chēng)性∩弦#可以通過(guò)上面介紹的兩種方法搏屑,使隨機(jī)結(jié)果更多分布在平均值之前或者之后。
自定義概率分布
很多情況下粉楚,策劃過(guò)來(lái)找你的時(shí)候辣恋,情景有可能是:我這里有10種掉落物品,每種的掉率我都想單獨(dú)配置模软,比如A掉率10%伟骨,B掉率20%等等和一個(gè)Excel文件。
最終的配置文件可能是像這樣一個(gè)數(shù)組燃异,前面是掉率(以100算100%)携狭,后面跟著物品ID。
local dropRate = {
{10, 100001},
{20, 100002},
{30, 100003},
{40, 100004},
}
掉率的總和不一定正好是100回俐,畢竟要考慮些對(duì)配置文件的容錯(cuò)性逛腿,所以先算出概率和sumRate,取random(sumRate)的值value鲫剿,依次遍歷dropDate表鳄逾,累加概率和weight稻轨,如果value小于等于weight灵莲,則算是落在當(dāng)前區(qū)間,返回對(duì)應(yīng)的物品ID殴俱。我用lua寫(xiě)了一段測(cè)試代碼政冻,畢竟lua的table實(shí)在是太方便了。
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()
--用倒序時(shí)間設(shè)置random的seed线欲,確保seed隨時(shí)間顯著變化
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()
測(cè)試結(jié)果和配置概率很接近明场,這樣就可以讓策劃盡情發(fā)揮他的奇怪掉率了。
總結(jié)
上面的部分只是最近看到的一些有意思的隨機(jī)數(shù)討論整理李丰,真正在實(shí)際項(xiàng)目中苦锨,隨機(jī)數(shù)的處理是跟隨不同的需求做變化的,隨機(jī)可以增加游戲過(guò)程的樂(lè)趣趴泌,可以給游戲增加賣(mài)點(diǎn)舟舒,也可以變成各種“坑”,對(duì)于開(kāi)發(fā)來(lái)說(shuō)嗜憔,只要這個(gè)坑是可控制的秃励,不要坑到自己就行了~