如何用 matter.js 製作西瓜遊戲?

matter.js 是什麼?

matter.js 是一個輕量級的 2D 物理引擎,它可以幫助你在網頁上製作出各種有趣的物理效果,例如碰撞、重力、彈力等等。這個物理引擎的特色在於它的易用性,只需要幾行程式碼,就可以讓你的網頁元素擁有物理效果,並且可以透過它的 API 來控制物理效果的各種參數。

西瓜遊戲是什麼?

西瓜遊戲是一款簡單的休閒遊戲,玩家的目標是通過將相同大小的西瓜合成,來製作出越來越大的西瓜。玩家只需拖動或點擊螢幕來控制小西瓜落下的位置,當兩個相同大小的西瓜碰撞時,就會合成為一個更大的西瓜。

這是一個成品的DEMO,你可以點擊這個連結來體驗一下這款遊戲,或是到GitHub上下載這個遊戲的原始碼。

製作遊戲素材

在製作西瓜遊戲之前,需要先製作遊戲的素材,而我在這裡使用了 Recraft: AI Image Generator 來產生遊戲素材,再將素材使用這個網站將圖片裁切成圓形後,套用到遊戲中。

遊戲介面

這個遊戲的介面很單純,只有一個遊戲畫面和一個分數顯示區域。可以參考以下的HTML來製作遊戲的介面。

1
2
3
4
5
6
7
8
<body>
<!-- 分數顯示區域 -->
<p id="score">
<span>SCORE:</span> <span class="num">0</span>
</p>
<!-- 遊戲畫面 -->
<div id="scene"></div>
</body>

安裝 matter.js

首先,需要安裝 matter.js,可以透過 npm 來安裝 matter.js。

1
npm install matter-js

你可以使用vite、webpack等工具來建立專案,這裡使用 vite 來建立專案。
建立vite專案請參考vite官方文檔

在進入點main.js中引入matter.js

1
import Matter from 'matter-js'; // 引入matter.js

引入遊戲素材

將遊戲素材引入到專案中,並使用預加載的方式來引入圖片。在這裡我使用了11不同尺寸的圖片,分別對應遊戲內11種不同的圓形的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import t1 from './assets/images/t1.png';
import t2 from './assets/images/t2.png';
import t3 from './assets/images/t3.png';
import t4 from './assets/images/t4.png';
import t5 from './assets/images/t5.png';
import t6 from './assets/images/t6.png';
import t7 from './assets/images/t7.png';
import t8 from './assets/images/t8.png';
import t9 from './assets/images/t9.png';
import t10 from './assets/images/t10.png';
import t11 from './assets/images/t11.png';

const imgUrl = [t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11];

// preload images
imgUrl.forEach((url) => {
const img = new Image();
img.src = url;
});

初始化 matter.js

在初始化 matter.js 之前,需要先了解 matter.js 的一些基本概念。matter.js 中有許多重要的物件,例如 Engine、World、Bodies、Body 等等,這些物件都是 matter.js 中的重要概念,需要了解這些物件的作用,才能夠正確地使用 matter.js 來製作遊戲。
在這個遊戲中,使用到 Engine, Render, Composite, Bodies, Body, Runner, Events 這些物件來製作遊戲,以下是這些物件的簡單介紹。

  • Engine: 用來創建一個物理引擎,並且可以透過它來控制物理效果的各種參數。
  • Render: 用來創建一個渲染器,並且可以透過它來渲染物理效果。
  • Composite: 用來創建一個物理世界,並且可以透過它來控制物理世界中的各種物體。
  • Bodies: 用來創建各種不同形狀的物體,例如圓形、矩形、多邊形等等。
  • Body: 用來控制物體的各種參數,例如位置、速度、角度等等。
  • Runner: 用來創建一個運行器,並且可以透過它來運行物理引擎。
  • Events: 用來創建一個事件監聽器,並且可以透過它來監聽物理世界中的各種事件。

在一開始,需要先使用Engine來創建一個物理引擎,接著使用Render來創建一個渲染器,然後使用Composite來創建一個物理世界,最後使用Runner來創建一個運行器,並且將物理引擎和渲染器連接起來。

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
const width = 900; // 遊戲畫面的寬度
const height = 900; // 遊戲畫面的高度
const limitY = 140; // 遊戲結束的Y軸位置

const {
Engine, Render, Composite, Bodies, Body, Runner, Events,
} = Matter;
let isGameOver = false; // 遊戲是否結束
// 分數,寫入分數的時候會自動更新分數顯示區域
const score = {
num: 0,
get value() {
return this.num;
},
set value(newValue) {
this.num = newValue;
document.querySelector('#score .num').textContent = this.num;
document.querySelector('#gameOverScore').textContent = this.num;
},
};

