iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 17
3

想一想,決定食物如何出現

俗話說,吃飯皇帝大,如果不吃東西,貪吃蛇就長不大,也跑不快,所以今天我們要來製作貪吃蛇的另一個重頭戲,就是食物

首先,我們要來決定食物出現的位置,這邊我沒有特別做什麼,就是讓食物隨機出現在地圖上,僅此而已。不過不受限的隨機出現,有可能會出現一個特別的現象,特別是當蛇越來越大條的時候,食物的位置很有可能會跟蛇的位置重疊,所以要等蛇爬過去之後才會看到食物。

不過這個部分就看各位玩家或開發者自己來決定,其實我自己覺得食物跟蛇的位置重疊也沒有什麼不好,而且這樣也多了一分驚喜,也多了一分難度,實際上玩過之後我覺得不會影響遊戲進行。反之,若食物跟蛇不能重疊,這樣蛇越來越長的時候,食物能夠出現的位置越少,所以蛇就越容易吃到食物。另一方面也是因為我不想要為了讓食物的位置避開蛇的位置而多寫很多判斷式,所以這邊我決定從簡來做,但如果大家覺得有必要讓食物避開蛇的位置,也可以自行調整。

產生食物

設計食物物件

食物的本質也很單純,就是一個紀錄 xy 位置的物件,如下:

const createFood = () => ({
   x: Math.floor(Math.random() * GAME_WIDTH),
   y: Math.floor(Math.random() * GAME_WIDTH),
});

Math.random() 函數會返回一個範圍在 [0, 1) 的浮點數,GAME_WIDTH 是我們地圖的大小,這邊是 30 ,Math.floor() 函式會回傳無條件捨去後的最大整數,所以上面的意思就是說,我希望在這個地圖的範圍內任選一個地方產生食物。

那要在什麼時機產生食物呢?

  1. 遊戲一開始初始的時候,
  2. 當食物被蛇吃掉的時候。

所以如下,初始的時候我們需要產生一個食物
containers/SnakeGame/reducer.js

const initialState = fromJS({
   blocks: defaultBlocks,
   snake: defaultSnake,
   food: createFood(),
});

在遊戲地圖上畫出食物

產生食物之後,我們要把食物畫在畫面上,作法跟 Day15 把蛇的頭畫在畫面上,以及 Day16 把蛇的身體畫在畫面上 是一樣的道理,

containers/SnakeGame/index.js

const updateGameView = (snake, block, food) => {
   //draw snake head
   if (snake.getIn(['headPosition', 'x']) === block.get('x') &&
       snake.getIn(['headPosition', 'y']) === block.get('y')) {
       return 'snake-game__map-block-item snake-game__draw-snake-body';
   }
   const snakeBody = snake.get('body');
   if (snakeBody.size > 1) { // body 裡面有東西才需要畫身體
       const found = snakeBody.find((bodyPos) => {
           return bodyPos.get('x') === block.get('x') &&
               bodyPos.get('y') === block.get('y');
       });
       // draw snake body
       if (found) { // 若方格的位置等於身體的位置,就把身體畫出來,塗成白色
           return 'snake-game__map-block-item snake-game__draw-snake-body';
       }
   }
   // draw food
   if (block.get('x') === food.get('x') &&
       block.get('y') === food.get('y')) {
       return 'snake-game__draw-snake-food';
   }
   return 'snake-game__map-block-item';
};

成功畫出食物之後,會看到下面這樣的成果:
create-food

給食物加上炫砲的特效

當然食物還有很重要一點,就是要讓他看起來很可口,讓人很想吃,為了讓時物看起來很誘人,我加了一些動畫在上面,在 styled-components 裡面可以直接使用 keyframes 來製作動畫。
containers/SnakeGame/Styled.js

import styled, { keyframes } from 'styled-components';
const pulse = keyframes`
   0% {
       -moz-box-shadow: 0 0 0 0 red;
       box-shadow: 0 0 0 0 red;
   }
   70% {
       -moz-box-shadow: 0 0 0 20px rgba(204,169,44, 0);
       box-shadow: 0 0 0 20px rgba(204,169,44, 0);
   }
   100% {
       -moz-box-shadow: 0 0 0 0 rgba(204,169,44, 0);
       box-shadow: 0 0 0 0 rgba(204,169,44, 0);
   }
`;
.snake-game__draw-snake-food {
    background: red;
    border-radius: 100%;
    animation: ${pulse} 2s infinite;
}

snakeGame__draw-food
到這邊我們就完成初始化的時候產生食物了。

讓蛇吃下食物

接下來我們要讓蛇吃下食物,並且吃完之後也要產生另一個食物在隨機的位置上。

