iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 29
3

前情提要

昨天我們做了下列這些功能

  1. 可以產生題目 generateLevelData()
  2. 可以循序播放題目音訊 playLevelSound(levelData, blocks)
  3. 可以播放答對或答錯的音效 playSoundEffect(type)
  4. 播放題目或答對答錯的時候,方塊可以閃爍 flashBlock()
  5. 答對或答錯判斷 answerVerify(answer, levelData)

今天我們要把流程串起來

流程控制

修改開始參數

首先,記得在 Day27 開始遊戲的時候,我們按下開始遊戲按鈕,會把 isGameStart 參數從 false 變成 true。

防止玩家亂按

接著,開始遊戲的同時,我們要播放一個答對的音效來告訴玩家遊戲開始了,然後就會馬上開始播放題目,播放題目的期間玩家縱使隨便按,也無法答題,等到題目播放完之後,再開放給玩家答題,下面是我們點擊 start 開始遊戲按鈕之後,會調用的函式 handleOnGameStart
src/containers/MemoryBlocks/index.js

handleOnGameStart = () => {
    const {
        levelData,
        blocks,
        isCorrect,
        handleSetInit,
        handleSetIsPlaying,
    } = this.props;
    handleSetInit();    // 把 isGameStart 參數從 false 變成 true
    handleSetIsPlaying(true); // 把 isPlaying 參數設為 true,表示題目播放中,不能亂按
    playSoundEffect(SOUND_EFFECT.correct); // 播放答對音效,表示遊戲開始
    flashAllBlocks(blocks, isCorrect);  // 播放答對音效的同時,所有方塊要亮起來
    setTimeout(() => {
        const finishedTime = playLevelSound(levelData, blocks); // 音效播完間隔2秒後開始播放題目
        setTimeout(() => {
            handleSetIsPlaying(false);  // 播放完題目之後,把 isPlaying 參數設為 false,玩家可以答題
        }, finishedTime);
    }, 2000);
}

我們剛剛有說,當題目以及音效播放時,把 isPlaying 參數設為 true ,表示玩家操作無效,等音樂和音效都播放完了,再把isPlaying 參數設為 false。所以,在 Day26 - 按下去有聲音之事件處理 的時候,我們要在按下方格所調用的函數 handleOnBlockClick 做一個判斷
src/containers/MemoryBlocks/index.js

handleOnBlockClick = (event) => {
    const {
        isPlaying,
        blocks,
        handleUpdateAnswer,
    } = this.props;
    if (isPlaying) { // 如果題目或是音效正在播放,則按下去無效,直接 return
        return;
    }
    const blockId = event.target.getAttribute('data-id'); // 取得 block id
    const audioObject = blocks.getIn([blockId, 'audio'])(); // 用 block id 取得對應的 Audio Object
    handleUpdateAnswer(parseInt(blockId, 10)); // 送出答案並記錄在 answer 陣列參數裡
    audioObject.currentTime = 0; // 播放歸零
    audioObject.play(); // 播放音訊
}

這樣的話,就可以防止玩家在音效還在播放的時候亂按而出錯了。
play-level-data

對錯判斷時的音樂及視覺效果

接著,我們昨天有做答對以及答錯的判斷,如果有任何一個音答錯,那就會播放答錯的音效,然後全部的方塊會閃紅色。那如果全部都答對,會播放答對的音效,全部的方塊也會亮起來,但是是亮方塊原本的顏色,表示過關。詳細實作方式及流程如下
src/containers/MemoryBlocks/index.js

