亂數產生

許多遊戲仰賴隨機性來實作核心遊戲機制。本頁將帶你了解常見的亂數類型,以及如何在 Godot 中實作它們。

在簡要介紹產生亂數的實用函式後,你會學到如何從陣列、字典中取得隨機元素,並學會在 GDScript 裡使用雜訊產生器。最後,我們也會看看密碼學安全的亂數產生,以及它與一般亂數產生的差異。

備註

電腦無法產生「真正」的亂數,而是依賴於 偽亂數產生器 (PRNGs)。

Godot 在內部使用 PCG 家族 的偽亂數產生器。

全域作用域與 RandomNumberGenerator 類別

Godot 提供兩種產生亂數的方式:透過*全域作用域*的方法,或是使用 RandomNumberGenerator 類別。

全域作用域的方法較易設置,但控制性較低。

RandomNumberGenerator 雖需更多程式碼,但能建立多個實例,各自擁有獨立種子與狀態。

本教學主要使用全域作用域的方法,除非該方法只存在於 RandomNumberGenerator 類別中。

randomize() 方法

備註

自 Godot 4.0 起,專案啟動時會自動將亂數種子設為隨機值。這表示你不必再於 _ready() 裡呼叫 randomize() 來確保每次執行專案時結果都不同。不過,如果你想用特定種子,或用其他方式產生種子,仍可自行呼叫 randomize()

在全域作用域中,你會找到 randomize() 方法。這個方法只需要在專案啟動時呼叫一次,以初始化亂數種子。 多次呼叫並無必要,甚至可能影響效能。

將其放在主場景腳本的 _ready() 方法中是個不錯的做法:

func _ready():
    randomize()

你也可以用 seed() 來設定固定的亂數種子。這樣每次執行專案時都會得到*確定性*的結果:

func _ready():
    seed(12345)
    # To use a string as a seed, you can hash it to a number.
    seed("Hello world".hash())

如果你使用 RandomNumberGenerator 類別,要對該實例呼叫 randomize(),因為每個實例都有自己獨立的種子:

var random = RandomNumberGenerator.new()
random.randomize()

取得亂數

讓我們看看 Godot 裡常用的亂數產生函式和方法。

函式 randi() 會回傳一個介於 0 到 2^32-1 的亂數。由於最大值很大,你通常會想用取餘數運算子 (%) 限制結果在 0 到你指定的上限之間:

# Prints a random integer between 0 and 49.
print(randi() % 50)

# Prints a random integer between 10 and 60.
print(randi() % 51 + 10)

randf() 會回傳一個 0 到 1 之間的亂數浮點數。這對於實作 加權隨機機率 等加權隨機系統很有用。

randfn() 會回傳符合 常態分佈 的亂數浮點數。這代表回傳值通常會落在平均值(預設 0.0)附近,並依標準差(預設 1.0)變動:

# Prints a random floating-point number from a normal distribution with a mean 0.0 and deviation 1.0.
print(randfn(0.0, 1.0))

randf_range() 接受兩個參數 fromto,會回傳一個介於 fromto 之間的亂數浮點數:

# Prints a random floating-point number between -4 and 6.5.
print(randf_range(-4, 6.5))

randi_range() 接受兩個參數 fromto,會回傳一個介於 fromto 之間的亂數整數:

# Prints a random integer between -10 and 10.
print(randi_range(-10, 10))

取得隨機陣列元素

我們可以使用亂數整數來從陣列中取得隨機元素,或直接用 Array.pick_random 方法來實現:

var _fruits = ["apple", "orange", "pear", "banana"]

func _ready():
    for i in range(100):
        # Pick 100 fruits randomly.
        print(get_fruit())

    for i in range(100):
        # Pick 100 fruits randomly, this time using the `Array.pick_random()`
        # helper method. This has the same behavior as `get_fruit()`.
        print(_fruits.pick_random())

func get_fruit():
    var random_fruit = _fruits[randi() % _fruits.size()]
    # Returns "apple", "orange", "pear", or "banana" every time the code runs.
    # We may get the same fruit multiple times in a row.
    return random_fruit

為了避免連續多次抽到同一個水果,我們可以在上述方法中再加入一些邏輯。這時就不能用 Array.pick_random,因為它無法防止重複:

var _fruits = ["apple", "orange", "pear", "banana"]
var _last_fruit = ""


func _ready():
    # Pick 100 fruits randomly.
    for i in range(100):
        print(get_fruit())


func get_fruit():
    var random_fruit = _fruits[randi() % _fruits.size()]
    while random_fruit == _last_fruit:
        # The last fruit was picked. Try again until we get a different fruit.
        random_fruit = _fruits[randi() % _fruits.size()]

    # Note: if the random element to pick is passed by reference,
    # such as an array or dictionary,
    # use `_last_fruit = random_fruit.duplicate()` instead.
    _last_fruit = random_fruit

    # Returns "apple", "orange", "pear", or "banana" every time the code runs.
    # The function will never return the same fruit more than once in a row.
    return random_fruit

這種方式能讓亂數產生的結果沒那麼容易重複。不過,它仍無法防止結果在某些值間來回重複。若要避免這種情況,建議改用 shuffle bag 模式。

取得隨機字典值

我們也可以將類似陣列的邏輯應用到字典:

var _metals = {
    "copper": {"quantity": 50, "price": 50},
    "silver": {"quantity": 20, "price": 150},
    "gold": {"quantity": 3, "price": 500},
}


func _ready():
    for i in range(20):
        print(get_metal())


