把網頁當畫布揮灑的 Canvas 07:貪吃蛇

來製作一款貪吃蛇遊戲吧!

到目前為止,我們所學的技能已經可以製作一款小小的貪吃蛇遊戲囉!來試試看吧!


開發的前置作業

撰寫程式之前,我們得先思考整個專案的設計模式與資料結構。從物件導向程式設計的角度來看,貪吃蛇遊戲包括以下兩個物件(類別):

  1. 貪吃蛇物件:設定貪吃蛇的屬性資料、狀態更新、設定方向、檢查邊界等等。
  2. 遊戲物件:設定遊戲的屬性資料、開始遊戲、結束遊戲、渲染畫面、狀態更新等等。

除了這兩個物件以外,我們還需要向量物件來幫助我們儲存位置資料,這裡也需要引入向量類別物件,詳細請看上一篇。

以下分別詳細說明貪吃蛇物件以及遊戲物件的資料屬性與方法。


貪吃蛇物件

玩過貪吃蛇的人應該都知道,貪吃蛇有一個身體長度 maxLength ,隨著貪吃蛇愈吃愈多,牠的身體長度也會愈來愈長。我們可以用一個陣列 body 來儲存貪吃蛇的身體資料,每筆身體資料表示貪吃蛇身體當下的位置(以向量表示)

每當遊戲狀態更新,就將貪吃蛇頭部位置資料 head 推進(push)進 body 中,並從尾端剔除(shift)一筆身體資料,讓貪吃蛇看起來不斷在往前進。這樣的資料結構稱為「佇列」(Queue),採用的是「先進先出」(FIFO, First-In-First-Out)的線性資料規則。

資料結構:佇列

蛇的速度 speed 決定蛇的前進方向與快慢;蛇的前進方向則以 direction 儲存。

1
2
3
4
5
6
7
const Snake = function() {
this.body = [];
this.maxLength = 5;
this.head = new Vector(0, 20); // Default position
this.speed = new Vector(1, 0);
this.direction = 'Right';
};

貪吃蛇的 update 方法是從 head 帶動的, head 每前進一個 speed ,便將它 push 到 body 裡面,並重新賦值給 head ,讓貪吃蛇的頭部不斷前進,帶出身體的部分。當 body 長度大於 maxLength ,就 shift 掉一個身體資料。

1
2
3
4
5
6
7
8
9
10
Snake.prototype.update = function() {
const newHead = this.head.add(this.speed); // Update the head's position
this.body.push(this.head); // Push the old head to snake's body
this.head = newHead; // Update the snake's head

// Delete the snake's tail when its length is over max length
while (this.body.length > this.maxLength) {
this.body.shift();
}
};

setDirection 方法則相對單純,我們透過鍵盤事件 keydown 按下方向鍵傳入所欲改變的方向 dir ,按照參數改變貪吃蛇的前進方向。特別需要注意的是,必須排除所欲改變方向和貪吃蛇目前前進方向相反的狀況,否則貪吃蛇可以不斷往自己身上跑。

checkBoundary 方法也很單純,我們判斷貪吃蛇的 head 位置是否都在遊戲物件的寬高範圍內即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Set the snake's speed
Snake.prototype.setDirection = function(dir) {
let target = new Vector();
switch (dir) {
case 'Up':
target = new Vector(0, -1);
break;
case 'Down':
target = new Vector(0, 1);
break;
case 'Left':
target = new Vector(-1, 0);
break;
case 'Right':
target = new Vector(1, 0);
break;
default:
return; // Do not do anything if the dir is not the direction key
}
// If the changing direction is the same as its original, do not change the direction
if (target.equal(this.speed.mul(-1)) === false) this.speed = target;
};

// Check whether the snake's head is beyond the boundary
Snake.prototype.checkBoundary = function(gameWidth) {
const xInRange = 0 <= this.head.x && this.head.x < gameWidth;
const yInRange = 0 <= this.head.y && this.head.y < gameWidth;
return xInRange && yInRange;
};

window.addEventListener('keydown', function(e) {
game.snake.setDirection(e.key.replace('Arrow', ''));
});


遊戲物件

整個貪吃蛇遊戲的畫面是用一格格小正方塊組成,因此我們用 blockWidth 來儲存小方塊的寬高, blockGutter 表示方塊之間的寬度, gameWidth 是貪吃蛇移動範圍的寬高, speed 是遊戲畫面更新的速率, foods 則是用來儲存食物資料的陣列, start 用來標記遊戲當前的狀態。

1
2
3
4
5
6
7
8
9
10
// Game's constructor function
const Game = function() {
this.blockWidth = 12;
this.blockGutter = 2;
this.gameWidth = 40;
this.speed = 30;
this.snake = new Snake();
this.foods = [];
this.start = false;
};

定義初始化整個遊戲的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Initialize the game object
Game.prototype.init = function() {
this.canvas = document.getElementById('my-canvas');
this.canvas.width =
this.blockWidth * this.gameWidth + this.blockGutter * (this.gameWidth - 1);
this.canvas.height = this.canvas.width;
this.ctx = this.canvas.getContext('2d');

this.update();
this.render();
this.generateFood();
};

const game = new Game();
game.init();

