iT邦幫忙

2022 iThome 鐵人賽

DAY 19
1
Modern Web

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

Trick 18: 收下我的承諾,遲早給你個交待-I Promise

  • 分享至 

  • xImage
  •  

在寫遊戲流程的時候,常常會遇到需要等待某件事情發生,接著再去做另一件事的情況。比如說城內守衛要巡邏,那是不是要先規畫好巡邏站一二三,然後先設定目標為第一站,逛逛逛,等逛到了第一站,再變更目標為第二站。逛到了第二站,再改目標到第三站。到了第三站,再改回目標第一站。

以上的流程,在2015年以前的JavaScript裏,必須藉由把回呼函式(callback)當成參數傳進函式來達成非同步的任務執行。

/** 這裏寫一個控制角色前往某處的示意函式
 * actor: 角色,假設actor有location屬性及goto(location)方法
 * location: 目標位置
 * callback: 走到目標後,要呼叫的函式
 */
function gotoLocation(actor: Actor, location: Point, callback: Function) {
    // 控制角色前進
    actor.goto(location);
    /** 用setInterval,每秒檢查一次目前位置
     * setInterval會回傳一個收據
     * 之後我們要用這個收據停止setInterval繼續運作
     */
    let intervalID = setInterval(
        // 每一秒要執行的函式
        function() {
            // 檢查角色的位置是不是和目標位置一樣
            if(actor.location.equals(location)) {
                // 停止這個interval繼續執行
                clearInterval(intervalID);
                // 回呼指定的函式
                callback();
            }
        },
        1000 // 1000毫秒
    );
}

// 先建立守衛角色
let guard = new Actor();
// 決定巡邏站
let patrolLocs = [
    new Point(10, 10),
    new Point(20, 10),
    new Point(15, 20),
];
// 開始巡邏的函式, 參數是角色和下一站的index
function gotoNextLocation(actor: Actor, nextPatrolIndex: number) {
    // 把下一站的index取巡邏站數量的餘數,不讓index超出範圍
    nextPatrolIndex %= patrolLocs.length;
    // 前往下一站
    gotoLocation(
        actor, // 角色
        patrolLocs[nextPatrolIndex], // 前往目標
        function() { // 到達目標後要執行的回呼函式
            // 呼叫自己,去下一站
            gotoNextLocation(actor, nextPatrolIndex + 1);
        }
    );
}
// 出發
gotoNextLocation(guard, 0);

在上面的示範程式中,gotoNextLocation()在到達目標巡邏站之後,會再次呼叫自己,只不過其中的nextPatrolIndex(下一站的位置)被往前推進到下一站了。

Promise(承諾)

Promise的概念可能上個世紀就出現了,不過在JavaScript裏是到了2015年ECMAScript 6 (ES6)標準完備後才有的。Promise會承諾你,不管它執行的成功與否,都會給你一個交待,只不過不一定在當下就能告訴你結果。

如果一個函式不是馬上就能完成任務,那麼就可以回傳一個Promise,給呼叫函式的人一個承諾。建立承諾時,會從承諾那裏得到兩個工具函式,resolve(解決)和reject(駁回)。當函式的工作順利完成後,建立Promise的人必須要遵守承諾去呼叫resolve(),當resolve()被呼叫的時候,當初得到這個承諾的人,就會收到這則消息。當工作失敗出現錯誤時,也同樣要遵守承諾呼叫reject(),讓當初得到這個承諾的人,知道這個工作失敗了。

如果給了承諾,卻忘了呼叫resolve()或reject(),那麼就違反了Promise的精神,整個程式就很有可能因此出現Bug。

相對的,得到Promise的人會處在等待的狀態,但在開始等待前,可以利用兩個方法去登記承諾兌現時要做的事。一個是承諾提供的then(function),在Promise所代表的工作完成後,會呼叫使用then()所指定的回呼函式。另一個是承諾提供的catch(function),在Promise所代表的工作失敗時,會呼叫使用catch()所指定的函式。大致的使用方法如下。

