只使用HTML 和 CSS,JavaScript開發貪食蛇遊戲

歡迎上車。今天我們將開始一場激動人心的冒險,在這裏我們將開發屬於我們自己的貪食蛇遊戲。通過將其分解爲一個個簡短的步驟來學習如何解決問題。在這段旅程結束時,你會學到一些新東西,並且有信心能獨立探索更多。

開始吧

首先新建一個文件“snake.html”,它將包含全部代碼。

因爲這是一個 HTML 文件,所以要做的第一件事就是申明 <!DOCTYPE>。請在 snake.html 文件中輸入以下內容:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>

  <body>
    Welcome to Snake!
  </body>
</html>

不錯,接下來在你最喜歡的瀏覽器中打開 snake.html。你應該能夠看到 Welcome to Snake!。

創建畫布

爲了完成遊戲,我們需要用到 <canvas> 標籤,並藉助 JavaScript 繪製圖像。

用以下代碼來替換 snake.html 裏的歡迎語。

<canvas id="gameCanvas" width="300" height="300"><canvas>

id 用於標識畫布且始終應該被指定。稍後我們會用它來訪問畫布。width 和 height 分別是畫布的寬和高,同樣也需要被指定。在本例中,畫布尺寸爲 300 px * 300 px。

snake.html 文件此時應該是這樣的:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>
  </body>
</html>

如果你刷新頁面,你會發現頁面一片空白。這是因爲默認情況下,畫布是空的並且沒有背景。讓我們來調整下。

爲畫布添加背景色和邊框

爲了看到畫布,我們可以寫一段 JavaScript 代碼給它加一個邊框。爲此,我們需要在 </canvas> 後添加 <script></script> 標籤,並在其中編寫 JavaScript 代碼。

如果你把 <script> 標籤放在了 <canvas> 標籤前面,那麼你的 JavaScript 代碼將不會生效,因爲此時 HTML 還未加載完。

現在我們來寫一些 JavaScript 代碼,寫在閉合的 <script></script> 標籤之間。代碼如下:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";

      // 獲取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一個二維繪圖上下文
      var ctx = gameCanvas.getContext("2d");
      // 選擇畫布的背景顏色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      // 選擇畫布的邊框顏色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 繪製一個“實心的”長方形來覆蓋整個畫布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 繪製畫布的“邊框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
    </script>
  </body>
</html>

首先,使用前面指定的 id(gameCanvas)獲取 canvas 元素。然後獲取畫布的“2d”上下文,這意味着我們將在 2D 空間繪製圖像。

最後,我們畫了一個 300 x 300 的白色矩形,邊框爲黑色。這個矩形從左上角(0,0)開始覆蓋了整個畫布。

如果你在瀏覽器中重新加載 snake.html,你會看到一個帶黑色邊框的白塊!幹得漂亮,現在我們已經有了一個畫布,可以用來創建我們的貪食蛇遊戲了!

用座標來表示貪食蛇

爲了讓遊戲能玩,我們需要知道蛇在畫布中的位置。爲此,我們用一系列座標來表示蛇。因此,要在畫布中間(150,150)畫一條橫向的蛇,我們可以這樣寫:

let snake = [
  {x: 150, y: 150},
  {x: 140, y: 150},
  {x: 130, y: 150},
  {x: 120, y: 150},
  {x: 110, y: 150},
];

注意蛇所有部位的 y 座標都是 150。每一部位的 x 座標比(左邊)前一個部位多 10 px。數組中的第一對座標 {x: 150, y: 150} 表示蛇頭,位於蛇的最右邊。

別急,在下一步我們畫蛇的時候,會對此有更清晰的認識。

開始畫蛇

爲了畫蛇,我們可以寫一個函數爲蛇身上的每一個部位畫一個矩形。

function drawSnakePart(snakePart) {
  ctx.fillStyle = 'lightgreen';
  ctx.strokestyle = 'darkgreen';
  ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
  ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
}

接下來,我們用另一個函數在畫布上把蛇展示出來。

function drawSnake() {
  snake.forEach(drawSnakePart);
}

