把網頁當畫布揮灑的 Canvas 05:物理基礎與dat-gui

學會 Canvas 的基本操作後,今天我們要試著繪製一顆彈跳球,就像下面這張 GIF ,讓球擁有速度、加速度等真實世界中的物理特性,如何不靠其他套件寫出來呢?

彈跳球

那就免不了複習一下國中物理啦!


物理基礎特性

速度:位置的變化量

我們可以透過速度(Velocity)來描繪網頁元素的位置變化,指的是單位時間內元素位置的變化量。而位置的變化是有方向性的,速度也因此具備方向性,屬於一種向量(Vector)。在二維直角座標系中,速度可以分解成單位時間內水平位置的變化量 Vx 與垂直位置的變化量 Vy

加速度:速度的變化量

加速度(Acceleration)用來描述速度的變化量,當然也是一種向量,在二維座標系中可以被分解為單位平方時間內水平速度的變化量 Ax 與垂直速度的變化量 Ay 。值得筆記的是,水平加速度與垂直加速度兩者是互相獨立的,好比常見的重力加速度,只會影響物件的垂直速度變化。

反彈:速度方向的變化

一個動態的物體碰撞到另一個靜態的物體,該動態物體將會反彈,從速度的角度來看,即速度的垂直分量 Vy 方向反轉

摩擦力:與速度方向相反的作用力

摩擦力(Friction)指的是兩個物體的表面互相接觸滑動時所產生抵制兩者相對移動的作用力,簡單來說,摩擦力和物體的運動方向相反,會導致物體的速度愈來愈

基本上,物體在液體或空氣中,都會受到摩擦力的影響。而物體的速度愈快,摩擦力也就愈大。在實作中,我們將速度乘上一個摩擦係數 fade ,達到摩擦力的效果。

常見的物理特性


彈跳球實作

程式結構規劃

初始化 Canvas :

1
2
3
4
5
6
7
8
9
10
11
12
const canvas = document.getElementById('my-canvas')
const ctx = canvas.getContext('2d')

// Set the size of canvas and assign it to ww / wh
let ww = canvas.width = window.innerWidth
let wh = canvas.height = window.innerHeight

// Resize the canvas when the window resized
window.addEventListener('resize', function() {
ww = canvas.width = window.innerWidth
wh = canvas.height = window.innerHeight
})

innerWidthinnerHeight 獲取瀏覽器內容可視(Viewport)區域的寬與高,包括垂直或水平滾動卷軸。outerWidthouterHeight 則獲取整個瀏覽器的寬與高,包括上方標籤列、搜尋列、書籤列、側邊欄位等等。兩者比較可以參考 W3Schools 的範例。

透過物件導向開發,用建構函式建立球(Ball)的類別和方法:

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
55
56
57
58
59
60
61
62
function Ball() {
this.p = {
x: ww / 2,
y: wh / 2
}
this.v = {
x: 3,
y: 3
}
this.a = {
x: 0,
y: 1
}
this.r = 50
this.dragging = false
}

// Draw the ball
Ball.prototype.draw = function() {
// Use canvas to draw
ctx.beginPath()
ctx.save()
ctx.translate(this.p.x, this.p.y)
ctx.arc(0, 0, this.r, 0, Math.PI * 2)
ctx.fillStyle = controls.color
ctx.fill()
ctx.restore()
}

Ball.prototype.update = function() {
if(this.dragging === false) {
// Update the ball's position
this.p.x += this.v.x
this.p.y += this.v.y

// Update the ball's velocity
this.v.x += this.a.x
this.v.y += this.a.y

// Set the friction to slow down the ball
this.v.x *= controls.fade
this.v.y *= controls.fade

// Renew the dat.GUI controls' values
controls.vx = this.v.x
controls.vy = this.v.y
controls.ay = this.a.y

this.checkBoundary()
}
}

// Make the ball bounce off when touch the canvas boundary
Ball.prototype.checkBoundary = function() {
// Check X boundary
if(this.p.x + this.r > ww ) this.v.x = - Math.abs(this.v.x)
if(this.p.x - this.r < 0 ) this.v.x = Math.abs(this.v.x)

// Check Y boundary
if(this.p.y + this.r > wh ) this.v.y = - Math.abs(this.v.y)
if(this.p.y - this.r < 0 ) this.v.y = Math.abs(this.v.y)
}

checkBoundary 方法中透過 Math.abs() 取得速度的絕對值後,再反轉,避免更新速度過快而產生反轉失效的 BUG 。

整個彈跳球專案的初始化、更新球的數值以及渲染畫面:

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
// Init the ball
function init() {
ball = new Ball()
}

init()

// Update the status of the ball
function update() {
if(controls.update) {
ball.update()
}
}

// Update the status of the ball 1000 / 30 times per second
setInterval(update, 1000 / 30)

// Draw the ball
function draw() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' // Set the opacity to make the afterimage
ctx.fillRect(0, 0, ww, wh) // Renew the whole canvas
ball.draw()