// 假設Man有一個非同步函式:買房(),會回傳一個Promise
let man = new Man();
man.買房()
   // 買房()回傳一個Promise,我們利用它提供的.then(function)
   // 來設定買房成功後要執行的函式
   .then(function() { 結婚(); })
   // 也可以利用Promise.catch處理出錯之後的應對函式
   .catch(function(失敗原因) { 分手("不是因為"+失敗原因); })

以上的範例中,不管是在 買房() 的過程出了差錯,或是 結婚() 時發生了什麼意外,都會往下找到第一個用.catch(function)指定的函式來處理錯誤。

我們把剛剛守衛巡邏的程式,改用Promise來實作看看。

/** 這裏寫一個控制角色前往某處的示意函式
 * actor: 角色,假設有actor.location這個屬性
 * location: 目標位置
 * 回傳一個不需要結果資料的Promise
 */
function gotoLocation(actor: Actor, location: Point): Promise<void> {
    // 建立並回傳一個Promise
    return new Promise<void>(
        // Promise的建構子要給一個函式
        // Promise會藉由這個函式給我們resolve和reject這兩個東西
        function(resolve, reject) {
            // 控制角色前進
            actor.goto(location);
            /** 用setInterval,每秒檢查一次目前位置
             * setInterval會回傳一個收據
             * 之後我們要用這個收據停止setInterval繼續運作
             */
            let intervalID = setInterval(
                // 每一秒要執行的函式
                function() {
                    // 檢查角色的位置是不是和目標位置一樣
                    if(actor.location.equals(location)) {
                        // 停止這個interval繼續執行
                        clearInterval(intervalID);
                        // 執行resolve,表示這個承諾的任務完成了
                        resolve();
                    }
                },
                1000 // 1000毫秒
            );
        }
    );
}

// 建立守衛角色
let guard = new Actor();
// 決定巡邏站
let patrolLocs = [
    new Point(10, 10),
    new Point(20, 10),
    new Point(15, 20),
];
// 開始巡邏的函式, 參數是角色和下一站的index
function gotoNextLocation(actor: Actor, nextPatrolIndex: number) {
    // 把下一站的index取巡邏站數量的餘數,不讓index超出範圍
    nextPatrolIndex %= patrolLocs.length;
    // 前往下一站,接收承諾
    let promise = gotoLocation(actor,patrolLocs[nextPatrolIndex])
    // 用.then來決定承諾兌現後要做什麼事
    promise.then(
        function() {
           // 去下一站
           gotoNextLocation(actor, nextPatrolIndex + 1);
       }
    );
}
// 出發
gotoNextLocation(guard, 0);

Promise.then(function)還會把function裏回傳的結果包裝成Promise再傳出來,所以能有Promise接龍的寫法。示範一次給大家看。

