iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 28
1
Modern Web

以經典小遊戲為主題之ReactJS應用練習系列 第 28

Day28 - 記憶方塊篇:音樂播放及對錯判斷

記憶方塊是結合視覺和聽覺的記憶遊戲,今天我們要來做音樂的播放以及對錯的判斷。

產生題目

首先,我們要來產生題目並且播放題目,記得在 Day22 的時候我們有說明過 levelData 這個參數,這個參數用一個由數字組成的陣列來表示本關的題目,假設題目是 [1, 2, 2] ,裡面的數字代表相對應的方塊的 id ,所以回答也需要按照順序按下 [1, 2, 2] 才算答對。

levelData: generateLevelData(DEFAULT_LEVEL, DEFAULT_SIDE_LENGTH),

產生題目的方式是隨機產生,但是要特別注意隨機產生的範圍,假設我們現在是 2x2 的方塊,所以方塊的 id 是 0 ~ 3 ,因此所產生的題目必須要在這個範圍之內,依此類推。

generateLevelData() 是我們產生題目的函數,有兩個輸入一個輸出,輸入是 level 以及 sideLength,輸出是一個數字陣列,就是我們的題目。需要輸入 level 是因為我們在 Day22 有說隨著關卡的增加,播放的音符數量也會越來越多,也就是題目會越來越長,越來越難記。另外需要 sideLength 當作輸入,就是前面那個理由,產生的題目需要在範圍內,所以我們需要知道目前是幾乘幾的方塊。

generateLevelData() 的實作如下:
src/containers/MemoryBlocks/utils.js

export const generateLevelData = (level, sideLength) => {
    const maxNote = sideLength * sideLength; // 取得題目的範圍,也就是 id 不能超過這個數字
    const numOfNote = level + sideLength; // 決定題目長度,關卡越後面,題目越長
    const levelData = Array.from(Array(numOfNote), (value, index) => Math.floor(Math.random() * maxNote)); // 以隨機的方式在範圍內產生出題目
    return levelData;
};

播放題目

有了題目之後,我們就可以播放題目了。
播放題目的方法如下

const finishedTime = playLevelSound(levelData, blocks);

playLevelSound() 這個播放題目的方法有兩個輸入和一個輸出,第一個輸入當然是我們透過前面 generateLevelData() 這個方法所產生出來的 leelData ,也就是本關卡的題目。第二個輸入是 blocks ,因為我們播放音樂的時候是用 id 去對應相對的音訊檔,levelData 陣列裡面的數字其實就是我們的 block id,而 blocks 就是存放我們每一個 block 的 Audio Object。

然後輸出是 finishedTime ,這個參數比較特別,我會需要設計這個輸出的參數是因為在 playLevelSound() 裡面播放音樂是透過 setTimeout() 來播放的,因此是非同步的,白話來說就是我們在播放音樂的同時,使用者還是可以做其他的操作,不需要等到音樂播放完畢才能夠有所動作,但是考慮到未來我們在答題的時候,我希望是音樂整個播放完之後再讓玩家答題,而不要音樂還在播放,還沒播完就讓玩家在那邊亂按,為了避免這樣的狀況,我希望知道播完這道題目需要多少時間,等到播完之後,再讓玩家的操作有效,所以我這邊才會需要取得 finishedTime ,也就是題目播放的時間長度。

接下來說明一下 playLevelSound() 的實作
src/containers/MemoryBlocks/utils.js

export const playLevelSound = (levelData, blocks) => {
    levelData.forEach((blockId, index) => {
        setTimeout(() => {
            const audioObject = blocks.getIn([blockId.toString(), 'audio'])();
            audioObject.currentTime = 0;
            audioObject.play();
            flashBlock(blockId, true);
        }, 500 * index);
    });
    return levelData.size * 500;
};

播放音樂的方式當然就是一個一個依序播放,我這邊希望每個聲音的間隔是 0.5 秒,也就是 500 ms ,但是因為 javascript 裡面沒有 sleep() 這個方法,所以我想說用 setTimeout() 來播放音樂,不過前面有提到,setTimeout() 是非同步的,所以如果播放聲音的調用時間都是 500 ms ,那就會變成等 500 ms 之後,全部的音一起播放,這個就不是我們想要的效果。

