今年 地味ハロウィン 的第一名我給這位 LOL - パンダが足にまとわりついてしまう飼育員
パンダが足にまとわりついてしまう飼育員#地味ハロウィン #DPZ pic.twitter.com/GlHyjuZJ5R
— チカ (@chica_1107) October 31, 2020
大約是在前陣子 GitHub 的 profile readme 很夯的時候,我在網路上看到了 matter.js 這個套件的作品,腦袋中就萌生一個點子想試試看,但因為真的沒有實際用處,也不確定效果好不好,就被我一直擱置,直到這個週末的空閒時間才決定要來實現它。
整體想法是這樣的,我想從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。
這邊用我老婆☺️ 的名字作為範例先給大家看看成果:
而放到 GitHub 頁面的效果如下:
效果跟我想像的還是有點差異,不過也有八成像了,今天就利用我製作的小玩具來介紹一下 matter.js 的基本使用方式。
matter.js 是一套由 JavaScript 撰寫的物理引擎,讓你能透過 JS 在瀏覽器上模擬物理反應,可以輕易調整物體重量、質量、速度,甚至是密度、摩擦力等等變量,非常適合用在需要呈現物理效果的 2D 遊戲中。
其提供的 API 也設計得簡單好用,只是雖然每個 API 都有文件,但內容都不太實用,如果你需要調整細節的話,要馬就自己慢慢更動嘗試,不然就得查看其原始碼會比較清楚。
而至於支援度部分也無須擔心,瀏覽器支援 IE8+,手機的觸控 Event 也不成問題。我覺得是另一個如同 GSAP 一樣值得花點時間學習把玩的前端工具。
在進入我們的範例製作解析前,我想先條列介紹 matter.js 中的常用套件,除了先了解整體的 Context 外,也能當作之後說明實作內容時的 reference。
matter.js 的 API 定義的很易懂,既然是做物理模擬,當然就要有 World
、Body
與 Constraint
,而這些也是你使用 matter.js 所需要的基礎元件。
World: matter.js 透過此模組來創建一個模擬世界,可以微調世界中的一些屬性,像是重力、邊界等等,而一個世界當然是由多個 Bodies 所組成。
Bodies: Bodies 模組提供你方法去生成一些物體,像是圓形物體、方形物體等等,你也可以傳入 svg、img 去客製化物體形狀與樣式。產生的物體放入 World 中後就可以被 render 在畫面上。
Body: 利用 Bodies 產生的物件可以利用 Body 模組來進行進一步的操控。透過 Body,你可以旋轉、縮放、位移你的物體,也可以更改物體本身的密度、速度等等。換句話說,Body 讓你調整物體的物理特性。
Engine: 引擎,顧名思義就是驅動整個模擬物理世界的動力,根據 Body 的物理性質來精準掌控 World
內 Body
彼此間的物理現象,確保能模擬出符合設定的反應。是 matter.js 的核心。主要的程式碼意外的沒有很長,可以大略看出 Engine 會負責控制 Bodies 之間的狀態更新。
Render: matter.js 有提供一個 Canvas based 的 Renderer,讓你能將 Engine 所催動的結果繪製出來,這個內建的 Render 模組主要是讓你用在開發與除錯上的,但對於簡單的動畫或遊戲,還是可以使用。另外要注意的是,該模組預設只會繪製出 wirefram 與向量,你要主動將 render.options.wireframes
設為 false,否則,以今天的模組為例(我們今天的範例也是用此模組開發。),他會變成這樣:
不過照這樣看來,依照官方的意思,如果你要使用 matter.js 來製作遊戲等等,基本上應該要自己實作 Render,你才能更好的控制畫面的變化。官方有提供一些 Renderer 的範例,也可以從其原始碼參考。
Composites: 這個模組有點像是 Bodies 模組,差別在於 Bodies 模組讓你創建出 ”一個“ 物體,而 Composites 提供方法讓你創建出多個物體所組合而成的物體,像是 Stack、Pyramid 或甚至是 Car, Chain 等等常用的內建組合。
Composite: 如同 Body 對應於 Bodies,Composite 就是對應於 Composites 的模組,讓你控制由 Composites 創建出的組合物體的物理特性。
Constraint: Constraint 模組讓你能為兩個物體之間增加物理限制,像是兩物體一定要間隔一定距離等等。這個模組在我們這次的範例中我沒有用到,不過官網有不少範例都有使用,像是 Newton's Cradle。
MouseConstraint: 如同 Constraint,這個模組讓你增加滑鼠與物體之間的”約束”,透過建立物體與滑鼠的限制,就可以讓使用者透過滑鼠與你創建的物體互動。前面的範例中沒用到,但後面我會稍微帶到如何使用。
const Engine = Matter.Engine;
const Render = Matter.Render;
const Composites = Matter.Composites;
const World = Matter.World;
const Bodies = Matter.Bodies;
const Body = Matter.Body;
起手式就是先將先前介紹過的模組都宣告出來。
// create engine
const engine = Engine.create();
const world = engine.world;
// create renderer
const render = Render.create({
element: document.body,
engine: engine,
options: {
width: 920,
height: 600,
}
});
接著創建 instance,利用 Engine.create()
創造 Engine 實例,而 engine.world
最後會需要傳給 World
模組,可以想像成是此引擎(Engine) 所驅動的世界(world)。
Render
的部分我們要指定使用的 engine、要渲染的 root element,以及寬高等基本選項。更細部的 properties 可以參考官網文件,以我們的範例來說,只需要這樣就夠了。
到目前為止,我們設定好了 Engine
與 Render
的實例,代表我們已經準備好了一個虛擬的世界,然而光是準備好還不夠,我們需要“啟動”它。
所謂的啟動,其實就是要不斷地去呼叫 Engine.update()
來觸發引擎計算,或是讓 Renderer 更新畫面,執行類似下面的動作:
(function run() {
window.requestAnimationFrame(run);
Engine.update(engine, 1000 / 60);
})();
而實際上 matter.js 內有另一個模組 Matter.Runner
,可以來幫忙運行引擎與觸發 Render,在 Engine
與 Render
物件內都有個叫 run
的 helper 函式,就是用到此內建 Runner 模組,只要將實例放入,matter.js 的 Runner
就會幫忙執行 Runner 該做的事:
Engine.run(engine);
Render.run(render);
不過,與前面提到的 Matter.Render
類似,依照官網說法,內建的 Matter.Runner
主要也是開發與除錯用途,只適合用在簡單的小應用上。
Engine 與 Render 都啟動了,虛擬世界已上線,再來就只要往裡面丟入物體就好了。
分析一下我的點子:從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。
大致需要幾個條件:
從 matter.js 的官網中可以找到許多範例,從那些範例內,可以大致摸索出自己需要哪些模組才能拼湊出這樣的效果。
首先,可以利用 Composites.stack
ref 來製造出堆疊好的 contribution graph:
API: Matter.Composites.stack(xx, yy, columns, rows, columnGap, rowGap, callback)
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
// ...略
const block = Bodies.rectangle(x, y, 15, 15, {
render: {
fillStyle: color[~~(Math.random() * 2)], // 隨機給定格子顏色
strokeStyle: '#fff',
},
frictionAir: 0.03,
});
// ...略
return block;
});
Composites.stack
前面六個參數可以定義一個 grid 空間,範例中我們在相對於 Render
設定範圍的 x 軸 125px 與 y 軸 15px 的位置開始放置 stack,並定義該 grid 是 45 x 7 的格子(GitHub 上每行七天,大約 45 週),每個方塊大小 15px x 15px,格子與格子之間我們不需要空格,因此 columnGap 與 rowGap 都填 0。
而最後的 callback 函數中,可以組合多個 body 來擺放在其 grid 空間中。舉例來說,我們想要繪製出 contribution graph 的話,就是在 callback 函式中,利用 Bodies.rectangle
來產生一個個的小方塊,在這個 callback 中可以做很多事情,包含定義方塊的顏色、狀態等等。
到這邊可以繪製出一個還不錯的 contribution graph:
要客製化 contribution graph 好像很不少方式,像是這個,但我沒想那麼多 LOL 畢竟一開始只是想實驗看看,所以就用最土炮的方式,用 pixilart 手動在 45x7 的格子上用 pixel art 的方式寫出名字,然後再慢慢把格子數出來,建立一個雙層陣列來存:
const nameBlock = [
[7, 8, 9, 10, 'A', 13, 14, 15, 16, 17, 'R', 20, 26, 'V', 28, 29, 30, 31, 32, 'I', 34, 35, 39, 'N'],
[6, 11, 'A', 13, 18, 'R', 20, 26, 'V', 30, 'I', 34, 35, 36, 39, 'N'],
[6, 11, 'A', 13, 17, 18, 'R', 20, 26, 'V', 30, 'I', 34, 36, 37, 39, 'N'],
[6, 7, 8, 9, 10, 11, 'A', 13, 16, 17, 'R', 20, 26, 'V', 30, 'I', 34, 37, 38, 39, 'N'],
[6, 11, 'A', 13, 15, 16, 'R', 21, 25, 'V', 30, 'I', 34, 38, 39, 'N'],
[6, 11, 'A', 13, 16, 17, 'R', 22, 24, 'V', 30, 'I', 34, 39, 'N'],
[6, 11, 'A', 13, 17, 18, 'R', 23, 'V', 28, 29, 30, 31, 32, 'I', 34, 39, 'N'],
];
然後在剛剛的 Composites.stack
的 callback 函數中,我就能判斷當下繪製的 body(rectangle)是不是屬於名字的一部分,進一步做處理:
// 根據當下的 rectangle 位置 (x, y) 與 nameBlock 做比對
const static = (x, y) => {
const indexX = (x - 125) / 15;
const indexY = (y - 15) / 15;
const block = nameBlock[indexY];
// 若是屬於名字的一部分,設定為 static,然後給予不同的顏色設定
if (block && block.indexOf(indexX) !== -1) {
return [true, ['#229A3B', '#196126']];
}
return [false, ['#EBEDEF', '#C5E48B']];
};
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
const [isStatic, color] = static(x, y);
const block = Bodies.rectangle(x, y, 15, 15, {
//...略
});
return block;
});
繪製成果:
另外,在上面我自製的 static
函式中,會根據 rectangle 是否屬於名字的一部分,回傳 isStatic
布林值,這個值其實是屬於 Body
的一個 property,若 isStatic
設為 true,則該物體就不會受到其他物體的物理影響,很適合用在製作牆壁之類的物體,也恰好可以用來滿足我希望名字能被定住的需求。
而由於我希望方塊們是在掉落到一半的時候,名字才卡住,而其餘的方塊得隨著地心引力繼續下落,所以我必須要延緩設定 isStatic
的時間點,不能在我使用 Bodies
創建 rectangle 時就設定,需要來個 setTimeout 才行:
setTimeout(() => {
Body.setStatic(block, isStatic);
}, 800);
由於因為“物理界”的正常現象,方塊會從我們設定的 y 軸 15px 的地方掉落,而在下落的 800ms 時,我們透過 Body.setStatic()
這個 method 讓屬於名字部分的方塊變為 static,這樣就能達到名字掉落一半時定住,其餘方塊繼續掉落的效果:
想要的效果達成一半了,就是方塊掉落速度太線性了,而且直直落到畫面外也有點好笑,我們需要製造一點障礙物以及改變物體的速度,產生撞擊的效果。
首先,增加障礙物。
要增加障礙物很簡單,matter.js 的範例裡面很多都有利用 Bodies.rectangle
去創建牆壁,控制物體的活動範圍,這在製作遊戲時也是很重要的一部分。我們也可以如法泡製,增加四面八方的牆壁:
API: Matter.Bodies.rectangle(x, y, width, height, [options])
const wallOption = {
render: {
fillStyle: 'transparernt',
strokeStyle: '#FBFBFB',
},
isStatic: true,
};
const topWall = Bodies.rectangle(450, 0, 650, 30, wallOption);
const bottomWall = Bodies.rectangle(450, 500, 600, 30, wallOption);
const rightWall = Bodies.rectangle(880, 10, 30, 420, wallOption);
const leftWall = Bodies.rectangle(110, 10, 30, 420, wallOption);
牆壁的製作就是利用前面提到的 isStatic
屬性,讓他固定住,然後設定好擺放位置與長寬即可。唯一要注意的是,牆壁的長度要調整,不能四面都ㄧ樣長,這樣小方塊撞擊到牆壁後,還能從邊緣掉落或向外噴散,效果會好一點。
加了牆壁後,讓小方塊不會直直掉落,有了一些回饋感:
接著是物體的速度。
Matter.Body
有提供 setVelocity
這個屬性可以立即增加物體本身的線性速度,調整的方式為給予一個向量,因此可以調整施予速度的方向性:
API: Matter.Body.setVertices(body, vertices), Vertor: { x: 0, y: 0 }
Body.setVelocity(block, {x: 3, y: -10});
這樣就會讓一個小方塊往 x 軸 3,y 軸 -10 的方向增加速度,再加上先前加入的牆壁與固定住的名字方塊,產生的撞擊反彈就能達成這樣的效果:
除此之外,Bodies.rectangle
在宣告時能夠傳入調整物理特性的 properties,像是 frictionAir
可以改變物體的空氣摩擦力,數值越高,物體掉落越慢,並且都能透過 Matter.Body
去操控,例如:
Body.set(block, { frictionAir: 0 });
相關 API 官網都有條列出來。
將上述調整物體物理特性的函式呼叫搭配適當的 setTimeout,就能夠完成我們今天的範例效果:
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
const [isStatic, color] = static(x, y);
const block = Bodies.rectangle(x, y, 15, 15, {
// ...略
});
setTimeout(() => {
Body.setStatic(block, isStatic);
}, 800);
setTimeout(() => {
Body.set(block, { frictionAir: 0 });
}, 600);
setTimeout(() => {
if (!isStatic) {
Body.setVelocity(block, {x: 3, y: -10});
}
}, 900);
return block;
});
喔對了,最後當然要記得把我們產生的 Stack composites 與牆壁放入模擬的世界中:
// const world = engine.world;
World.add(world, [
stack,
// walls
topWall,
bottomWall,
rightWall,
leftWall
]);
matter.js 主打物理引擎,當然不是單純用來製造動畫,而是用來製作遊戲等等,也就是說要能與使用者互動,而方法就是一開始提到過的 MouseConstraint
,雖然這次範例用不著這個東西,但還是放個使用方法在這邊供參考:
const Mouse = Matter.Mouse;
const MouseConstraint = Matter.MouseConstraint;
const mouse = Mouse.create(render.canvas);
const mouseConstraint = MouseConstraint.create(engine, {
mouse: mouse,
constraint: {
stiffness: 3,
render: {
visible: false
}
}
});
World.add(world, mouseConstraint);
用法其實很簡單,其中 constraint 參數 visible
代表著滑鼠的拖拉軌跡會不會呈現出來,而 stiffness 可以算是調整所設定的 constraint 的韌度,調整該值可以影響物體受牽制(與滑鼠互動)後產生的彈性。文字可能有點難以描述,有需要使用的時候可以從官網文件查看可調整的參數值,試試看效果再決定要如何設置。
上述設定的效果如下:
最後放上程式碼連結供各位參考:https://codepen.io/arvin0731/pen/qBNoLQv
Matter.js 應該算是蠻久的一個工具了,以使用上來說非常容易上手,做些小動畫小遊戲蠻適合的,至於要真的用來製作複雜的遊戲的話,可能還是要再多研究他的效能如何,畢竟我這次並沒有觸碰到那塊,就歡迎有接觸過的讀者分享了!
畢竟這個範例也是拼拼湊湊而來的,週末小玩具就是這樣,的確沒辦法理解到他底層是如何實作,但是至少完成了想要的效果,然後也知道了這個工具的一些基本用法,之後有需要時可以快速拿來使用。
不過,提醒自己也提醒大家,要記得撥出時間去理解底層原理,因為這才是能讓你成長的要素,共勉之啦!