此時 snake.html 文件應該是這樣的。

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";
      const SNAKE_COLOUR = 'lightgreen';
      const SNAKE_BORDER_COLOUR = 'darkgreen';

      let snake = [
        {x: 150, y: 150},
        {x: 140, y: 150},
        {x: 130, y: 150},
        {x: 120, y: 150},
        {x: 110, y: 150}
      ]

      // 獲取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一個二維繪製上下文
      var ctx = gameCanvas.getContext("2d");
      //  選擇畫布的背景顏色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  選擇畫布的邊框顏色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 繪製一個“實心的”長方形來覆蓋整個畫布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 繪製畫布的“邊框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
      drawSnake();

      /**
       * 在畫布上畫蛇
       */
      function drawSnake() {
        // 循環遍歷蛇的每一部分,並將其繪製到畫布上
        snake.forEach(drawSnakePart)
      }
      /**
       * 在畫布上畫蛇的一個部分
       * @param { object } snakePart —— 需要繪製的部位的所在座標
       */
      function drawSnakePart(snakePart) {
        // 設置蛇身體的背景顏色
        ctx.fillStyle = SNAKE_COLOUR;
        // 設置蛇身的邊框色
        ctx.strokestyle = SNAKE_BORDER_COLOUR;
        // 在蛇身座標所在的位置,繪製“實心”的矩形以表示蛇    
        ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
        // 繪製蛇身的邊框
        ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
      }
    </script>
  </body>
</html>

刷新頁面,你會發現畫布中間有一條綠色的蛇。

讓貪食蛇橫向移動

接下來我們想要讓蛇能動起來。

嗯,爲了讓蛇能夠一步一步(10 px)移動到最右邊,我們可以把蛇的每個部位的 x 座標,每次增加 10 px(dx = +10 px)。同理,爲了讓蛇移動到最左邊,每次把蛇的每個部位的 x 座標減少 10 px(dx = -10)。

dx 是蛇的橫向移動速度。

蛇向右移動了 10 px 後,座標如下圖所示:

[圖片上傳失敗...(image-21b4ae-1546593847219)]

用 advanceSnake 函數來更新蛇的狀態。

function advanceSnake() {
  const head = {x: snake[0].x + dx, y: snake[0].y};
  snake.unshift(head);
  snake.pop();
}

首先,我們爲蛇畫了個新頭。然後使用 unshift 方法將新頭放在蛇的第一個部位,然後使用 pop 方法移除蛇的最後一部分。這樣以後,如上圖所示,所有蛇身上其他部位都移動到了對應位置。

讓貪食蛇縱向移動

爲了讓蛇能夠上下移動,我們不能直接將蛇的所有 y 座標調整 10 px。那會讓整條蛇上下移動。

相反,我們可以調整蛇頭的 y 座標。減少 10 px 蛇會向下移動,增加 10 px 蛇會向上移動。這樣就能讓蛇正確地移動了。

幸運的是,因爲我們編寫的 advanceSnake 函數很棒,所以這很容易做到。在 advanceSnake 函數中,更新 head 讓 y 座標隨着 dy 變化。

const head = {x: snake[0].x + dx, y: snake[0].y + dy};

爲了驗證 advanceSnake 函數是否正確,我們可以臨時性地在 drawSnake 函數前調用它。

// 向右走一步
advanceSnake()
// 水平速度改爲 0
dx = 0;
// 垂直速度改爲 10
dy = -10;
// 向上走一步
advanceSnake();
// 在畫布上畫蛇
drawSnake();

現在 snake.html 代碼如下:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";
      const SNAKE_COLOUR = 'lightgreen';
      const SNAKE_BORDER_COLOUR = 'darkgreen';

      let snake = [
        {x: 150, y: 150},
        {x: 140, y: 150},
        {x: 130, y: 150},
        {x: 120, y: 150},
        {x: 110, y: 150}
      ]

      // 橫向移動速度
      let dx = 10;
      // 縱向移動速度
      let dy = 0;

      // 獲取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一個二維繪製上下文
      var ctx = gameCanvas.getContext("2d");
      //  選擇畫布的背景顏色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  選擇畫布的邊框顏色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 繪製一個“實心的”長方形來覆蓋整個畫布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 繪製畫布的“邊框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);

      // 向右走一步
      advanceSnake()
      // 水平速度改爲 0
      dx = 0;
      // 垂直速度改爲 10
      dy = -10;
      // 向上走一步
      advanceSnake();
      // 在畫布上畫蛇
      drawSnake();

      /**
        * 根據蛇的水平移動速度改變蛇的 x 座標,
        * 根據蛇的垂直移動速度改變蛇的 y 座標
        */
      function advanceSnake() {
        const head = {x: snake[0].x + dx, y: snake[0].y + dy};
        snake.unshift(head);
        snake.pop();
      }

      /**
       * 在畫布上畫蛇
       */
      function drawSnake() {
        // 循環遍歷蛇的每一部分,並將其繪製到畫布上
        snake.forEach(drawSnakePart)
      }
      /**
       * 在畫布上畫蛇的一個部分
       * @param { object } snakePart —— 需要繪製的部位的所在座標
       */
      function drawSnakePart(snakePart) {
        // 設置蛇身體的背景顏色
        ctx.fillStyle = SNAKE_COLOUR;
        // 設置蛇身的邊框色
        ctx.strokestyle = SNAKE_BORDER_COLOUR;
        // 在蛇身座標所在的位置,繪製“實心”的矩形以表示蛇    
        ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
        // 繪製蛇身的邊框
        ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
      }
    </script>
  </body>
