iT邦幫忙

2022 iThome 鐵人賽

DAY 2
3
Modern Web

30個遊戲程設的錦囊妙計系列 第 2

Trick 1: 萬惡的摸彩箱

萬惡的抽卡遊戲

玩過手機上的抽卡遊戲嗎?

就是店家的桌上放著一疊蓋起來的角色卡片,每花十元可以抽一張卡,但是抽到的總是那幾張老卡,最可愛的那個角色開了幾十百來張還是抽不到,但是每天早上起床,還是忍不住又去找店家抽了幾張。

這個摸彩箱不只在抽卡遊戲中有,遊戲中打怪掉落的道具、開寶箱得到的裝備、過關時獲得的獎勵,都會用到一樣的機制。

機率均等的摸彩箱

機率均等的摸彩箱很簡單,我們可以把N個獎項排成一個陣列,然後用亂數取一個0到N-1的數字,按這個數字去取陣列中的值就可以了。

/** 將所有獎項定義在一個陣列中
 * []是用來定義陣列的方式
 */
let items = [
    '光劍',
    '鐵盾',
    '貓草',
];

/** 取一個0到N-1的數字
 * Math.random(): 會得到一個介於0到1的亂數
 *                其中 0是可能得到的數字,1是不可能得到的數字
 * Math.floor(x): 可以將數字x的小數以下無條件捨去
 * items.length: 取得items陣列的長度
 */
let index = Math.floor(Math.random() * items.length);

/** 陣列中在index位置的東西就是這次取得的獎項
 * console.log(string) 可以在控制台列印出提供的字串
 */
let prize = items[index];
console.log('你得到了一個' + prize);

Math.floor(x)會找到最靠近x且比x小的整數。
若不想影響整數位,僅捨去小數點後面,可以使用Math.trunc(x)。
Math.floor(-1.234) 會得到 -2
Math.trunc(-1.234) 會得到 -1

機率不均的摸彩箱

機率不等,代表每個獎項在箱子中各自有不同的比重。那麼,這麼多不同比重的東西,我們要怎麼利用亂數來依比重取得獎項呢?
weight-ropes
我們可以把獎項綁在不同長度的繩子上,然後把繩子頭接尾地串在一起,這樣問題就簡單了,只要在整條接起來的繩子上隨機選一點,然後看看這一點所在的繩子是綁著哪個獎項就得了。

/** 宣告一個獎項的類別 */
class Item {
    /** 建構子: Typescript可以在建構子裏面宣告類別的屬性 */
    constructor(public item: string, public weight: number) {
    }
}

/** 將所有獎項定義在一個陣列中
 * 參數就是建構子中的 name(名字),以及weight(比重)
 */
let items = [
    new Item('光劍', 3),
    new Item('鐵盾', 2),
    new Item('貓草', 5),
];

/** 計算整條陣列接起來的繩長 
 * Array.reduce(函式:將陣列的其中一個元素加入計算, 初始值)
 * 下面會有針對Array.reduce()的解說
 */
let totalWeight = items.reduce(
                    // reduce對每個元素進行的回呼函式
                    (weightsSoFar, item) => {
                        // 回傳目前累積的值+元素的值
                        return weightsSoFar + item.weight
                    },
                    0 // 起始的累積值
                  );
console.log('比重總值 = ' + totalWeight);

/** 取一個不超過繩長的位置 */
let position = Math.random() * totalWeight;

/** 依位置去找Item
 * 首先宣告變數prize,等一下用來儲存position上找到的Item
 * 然後宣告我們目前找到的繩子累積了多少長度
 * 並用for迴圈從頭遍歷所有道具
 */
let prize: Item = null;
let accumulatedWeight = 0;
for (let item of items) {
    /** 累積下一個獎項的繩長 */
    accumulatedWeight += item.weight;
    /** 如果累積繩長比獎項的位置大,表示這截道具綁著的繩子就是答案了 */
    if (accumulatedWeight > position) {
        prize = item;
        break;
    }
}
console.log('你得到了一個' + prize.item);

指定獎項機率

