Well in Time
使用 Matter.js 2D 物理引擎製作動畫
11-01-202015 Min Read

今年 地味ハロウィン 的第一名我給這位 LOL - パンダが足にまとわりついてしまう飼育員

前言

大約是在前陣子 GitHub 的 profile readme 很夯的時候,我在網路上看到了 matter.js 這個套件的作品,腦袋中就萌生一個點子想試試看,但因為真的沒有實際用處,也不確定效果好不好,就被我一直擱置,直到這個週末的空閒時間才決定要來實現它。

整體想法是這樣的,我想從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。

這邊用我老婆☺️ 的名字作為範例先給大家看看成果:

wife

而放到 GitHub 頁面的效果如下:

github

效果跟我想像的還是有點差異,不過也有八成像了,今天就利用我製作的小玩具來介紹一下 matter.js 的基本使用方式。

基本介紹

matterjs-website

matter.js 是一套由 JavaScript 撰寫的物理引擎,讓你能透過 JS 在瀏覽器上模擬物理反應,可以輕易調整物體重量、質量、速度,甚至是密度、摩擦力等等變量,非常適合用在需要呈現物理效果的 2D 遊戲中。

其提供的 API 也設計得簡單好用,只是雖然每個 API 都有文件,但內容都不太實用,如果你需要調整細節的話,要馬就自己慢慢更動嘗試,不然就得查看其原始碼會比較清楚。

而至於支援度部分也無須擔心,瀏覽器支援 IE8+,手機的觸控 Event 也不成問題。我覺得是另一個如同 GSAP 一樣值得花點時間學習把玩的前端工具。

在進入我們的範例製作解析前,我想先條列介紹 matter.js 中的常用套件,除了先了解整體的 Context 外,也能當作之後說明實作內容時的 reference。

Matter.js 的通用模組

matter.js 的 API 定義的很易懂,既然是做物理模擬,當然就要有 WorldBodyConstraint,而這些也是你使用 matter.js 所需要的基礎元件。

World: matter.js 透過此模組來創建一個模擬世界,可以微調世界中的一些屬性,像是重力、邊界等等,而一個世界當然是由多個 Bodies 所組成。

Bodies: Bodies 模組提供你方法去生成一些物體,像是圓形物體、方形物體等等,你也可以傳入 svg、img 去客製化物體形狀與樣式。產生的物體放入 World 中後就可以被 render 在畫面上。

Body: 利用 Bodies 產生的物件可以利用 Body 模組來進行進一步的操控。透過 Body,你可以旋轉、縮放、位移你的物體,也可以更改物體本身的密度、速度等等。換句話說,Body 讓你調整物體的物理特性。

Engine: 引擎,顧名思義就是驅動整個模擬物理世界的動力,根據 Body 的物理性質來精準掌控 WorldBody 彼此間的物理現象,確保能模擬出符合設定的反應。是 matter.js 的核心。主要的程式碼意外的沒有很長,可以大略看出 Engine 會負責控制 Bodies 之間的狀態更新。

Render: matter.js 有提供一個 Canvas based 的 Renderer,讓你能將 Engine 所催動的結果繪製出來,這個內建的 Render 模組主要是讓你用在開發與除錯上的,但對於簡單的動畫或遊戲,還是可以使用。另外要注意的是,該模組預設只會繪製出 wirefram 與向量,你要主動將 render.options.wireframes 設為 false,否則,以今天的模組為例(我們今天的範例也是用此模組開發。),他會變成這樣:

matterjs-wireframe

不過照這樣看來,依照官方的意思,如果你要使用 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 可以參考官網文件,以我們的範例來說,只需要這樣就夠了。

到目前為止,我們設定好了 EngineRender 的實例,代表我們已經準備好了一個虛擬的世界,然而光是準備好還不夠,我們需要“啟動”它。

所謂的啟動,其實就是要不斷地去呼叫 Engine.update() 來觸發引擎計算,或是讓 Renderer 更新畫面,執行類似下面的動作:

(function run() {
  window.requestAnimationFrame(run);
  Engine.update(engine, 1000 / 60);
})();

而實際上 matter.js 內有另一個模組 Matter.Runner,可以來幫忙運行引擎與觸發 Render,在 EngineRender 物件內都有個叫 run 的 helper 函式,就是用到此內建 Runner 模組,只要將實例放入,matter.js 的 Runner 就會幫忙執行 Runner 該做的事:

Engine.run(engine);
Render.run(render);

不過,與前面提到的 Matter.Render 類似,依照官網說法,內建的 Matter.Runner 主要也是開發與除錯用途,只適合用在簡單的小應用上。

萬事俱備,只欠東風

Engine 與 Render 都啟動了,虛擬世界已上線,再來就只要往裡面丟入物體就好了。

分析一下我的點子:從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。

大致需要幾個條件:

  • 一堆小方塊來堆疊出 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:

github-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;
});

繪製成果:

withName

另外,在上面我自製的 static 函式中,會根據 rectangle 是否屬於名字的一部分,回傳 isStatic 布林值,這個值其實是屬於 Body 的一個 property,若 isStatic 設為 true,則該物體就不會受到其他物體的物理影響,很適合用在製作牆壁之類的物體,也恰好可以用來滿足我希望名字能被定住的需求。

而由於我希望方塊們是在掉落到一半的時候,名字才卡住,而其餘的方塊得隨著地心引力繼續下落,所以我必須要延緩設定 isStatic 的時間點,不能在我使用 Bodies 創建 rectangle 時就設定,需要來個 setTimeout 才行:

setTimeout(() => {
  Body.setStatic(block, isStatic);
}, 800);

由於因為“物理界”的正常現象,方塊會從我們設定的 y 軸 15px 的地方掉落,而在下落的 800ms 時,我們透過 Body.setStatic() 這個 method 讓屬於名字部分的方塊變為 static,這樣就能達到名字掉落一半時定住,其餘方塊繼續掉落的效果:

name-fix-block-drop

增加阻礙、摩擦力與速度

想要的效果達成一半了,就是方塊掉落速度太線性了,而且直直落到畫面外也有點好笑,我們需要製造一點障礙物以及改變物體的速度,產生撞擊的效果。

首先,增加障礙物。

要增加障礙物很簡單,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-js-wall

接著是物體的速度。

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 的方向增加速度,再加上先前加入的牆壁與固定住的名字方塊,產生的撞擊反彈就能達成這樣的效果:

velocity

除此之外,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
]);

One more thing...

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 的韌度,調整該值可以影響物體受牽制(與滑鼠互動)後產生的彈性。文字可能有點難以描述,有需要使用的時候可以從官網文件查看可調整的參數值,試試看效果再決定要如何設置。

上述設定的效果如下:

matter-js-mouse

最後放上程式碼連結供各位參考:https://codepen.io/arvin0731/pen/qBNoLQv

結論

Matter.js 應該算是蠻久的一個工具了,以使用上來說非常容易上手,做些小動畫小遊戲蠻適合的,至於要真的用來製作複雜的遊戲的話,可能還是要再多研究他的效能如何,畢竟我這次並沒有觸碰到那塊,就歡迎有接觸過的讀者分享了!

畢竟這個範例也是拼拼湊湊而來的,週末小玩具就是這樣,的確沒辦法理解到他底層是如何實作,但是至少完成了想要的效果,然後也知道了這個工具的一些基本用法,之後有需要時可以快速拿來使用。

不過,提醒自己也提醒大家,要記得撥出時間去理解底層原理,因為這才是能讓你成長的要素,共勉之啦!

資料來源

  1. matter-js website
  2. Getting Started with Matter.js
  3. pixilart
© by Arvin Huang. All rights reserved.
theme inspired by @mhadaily
Last build: 11-07-2020