Vanilla JS 貪食蛇遊戲專案心得

2023/12/29閱讀時間約 27 分鐘


專案緣起

在我小的時候,妹妹身體欠佳,時常需要住院治療氣喘。當時智慧型手機尚未問世,因此在醫院陪診期間,爸爸會將他的 Nokia 手機給我把玩,而我最喜歡的遊戲,就是像素風格的貪食蛇。我喜歡看蛇在我的努力下,不斷成長茁壯的模樣。

開始學習 JavaScript 後,我便聽說貪食蛇是適合新手練功的專案。聽聞後只想,怎麼可能用 JavaScript 操控蛇自動在畫面上移動?JavaScript 不是寫網站的嗎?

然而隨著學習進度邁進、小小宇宙經歷數次爆炸後,我似乎看出了大致的實作方向:

  1. CSS Grid:無論是蛇或食物,其實都可以用網格系統來定位,反正都要走像素風了。
  2. setInterval() :這個 Web API 是我在學習非同步處理時,了解 setTimeout() 之餘剛好看到的酷東西,可以當作貪食蛇自動移動,甚至控制移動速度的方法,文章後面將有更詳細的說明。

剩下來的部分,就是用各種 JavaScript 函式來控制遊戲流程了。如果想要先玩玩看的話,可以參考 Github 連結,或是 live demo

本篇文章會將重點放在 JavaScript 的遊戲邏輯,HTML 及 CSS 不多著墨。

專案介紹

  • 使用者在初始畫面,按下空白鍵便可以開始遊戲
  • 透過鍵盤的上下左右控制貪食蛇的移動方向。
  • 每吃到一個食物,貪食蛇會長(ㄓㄤˇ)長(ㄔㄤˊ)一格。
  • 貪食蛇的頭只要碰到牆壁或自己的身體,即視為遊戲結束
  • 貪食蛇長(ㄓㄤˇ)長(ㄔㄤˊ)到一定程度,移動速度將增加。
  • 左上角記錄當下的遊玩分數,右上角則是前面的最高分紀錄。

💡冷知識:泰文的蛇是 งู้,在泰國文化中,夢到蛇是未來將變富有的預兆,所以有了這句俚語 ฝันงู้จะเป็นรวย (夢蛇將富裕)。


實作邏輯

變數定義

// Variables
const gridSize = 20;
let snakeCoordinate = [
{
// Coordinate of snake pixels in the 20x20 grid map
x: 10,
y: 10,
},
];
let foodCoordinate = generateFoodCoordinate();
let direction = "right";
let gameInterval;
let gameSpeedDelay = 200;
let gameStarted = false;
let highScore = 0;


  • gridSize:規定遊戲區域內的網格數量,CSS 中定義 #game-board 的 row 和 column 都是這個數字。
  • snakeCoordinate:貪食蛇身體的每一格,都是陣列中的一個物件,物件包含 xy 兩個值,分別對應到 Grid 系統的行與列。預設為 {x: 10, y:10},讓貪食蛇的起始位置處於正中間。
  • foodCoordinate:透過 generateFoodCoordinate() 函式隨機產生食物的位置,一樣使用 x 與 y 軸的二維定位方式。
  • direction:控制貪食蛇的移動方向,會搭配 KeyboardEvent 回傳使用者的鍵盤輸入值一起使用。
  • gameInterval、gameSpeedDelay:稍後會搭配 setInterval() 一起用,負責貪食蛇移動的速度。

定位貪食蛇與食物

由於貪食蛇和食物一開始不會馬上出現在畫面上 (初始化面為 menu,按下空白鍵才開始遊戲),所以我們建立 createGameElement() 這個函式,tagclassName 兩個參數,用來建立貪食蛇與食物的元素:

// Write a function to create snake or food HTML elements
function createGameElement(tag, className) {
const element = document.createElement(tag);
element.className = className;

return element;
}


接下來輪到定位的函式,setPosition() 裡面需要帶入 elementposition 兩個參數,position 為 x 與 y 值的物件,然後透過 HTML DOM Style Object,將元素的 gridRow 還有 gridColumn 設定為 position 的 x 與 y 值:

// Write a function to set the position of snake or food
function setPosition(element, position) {
element.style.gridColumn = position.x;
element.style.gridRow = position.y;
}


前置作業完成,現在來看看貪食蛇與食物的繪製函式吧,先從 drawSnake() 開始好了:

let snakeCoordinate = [
{
// Coordinate of snake pixels in the 20x20 grid map
x: 10,
y: 10,
},
];