</html>

刷新頁面,蛇動了!

clipboard.png

自己是一個五年的前端工程師

這裏推薦一下我的前端學習=交流=q==u=n=:731771211,裏面都是學習前端的,從最基礎的HTML+CSS+JS【炫酷特效,遊戲,插件封裝,設計模式】到移動端HTML5的項目實戰的學習資料都有整理,送給每一位前端小夥伴,歡迎加入。

點擊:加入

重構代碼

在繼續下一步之前,讓我們重構一下現有代碼,將繪製畫布的代碼封裝在一個函數中。這有助於完成下一步。

代碼重構是重構現有計算機代碼的過程,同時不改變其外部行爲。
function clearCanvas() {
  ctx.fillStyle = "white";
  ctx.strokeStyle = "black";
  ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
}

讓貪食蛇自動移動

好的,既然我們已經成功地重構了代碼,那麼接下來讓貪食蛇能夠自動移動吧。

之前爲了測試 advanceSnake 函數是否正確,我們調用了它兩次。一次爲了讓蛇向右移動,一次爲了讓它向上移動。

因此如果想要讓蛇右移五次,那麼我們需要連續調用 advanceSnake() 函數五次。

clearCanvas();
advanceSnake();
advanceSnake();
advanceSnake();
advanceSnake();
advanceSnake();
drawSnake();

但是,如上所示連續五次調用 advanceSnake 函數會讓蛇一下子向前跳 50 px。

相反,我們想讓蛇看起來像是一步一步地前進。

爲此,我們用 setTimeout 在每次調用之間添加一個短暫的延時。我們還需要確保每次調用 advanceSnake 函數時都調用 drawSnake 函數。如果我們不這樣做,我們將無法看到蛇移動的中間步驟。

setTimeout(function onTick() {
  clearCanvas();
  advanceSnake();
  drawSnake();
}, 100);
setTimeout(function onTick() {
  clearCanvas();
  advanceSnake();
  drawSnake();
}, 100);
...
drawSnake();

注意我們同樣在每個 setTimeout 中調用了 clearCanvas() 函數。這是爲了移除蛇先前所有的位置,以免留下痕跡。

儘管如此,上述代碼仍然有問題。沒有任何代碼告訴程序它必須等待一個 setTimeout 完成後才能移動到下一個setTimeout。也就是說蛇仍然會在一個短暫延遲後向前跳 50 px。

要解決這個問題,我們必須將代碼包裝在函數中,每次只調用一個函數。

stepOne();

function stepOne() {
  setTimeout(function onTick() {
    clearCanvas();
    advanceSnake();
    drawSnake();
   // 調用第二個函數
   stepTwo();
  }, 100)
}
function stepTwo() {
  setTimeout(function onTick() {
    clearCanvas();
    advanceSnake();
    drawSnake();
    // 調用第三個函數
    stepThree();
  }, 100)
}
...

如何讓貪食蛇一直前進?我們可以改爲創建一個 main 函數並遞歸調用它,而不是創建無數個相互調用的函數。

function main() {
  setTimeout(function onTick() {
    clearCanvas();
    advanceSnake();
    drawSnake();
    // 再次調用 main 函數
    main();
  }, 100)
}

貪食蛇現在會一直向右移動。雖然一旦它到達畫布的盡頭,將繼續其無盡的旅程並進入未知的地方。我們將在適當的時候解決這個問題,耐心點年輕的朋友們

image

調整蛇的移動方向

我們的下一個任務是在按下任意方向鍵時更改蛇的移動方向。在 drawSnakePart 函數之後添加以下代碼。