componentDidUpdate(prevProps, prevState) {
    const {
        blocks,
        levelData,
        chance,
        isComplete,
        isCorrect,
        isPlaying,
        handleUpdateIsComplete,
        handleUpdateIsCorrect,
        handleSetGameRestart,
        handleSetIsPlaying,
    } = this.props;

    if (isPlaying) {
        return;
    }
    if (isComplete) { // 如果這一題全部都答對
        handleSetIsPlaying(true);   // 音效開始播放,isPlaying 設為 true
        handleUpdateIsComplete(false); // 進到下一題前,要重設 isComplete 參數
        setTimeout(() => {
            playSoundEffect(SOUND_EFFECT.correct); // 播放答對音效
            flashAllBlocks(blocks, isCorrect); // 全部方塊閃答對的燈
        }, 500);
        setTimeout(() => {
            const finishedTime = playLevelSound(levelData, blocks); // 播放下一題的題目
            setTimeout(() => {
                handleSetIsPlaying(false); // 播完題目之後,isPlaying 設為 false,玩家可以開始答題
            }, finishedTime);
        }, 3000);
    } else if (!isCorrect) { // 如果有任何一個音答錯
        handleSetIsPlaying(true);   // 音效開始播放,isPlaying 設為 true
        clearAllTimeouts(); // 清除目前所有的 setTimeout
        handleUpdateIsCorrect(true); // 重設 isCorrect參數
        setTimeout(() => {
            playSoundEffect(SOUND_EFFECT.wrong); // 播放答錯音效
            flashAllBlocks(blocks, isCorrect); // 全部方塊閃答錯的燈
        }, 500);
        setTimeout(() => {
            const finishedTime = playLevelSound(levelData, blocks); // 重新播放這一題的題目
            setTimeout(() => {
                handleSetIsPlaying(false); // 播完題目之後,isPlaying 設為 false,玩家可以開始答題
            }, finishedTime);
        }, 3000);
    }
}

補充說明一下,我們播放音樂的時候,是透過 setTimeout() 這個非同步的方法來幫我們實現,但是有時候我們需要清除目前所有正在進行的音效播放,例如答錯要重新答題的時候,所以這邊我客製了一個 clearAllTimeouts() 的方法來幫助我們清除目前所有的 setTimeout()。

這邊說明一下原理,每當我們執行一次 setTimeout() 的時候,都會回傳一個整數形態的 id,由於 id 會隨著 setTimeout() 被調用的增加而增加,所以我們就呼叫一次沒有做事情的 setTimeout() 取得目前最大的 id,然後把小於這個 id 的所有動作都清除
src/containers/MemoryBlocks/utils.js

export const clearAllTimeouts = () => {
    // clear "all" timeouts
    const biggestTimeoutId = window.setTimeout(function () { }, 1);
    for (let i = 1; i <= biggestTimeoutId; i++) {
        clearTimeout(i);
    }
};

清除所有的定时器

到這邊,我們就已經完成了答對以及答錯流程了。
play-efflect

回答進度條

接下來,我們要來時做我們的進度條,當題目越來越長的時候,我們有時候會忘記到底我們已經答對了幾個音,或是還要答對幾個音才能到下一題。所以我這邊希望能夠給玩家一個提示。

我們先新增一個 <Progress /> 元件,元件裡面有兩個輸入當作 props,一個是題目,一個是回答。在畫面部分,這一題有幾個音,那 <Progress /> 裡面就需要出現幾個小黑豆,那我們答對幾題,就讓其中幾個小黑豆亮起來,其實就只有這樣而已。

為了達到上面這個目標,我的做法是,因為 answer 這個陣列裡面只會存回答正確的答案,然後每一個答案的 index 會對應到 levelData 這個陣列的 index,所以這兩個陣列的關係是 answer 陣列永遠都是 levelData 的子集合,而且有順序性,也可以說 answer 陣列永遠都被包含於 levelData。所以說,answer 陣列裡面就是需要亮起來的那幾個小黑豆,也就是會變成小亮豆。因此我的做法是,跑一次 levelData 的迴圈,其中每一個元素的 index 若小於 answer 的大小,就表示是已經回答的正確答案,就讓它亮起來,其他的則保持小黑豆。

程式碼如下

src/containers/MemoryBlocks/components/Progress/index.js

const Progress = ({ levelData, answer }) => {
    const progressNode = Array.from(Array(levelData.size), (value, index) => ({
        id: index,
        className: (index < answer.size) ? 'progress__node progress__node-active' : 'progress__node',
    }));
    return (
        <StyledProgress>
            {
                progressNode.map((node) => (
                    <div
                        key={node.id}
                        className={node.className}
                    />
                ))
            }
        </StyledProgress>
    );
};

progress

重新開始與重播按鈕