// 建立物理引擎
const engine = Engine.create();
// 物理引擎更新頻率,單位為毫秒,60FPS為16.67毫秒,為了避免物理碰撞時的穿透問題,這裡設定為10毫秒
Engine.update(engine, 10);
const { world } = engine;
// 重力加速度
world.gravity.y = 1.5;
// 建立渲染器
const render = Render.create({
element: scene,
engine,
options: {
width,
height,
wireframes: false,
background: 'transparent',
},
});

建立遊戲物體

在 matter.js 中,可以使用Bodies來創建各種不同形狀的物體,例如圓形、矩形、多邊形等等。在這個遊戲中,需要創建放置球體的牆面,以及球體本身。可以使用Bodies.rectangle創建牆面,並且使用Bodies.circle創建球體。

除此之外,還需要使用collisionFilter來設定碰撞的分組,讓最上方的球體不會與其他球體碰撞。

首先使用Bodies.rectangle,建立左、右、底部的牆面。

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
const categoryOn = 0x0001; // 球體的碰撞分組,使用16進位表示
const categoryOff = 0x0002; // 牆面的碰撞分組,使用16進位表示

// 建立左、右、底部的牆面,厚度為20
// Bodies.rectangle(x, y, width, height, [options])
const wallLeft = Bodies.rectangle(-10, height / 2, 20, height, {
isStatic: true, // 讓牆面不會受到重力或碰撞的影響
render: {
fillStyle: '#000000',
},
collisionFilter: {
group: 1, // 數字為正數時將總是碰撞,數字為負數時將總是不碰撞
category: categoryOn, // 物體的分組
mask: categoryOn, // 允許與哪個分組的物體與之碰撞
},
});
const wallRight = Bodies.rectangle(width + 10, height / 2, 20, height, {
isStatic: true,
render: {
fillStyle: '#000000',
},
collisionFilter: {
group: 1, // 數字為正數時將總是碰撞,數字為負數時將總是不碰撞
category: categoryOn, // 物體的分組
mask: categoryOn, // 允許與哪個分組的物體與之碰撞
},
});
const wallBottom = Bodies.rectangle(width / 2, height + 10, width, 20, {
isStatic: true,
render: {
fillStyle: '#000000',
},
collisionFilter: {
group: 1, // 數字為正數時將總是碰撞,數字為負數時將總是不碰撞
category: categoryOn, // 物體的分組
mask: categoryOn, // 允許與哪個分組的物體與之碰撞
},
});

// 將牆面加入物理世界
Composite.add(world, [wallLeft, wallRight, wallBottom]);

接著使用Bodies.circle創建球體的function,用來創造不同等級或狀態的球體。
這個function的參數有level、isStatic、x、y、canCollision,分別代表球體的等級、是否靜態、x軸位置、y軸位置、是否可以碰撞。
物體建立後,執行渲染器和運行器。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 創建球體
* @param {number} level - 球體等級
* @param {boolean} isStatic - 是否靜態
* @param {number} x - x軸位置
* @param {number} y - y軸位置
* @param {boolean} canCollision - 是否可以碰撞
* @returns {object} - 球體物件
*/
const createBall = (level, isStatic = true, x = 0, y = null, canCollision = true) => {
// 球體比例
const scale = 0.85;
// 球體大小,共有11種不同大小的球體
const sizes = [30, 45, 60, 80, 100, 120, 140, 160, 180, 200, 220];
const size = sizes[level] * scale;
const ball = Bodies.circle(x, y ?? limitY, size, {
label: 'ball',
restitution: 0.1, // 彈力
density: 0.005, // 密度
airFriction: 0, // 空氣摩擦
mass: 5, // 質量
level, // 球體等級
render: {
sprite: {
texture: imgUrl[level], // 球體圖片
xScale: scale, // x軸比例
yScale: scale, // y軸比例
},
},
collisionFilter: {
group: canCollision ? 1 : -1, // 數字為正數時將總是碰撞,數字為負數時將總是不碰撞
category: canCollision ? categoryOn : categoryOff, // 物體的分組
mask: canCollision ? categoryOn : categoryOff, // 允許與哪個分組的物體與之碰撞
},
});

Composite.add(world, ball);

if (isStatic) {
Body.setStatic(ball, isStatic); // 設定球體是否靜態
}

return ball;
};

// 創建初始球體,供玩家拖動
let holdBall = createBall(0, true, render.options.width / 2, limitY, false);

// 執行渲染器
Render.run(render);
// 建立運行器
const runner = Runner.create();
// 執行運行器
Runner.run(runner, engine);

遊戲事件

在遊戲中,需要監聽一些事件,例如滑鼠拖動、滑鼠點擊、碰撞等等。
matter.js 提供了Events物件,可以用來監聽物體碰撞、畫布更新等等事件。