function changeDirection(event) {
  const LEFT_KEY = 37;
  const RIGHT_KEY = 39;
  const UP_KEY = 38;
  const DOWN_KEY = 40;
  const keyPressed = event.keyCode;
  const goingUp = dy === -10;
  const goingDown = dy === 10;
  const goingRight = dx === 10;
  const goingLeft = dx === -10;
  if (keyPressed === LEFT_KEY && !goingRight) {
    dx = -10;
    dy = 0;
  }
  if (keyPressed === UP_KEY && !goingDown) {
    dx = 0;
    dy = -10;
  }
  if (keyPressed === RIGHT_KEY && !goingLeft) {
    dx = 10;
    dy = 0;
  }
  if (keyPressed === DOWN_KEY && !goingDown) {
    dx = 0;
    dy = 10;
  }
}

這沒有什麼特別的。我們檢查按下的鍵是否與其中一個方向鍵匹配。如果匹配到,我們如上所述改變垂直和水平速度。

請注意,我們還要檢查蛇是否往新預期方向的相反方向上移動。這是爲了防止我們的蛇掉頭,例如當蛇向左移動時按下右鍵。

要將 changeDirection 加到遊戲代碼中,可以在 document 上使用 addEventListener 來“監聽”是否有鍵被按下。然後我們隨着 keydown 事件調用 changeDirection 方法。接着在 main 函數之後添加以下代碼。

document.addEventListener("keydown", changeDirection)

你現在應該可以使用四個方向鍵更改蛇的方向。

接下來讓我們看看如何生成食物並讓我們的蛇長大。

爲蛇生成食物

爲了生成蛇食,我們必須生成一組隨機座標。我們可以使用輔助函數 randomTen 來生成兩個數字。一個用於 x 座標,一個用於 y 座標。

我們還必須確保食物不與蛇的位置重疊。如果重疊了,我們必須生成一個新的食物位置。

function randomTen(min, max) {
  return Math.round((Math.random() * (max-min) + min) / 10) * 10;
}
function createFood() {
  foodX = randomTen(0, gameCanvas.width - 10);
  foodY = randomTen(0, gameCanvas.height - 10);
  snake.forEach(function isFoodOnSnake(part) {
    const foodIsOnSnake = part.x == foodX && part.y == foodY
    if (foodIsOnSnake)
      createFood();
  });
}

然後我們需要寫一個在畫布上繪製食物的函數。

function drawFood() {
 ctx.fillStyle = 'red';
 ctx.strokestyle = 'darkred';
 ctx.fillRect(foodX, foodY, 10, 10);
 ctx.strokeRect(foodX, foodY, 10, 10);
}

最後我們可以在調用 main 之前調用 createFood。別忘了還要更新 main 函數以調用 drawFood 函數。

function main() {
  setTimeout(function onTick() {
    clearCanvas();
    drawFood()
    advanceSnake();
    drawSnake();
    main();
  }, 100)
}

讓蛇長大

讓蛇長大很簡單。更新 advanceSnake 函數,檢查蛇頭是否碰到了食物。如果碰到了,我們可以跳過移除蛇的最後部分這一操作,同時創建一個新的食物位置。

function advanceSnake() {
  const head = {x: snake[0].x + dx, y: snake[0].y};
  snake.unshift(head);
  const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
  if (didEatFood) {
    createFood();
  } else {
    snake.pop();
  }
}

記錄遊戲分數

爲了讓遊戲更有樂趣,我們還可以添加一個分數,當蛇吃食物時分數增加。

在聲明瞭 snake 後,創建一個新的變量 score,並將其設爲 0。

let score = 0;

接下來在畫布前添加一個 id 爲“score”的新 div,用於顯示分數。

<div id="score">0</div>
<canvas id="gameCanvas" width="300" height="300"></canvas>

最後更新 advanceSnake, 當蛇吃食物時,增加並顯示分數。

function advanceSnake() {
  ...
  if (didEatFood) {
    score += 10;
    document.getElementById('score').innerHTML = score;
    createFood();
  } else {
    ...
  }
}

勝利就在眼前了

結束遊戲

還剩下最後一部分,那就是結束遊戲。爲此創建一個函數 didGameEnd,當遊戲結束時返回 true,否則返回 false。

function didGameEnd() {
  for (let i = 4; i < snake.length; i++) {
    const didCollide = snake[i].x === snake[0].x &&
      snake[i].y === snake[0].y
    if (didCollide) return true
  }
  const hitLeftWall = snake[0].x < 0;
  const hitRightWall = snake[0].x > gameCanvas.width - 10;
  const hitToptWall = snake[0].y < 0;
  const hitBottomWall = snake[0].y > gameCanvas.height - 10;
  return hitLeftWall || 
         hitRightWall || 
         hitToptWall ||
         hitBottomWall
} 

