歡迎上車。今天我們將開始一場激動人心的冒險,在這裏我們將開發屬於我們自己的貪食蛇遊戲。通過將其分解爲一個個簡短的步驟來學習如何解決問題。在這段旅程結束時,你會學到一些新東西,並且有信心能獨立探索更多。
開始吧
首先新建一個文件“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>
刷新頁面,蛇動了!
自己是一個五年的前端工程師
這裏推薦一下我的前端學習=交流=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)
}
貪食蛇現在會一直向右移動。雖然一旦它到達畫布的盡頭,將繼續其無盡的旅程並進入未知的地方。我們將在適當的時候解決這個問題,耐心點年輕的朋友們
調整蛇的移動方向
我們的下一個任務是在按下任意方向鍵時更改蛇的移動方向。在 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 內)
- [ ] 遊戲結束
分析導致 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>
結尾
編程並不是一件很難學習,雖然最初未必敢於嘗試,但這毫無疑問是非常有價值的。
前端開發進階點擊:加入