// 控制守衛按路線前往三個地方
gotoLocation(guard, new Point(10, 10))
    .then(() => gotoLocation(guard, new Point(20, 10))
    .then(() => console.log("還剩一站"))
    .then(() => gotoLocation(guard, new Point(15, 20))
    .then(() => console.log("呼~可以休息了"))

上面的範例中,當第一次gotoLocation結束後,會執行第二行給的箭頭函式。這個箭頭函式會回傳另一個gotoLocation丟出來的承諾,所以第三行的.then()是針對第二次gotoLocation所給出的承諾作反應。第三行的console.log()雖然沒有回傳東西,但第三行的.then()仍會把這個結果包裝成Promise再傳給第四行使用。

這樣的寫法,是不是有點像我們講話的順序:「前往10,10」➜「.然後(前往20,10)」➜「.然後(講講話)」➜「.然後(前往15,20)」➜「.然後(再講會兒話)」,因此大大提高了程式碼的可讀性。

非同步與等待(async, await)

2017年JavaScript的標準來到了ECMAScript 2017(ES8),在這一版的標準中加入了非同步函式兩個超酷的關鍵字,async以及await,這兩個關鍵字進一步加強了程式碼的可讀性。

如果一個函式是非同步的,也就是它回傳的值是一個Promise,那麼我們在呼叫這個函式時,就可以用await關鍵字來等待。程式在await的時候不會繼續往下執行,而會等到該函式的任務執行完畢(承諾兌現),才會再繼續往下走。

我們再把剛剛的程式碼,改用await來試式。

// gotoLocation函式不變,這邊就不重覆寫了
...

/** 開始巡邏的函式, 參數是角色和下一站的index
 * 由於函式裏有用到await,
 * 代表這個函式也是非同步(不會馬上完成的函式)
 * 所以要用async關鍵字宣告為非同步函式
 */
async function gotoNextLocation(actor: Actor, nextPatrolIndex: number) {
    while(true) {
        // 把下一站的index取巡邏站數量的餘數,不讓index超出範圍
        nextPatrolIndex %= patrolLocs.length;
        // 執行gotoLocation,並等它執行完畢
        await gotoLocation(actor,patrolLocs[nextPatrolIndex]);
        // 目標改到下一站
        nextPatrolIndex++;
    }
}
// 出發
gotoNextLocation(guard, 0);

小哈知道第一次看到非同步函式,同學們應該都嚇壞了。不過在認識、理解、運用熟練之後,我想任何人都會愛上它的。同學們可以從今天的示範程式中去揣摩非同步函式的意義與流程,再慢慢研究Promise/async/await更深入的應用。

CG示範專案


錯誤處理

上面的例子中並沒有說明Promise.reject()的應用實例,因為擔心一次講太多,會給同學們留下陰影。今天的最後,稍微講一下如何使用在建立Promise時得到的reject,以及使用Promise.catch()怎麼接住錯誤訊息。

我們寫一個除法的函式,這個函式會回傳一個Promise,並且在除數為0的時候,以reject()來告訴得到承諾的人『除數不可以是0喔!』

/** 處理兩數相除的非同步函式
 * 參數是兩個數字,到時會計算 num1 / num2 的結果
 * 回傳一個帶有數字結果的Promise
 */
function devide(num1: number, num2: number): Promise<number> {
    // 建立Promise, 同學們最好早點習積箭頭函式~
    return new Promise((resolve, reject) => {
        if(num2 == 0) {
            // 除數不能是0,我們呼叫reject來拒絕執行這個任務
            reject("除數不可以是0喔!");
        } else {
            let result = num1 / num2;
            resolve(result);
        }
    });
}

// 用來做點事的函式
async function doSomething() {
    try {
        let result = await devide(1, 0);
        console.log(`結果 = ${result}`);
    } catch (error) {
        console.log(`糟糕!有錯: ${error}`);
    }
}

// 做點事吧
doSomething();

因為我們給devide函式的第二個參數是0,所以這段程式在執行時會出錯並且被函式reject()。我們用try catch包住devide()就可以攔截到這則錯誤,錯誤訊息會藉catch的參數error傳給我們。

如果使用ES6的寫法,上面的例子就會變成如下的程式。

// devide函式和上面寫的一樣
function devide(num1: number, num2: number): Promise<number> {
    ...
}
// 執行函式
devide(1, 0)
    .then((result) => {
        console.log(`結果 = ${result}`);
    })
    .catch((error) => {
        console.log(`糟糕!有錯: ${error}`);
    });

CG示範專案的錯誤處理範例
這段程式寫在專案的測試檔裏,所以要執行這段程式,請將『試玩遊戲』的按鈕切換為『模組測試』。


上一篇
Trick 17: 綿延不絕的隨機地形是咋做出來的?
下一篇
Trick 19: 事件驅動的程式設計
系列文
30個遊戲程設的錦囊妙計32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言