這次要來實作非常經典的貪吃蛇遊戲。
一直以來都想用 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 迴圈巡覽每一節身體一個一個物件塗上色。並且加上邊框,這些可以利用 fillRect
、strokeRect
處理。頭的部份是重點,要讓玩家能夠識別,因此當巡覽到 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);
大致上是這樣,不過完整的流程可以參考我的程式碼。整個寫下來設計介面、功能都不難,但是要很注意流程上的安排。