// Write a function to draw the snake 🐍
function drawSnake() {
// Don't show the snake until the game started
if (gameStarted) {
snakeCoordinate.forEach((segment) => {
const snakeElement = createGameElement("div", "snake");
setPosition(snakeElement, segment);
board.appendChild(snakeElement);
});
}
}
  1. 運用條件式,偵測 gameStarted() 為 true 時才開始繪製。
  2. 由於貪食蛇由多個網格組成,所以 snakeCoordinate 是陣列。透過 forEach() 遍歷陣列中每個物件,先建立新的 div,再將物件當作參數,傳入 setPosition() 進行定位。


現在我們將目光轉到食物上面,由於食物需要隨機產生定位物件,所以我們建立 generateFoodCoordinate() 函式:

// Write a function to generate food at a random place
function generateFoodCoordinate() {
const x = Math.floor(Math.random() * gridSize) + 1;
const y = Math.floor(Math.random() * gridSize) + 1;

return { x, y };
}


接著和貪食蛇一樣,食物也需要一個繪製的函式,注意這邊的 foodCoordinate 變數,要接收的是 generateFoodCoordinate() 的回傳值 (定位物件):

let foodCoordinate = generateFoodCoordinate();

// Write a function to draw food
function drawFood() {
// Don't show food until the game started
if (gameStarted) {
const foodElement = createGameElement("div", "food");
setPosition(foodElement, foodCoordinate);
board.appendChild(foodElement);
}
}


在後續的遊戲邏輯中,貪食蛇和食物的繪製都會同時發生,所以我們把 drawSnake() 以及 drawFood() 包在 draw() 當作 callback 來統一管理。至於 board.innerHTML = "" 只是單純清空遊戲介面,因為遊戲結束後,logo 和介紹文字會重新出現。

// Write a function to draw game elements

function draw() {

board.innerHTML = "";

drawSnake();

drawFood();

}

移動貪食蛇

貪食蛇和食物都出現在畫面上了,接下來就是重頭戲......讓貪食蛇自動移動!由於中間牽涉到吃到食物、撞到牆壁、碰到自己等等邏輯,著實花了我不少時間思考、除錯,可說是本次專案最為艱辛的部分。

移動貪食蛇整體來說可拆解成這樣的思路:

  1. snakeCoordinate 陣列中,第一個定位物件的 xy 值依照方向進行增減。
  2. snakeCoordinate 陣列中最後一個定位物件移除。

moveSnake() 函式滿長的,所以我們分成兩部分來看:

function moveSnake() {
// Use shallow copy tp avoid altering the original object
const head = { ...snakeCoordinate[0] };
switch (direction) {
case "right":
head.x++;
break;
case "left":
head.x--;
break;
case "up":
head.y--;
break;
case "down":
head.y++;
break;
}
// Put the new head coordinate to the start of the snakeCoordinate array
snakeCoordinate.unshift(head);
...
}


我們先建立名為 head 的 local 變數,代表貪食蛇的頭部 (定位物件),因為後續檢查是否撞牆或碰到自己都需要用到 head。我們不想要影響到原先的 snakeCoordinate,所以透過解構語法,將 snakeCoordinate 陣列中第一個定位物件,放到一個新物件當作 head

const head = { ...snakeCoordinate[0] };


接著我們以 direction 為條件,來決定 head 定位物件的 x 與 y 值或增或減,明顯是個 switch...case 的試用情境:

  switch (direction) {
case "right":
head.x++;
break;
case "left":
head.x--;
break;
case "up":
head.y--;
break;
case "down":
head.y++;
break;
}


最後別忘了將調整完畢的 head 定位物件,重新放入到 snakeCoordinate 陣列的首位。這邊再次強調,head 是透過解構複製出來的

// Put the new head coordinate to the start of the snakeCoordinate array
snakeCoordinate.unshift(head);