接下來我們要實作重新開始的按鈕及重播音樂按鈕,跟開始遊戲按鈕一樣,首先我們需要一個按鈕。這個按鈕在 Day23 頁面佈局的時候,我們已經給為這兩個按鈕留了在右下角的位置。
src/containers/MemoryBlocks/index.js

{
    isGameStart &&
    <div className="memory-blocks__group-btn-wrapper">
        <button
            className="memory-blocks__hint-btn memory-blocks__font-music"
            onClick={this.handleOnReplaySound}
        >
            重播
        </button>
        <button
            className="memory-blocks__restart-btn"
            onClick={this.handleOnGameRestart}
        >
            Restart
        </button>
    </div>
}

然後因為我不希望在遊戲開始之前就讓玩家去按這兩個按鈕,所以我希望在按下開始遊戲之後, isGameStart 這個參數被設為 true 了,這兩個按鈕才會出現。

這兩個按鈕我用一個 div tag 當作外容器來包住,目的是為了幫助裡面這兩個按鈕定位,所以我把外容器宣告成 display: flex; 並且讓他靠右對齊,然後橫向排列。
src/containers/MemoryBlocks/Styled.js

.memory-blocks__group-btn-wrapper {
    display: flex;
    justify-content: flex-end;
    margin-top: 10px;
    .memory-blocks__hint-btn {
        margin-right: 10px;
        ${buttonMixin()}
    }
    .memory-blocks__restart-btn {
        ${buttonMixin()}
    }
}

然後可以注意到,這兩個按鈕我是套用跟開始按鈕一樣的 buttonMixin() 樣式,所以樣式會跟開始按鈕一致,都是圓圓像膠囊一樣的按鈕。
button-layout

重新開始按鈕功能

接下來,我們先來實作重新開始遊戲按鈕的功能,一樣,我讓這個按鈕 onClick 的時候調用一個名為 handleOnGameRestart() 的函數,這個函數會執行我們重新開始遊戲所需要的所有動作。
src/containers/MemoryBlocks/index.js

handleOnGameRestart = () => {
    const {
        handleSetGameRestart,
    } = this.props;
    clearAllTimeouts(); // 暫停並清除目前所有的 setTimeout
    handleSetGameRestart(); // 重設所有參數並重新開始遊戲
}

透過 handleSetGameRestart() 發一個 action 到 reducer 之後,我們 reducer 所要做的事情如下
src/containers/MemoryBlocks/reducer.js

case SET_RESTART_GAME: {
    return initialState
        .set('isGameStart', false)
        .set('levelData', fromJS(generateLevelData(DEFAULT_LEVEL, DEFAULT_SIDE_LENGTH)));
}

這邊做兩件事,一個當然是把 isGameStart 這個參數設為 false,另一件事情,就是我們要產生新的題目。
restart

重播音樂功能

再來,我們要實作重播題目音樂的功能,我們希望重播按鈕按下去之後,可以重播那一題的題目。跟重新開始遊戲一樣,我們按下重播按鈕的時候,調用了一個函式,這個函數我命名為 handleOnReplaySound() ,在這個函式裡,我們所要做的事情如下
src/containers/MemoryBlocks/index.js

handleOnReplaySound = () => {
    const {
        levelData,
        blocks,
        chance,
        handleSetReplaySound,
        handleSetIsPlaying,
    } = this.props;
    if (!chance) {
        return;
    }
    clearAllTimeouts(); // 重播遊戲之前,先清空目前所有可能在播放的音樂以及任何setTimeout的動作
    handleSetIsPlaying(true); // 音效開始播放,isPlaying 設為 true
    setTimeout(() => {
        const finishedTime = playLevelSound(levelData, blocks); // 開始重播題目音樂
        setTimeout(() => {
            handleSetIsPlaying(false); // 重播完之後,把 isPlaying 設為 true,使用者可以開始動作
        }, finishedTime);
    }, 500);
}

這樣我們就可以順利重播題目啦!
repeat

參考程式碼 & 遊戲展示

Memory Blocks - Github


上一篇
Day28 - 記憶方塊篇:音樂播放及對錯判斷
下一篇
Day30 - 記憶方塊篇:難度控制 & 完賽感言
系列文
以經典小遊戲為主題之ReactJS應用練習30

尚未有邦友留言

立即登入留言