所以為了達到每隔 500 ms 播放一個音,我這裡使用了一點黑魔法,由於我們可以取得 levelData 每一個音的 index ,因此我們可以把間隔時間做累加,如果不想要 500 ms 之後全部一起播放,我們就要把每個音播放的時間隔開,那就是 500 ms 的時候播第一個音,然後再等 500 ms ,也就是 500 + 500 ms 播放第二個音,第三個音就是 500 + 500 + 500 ,依此類推,我們就可以用 500 * index 來做出循序播放的效果。

因此,我們也就可以計算出前面提到的 finishedTime ,就是把 levelData 這個陣列的長度乘上播放間隔 500 ms。

製作音效

能夠依序播放題目的音訊之後,我們要來製作答對以及答錯音效,當然這些音效也可以播放來自其他來源的單一個音訊檔。但是這邊我想要用現有的鋼琴聲音檔同時播放來製造出和聲的效果,來當作我們的音效。

下面就是我們答對和答錯的音效
src/containers/MemoryBlocks/constants.js

export const CHORD = {
    correct: [1, 3, 5, 8].map((note) => new Audio(PIANO_SOUNDS_URL + note + '.wav')),
    wrong: [2, 4, 5.5, 7].map((note) => new Audio(PIANO_SOUNDS_URL + note + '.wav')),
};

我這邊用 C大調 的 大三和弦 來當作我的答對音效,簡譜記法就是 1, 3, 5, 8 ,也就是 Do, Mi, Sol, Do,在音樂裡面, Do 和 Mi 是大三度,而 Do 和 Sol 是完全五度,聽起來給人一種很和諧的感覺,因此我把它當成答對的音效。

然後答錯的音效我給他一個不和諧的和聲,Re, Fa, #Sol, Si,這個不和諧的和聲聽起來有點刺耳,聲音跟聲音之間有打架的感覺,好像小朋友彈鋼琴的時候按錯或亂按一樣,所以我覺得很適合當作答錯時的音效。

播放音效的時候,就是讓這些我們已經選定的音同時播放即可,因此跟上面循序播放題目很像,只是這邊我們就不需要用到 setTimeout() 了,因為我們要他同時一起播放。playSoundEffect() 只有一個輸入,type 有可能是 correct 或是 wrong ,隨著 type 是什麼,就會播放出答對的音效和答錯的音效。
src/containers/MemoryBlocks/utils.js

export const playSoundEffect = (type) => {
    const soundSet = CHORD[type];
    soundSet.forEach((audioObject) => {
        audioObject.currentTime = 0;
        audioObject.play();
    });
};

方塊閃爍

接下來我們要來處理視覺效果,在前面 playLevelSound() 的時候我們已經可以循序播放題目音訊檔了,但是我希望播到哪個音的時候,那個方塊就會亮起來再暗掉,所以聲音和亮起來的特效會同時進行。

亮起來的方法是 flashBlock(blockId, isCorrect) ,他有兩個輸入,第一個是 block id ,因為我們要指定 id 的 block 改變 CSS 樣式,讓他有亮起來的效果,再來第二個輸入是亮起答對的顏色還是答錯的顏色,但是因為這邊我們播放題目音樂沒有所謂的答對答錯,所以這裡 isCorrect 的這個參數我們就是預設他是答對的。
src/containers/MemoryBlocks/utils.js

export const flashBlock = (id, isCorrect) => {
    const blockActiveColor = isCorrect ? 'block__block-item-active' : 'block__block-item-active-wrong';
    document.getElementById(`block-${id}`).classList.add(blockActiveColor);
    setTimeout(() => {
        document.getElementById(`block-${id}`).classList.remove(blockActiveColor);
    }, 200);
};

這邊其實也很直白,答對的話,就是塞入亮起來的 CSS 樣式,然後 200 ms 之後再撤掉,製作一種一閃而過的感覺,亮起來的樣式如下
src/containers/MemoryBlocks/components/Block/Styled.js