func get_metal():
    var random_metal = _metals.values()[randi() % metals.size()]
    # Returns a random metal value dictionary every time the code runs.
    # The same metal may be selected multiple times in succession.
    return random_metal

加權隨機機率

randf() 方法會回傳介於 0.0 到 1.0 的浮點數。你可以用來實作「加權」機率,讓不同結果有不同出現機率:

func _ready():
    for i in range(100):
        print(get_item_rarity())


func get_item_rarity():
    var random_float = randf()

    if random_float < 0.8:
        # 80% chance of being returned.
        return "Common"
    elif random_float < 0.95:
        # 15% chance of being returned.
        return "Uncommon"
    else:
        # 5% chance of being returned.
        return "Rare"

你也可以在 RandomNumberGenerator 實例上使用 rand_weighted() 方法來取得加權亂數*索引*。這個方法會回傳一個介於 0 到你傳入陣列長度的亂數整數。陣列中的每個浮點數值代表該索引被選中的*相對*機率。值越大,被選中機率越高;如果為 0 則永遠不會被選中。

例如,若你傳入 [0.5, 1, 1, 2],該方法選到索引 3``(數值為 2)的機率會是索引 ``12``(數值為 1)的兩倍,選到索引 ``0``(數值為 0.5)的機率則是索引 ``12 的一半。

由於回傳值就是陣列的索引,你可以用它來從另一個陣列中取值,例如:

# Prints a random element using the weighted index that is returned by `rand_weighted()`.
# Here, "apple" will be returned twice as rarely as "orange" and "pear".
# "banana" is twice as common as "orange" and "pear", and four times as common as "apple".
var fruits = ["apple", "orange", "pear", "banana"]
var probabilities = [0.5, 1, 1, 2];

var random = RandomNumberGenerator.new()
print(fruits[random.rand_weighted(probabilities)])

運用 Shuffle Bag 提升亂數品質

以剛剛的例子來說,我們希望隨機選取水果。不過,每次都直接用亂數選可能造成分布不均勻,玩家有可能連續三次(或更多)抽到同一種水果。

這時可以用 shuffle bag 模式來解決。每次選完從陣列移除該元素,直到陣列為空時再初始化回原本的內容:

var _fruits = ["apple", "orange", "pear", "banana"]
# A copy of the fruits array so we can restore the original value into `fruits`.
var _fruits_full = []


func _ready():
    _fruits_full = _fruits.duplicate()
    _fruits.shuffle()

    for i in 100:
        print(get_fruit())


func get_fruit():
    if _fruits.is_empty():
        # Fill the fruits array again and shuffle it.
        _fruits = _fruits_full.duplicate()
        _fruits.shuffle()

    # Get a random fruit, since we shuffled the array,
    # and remove it from the `_fruits` array.
    var random_fruit = _fruits.pop_front()
    # Returns "apple", "orange", "pear", or "banana" every time the code runs, removing it from the array.
    # When all fruit are removed, it refills the array.
    return random_fruit

執行上述程式碼時,仍有機會連續兩次抽到同一個水果。當某水果被選到後,除非陣列已清空,否則不會再被選到。當陣列清空時才會重設,這時相同水果才可能再次被抽到,但只會出現一次。

隨機雜訊

如果你需要一個會*隨著輸入逐步改變*的亂數,上述方法就有其侷限。輸入可以是位置、時間或其他資料。

這時可以用隨機*雜訊*函式。雜訊函式在程式化生成時很受歡迎,常用來產生擬真的地形。Godot 提供 FastNoiseLite,支援 1D、2D、3D 雜訊。以下是 1D 雜訊的範例:

var _noise = FastNoiseLite.new()

func _ready():
    # Configure the FastNoiseLite instance.
    _noise.noise_type = FastNoiseLite.NoiseType.TYPE_SIMPLEX_SMOOTH
    _noise.seed = randi()
    _noise.fractal_octaves = 4
    _noise.frequency = 1.0 / 20.0

    for i in 100:
        # Prints a slowly-changing series of floating-point numbers
        # between -1.0 and 1.0.
        print(_noise.get_noise_1d(i))

密碼學安全的偽亂數產生

前述方法都**不適合**用於*密碼學安全*的偽亂數產生(CSPRNG)。遊戲用途沒問題,但如果涉及加密、驗證或簽章時,這樣還不夠安全。

Godot 提供 Crypto 類別來處理這類需求。該類別支援非對稱金鑰加解密、簽章/驗證,也能產生密碼學安全的隨機位元組、RSA 金鑰、HMAC 摘要與自簽 X509Certificate 等。

CSPRNG 的缺點是效能明顯慢於一般偽亂數產生器,API 也較不直覺。因此, CSPRNG 不建議用於遊戲玩法相關的亂數。

以下範例演示如何用 Crypto 類別產生兩個介於 02^32 - 1 (含)的隨機整數:

var crypto := Crypto.new()
# Request as many bytes as you need, but try to minimize the amount
# of separate requests to improve performance.
# Each 32-bit integer requires 4 bytes, so we request 8 bytes.
var byte_array := crypto.generate_random_bytes(8)

# Use the ``decode_u32()`` method from PackedByteArray to decode a 32-bit unsigned integer
# from the beginning of `byte_array`. This method doesn't modify `byte_array`.
var random_int_1 := byte_array.decode_u32(0)
# Do the same as above, but with an offset of 4 bytes since we've already decoded
# the first 4 bytes previously.
var random_int_2 := byte_array.decode_u32(4)

prints("Random integers:", random_int_1, random_int_2)

也參考

更多如何將產生的位元組解碼為各種資料型態(如整數或浮點數)的方法,請參閱 PackedByteArray 的說明文件。