首先,我們檢查蛇的頭部是否碰到蛇身上其他部分,如果碰到了那麼返回 true。

請注意,我們從索引值 4 開始循環。這有兩個原因:一個是如果索引爲 0,didCollide 則會立即判斷爲 true,導致遊戲結束。另一個是,蛇的前三個部分不可能相互接觸。

接下來我們檢查蛇是否在畫布上撞牆了,如果是,那麼返回 true,否則返回 false 。

現在,如果 didEndGame 返回 true,我們可以在主函數中提前返回,從而結束遊戲。

function main() {
  if (didGameEnd()) return;
  ...
}

snake.html 現在應該是這樣的:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <div id="score">0</div>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";
      const SNAKE_COLOUR = 'lightgreen';
      const SNAKE_BORDER_COLOUR = 'darkgreen';
      const FOOD_COLOUR = 'red';
      const FOOD_BORDER_COLOUR = 'darkred';
      let snake = [
        {x: 150, y: 150},
        {x: 140, y: 150},
        {x: 130, y: 150},
        {x: 120, y: 150},
        {x: 110, y: 150}
      ]
      // 玩家的分數
      let score = 0;
      // 橫向移動速度
      let dx = 10;
      // 縱向移動速度
      let dy = 0;
      // 獲取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一個二維繪製上下文
      var ctx = gameCanvas.getContext("2d");
      //  選擇畫布的背景顏色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  選擇畫布的邊框顏色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 繪製一個“實心的”長方形來覆蓋整個畫布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 繪製畫布的“邊框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 開始遊戲
      main();
      // 生成第一個食物位置
      createFood();
      // 按下任意一個鍵,都會調用 changeDirection
      document.addEventListener("keydown", changeDirection);
      function main() {
        if (didGameEnd()) return;
        setTimeout(function onTick() {
          clearCanvas();
          drawFood();
          advanceSnake();
          drawSnake();
          // Call main again
          main();
        }, 100)
      }
      /**
      * 設置畫布的背景色爲 CANVAS_BACKGROUND_COLOUR 
      * 並繪製畫布的邊框
      */
      function clearCanvas() {
        //  選擇畫布的背景顏色
        ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
        //  選擇畫布的邊框顏色
        ctx.strokestyle = CANVAS_BORDER_COLOUR;
        // 繪製一個“實心的”長方形來覆蓋整個畫布
        ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
        // 繪製畫布的“邊框”
        ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
      }
      /**
       * 當蛇撞牆或者蛇頭碰到蛇身的時候返回 true
       */
      function didGameEnd() {
        for (let i = 4; i < snake.length; i++) {
          const didCollide = snake[i].x === snake[0].x && snake[i].y === snake[0].y
          if (didCollide) return true
        }
        const hitLeftWall = snake[0].x < 0;
        const hitRightWall = snake[0].x > gameCanvas.width - 10;
        const hitToptWall = snake[0].y < 0;
        const hitBottomWall = snake[0].y > gameCanvas.height - 10;
        return hitLeftWall || hitRightWall || hitToptWall || hitBottomWall
      }
      /**
       * 在畫布上畫食物
       */
      function drawFood() {
        ctx.fillStyle = FOOD_COLOUR;
        ctx.strokestyle = FOOD_BORDER_COLOUR;
        ctx.fillRect(foodX, foodY, 10, 10);
        ctx.strokeRect(foodX, foodY, 10, 10);
      }
      /**
       * 根據蛇的水平移動速度改變蛇的 x 座標,
       * 根據蛇的垂直移動速度改變蛇的 y 座標
       */
      function advanceSnake() {
        // 繪製新的頭部
        const head = {x: snake[0].x + dx, y: snake[0].y + dy};
        // 將新的頭部放到蛇身體的第一個部位
        snake.unshift(head);
        const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
        if (didEatFood) {
          // 增加分數
          score += 10;
          // 在屏幕上顯示分數
          document.getElementById('score').innerHTML = score;
          // 生成新的食物
          createFood();
        } else {
          // 移除蛇的最後一個部分
          snake.pop();
        }
      }
      /**
        * 給定一個最大值和最小值,生成一個 10 的倍數的隨機數     
        * @param { number } min —— 隨機數的下限
        * @param { number } max —— 隨機數的上限
       */
      function randomTen(min, max) {
        return Math.round((Math.random() * (max-min) + min) / 10) * 10;
      }
     /**     
      * 隨機生成食物座標
      */
      function createFood() {
        // 隨機生成食物的 x 座標
        foodX = randomTen(0, gameCanvas.width - 10);
        // 隨機生成食物的 y 座標
        foodY = randomTen(0, gameCanvas.height - 10);
        // 如果新生成的食物與蛇當前位置重疊,重新爲食物生成一個位置
        snake.forEach(function isOnSnake(part) {
          if (part.x == foodX && part.y == foodY) createFood();
        });
      }
      /**
       * 在畫布上畫蛇
       */
      function drawSnake() {
        // 循環遍歷蛇的每一部分,並將其繪製到畫布上
        snake.forEach(drawSnakePart)
      }
      /**
       * 在畫布上畫蛇的一個部分
       * @param { object } snakePart  —— 需要繪製的部位的所在座標
       */
      function drawSnakePart(snakePart) {
        // 設置蛇身體的背景顏色
        ctx.fillStyle = SNAKE_COLOUR;
        // 設置蛇身的邊框色
        ctx.strokestyle = SNAKE_BORDER_COLOUR;
        // 在蛇身座標所在的位置,繪製“實心”的矩形以表示蛇      
        ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
        // 繪製蛇身的邊框
        ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
      }
      /**     
      * 根據按下的鍵,改變蛇的水平移動速度和垂直移動速度
      * 爲了避免蛇反轉,蛇的移動方向不能直接變成相反的方向,
      * 比如說,當前方向是“向右”,那麼下一個方向不能是“向左”
      * @param { object } event —— 鍵盤事件
      */
      function changeDirection(event) {
        const LEFT_KEY = 37;
        const RIGHT_KEY = 39;
        const UP_KEY = 38;
        const DOWN_KEY = 40;
        const keyPressed = event.keyCode;
        const goingUp = dy === -10;
        const goingDown = dy === 10;
        const goingRight = dx === 10;
        const goingLeft = dx === -10;
        if (keyPressed === LEFT_KEY && !goingRight) {
          dx = -10;
          dy = 0;
        }
        if (keyPressed === UP_KEY && !goingDown) {
          dx = 0;
          dy = -10;
        }
        if (keyPressed === RIGHT_KEY && !goingLeft) {
          dx = 10;
          dy = 0;
        }
        if (keyPressed === DOWN_KEY && !goingUp) {
          dx = 0;
          dy = 10;
        }
      }
    </script>
  </body>
