Redux Hero Part 4:每個英雄都需要一個大反派(一種有趣的方式介紹 redux-saga)

黃大發丶發表於2019-01-27

翻譯自 Redux Hero 系列文章第 4 篇,原文連結請戳我

當你想到像 《勇者鬥惡龍》(Dragon Warrior)《最終幻想》(Final Fantasy) 這樣經典的 RPG 遊戲時,你就會發現這些型別的遊戲內容是在一張大地圖上面四處遊蕩然後與遇到的怪物展開戰鬥。

但是為了讓英雄輕鬆些,英雄是不會在每一次前進操作的時候都會碰到大怪獸的,大怪獸會隨機地分佈在大地圖上的某個角落但不會出現在所有角落(否則英雄會忙不過來的)。所以這裡的關鍵在於,隨機性(Randomness)

但是在 Redux 的世界裡,所有的東西都應該是純函式(確定性和無副作用),所以隨機性在哪裡呢?

然後這裡還有一個更大的問題:我們連貫的應用邏輯(主角出生,閒逛地圖,打怪獸,被怪獸打,扣血,主角領盒飯)一般應該放在哪裡?(where does my application logic belong in general) 似乎我們更通常的做法是把這些連貫的應用邏輯分散到不同的角落裡(逛地圖的操作屬於一個 redux 模組檔案,主角狀態和血量屬於另外一個 redux 模組檔案)。

幸運的是,這時候 redux-saga 出現了,提供了一種有效的解決方案。

首先,為我們這個英雄打怪獸領便當的過程定義虛擬碼:

loop while player is still alive
    wait for player to move
    are we in a safe place?
    randomly decide if there is a monster
    fight the monster
end loop
複製程式碼

當主角還活著

讓我們來完善我們的第一行程式碼:

export function* gameSaga() {
    // 一直迴圈,只要主角血槽還沒空
    let playerAlive = true;
    while (playerAlive) {
        // 活著的時候,你能做很多事情。
        // 所以要珍惜活著的每一份每一秒。
    }
}
複製程式碼

redux-saga 依賴 生成器(generator),我們這裡不會對生成器進行詳細介紹(因為我們的故事是英雄打怪獸)。想了解關於生成器的更多東西,請戳 function on MDNES6 Generators by David Walsh

等待主角移動

然後我們來為主角移動創造一些 action 吧:我們會有一個 MOVE 的 action 型別和一個 move() 的 action 型別建構函式。我們鍵盤上的一些按鍵會派發這些 types 。

const Actions = {
    MOVE: 'MOVE',
    // ...
}
const move = ({ x, y }) => ({
    type: Actions.MOVE,
    payload: { x, y }
})
複製程式碼

一旦 dispatch ,action 就會被中介軟體攔住(這裡我們的 redux-saga 就是其中之一的中介軟體)。這時候 redux-saga 把 action 壁咚了之後,就可以 猥瑣欲為 了。

export function* gameSaga() {
    let playerAlive = true;
    while (playerAlive) {
        // 等待主角移動
        yield take(Actions.MOVE)
        // 只有當主角移動了才會進入到下一行來
    }
}
複製程式碼

take 會阻塞 saga ,直到指定的 action 被 dispatch。但是注意,這裡的阻塞不會阻塞 ui 或頁面操作,一個 saga 其實很像一個後臺自動執行著的程式

主角是否安全(沒碰到怪獸)

我們不希望在有公主的城堡房間裡都會碰到怪獸忙著打架,這樣主線故事就不浪漫了。

export function* gameSaga() {
    let playerAlive = true;
    while (playerAlive) {
        yield take(Actions.MOVE);
        
        // 主角是否安全
        const location = yield select(getLocation);
        if (location.safe) continue;
    }
}
複製程式碼

select 允許我們從 store 中拿取 state。這裡的 getLocation 是一個選擇器,接收 state 作為它的引數:

export const getLocation = state => {
    const { x, y } = state.hero.position;
    return worldMap[ y, x ];
}
複製程式碼

redux-saga 代替我們訪問 store ,並用 getState 獲取狀態傳遞給我們的選擇器(selector)。

隨機決定某個位置是否有怪獸

我們不能把 Math.random() 的呼叫放到 reducer 裡,因為 Redux 三大原則 之一是函式必須是純的(沒有副作用,禁止直接修改應用狀態,而是返回一個新的狀態)(只依賴於輸入引數,同樣的引數永遠返回相同的結果,不管何時何地呼叫這個純函式)。顯然 Math.random() 是不純的。

我們先來看一個壞的例子:

export const reducer = (state = {}, action) => {
    switch (action.type) {
        case Action.MOVE:
            const monsterProbability = Math.random(); // BAD!!
            if (monsterProbability > location.encounterThreshold) {
                // 我們的主角遇到了一隻怪獸
            }
            return newState;
    }
}
複製程式碼

上面那個是壞例子,然而我們卻可以在 saga 裡面這樣幹:

export function* gameSaga() {
    let playerAlive = true;
    while (playerAlive) {
        yield take(Actions.MOVE);
        
        const location = yield select(getLocation);
        if (location.safe) continue;
        
        // 隨機決定是否會遇到怪獸
        const monsterProbability = yield call(Math.random);
        if (monsterProbability < location.encounterThreshold) continue;
        // 我們的主角在這裡遇到了一隻怪獸
    }
}
複製程式碼

因為我們的 random 不是直接在 redux 內部使用的,所以並不會破壞 redux 的原則和純函式性質。

