Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

亂數產生

許多遊戲仰賴隨機性來實作核心遊戲機制。本頁將帶你了解常見的亂數類型,以及如何在 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 的說明文件。