</html>

現在貪食蛇遊戲已經可以玩了,不過,我們還是來看看最後一個問題。我保證,這絕對是最後一個。

潛藏的 bugs

如果你玩了足夠多次遊戲,可能會注意到遊戲有時會意外結束。這是一個很好的例子,展示了 bug 會潛入我們的程序並製造麻煩。

發現 bug 的時候,解決它的最好方法是首先找到可靠的方法來重現它。也就是說,找到導致意外行爲的精確步驟。然後,需要了解它們導致意外行爲的原因,最後尋求解決方案。

重現 bug

在我們的例子中,重現 bug 的步驟如下:

  • [ ] 蛇正向左移動
  • [ ] 玩家按下向下鍵
  • [ ] 玩家立即按下向右鍵(在 100 ms 內)
  • [ ] 遊戲結束

image

分析導致 bug 的原因

讓我們一步步分解 bug 產生的過程。

蛇正在向左移動

  • [ ] 水平速度,dx 等於 -10
  • [ ] 調用 main 函數
  • [ ] 調用 advanceSnake 函數,蛇向左移動 10 px。

玩家按下向下鍵

調用 changeDirection
keyPressed === DOWN_KEY && !goingUp 的值爲 true
dx 更改爲 0
dy 更改爲 +10

玩家立即按下向右鍵(在 100 ms 內)

  • [ ] 調用 changeDirection
  • [ ] keyPressed === RIGHT_KEY && !goingLeft 的值爲 true
  • [ ] dx 更改爲 +10
  • [ ] dy 更改爲 0

遊戲結束

  • [ ] main 函數延時 100 ms 後被調用
  • [ ] 調用 advanceSnake,蛇向右移動 10 px。
  • [ ] const didCollide = snake[i].x === snake[0].x && snake[i].y ===

      snake[0].y 的值爲true
  • [ ] didGameEnd 返回 true
  • [ ] main 函數提前返回
  • [ ] 遊戲結束