.block__block-item-active {
    ${(props) => {
        const id = props.blockId;
        return `
            animation: none;
            background: ${BLOCK_COLORS[id]};
            box-shadow: 0px 0px ${SHADOW_WIDTH}px 7px ${BLOCK_COLORS[id]};
        `;
    }}
    transition: 0s;
}

另一方面,答錯的時候也會亮起來,只是亮起來的顏色我希望是答錯的顏色,我這邊給他紅色,紅色給人一種警告和錯誤的提示
src/containers/MemoryBlocks/components/Block/Styled.js

.block__block-item-active-wrong {
    ${(props) => {
        const RED = BLOCK_COLORS[0];
        return `
            animation: none;
            background: ${RED};
            box-shadow: 0px 0px ${SHADOW_WIDTH}px 7px ${RED};
        `;
    }}
    transition: 0s;
}

答對或答錯判斷

最後,我們要來判斷答題的對錯,在 Day26 我們已經把玩家點擊的 block id 存進 answer 裡面了,接下來就是要逐一比對是否答對

所以第一步,我們要把點擊到的 block id 塞進 answer 裡面,然後,我們就來進行比對。

src/containers/MemoryBlocks/reducer.js

const updatedAnswer = state.get('answer').push(action.payload);
const isCorrect = answerVerify(updatedAnswer, levelData);

answerVerify() 是我們判斷對錯的方法,有兩個輸入一個輸出,兩個輸入當然一個就是玩家答題,另一個就是題目,也就是正確答案。輸出的話就是正確或答錯,正確就是 true,答錯就是 false。

接下來,是我們比對答案的實作,邏輯就是,用一個迴圈來循序比對 answer 每個元素是否跟 levelData 相同,若全部相同,則回傳 true,否則回傳 false。
src/containers/MemoryBlocks/reducer.js

const answerVerify = (answer, levelData) => {
    let isCorrect;
    answer.forEach((note, index) => {
        if (note === levelData.get(index)) {
            isCorrect = true;
        } else {
            isCorrect = false;
        }
    });
    return isCorrect;
};

假設這一題有三個音,分別是 [0, 1, 3] ,如果有任何一個音答錯,我們會把 isCorrect 設為 false ,請且清空 answer 陣列,讓玩家重新答題。假設答對一個音,就會把那個存進 answer 裡面。
src/containers/MemoryBlocks/reducer.js

if (isCorrect) {
    // if correct
    return state.set('answer', updatedAnswer);
} else {
    // if wrong
    return state
        .set('isCorrect', false)
        .set('answer', List());
}

那如果全部都答對了,我們就會把 isComplete 參數從 false 設為 true。用這個參數我們可以來判斷這一題是否答題正確,然後可以進入下一題
src/containers/MemoryBlocks/reducer.js

if (isCorrect && (updatedAnswer.size === levelData.size)) {
    // if correct and complete
    const updatedLevel = level + 1;
    const updatedSideLength = (sideLength + 1) > MAX_SIDE_LENGTH ? MAX_SIDE_LENGTH : (sideLength + 1);
    return state
        .set('isComplete', true) // 表示這題全部的音都答對
        .set('level', updatedLevel) // 關卡 + 1
        .set('levelData', fromJS(generateLevelData(updatedLevel, sideLength))) // 產生新的題目
        .set('answer', List()); // 重設使用者回答
}

今日總結

總結一下我們今天做的功能

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

今天我們做了很多功能,內容有點多,但是今日的準備是為了明日的功能鋪路。明天我們會把整個流程串起來,並且會用到今天我們實現的功能,所以今天就不 Demo 了,明天我們把流程都做完整之後,再一次來 Demo。

參考程式碼 & 遊戲展示

Memory Blocks - Github


上一篇
Day27 - 記憶方塊篇:開始遊戲
下一篇
Day29 - 記憶方塊篇:遊戲關卡控制
系列文
以經典小遊戲為主題之ReactJS應用練習30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言