首先,為了讓玩家能夠操作,需要先註冊移動、放開事件的事件,詳情如下。

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
/**
* 拖動事件,用來控制球體的位置
*/
const handleMoveEvents = (e) => {
if (!holdBall) return;
e.preventDefault();
const x = e.offsetX === undefined ? e.touches[0].clientX : e.offsetX; // 滑鼠或觸控的x軸位置
Body.setPosition(holdBall, { x, y: limitY }); // 設定球體的位置
};
/**
* 放開事件,用來放開球體,並且檢查是否已結束遊戲,若未結束則創建新的隨機等級的球體
*/
const handleDropEvents = (e) => {
if (!holdBall) return;
Body.set(holdBall, 'collisionFilter', {
group: 1,
category: categoryOn,
mask: categoryOn,
});
// 解除球體的靜態狀態,讓球體可以受到重力影響
Body.setStatic(holdBall, false);
// 設定球體的更新時間
holdBall.updateTs = Date.now();
// 清空使用者可以拖動的球體
holdBall = null;
const x = e.offsetX === undefined ? e.changedTouches[0].clientX : e.offsetX;
setTimeout(() => {
// 若遊戲已結束則不創建新的球體
if (isGameOver) return;
const level = Math.floor(Math.random() * 5); // 隨機等級
holdBall = createBall(level, true, x, null, false); // 創建新的球體
}, 500);
};

// 註冊滑鼠或觸控事件
scene.addEventListener('mousemove', handleMoveEvents, false);
scene.addEventListener('touchmove', handleMoveEvents);
scene.addEventListener('mouseup', handleDropEvents, false);
scene.addEventListener('touchend', handleDropEvents);

接著,需要監聽以下事件並執行對應的邏輯。

  • collisionStart: 球體碰撞事件,用來合成球體
  • afterUpdate: 檢測球體是否超出遊戲畫面,若超出則結束遊戲
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
// 監聽碰撞事件
Events.on(engine, 'collisionStart', (event) => {
const { pairs } = event;
// 遍歷碰撞對
let validPairs = new Set();
for (let i = 0; i < pairs.length; i += 1) {
const pair = pairs[i];
// 檢查碰撞對中的物體是否為非靜態的、沒有被合併過、且等級相同、且等級小於10
if (!pair.bodyA.isStatic
&& !pair.bodyB.isStatic
&& pair.bodyA.level < 10
&& pair.bodyA.level === pair.bodyB.level
&& !validPairs.has(pair.bodyA.id)
&& !validPairs.has(pair.bodyB.id)
) {
// 取得兩個物體的中心點
const x = (pair.bodyA.position.x + pair.bodyB.position.x) / 2;
const y = (pair.bodyA.position.y + pair.bodyB.position.y) / 2;
// 將新物體添加到世界中,並移除碰撞的原始物體
Composite.remove(engine.world, [pair.bodyA, pair.bodyB]);
const ball = createBall(pair.bodyA.level + 1, false, x, y);
ball.updateTs = Date.now();
score.value += 10 * (pair.bodyA.level + 1); // 更新分數
validPairs = new Set([...validPairs, pair.bodyA.id, pair.bodyB.id]);
}
}
});
// 檢測所有球的包圍框是否小於限制,是的話就結束遊戲
Events.on(engine, 'afterUpdate', () => {
// 取得所有球體
const balls = Composite.allBodies(engine.world).filter((body) => body.label === 'ball'
&& body.collisionFilter.group === 1
&& body.collisionFilter.category === categoryOn
&& Date.now() - body.updateTs > 1000);
// 檢查是否有球體超出遊戲畫面
isGameOver = balls.some((ball) => ball.bounds.min.y < limitY);
if (isGameOver) {
Runner.stop(runner); // 停止運行器
alert('獲得分數: ' + score.value);
}
});

最後,若玩家希望重新開始遊戲,可以在介面上多一顆重新開始的按鈕,並使用以下程式碼,來重新開始遊戲。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 重新開始遊戲按鈕
document.querySelector('#restart').addEventListener('click', () => {
score.value = 0; // 重置分數
isGameOver = false; // 重置遊戲狀態
// 清除除了邊界以外的所有物體
Composite.allBodies(engine.world).forEach((body) => {
if (body.label === 'ball') {
Composite.remove(engine.world, body);
}
});
holdBall = createBall(0, true, render.options.width / 2, limitY, false);
Runner.run(runner, engine);
});

結語

matter.js 真的很適合用來做2D物理遊戲,只需要短短幾行程式碼,就可以做出許多有趣的物理效果,更多物理效果的使用方式可以參考 matter.js 官方文檔
這個遊戲的原始碼已經上傳到 GitHub ,你可以到這裡下載這個遊戲的原始碼,並且自己動手來製作一個西瓜遊戲。