解決 bug

在分析了發生的事情之後,我們瞭解到遊戲結束是因爲蛇掉頭了。

這是因爲當玩家按下向下鍵時,dx 被設置爲 0。因此 keyPressed === RIGHT_KEY && !goingLeft 值爲 true,同時 dx 更改爲 10。

重要的是要注意,在 100 ms 時間內,方向改變了。如果超過 100 ms,那麼蛇首先會向下移而不會掉頭。

爲了解決這個 bug,必須確保只有在 main 和 advanceSnake 被調用之後,纔可以改變蛇的方向。可以創建一個變量 changingDirection。當調用 changeDirection 時設置 changingDirection 爲 true,並在調用 advanceSnake 時設置爲 false。

在 changeDirection 函數中,如果 changingDirection 爲 true,就提前返回。

function changeDirection(event) {
  const LEFT_KEY = 37;
  const RIGHT_KEY = 39;
  const UP_KEY = 38;
  const DOWN_KEY = 40;
  if (changingDirection) return;
  changingDirection = true;
  ...
}
function main() {
  setTimeout(function onTick() {
    changingDirection = false;

    ...
  }, 100)
}

以下是 snake.html 的最終版本

注意我還在 <style></style> 標籤之間添加了一些樣式。這是爲了讓畫布和分數顯示在屏幕中間。
<!DOCTYPE html>
<html>
  <head>
    <title>Snake Game</title>
    <link href="https://fonts.googleapis.com/css?family=Antic+Slab" rel="stylesheet">

  </head>

  <body>

    <div id="score">0</div>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <style>
      #gameCanvas {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
      }
      #score {
        text-align: center;
        font-size: 140px;
        font-family: 'Antic Slab', serif;
      }
    </style>
  </body>

  <script>
    const GAME_SPEED = 100;
    const CANVAS_BORDER_COLOUR = 'black';
    const CANVAS_BACKGROUND_COLOUR = "white";
    const SNAKE_COLOUR = 'lightgreen';
    const SNAKE_BORDER_COLOUR = 'darkgreen';
    const FOOD_COLOUR = 'red';
    const FOOD_BORDER_COLOUR = 'darkred';
    let snake = [
      {x: 150, y: 150},
      {x: 140, y: 150},
      {x: 130, y: 150},
      {x: 120, y: 150},
      {x: 110, y: 150}
    ]
    // 玩家的分數
    let score = 0;
    // changingDirection 爲 true 時表示蛇正在改變方向
    let changingDirection = false;
    // 食物的 x 座標
    let foodX;
    // 食物的 y 座標
    let foodY;
    // 橫向移動速度
    let dx = 10;
    // 縱向移動速度
    let dy = 0;
    // 獲取 canvas 元素
    const gameCanvas = document.getElementById("gameCanvas");
    // 返回一個二維繪製上下文
    const ctx = gameCanvas.getContext("2d");
    // 開始遊戲
    main();
    // 生成第一個食物位置
    createFood();
    // 按下任意一個鍵,都會調用 changeDirection
    document.addEventListener("keydown", changeDirection);
    /**
     * 遊戲的主函數
     * 遞歸調用以推進遊戲  
     */
    function main() {
      // 判定遊戲結束時提前返回從而終止遊戲
      if (didGameEnd()) return;
      setTimeout(function onTick() {
        changingDirection = false;
        clearCanvas();
        drawFood();
        advanceSnake();
        drawSnake();
        // 再次調用 main
        main();
      }, GAME_SPEED)
    }
    /**
     * 設置畫布的背景色爲 CANVAS_BACKGROUND_COLOUR 
     * 並繪製畫布的邊框
     */
    function clearCanvas() {
      //  選擇畫布的背景顏色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  選擇畫布的邊框顏色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 繪製一個“實心的”長方形來覆蓋整個畫布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 繪製畫布的“邊框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
    }
    /**
     * 在畫布上畫食物
     */
    function drawFood() {
      ctx.fillStyle = FOOD_COLOUR;
      ctx.strokestyle = FOOD_BORDER_COLOUR;
      ctx.fillRect(foodX, foodY, 10, 10);
      ctx.strokeRect(foodX, foodY, 10, 10);
    }
    /**
     * 根據蛇的水平移動速度改變蛇的 x 座標,
     * 根據蛇的垂直移動速度改變蛇的 y 座標
     */
    function advanceSnake() {
      // 繪製新的頭部
      const head = {x: snake[0].x + dx, y: snake[0].y + dy};
      // 將新的頭部放到蛇身體的第一個部位
      snake.unshift(head);
      const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
      if (didEatFood) {
        // 增加分數
        score += 10;
        // 在屏幕上顯示分數
        document.getElementById('score').innerHTML = score;
        // 生成新的食物
        createFood();
      } else {
        // 移除蛇的最後一個部分
        snake.pop();
      }
    }
    /**
     * 當蛇撞牆或者蛇頭碰到蛇身的時候返回 true
     */
    function didGameEnd() {
      for (let i = 4; i < snake.length; i++) {
        if (snake[i].x === snake[0].x && snake[i].y === snake[0].y) return true
      }
      const hitLeftWall = snake[0].x < 0;
      const hitRightWall = snake[0].x > gameCanvas.width - 10;
      const hitToptWall = snake[0].y < 0;
      const hitBottomWall = snake[0].y > gameCanvas.height - 10;
      return hitLeftWall || hitRightWall || hitToptWall || hitBottomWall
    }
    /**
     * 給定一個最大值和最小值,生成一個 10 的倍數的隨機數     
     * @param { number } min —— 隨機數的下限
     * @param { number } max —— 隨機數的上限
     */
    function randomTen(min, max) {
      return Math.round((Math.random() * (max-min) + min) / 10) * 10;
    }
    /**     
     * 隨機生成食物座標
     */
    function createFood() {
      // 隨機生成食物的 x 座標
      foodX = randomTen(0, gameCanvas.width - 10);
      // 隨機生成食物的 y 座標
      foodY = randomTen(0, gameCanvas.height - 10);
      // 如果新生成的食物與蛇當前位置重疊,重新爲食物生成一個位置
      snake.forEach(function isFoodOnSnake(part) {
        const foodIsoNsnake = part.x == foodX && part.y == foodY;
        if (foodIsoNsnake) createFood();
      });
    }
    /**
     * 在畫布上畫蛇
     */
    function drawSnake() {
      // 循環遍歷蛇的每一部分,並將其繪製到畫布上
      snake.forEach(drawSnakePart)
    }
    /**
     * 在畫布上畫蛇的一個部分
     * @param { object } snakePart —— 需要繪製的部位的所在座標
     */
    function drawSnakePart(snakePart) {
      // 設置蛇身體的背景顏色
      ctx.fillStyle = SNAKE_COLOUR;
      // 設置蛇身的邊框色
      ctx.strokestyle = SNAKE_BORDER_COLOUR;
      // 在蛇身座標所在的位置,繪製“實心”的矩形以表示蛇      
      ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
      // 繪製蛇身的邊框
      ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
    }
    /**     
     * 根據按下的鍵,改變蛇的水平移動速度和垂直移動速度
     * 爲了避免蛇反轉,蛇的移動方向不能直接變成相反的方向,
     * 比如說,當前方向是“向右”,那麼下一個方向不能是“向左”
     * @param { object } event —— 鍵盤事件
     */
    function changeDirection(event) {
      const LEFT_KEY = 37;
      const RIGHT_KEY = 39;
      const UP_KEY = 38;
      const DOWN_KEY = 40;
      /**
       * 避免貪食蛇掉頭
       * 舉個例子:
       * 蛇正在向右移動。玩家按下向下鍵然後迅速地按下向左鍵。
       * 此時蛇不會先向下移動一步,而會馬上改變方向
       */
      if (changingDirection) return;
      changingDirection = true;

      const keyPressed = event.keyCode;
      const goingUp = dy === -10;
      const goingDown = dy === 10;
      const goingRight = dx === 10;
      const goingLeft = dx === -10;
      if (keyPressed === LEFT_KEY && !goingRight) {
        dx = -10;
        dy = 0;
      }

      if (keyPressed === UP_KEY && !goingDown) {
        dx = 0;
        dy = -10;
      }

      if (keyPressed === RIGHT_KEY && !goingLeft) {
        dx = 10;
        dy = 0;
      }

      if (keyPressed === DOWN_KEY && !goingUp) {
        dx = 0;
        dy = 10;
      }
    }
  </script>
</html>

結尾

編程並不是一件很難學習,雖然最初未必敢於嘗試,但這毫無疑問是非常有價值的。
前端開發進階點擊:加入

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章