[JS 小東西] 利用 JavaScript 實作貪吃蛇

這次要來實作非常經典的貪吃蛇遊戲。

一直以來都想用 JavaScript 實作一些以前簡單的小遊戲,主要是因為會動的成品給人的成就感總是比較大,加上自己小時候就是小遊戲迷。至於為什麼是 JavaScript?並不是因為這是我擅長的語言(沒有其他語言熟悉),純粹是可以搭配網頁架構來寫,實作上很方便XD


規則說明

這次要寫的貪吃蛇比以前玩的規則還要簡單一點點。貪吃蛇可以透過上下左右按鍵移動,並且需在遊戲中碰觸食物,碰觸後貪吃蛇的身體會增長。過程中不能碰觸到自己身體,否則遊戲結束。若碰到牆壁,則會「穿牆」,由另一邊的牆出來。

使用語言

這次只需用最基本的 HTML、JavaScript 以及 CSS 來寫,而重點部分都放在 JavaScript。

基礎設定

基本設定不多,主要是定好遊戲畫面的大小、蛇的長相、紀錄分數的位置。

這次我們會利用 canvas 在 .js 檔中畫我們的介面。html 和 css 都不多,只是設定字體或是顏色等等,這部分這裡就不多提,可以去原始碼中看完整的設定。

按鈕

我們按鈕只有一個 Start,主要功能是重新開始遊戲,並在每次按下按鈕後重新載入頁面,需要利用 location.reload()

蛇本身

畢竟是貪吃蛇,蛇這個主角本身是一個大重點。首先我們需要一張畫布給這蛇表現,再來是設定蛇的長相和生成位置。

const canvas = document.getElementById("snakeCanvas");
const ctx = canvas.getContext("2d");

因為是 2D 遊戲,所以 getContext 中的參數為 2d。在 index.html 檔案中已經有預留畫布位置了,id 為 snakeCanvas,因此利用 getElementById 把這元素抓過來處理。

蛇身設定

至於蛇長什麼樣子呢,我通常都是直接畫出來輔助思考。我希望遊戲空間是 360 * 360 的正方形,而蛇一節的大小設定為 20 * 20,也就是說整個空間會是 18 格 row 和 column。蛇本身的初始條件為四節,頭的話會設定不同顏色,方便玩家判斷。

定好蛇的單位長為 unit、以及畫布大小 360 * 360,其他的都交給程式運算,以後比較好維護。

蛇的身體是一格一格的,像是關節,除了決定長度之外,這些格子的位置之後在移動設定上也扮演很重要的角色。除此之外我需要將蛇的位置初始化,這邊利用 array 來儲存蛇的位置,一共有四節。頭是第一節,從位置 (80, 0) 開始依序排下去。

let snake = [];

snake[0] = {
  x: 80,
  y: 0,
};

snake[1] = {
  x: 60,
  y: 0,
};

snake[2] = {
  x: 40,
  y: 0,
};

snake[3] = {
  x: 20,
  y: 0,
};

塗上顏色

利用 for 迴圈巡覽每一節身體一個一個物件塗上色。並且加上邊框,這些可以利用 fillRectstrokeRect 處理。頭的部份是重點,要讓玩家能夠識別,因此當巡覽到 index = 0,要另外指定不同的顏色。

蛇的移動

蛇的樣態已經初步成形後,再來要規劃蛇的移動模式。

更新畫布

動畫其實都是由一幀一幀的靜態圖片來的,在電腦上的處理就類似不斷圖片更新,新的圖蓋過舊的圖,而蛇的移動也是。至於蛇本身在新位置的生成,由於是陣列所儲存的蛇,因此可以利用 pop()unshift() 去控制。

不同方向的增減不太一樣,畫個圖會比較清楚。處理完蛇頭的新位置後就儲存在 newHead 中。

這邊我們寫上各個方向的處理:

let snakeX = snake[0].x;
let snakeY = snake[0].y;

if (direction == "Right") {
  snakeX += unit;
} else if (direction == "Down") {
  snakeY += unit;
} else if (direction == "Left") {
  snakeX -= unit;
} else if (direction == "Up") {
  snakeY -= unit;
}

let newHead = {
  x: snakeX,
  y: snakeY,
};

偵測鍵盤

蛇可以透過鍵盤的方向鍵進行移動,要注意不能夠有 180 度轉動的情況。

雖然我們寫了各個單一方向的更新,但還沒寫鍵盤改變方向的更新方式。鍵盤偵測上我們可以利用 window.addEventListener("keydown", changeDirection) 來處理,首先利用下面的程式測試:

function changeDirection(e) {
  console.log(e);
}

當我們按壓鍵盤上下左右時,可以看到主控台出現相對應的值。

不過這不是我們要的功能,所以記錄完可以將它刪掉了XD 這些值(key)能夠給程式判斷。另外因為不能有 180 度換方向的狀況,因此我們要注意蛇目前的前進方向。這些判斷我寫在 changeDirection() 中:

let direction = "Right"; //snake's initial direction
window.addEventListener("keydown", changeDirection);

function changeDirection(e) {
  if (e.key == "ArrowRight" && direction != "Left") {
    direction = "Right";
  } else if (e.key == "ArrowDown" && direction != "Up") {
    direction = "Down";
  } else if (e.key == "ArrowUp" && direction != "Down") {
    direction = "Up";
  } else if (e.key == "ArrowLeft" && direction != "Right") {
    direction = "Left";
  }
  //prevent multiple click
  window.removeEventListener("keydown", changeDirection);
}

鍵盤偵測到之後,就不接受任何 keydown,因此會使用 window.removeEventListener("keydown", changeDirection),避免鍵盤連擊造成的問題,後續要記得畫完所有物件後,要把這個監測加回去。

穿牆

這次的貪吃蛇撞牆並不會導致遊戲結束,而是由另外一邊的牆穿出來,讓遊戲更有靈活度。基本上判斷也不會太難,例如以往右走的狀況來說,當蛇頭的位置已經大於畫布的寬度時,表示已經碰到牆,位置需要改到另外一邊,也就是 x = 0 的地方;而往左走的狀況來說,當位置已經小於 0,也表示超出畫布了,要改從畫布寬度減掉一節的寬度位置。

if (snake[i].x >= canvas.width){
  snake[i].x = 0;
} else if (snake[i].x < 0){
  snake[i].x = canvas.width - unit;
} else if (snake[i].y >= canvas.height){
  snake[i].y = 0
} else if (snake[i].y < 0){
  snake[i].y = canvas.height - unit;
}

雖然還要判斷吃到食物的狀況,但因為食物的實體還沒有被建構,所以先放一邊。

食物設定

接下來要設定食物,首先也是一樣初始化。因為食物的出現必須是隨機的,所以首先我們利用 Math.random() 製造隨機的位置。

class Food {
  constructor() {
    this.x = Math.floor(Math.random() * col) * unit;
    this.y = Math.floor(Math.random() * row) * unit;
  }
  drawFood() {
    ctx.fillStyle = "rgb(238, 44, 44)";
    ctx.fillRect(this.x, this.y, unit, unit);
  }
}

蛇吃到食物

實作完蛇和食物後,就可以回來編寫蛇和食物的互動了。

蛇吃到食物後有兩種變化:增加蛇身長度以及增加分數。而判斷吃到食物,實際上就是蛇的頭和食物重疊,也就是說 x 軸和 y 軸的值要一樣。

增加分數沒什麼,我們會自己寫一個 setTopScore() 判斷目前是否為最高分,或是採用本機儲存的最高分,這部分就會用到 localStorage 相關,並且每次遊戲開始時用 loadTopScore() 將有儲存的最高分數載入。

這邊利用 JavaScript 動態產生分數:

let score = 0;
if (snake[0].x == snakeFood.x && snake[0].y == snakeFood.y) {
  score++;
  setTopScore(score);
  document.getElementById("score1").innerHTML = "Score: " + score;
  document.getElementById("score2").innerHTML = "Top Score: " + topScore;
}

function loadTopScore() {
  if (localStorage.getItem("topScore") == null) {
    topScore = 0;
  } else {
    topScore = Number(localStorage.getItem("topScore"));
  }
}

function setTopScore(score) {
  if (score > topScore) {
    localStorage.setItem("topScore", score);
    topScore = score;
  }
}

增加蛇的長度本身沒什麼困難度,前面我們實作過移動了,這裡我們除了一樣利用 unshift() 讓新產生的頭往前之外,不要 pop() 丟棄最後一節就能製造出增長的效果。這裡增加一個條件判斷即可:

if (snake[0].x == snakeFood.x && snake[0].y == snakeFood.y) {
  snakeFood.pickLocation();
  score++;
  setTopScore(score);
  document.getElementById("score1").innerHTML = "Score: " + score;
  document.getElementById("score2").innerHTML = "Top Score: " + topScore;
} else {
  snake.pop();
}
snake.unshift(newHead);

大致上是這樣,不過完整的流程可以參考我的程式碼。整個寫下來設計介面、功能都不難,但是要很注意流程上的安排。

參考資料

讓我知道你在想什麼!