假設遊戲中已經有了一個早定義好所有獎項比重的摸彩箱,但中秋到了,我們要推出光劍中獎率提高到50%的活動,這下該怎麼重新計算這些獎項的比重呢?

首先我們把綁著光劍的繩子從陣列中抽出來,然後計算能讓光劍的抽中率變成50%的比重,接著將光劍綁上新繩子再放回去就行了。

假設原本繩子總長是totalWeight,減掉光劍的繩子長度變成T,然後再假設綁著光劍的新繩長應該要是W,並且要讓光劍放回去後被抽中的機率是P,那麼我們就可以拼出下面這個數學式,
https://chart.googleapis.com/chart?cht=tx&chl=%5Cfrac%7BW%7D%7BT%2BW%7D%20%3D%20P
也就是光劍長除以新的總長要等於光劍的抽中率。接著就能導出
https://chart.googleapis.com/chart?cht=tx&chl=W%20%3D%20PT%20%2B%20PW%20%5Ccolor%7BBlue%7D%7B%5Ccolor%7BBlue%7D%20%5Crightarrow%7D%20(1-P)W%20%3D%20PT%20%7B%5Ccolor%7BBlue%7D%20%5Crightarrow%7D%20W%20%3D%20%5Cfrac%7BP%7D%7B1-P%7DT
其中的W就是我們需要的光劍新比重。下面我們把這個演算法用TypeScript寫下來給同學們參考。

/** 更改光劍的抽中率
 * 首先要找到光劍的Item
 */
let itemToChange = items.find(item => item.name == '光劍');
/** 先看看原本的光劍抽中率 */
console.log('原光劍比重 => ' + (itemToChange.weight / totalWeight));
/** 定義光劍的新抽中率為0.5, 以及扣掉光劍後的總繩長T */
let P = 0.5;
let T = totalWeight - itemToChange.weight;
/** 依公式,光劍的新比重W就可以算出來 */
let W = P / (1 - P) * T;
itemToChange.weight = W;
/** 別忘了最後要把新的光劍比重加回totalWeight */
totalWeight = T + W;

console.log('新的比重總值 => ' + totalWeight);
console.log('新的光劍比重 => ' + itemToChange.weight);
console.log('光劍抽中率 => ' + (itemToChange.weight / totalWeight));

CG示範專案


註解

TypeScript的建構子

建構子,就是在程式呼叫 new 的時候,實際會呼叫的函式。

// 類別
class Item {
    // 類別的建構子
    constructor() {
    }
}

// 新增類別的實體, 實際上就是在呼叫 Item中的constructor函式
let item = new Item();

TypeScript的建構子中可以直接宣告類別的屬性。以下兩種宣告Item的方式是一樣的

class Item {
    public name: string;
    
    constructor(name: string) {
        this.name = name;
    }
}
class Item {
    constructor(public name: string) {
    
    }
}

箭頭函式

上面的程式範例中,小哈寫了一些函式,但是函式的關鍵字function,一次也沒看到過,這是怎麼回事呢?

原來是JavaScript/TypeScript新增了一種函式宣告的方法,叫箭頭函式(Arrow Function Expressions)。
先舉兩個例子給大家參考,

// 原有的函式宣告
function addNumber(num1: number, num2: number) {
    return num1 + num2;
}
// 箭頭函式宣告
let addNumber = (num1: number, num2: number) => {
    return num1 + num2;
}
/** 箭頭函式宣告,如果內容只有一行用來回傳值的話
 * 可以把函式內容外的{}以及return字樣拿掉
 */
let addNumber = (num1: number, num2: number) => num1 + num2;
/** 箭頭函式宣告,如果參數只有一個的話
 * 原本包著參數的括號也可以拿掉
 */
let addTwo = num1 => num1 + 2;

箭頭函式和使用function關鍵字宣告的函式,在以上的例子中是一模一樣的,但是在類別的函式裏面宣告,這兩種函式對函式裏this的解讀是不同的。下面再舉例說明,

class Item {
    constructor(public name: string) { }
    