打怪獸 !!

因為戰鬥的過程會比較複雜,所以我們建立另外的單獨的 saga 來處理戰鬥過程。fightSaga 最終會返回一個布林值(如果主角活下來了就返回 true ,否則返回 false):

export function* gameSaga() {
    let playerAlive = true;
    while (playerAlive) {
        yield take(Actions.MOVE);
        
        const location = yield select(getLocation);
        if (location.safe) continue;
        
        const monsterProbability = yield  (Math.random);
        if (monsterProbability < location.encounterThreshold) continue;
        
        // 打怪獸
        playerAlive = yield call(fightSaga);
    }
}
複製程式碼

下面給出 fightSaga 的虛擬碼實現:

begin loop
   monster's turn to attack
   is player dead? return false
   player fight options
   is monster dead? return true
end loop
複製程式碼

然後下面就是正式的 JavaScript 實現:

export function* fightSaga() {
    const monster = yield select(getMonster);
    
    while (true) {
        // 怪獸發起攻擊 !!
        yield call(monsterAttackSaga, monster);
        
        // 主角死了沒死 ??
        const playerHealth = yield select(getHealth);
        if (playerHealth <= 0) return false;
        
        // 主角發起攻擊 !!
        yield call(playerFightOptionsSaga);
        
        // 怪獸死了沒死 ??
        const monsterHealth = yield select(getMonsterHealth);
        if (monsterHealth <= 0) return true;
    }
}
複製程式碼

防止 saga 函式程式碼量過大時很重要的,我們可以把上面所有我們還沒有實現的 saga 全部內聯寫到 fightSaga 裡面,但是這樣會使可閱讀性下降,並且細分 saga 也更便於以後測試。所以這裡 fightSaga 只負責宣告順序,怪獸攻擊然後主角攻擊,直到最後怪獸沒了或者主角領便當了。

接下來我們來實現怪獸攻擊函式 monsterAttackSaga主角攻擊函式 playerFightOptionsSaga

輪到怪獸攻擊了

讓我們思考一下游戲體驗。對於玩家來說,如果怪獸在主角進行 move 移動操作後馬上對他執行攻擊(每走一步都會馬上被捱揍,管你走沒走出攻擊區域),這會對玩家造成巨大的心靈創傷。所以我們會加入延遲,在主角移動之後的一段時間內,怪獸不會瞬間攻擊我們的主角。

export function* monsterAttackSaga(monster) {
    // 等待一小段時間延遲
    yield call(delay, 1000);
    
    // 隨機產生傷害數值
    let damage = monster.strength;
    const critProbablity = yield call(Math.random);
    if (critProbability >= monster.critThreshold) damage *= 2;
    
    // 華麗麗的攻擊前預備動作
    yield put(animateMonsterAttack(damage));
    yield call(delay, 1000);
    
    // 攻擊 !!
    yield put(takeDamage(damage));
}
複製程式碼

put是 redux-saga 用來*派發 action(dispatch action)*的。animateMonsterAttack() 會返回 { type:.. , payload: .. } 的 action 物件。

主角戰鬥函式

主角的戰鬥函式要比怪獸複雜些,因為主角不單單只是進行攻擊,他是主角他還可以做其他事情(比如中途吻一下公主或者露出悲傷抑鬱的神情,或者正常一點的就是喝藥補血和放大招)。

wait for player to select an action
if attack, run the attack sequence
if potion, run the heal sequence
if run away, run the escape sequence
複製程式碼
export function* playerFightOptionsSaga() {
    // 等待玩家選擇一個動作執行
    const { attack, heal, escape } = yield race({
        attack: take(Actions.ATTACK),
        heal: take(Actions.DRINK_POTION),
        escape: take(Actions.RUN_AWAY)
    });
    
    if (attack) yield call(playerAttackSaga);
    if (heal) yield call(playerHealSaga);
    if (escape) yield call(playerEscapeSaga);
}
複製程式碼

我們可以往 race 裡面放任何東西 —— 甚至是執行另外一個 saga 函式 —— race 會取第一個執行完畢的函式結果返回,其他未執行完成的函式就會被取消掉。下面我們演示如何使用 race 來使玩家讀存檔:

export function* metaSaga() {
    // 等待靜態資源(assets)載入
    // 展示片頭動畫
    // 等待玩家點選開始遊戲
    
    // 開始遊戲的同時,監聽玩家讀取存檔操作
    while (true) {
        yield race({
            play: call(gameSaga),
            load: take(Actions.LOAD_GAME)
        })
    }
}
複製程式碼

LOAD_GAME action 會將 state 還原成初始狀態 initialState,然後 saga 就會攔截到 LOAD_GAME 事件。從而使 load 執行完畢(resolve)而使得 play 執行中斷(reject)(可以把 saga 的 race 想象成 Promise.race)。最終 redux-saga 就會中斷掉遊戲 gameSaga 的進行。

概述總結

Effect(作用) Purpose(目的)
take to wait for an aciton(等待一個 action 動作發生)
select to access state(獲取一個 state 狀態)
call to call a function or another saga(執行一個函式或一個 saga)
delay to delay execution(延時執行)
put to dispatch an action(派發 action)
race to wait for the first completion from a set of effects(等待第一個執行完成的函式結果返回,然後取消掉其餘的函式執行)

瞭解更多 saga 的 api 請戳 Redux-Saga documentation

此係列其他文章


黃大發的讚賞碼

相關文章