// requestAnimationFrame(draw) // Recurse to draw the canvas
setTimeout(draw, 1000 / controls.FPS) // Use setTimeout to customize the FPS
}

// requestAnimationFrame(draw)
draw()

⭐ 這裡為了自己控制 FPS 而使用 setTimeout(draw, 1000 / controls.FPS) ,其實使用 requestAnimationFrame(draw) 繪製畫面也是可以的!

⭐ 為什麼使用 setTimeout() 而不是 setInterval() ?因為每次渲染畫面後,都倒數下一幀所需的秒數。若用 setInterval() 則會每次渲染畫面後都排定一個間隔計時器,導致畫面 LAG 。

dat.GUI 控制工具

在製作動畫互動網頁特效的過程中,使用 dat.GUI 能夠幫助我們快速調整物件的物理參數。

透過 CDN 載入:

1
<script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.js" integrity="sha256-B2A44YCanY9zaHL5nwcihGM3GiWpOE4md3COMTdAggM=" crossorigin="anonymous"></script>

自定義 dat.GUI 物件,在此實作中包括水平速度、垂直速度、垂直加速度以及 FPS 等等。

1
2
3
4
5
6
7
8
9
10
11
12
const controls = {
vx: 0,
vy: 0,
ay: 0.6,
fade: 0.99, // 摩擦力大小
update: true, // 是否實時更新畫面
color: '#fff', // 球的顏色
step: function() { // 透過點擊更新一次畫面
ball.update()
},
FPS: 30
}

初始化 dat.GUI 並透過 add() 加入控制參數,唯有當控制項為顏色色票時,必須以 addColor() 加入。

1
2
3
4
5
6
7
8
9
10
11
const gui = new dat.GUI()

// add(controlObj, propertyStr, minValue, maxValue)
gui.add(controls, 'vx', -50, 50).listen().onChange(value => ball.v.x = value)
gui.add(controls, 'vy', -50, 50).listen().onChange(value => ball.v.y = value)
gui.add(controls, 'ay', -1, 1).step(0.001).listen().onChange(value => ball.a.y = value)
gui.add(controls, 'fade', 0, 1).step(0.01).listen()
gui.add(controls, 'update')
gui.addColor(controls, 'color')
gui.add(controls, 'step')
gui.add(controls, 'FPS', 1, 120)

listen() 即時更新 dat.GUI 的數值。

step() 參數的精準度,即最小的單位級距。

onChange(function() { ... }) 當參數改變時所要執行的動作,類似於 change 監聽事件。

onFinishChange(function() { ... }) 當參數改變完成時所要執行的動作,類似於解除 focus  監聽。

更多 dat.GUI 教學可以參考官方教學指南

實作拖曳效果

拖曳實作的邏輯:

  1. 當滑鼠點下(mousedown),開啟拖曳模式: dragging = true
  2. 滑鼠移動時(mousemove),讓物件拖曳變化量 = 滑鼠移動變化量,且物件速度 = 滑鼠最後移動的變化量。就像皮卡丘打排球遊戲,滑鼠最後移動的變化量等於我們給排球的作用力,作為慣性將球拋射出去。
  3. 當滑鼠點開(mouseup),結束拖曳模式。

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
// Set the mouse position
const mousePos = {x: 0, y: 0}

// Drag the ball when mousedown
canvas.addEventListener('mousedown', function(e) {
mousePos.x = e.x
mousePos.y = e.y

let dist = getDistance(mousePos, ball.p)
if(dist < ball.r) ball.dragging = true
})

// Change the mouse position when mouse moving
canvas.addEventListener('mousemove', function(e) {
let nowPos = {x: e.x, y: e.y}

if(ball.dragging) {
let dx = nowPos.x - ball.p.x
let dy = nowPos.y - ball.p.y

ball.p.x += dx
ball.p.y += dy

ball.v.x = dx
ball.v.y = dy
}

// Show the draggable icon when mouse moving in the ball
let dist = getDistance(nowPos, ball.p)
canvas.style.cursor = dist <= ball.r ? 'move' : 'initial'
})

// Undrag the ball when mouseup
canvas.addEventListener('mouseup', function(e) {
ball.dragging = false
})

計算兩物距離

計算兩物件之間的距離:兩物水平位置差的平方加上兩物垂直位置差的平方,再開根號。

1
2
3
4
5
6
function getDistance(p1, p2) {
let distX = p1.x - p2.x
let disY = p1.y - p2.y
let dist = Math.pow(distX, 2) + Math.pow(disY, 2)
return Math.sqrt(dist)
}

計算平方: Math.pow(base, exponent)

計算平方根: Math.sqrt()


參考資料

  1. 動畫互動網頁特效入門(JS/CANVAS):5-3 物理基礎
把網頁當畫布揮灑的 Canvas 06:向量 把網頁當畫布揮灑的 Canvas 04:狀態儲存與還原

評論

Your browser is out-of-date!

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

×