    sayHello() {
        let getName1 = () => {
            return this.name;
        }
        function getName2() {
            return this.name;
        }
        // 下面這行會正常印出this.name,這裏的this是指Item物件
        console.log(getName1()); // 
        // 下面這行會出錯,在getName2函式裏的this會找不到物件(undefined)
        console.log(getName2()); 
    }
}

Array.reduce()

Array.reduce是剛接觸程式設計的人不太敢用的,因為不確定這函式的運作方式到底是什麼,所以在今天的最後,就由小哈來講解一下這個好用卻常常被忽略的陣列工具。

我們借用上文寫的道具陣列來簡單說明一下,items是一個Item物件的陣列,每個物件都有name和weight兩個屬性。

let items = [
    new Item('光劍', 3),
    new Item('鐵盾', 2),
    new Item('貓草', 5),
];

Array.reduce的第一個參數是一個函式,函式會被提供目前已經計算到的結果以及正要列入計算的物件,然後這個函式要再使用這兩個參數計算新的結果,並把這個結果傳出去。
Array.reduce的第二個參數是在計算第一個物件前的初始結果。

所以下面這個式子的運作順序是這樣的,

items.reduce((weightsSoFar, item) => weightsSoFar + item.weight, 0);
  1. 一開始先把結果定為第二個參數,也就是0
  2. 然後把0當成weightSoFar,並把光劍那個道具當成item,去呼叫reduce第一個參數給的函式。
  3. 於是在第一個參數給的函式中,就會把0加上光劍的weight(3)等於5當成函式的回傳值。
  4. 接著reduce再把5當成weightSoFar,把鐵盾當成item,去呼叫reduce第一個參數給的函式。
  5. 在函式中計算5+鐵盾的2=7傳回去。
  6. 接著reduce再把7當成weightSoFar,把貓草當成item,去呼叫reduce第一個參數給的函式。
  7. 在函式中計算7+貓草的3=10傳回去。
  8. 最後陣列全部跑完了,整個reduce就會回傳10這個數值。

下面這一段程式碼和上面的Array.reduce是相等效用的,

function getTotalWeight(items: Item[], initialValue: number): number {
    let weightsSoFar = initialValue;
    for(let item of items) {
        weightsSoFar += item.weight;
    }
    return weightsSoFar;
}

今天多寫了很多TypeScript相關的段落,讓篇幅大了一點。希望之後我們越來越上軌道,就不必多講這麼多雜七雜八的東西了。


上一篇
Trick 0: 什麼都不會怎麼寫遊戲?
下一篇
Trick 2: 迷你四輪車演算法
系列文
30個遊戲程設的錦囊妙計32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
hanksu168
iT邦研究生 3 級 ‧ 2022-09-05 11:08:36

請問台彩的 Bingo!Bingo!採用電腦程式每隔5分鐘開獎1次不就可以作弊?如何作弊?前5分鐘已買好的彩卷設定不開出?
又一些網紅直播賣貨電腦抽獎頭獎是汽車或現金不就可設定為不開出或是開出給親戚朋友的獎號?
謝謝!!

小哈片刻 iT邦研究生 5 級 ‧ 2022-09-05 19:06:18 檢舉

以電腦程式開獎的話,如果不是親自看到裏面的程式碼,就無法確定他實際運作的機率分布,更不用說遠程操控或數字規則等影響公平性的其他機制。
也許未來應該要用現在最夯的區塊鏈,發展去中心化的第三方抽獎服務,來提供直播主比較有公信力的抽獎活動。

0
miku3920
iT邦新手 2 級 ‧ 2022-09-14 22:43:53

哇,好讚
我超喜歡這種知識密度超高的長篇大論
去年我寫的鐵人賽也是這種風格
今年想題目想太久沒時間準備
明年再來挑戰

小哈片刻 iT邦研究生 5 級 ‧ 2022-09-14 22:59:26 檢舉

我剛剛有去看了大哥您去年的鐵人賽,果然超酷,是以自己的經驗為出發點,這樣的文章看起來就是不一樣~

我要留言

立即登入留言