update 方法更新貪吃蛇的狀態,並判斷貪吃蛇的頭部位置是否吃到食物或撞到牆壁/自己,函式最後要設定計時器 setTimeout ,倒數下一次更新的時間。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Update the game object's status
Game.prototype.update = function() {
if (this.start) {
this.playSound('A2', -30);
this.snake.update();

// If the snake's head collides the food, add the snake's length and delete the food
this.foods.forEach((food, i) => {
if (this.snake.head.equal(food)) {
this.snake.maxLength++;
this.foods.splice(i, 1);
this.generateFood();
}
});

// If the snake's head collides with its own body, end the game
this.snake.body.forEach(body => {
if (this.snake.head.equal(body)) {
this.endGame();
}
});

// If the snake's head collides with the canvas boundary, end the game
if (this.snake.checkBoundary(this.gameWidth) === false) {
this.endGame();
}
}

// As the snake gets longer, its speed gets faster
this.speed = Math.sqrt(this.snake.body.length) + 5;
setTimeout(() => this.update(), parseInt(1000 / this.speed));
};

render 方法只管畫面渲染,包括繪製遊戲背景的方塊、貪吃蛇以及食物。這裡另外定義 getPosition 獲取向量在畫面上的準確位置, drawBlock 則是繪製單一小方塊,方便管理和複用。記得函式最後透過 requestAnimationFrame(() => this.render()) 確保渲染與瀏覽器畫面更新頻率同步,這裡使用 ES6 箭頭函式來綁定 this 參考的物件對象 game ,因為 requestAnimationFrame 屬於全域物件的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Render the canvas
Game.prototype.render = function() {
// Draw the whole canvas
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

// Draw all blocks
for (let x = 0; x < this.gameWidth; x++) {
for (let y = 0; y < this.gameWidth; y++) {
this.drawBlock(new Vector(x, y), 'rgba(255, 255, 255 ,0.03)');
}
}

// Draw the snake
this.snake.body.forEach(snakeBody => {
this.drawBlock(snakeBody, 'white');
});

// Draw the food
this.foods.forEach(food => {
this.drawBlock(food, 'red');
});

// Since requestAnimationFrame is the window's method, we use arrow function to bind 'this' for render()
requestAnimationFrame(() => this.render());
};

// Get each block's position
Game.prototype.getPosition = function(x, y) {
return new Vector(
x * this.blockWidth + (x - 1) * this.blockGutter,
y * this.blockWidth + (y - 1) * this.blockGutter
);
};

// Draw each block
Game.prototype.drawBlock = function(v, color) {
const pos = this.getPosition(v.x, v.y);
this.ctx.fillStyle = color;
this.ctx.fillRect(pos.x, pos.y, this.blockWidth, this.blockWidth);
};

開始與結束遊戲:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// End the game
Game.prototype.endGame = function() {
this.start = false;
document.querySelector('.panel').style.display = 'block';
document.querySelector('h2').textContent = `Score: ${(this.snake.maxLength - 5) * 10}`;
this.playSound('A3');
this.playSound('E2', -10, 200);
this.playSound('A2', -10, 400);
};

// Get each block's position
Game.prototype.getPosition = function(x, y) {
return new Vector(
x * this.blockWidth + (x - 1) * this.blockGutter,
y * this.blockWidth + (y - 1) * this.blockGutter
);
};


漣漪特效

透過讓漣漪半徑 r 累加的方式實現漣漪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Generate food in a random block 
Game.prototype.generateFood = function() {
const x = parseInt(Math.random() * this.gameWidth)
const y = parseInt(Math.random() * this.gameWidth)
this.foods.push(new Vector(x, y))
this.playSound('E5', -30)
this.playSound('A5', -30, 50)
this.drawEffect(x, y)
}

// Draw the ripple effect of food
Game.prototype.drawEffect = function(x, y) {
const pos = this.getPosition(x, y);
const _this = this; // Save the 'this' as '_this' to access the game object when using requestAnimationFrame()
let r = 2;

const effect = function() {
r++; // Keep adding r and draw the ripple until r = 100
_this.ctx.strokeStyle = `rgba(255, 0, 0, ${(100 - r) / 100})`;
_this.ctx.beginPath();
_this.ctx.arc(
pos.x + _this.blockWidth / 2,
pos.y + _this.blockWidth / 2,
r,
0,
Math.PI * 2
);
_this.ctx.stroke();

if (r < 100) {
requestAnimationFrame(effect);
}
};
requestAnimationFrame(effect);
};


Tone.js 製作音效

Tone.js 是一款創造互動音效的 JS 函式庫,透過它,我們可以為貪吃蛇遊戲增添互動樂趣。

透過 CND 載入:

1
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.5.40/Tone.js" integrity="sha256-wnyxyrV3KXNbHdboAqucMZIgMhqc9JMufnAyDCf9jGE=" crossorigin="anonymous"></script>

在遊戲物件中定義播放音效的方法,並在函式中初始化 Tone.js :

1
2
3
4
5
6
7
8
// Play sound with Tone.js
Game.prototype.playSound = function(note, volume, when) {
setTimeout(function() {
const synth = new Tone.Synth().toDestination(); // Construct a new synth from Tone.js
synth.volume = volume || -12; // Set the volume
synth.triggerAttackRelease(note, '8n'); //
}, when || 0);
};


參考資料

  1. 動畫互動網頁特效入門(JS/CANVAS):[Project 5] 製作橫衝直撞的貪吃蛇
把網頁當畫布揮灑的 Canvas 08:開發架構與模板 把網頁當畫布揮灑的 Canvas 06:向量

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×