在我小的時候,妹妹身體欠佳,時常需要住院治療氣喘。當時智慧型手機尚未問世,因此在醫院陪診期間,爸爸會將他的 Nokia 手機給我把玩,而我最喜歡的遊戲,就是像素風格的貪食蛇。我喜歡看蛇在我的努力下,不斷成長茁壯的模樣。
開始學習 JavaScript 後,我便聽說貪食蛇是適合新手練功的專案。聽聞後只想,怎麼可能用 JavaScript 操控蛇自動在畫面上移動?JavaScript 不是寫網站的嗎?
然而隨著學習進度邁進、小小宇宙經歷數次爆炸後,我似乎看出了大致的實作方向:
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;
#game-board
的 row 和 column 都是這個數字。x
與 y
兩個值,分別對應到 Grid 系統的行與列。預設為 {x: 10, y:10}
,讓貪食蛇的起始位置處於正中間。generateFoodCoordinate()
函式隨機產生食物的位置,一樣使用 x 與 y 軸的二維定位方式。setInterval()
一起用,負責貪食蛇移動的速度。由於貪食蛇和食物一開始不會馬上出現在畫面上 (初始化面為 menu,按下空白鍵才開始遊戲),所以我們建立 createGameElement()
這個函式,tag
與 className
兩個參數,用來建立貪食蛇與食物的元素:
// 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()
裡面需要帶入 element
和 position
兩個參數,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);
});
}
}
gameStarted()
為 true 時才開始繪製。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();
}
貪食蛇和食物都出現在畫面上了,接下來就是重頭戲......讓貪食蛇自動移動!由於中間牽涉到吃到食物、撞到牆壁、碰到自己等等邏輯,著實花了我不少時間思考、除錯,可說是本次專案最為艱辛的部分。
移動貪食蛇整體來說可拆解成這樣的思路:
snakeCoordinate
陣列中,第一個定位物件的 x
或 y
值依照方向進行增減。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();
}
startGame()
中我們會設定遊戲初始的 gameInterval
,由於吃到食物後貪食蛇移動速度提升,所以要先將舊的 gameInterval
移除掉。increaseSpeed()
提升貪食蛇移動速度,將降低 gameSpeedDelay
。updateScore()
增加畫面上的遊戲分數。setInterval()
重新賦值 gameInterval
,後面會有更詳細的說明。裡面有個 checkCollision()
的函式,用來確認貪食蛇是否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]
是蛇的頭部,而頭是不會碰到自己的,就像派大星看不到自己的額頭一樣。不是什麼大問題,但我當初也在這邊卡了一段時間......
只要符合遊戲結束條件就會觸發 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()
這個方便的字串方法 🫰
ThepadStart()
method ofString
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");
不是什麼酷炫的功能,但意外地實用,很高興能學到~
常聞道閱讀自己數月前的程式碼,宛如隔世。起初我也只是笑笑,直到這次回顧,才體會到確實如此。雖然和其他人的專案比,這個遊戲簡直是小兒科的程度,但我還是花了不少時間搞懂自己當初為什麼這樣寫,後來索性寫成一篇文章來記錄,當作前世的紀念。孟婆湯這種東西,我會持續喝下去的。
💡冷知識補充:除了蛇之外,在泰國文化中,夢到大便據說會帶來好運。