好的,緊接著第二部分,會將重心放在如果貪食蛇吃到食物後的邏輯 😋
// If the snake eats the food
if (head.x === foodCoordinate.x && head.y === foodCoordinate.y) {
// Regenerate food
foodCoordinate = generateFoodCoordinate();
// Clear past interval function
clearInterval(gameInterval);
// Increase game speed
increaseSpeed();
// Update score
updateScore();
gameInterval = setInterval(() => {
moveSnake();
checkCollision();
draw();
}, gameSpeedDelay);
// If the snake did not eat the food after moving
} else {
// Remove the last segment of the snake
snakeCoordinate.pop();
}
  1. 透過條件式,判斷貪食蛇頭部是否和食物的位置重疊
  2. 食物被吃掉了,所以要重新產生新食物的隨機位置。
  3. 後面會談到,在 startGame() 中我們會設定遊戲初始的 gameInterval,由於吃到食物後貪食蛇移動速度提升,所以要先將舊的 gameInterval 移除掉。
  4. 呼叫 increaseSpeed() 提升貪食蛇移動速度,將降低 gameSpeedDelay
  5. 呼叫 updateScore() 增加畫面上的遊戲分數。
  6. 透過 setInterval() 重新賦值 gameInterval,後面會有更詳細的說明。裡面有個 checkCollision() 的函式,用來確認貪食蛇是否
  7. 如果條件式判斷本次移動中,貪食蛇沒有吃到食物,就把 snakeCoordinate 陣列中最後一個定位物件移除掉,達到移動的完整效果。



插曲:setInterval() - JavaScript 通往自動化的靈魂之窗

根據 MDN 文件setInterval() API 會在給定的時間延遲內,持續呼叫特定函式或程式碼

The setInterval() method, offered on the Window and WorkerGlobalScope interfaces, repeatedly calls a function or executes a code snippet, with a fixed time delay between each call.

而且 setInterval() 將回傳一組獨特的 interval ID,用於 clearInterval() 當作辨識碼來停止這組 interval。這樣看起來,同一支程式碼中,是可以建立多組 interval 的。

This method returns an interval ID which uniquely identifies the interval, so you can remove it later by calling clearInterval().

如果有用過 setTimeout(),那很快就會發現 setInterval() 的語法幾乎一模一樣,帶入要執行的函式以及延遲時間 (delay) 即可,延遲時間以毫秒計算

setInterval(func, delay) //Dealy should set in milliseconds


了解基本用法後,回歸到 moveSnake() 的問題。我們需要透過 interval 自動執行的 callback 函式總計有三個:

  • moveSnake() → 移動貪食蛇,並檢查該次移動是否有吃到食物
  • checkCollision() → 檢查貪食蛇是否撞牆,或碰到自己的身體 (灬ºωº灬)
  • draw()重新定位、繪製貪食蛇與食物


由於不只一個函式要執行,所以我們在 setInterval() 透過一組匿名函式 (箭頭函式)來呼叫 3 個 callback,這麼做除了能確保 3 組 callback 都能在 interval 之間被順利呼叫之外,還可以達到非同步的效果。

簡單來說,我們不希望程式碼卡在 interval,而是把 interval 的 callback 先外包給 web api 等待 delay 時間,於此同時,JavaScript 可以繼續跑後面的程式碼,比如說把貪食蛇的屁股移除掉。

由於 JavaScript 是單執行緒的語言,一次只能做一件事情、跑一行程式碼,所以需要這麼設計來達成非同步,更多介紹可以參考這篇學習筆記:JavaScript 同步、非同步、Event loop

在後面的 startGame() 函式,我們也會透過 setInterval() 來給 gameInterval 初始的遊戲速度。

小總結:透過 setInterval(),我們能在給定 delay 間自動執行遊戲邏輯。

遊戲結束的檢查機制

遊戲結束的條件只有兩個,非常單純:

  • 貪食蛇撞牆 就和現在學習網頁開發的我一樣 🧱
  • 貪食蛇自摸碰到自己 🀄

想通之後,就可以撰寫出 checkCollision()

function checkCollision() {
const head = snakeCoordinate[0];
// Check if the snake hit the wall
if (head.x < 1 || head.x > gridSize || head.y < 1 || head.y > gridSize) {
resetGame();
}
// Check if the snake touched its tal. Notice that we start the loop from index 1 because index 0 is snake's head so the body begins with index 1
for (let i = 1; i < snakeCoordinate.length; i++) {
if (head.x === snakeCoordinate[i].x && head.y === snakeCoordinate[i].y) {
resetGame();
}
}
}


這邊可以留意一下,我們不像前面 moveSnake() 透過解構語法複製 head,因為現在只是要檢查而已,本來就不會執行任何可能影響到 snakeCoordinate 陣列的動作。

另一件事情是,檢查貪食蛇是否碰到自己的 for 迴圈,iterator 我們從 1 開始。原因在於 snakeCoordinate[0] 是蛇的頭部,而頭是不會碰到自己的,就像派大星看不到自己的額頭一樣。不是什麼大問題,但我當初也在這邊卡了一段時間......

raw-image