所以我們先來解決讓蛇吃下食物。蛇吃下食物的判斷很直覺,就是頭的位置跟食物的位置重疊,就表示蛇吃了食物,蛇吃下食物的時候,有三件事情要做

  1. 創造一個新的食物,放在隨機位置
  2. 身體要變長,所以蛇的最大長度 maxLength 要增加
  3. 速度要變快,所以 snake 的 speed 值要變小,因為 speed 決定 setInterval() 的週期

擷取部分程式碼,示意如下:
containers/SnakeGame/reducer.js

const food = state.get('food');
const isEatFood = food.get('x') === headPositionX && food.get('y') === headPositionY;
return state
    // create new food
    .updateIn(['food'], (food) => {
        if (isEatFood) {
            return fromJS(createFood());
        }
        return food;
    })
    // update snake maxLength
    .updateIn(['snake', 'maxLength'], (maxLength) => {
        if (isEatFood) {
            return maxLength + 1;
        }
        return maxLength;
    })
    // update snake speed after eating food
    .updateIn(['snake', 'speed'], (speed) => {
        if (isEatFood) {
            const updatedSpeed = (speed - SNAKE_DELTA_SPEED) > SNAKE_LIMITED_SPEED ? (speed - SNAKE_DELTA_SPEED) : SNAKE_LIMITED_SPEED;
            return updatedSpeed;
        }
        return speed;
    })

由於我們一開始把讓蛇移動的 setInterval() 寫在生命週期的 componentDidMount 裡面,但是由於 componentDidMount 是只有在當元件被寫入 DOM 的時候會執行,通常初始化需要的操作,或是有需要進行 Ajax 非同步處理的時候會在這邊進行。

不過當我們更新蛇的速度的時候,componentDidMount 並不會被執行到,所以如果把 setInterval() 寫在這邊的話,會沒有辦法讓蛇吃到食物之後,更新他的速度。

所以這邊我想要把 setInterval() 寫到 componentDidUpdate 裡面
componentDidUpdate() is invoked immediately after updating occurs. This method is not called for the initial render.

因為他會在每次更新後觸發,剛好符合我們的需求,但是我只希望初始的時候和更新蛇的速度的時候執行一次 setInterval() ,而不是每次有任何的參數更新的時候都需要執行到這一段。所以我這邊另外又設計一個參數 isSpeedModified 來當作旗標 flag 的功能,好像一個閘門一樣,需要的時候就進來更新並執行,不需要的時候就跳過去。

所以我們需要在剛剛的地方設一個參數,我命名為 isSpeedModified ,如果是 true 的話,就清掉之前的,重新執行一次 setInterval() ,如果是 false ,就不重複執行。
所以這邊如果蛇吃到食物並更新速度的話,就需要把 isSpeedModified 設成 true,讓他重新執行一次 setInterval() ,這樣才會變快,執行完之後就要馬上把 isSpeedModified 設為 false,避免沒有必要的重複執行。

containers/SnakeGame/reducer.js

// update isSpeedModified
.updateIn(['isSpeedModified'], (isSpeedModified) => {
    if (isEatFood) {
        return true;
    }
    return false;
});

containers/SnakeGame/index.js

componentDidUpdate(prevProps, prevState) {
    const {
        isSpeedModified,
    } = prevProps;
    const {
        snake,
        handleOnSetSnakeMoving,
        handleOnSetSpeedModified,
    } = this.props;
    if (isSpeedModified) { // to udpate speed
        handleOnSetSpeedModified(false);
        clearInterval(gameInterval);
        gameInterval = setInterval(() => {
            handleOnSetSnakeMoving()
        }, snake.get('speed'));
    }
}

我們在下面幾種狀況的時候會需要清掉並重設 setInterval()

  1. 蛇吃到東西要更新速度的時候,需要用 clearInterval() 清除前一次的 setInterval(),然後再用新的速度來重新執行一次 setInterval()。
  2. 遊戲開始的時候,我們需要執行一次 setInterval()。
  3. 遊戲暫停的時候我們需要執行 clearInterval() 讓蛇不要再動,直到遊戲繼續的時候,需要執行一次 setInterval()。

第一點是我們這次要做的工作,後面兩點是我們之後要做的工作。
備註一下,因為我們現在還沒做開始按鈕,所以雖然我們說把 setInterval() 換到 componentDidUpdate ,不過在實作開始遊戲按鈕之前,還是要在 componentDidMount 的時候初始化一下,執行一次 setInterval() ,因為 componentDidUpdate 在一開始的時候是不會執行的。

React Component 規格與生命週期(Life Cycle)

今日成果

到這邊為止,我們的成果如下,基本上就是一個像樣的貪吃蛇啦!
snakeGame__eat-food


參考程式碼

Snake - Github


上一篇
Day16 - 貪吃蛇篇:加入蛇的身體
下一篇
Day18 - 貪吃蛇篇:吃到自己會死
系列文
以經典小遊戲為主題之ReactJS應用練習30

尚未有邦友留言

立即登入留言