只要符合遊戲結束條件就會觸發 resetGame()

function resetGame() {
updateHighScore();
stopGame();
// Reset the game elements
snakeCoordinate = [{ x: 10, y: 10 }];
foodCoordinate = generateFoodCoordinate();
direction = "right";
gameSpeedDelay = 200;
// Update score
updateScore();
}



遊戲開始、結束、按鍵監聽

function startGame() {
gameStarted = true;
// Hide instruction text and logo once the game has started
instructionText.style.display = "none";
logo.style.display = "none";
gameInterval = setInterval(() => {
moveSnake();
checkCollision();
draw();
}, gameSpeedDelay);
}

function stopGame() {
clearInterval(gameInterval);
gameStarted = false;
// Recall the instruction text
instructionText.style.display = "block";
// Recall the logo
logo.style.display = "block";
}


這邊只是想提一下,在 startGame() 我們也使用了 setInterval() 來給 gameInterval 初始值,讓遊戲自動執行以下 callback:

  • 移動貪食蛇
  • 檢查是否遊戲結束
  • 重新定位、繪製遊戲元素

而在 stopGame() 中,要記得透過 clearInterval()gameInterval 的紀錄清洗掉。

另一個要點是,使用者能夠過鍵盤上下左右來控制貪食蛇的移動方向;除此之外,使用者也要能按下空白鍵來開始遊戲,因此我們來寫個條件式邏輯:

// Listen for key press event
function handleKeyPress(event) {
// Start the game if user pressed space key
if (
(!gameStarted && event.code === "Space") ||
(!gameStarted && event.key === "")
) {
startGame();
} else {
// Press keys to move the snake
switch (event.key) {
case "ArrowUp":
direction = "up";
break;
case "ArrowDown":
direction = "down";
break;
case "ArrowLeft":
direction = "left";
break;
case "ArrowRight":
direction = "right";
break;
}
}
}


gameStart 為 true,而且監聽器偵測到空白鍵時,才會執行 startGame()。如此一來,便能確保遊戲中途按下空白鍵也不會重新開始遊戲。

底下關於偵測上下左右按鍵的作法,可以參考 KeyboardEvent: key property 的範例。



插曲:分數要以三位數的格式呈現

由於更新分數、最高分數的程式碼相對容易,我這邊就不贅述了。但實作上我有遇到一個問題,那就是參考的設計稿以三位數格式來呈現遊玩分數,所以 5 分要變成 005,以此類推。該怎麼做呢?

原先我以為要自己寫一個 convertor 函式,後來上網查了一下,發現 JavaScript 竟然有提供 padStart() 這個方便的字串方法 🫰

The padStart() method of String values pads this string with another string (multiple times, if needed) until the resulting string reaches the given length. The padding is applied from the start of this string.

簡單來說,這能在字串值前面填充 (pad) 其他字串直到給定長度。要注意填充是從字串頭開始往下的。

所以說,我們想把 5 變成 005 可以這樣做。第一個參數帶入給定長度,第二個參數就是要填充的其他字串:

const str1 = '5';

console.log(str1.padStart(3, '0'));
// Expected output: "005"


由於我們在遊戲中的分數是以 number 當作資料類型,所以要記得先透過 toString() 轉換成字串:

score.textContent = currentScore.toString().padStart(3, "0");
highScoreText.textContent = highScore.toString().padStart(3, "0");


不是什麼酷炫的功能,但意外地實用,很高興能學到~


結語:

常聞道閱讀自己數月前的程式碼,宛如隔世。起初我也只是笑笑,直到這次回顧,才體會到確實如此。雖然和其他人的專案比,這個遊戲簡直是小兒科的程度,但我還是花了不少時間搞懂自己當初為什麼這樣寫,後來索性寫成一篇文章來記錄,當作前世的紀念。孟婆湯這種東西,我會持續喝下去的。


下一步

  • 把 JavaScript 改得更 pure function 一點,目前還會影響到全域變數
  • 嘗試套用 React 框架(?)
  • 新增功能,讓使用者一開始能選擇難度和貪食蛇的顏色
  • 夢到蛇然後賺大錢,連轉職都免了

💡冷知識補充:除了蛇之外,在泰國文化中,夢到大便據說會帶來好運。


參考資料

16會員
34內容數
Bonjour à tous,我本身是法文系畢業,這邊會刊登純文組學習網頁開發的筆記。如果能鼓勵更多文組夥伴一起學習,那就太開心了~
留言0
查看全部
發表第一個留言支持創作者!