把網頁當畫布揮灑的 Canvas 06:向量

上回實作彈力球時,我們必須設定彈力球的位置速度加速度等物理特性,從數學的角度來看,這些特性都是具有方向性的數量,也就是所謂的向量(Vector)

學過物件導向開發的觀念,我們可以建立向量類別(Class)來儲存這些物理特性,將程式碼化繁為簡,提升程式碼複用的彈性。


向量概念

向量,指的是「具有方向性的數量(值)」。在座標系統中,我們可以把每一個座標點都視為一個向量,代表相對於原點 (0, 0) 的向量,簡單來說,就是從原點出發移動至該座標點的向量

向量加法

向量加法,等於兩向量相加。

A = (1, 2)
B = (-3, 5)
C = A + B = (-2, 7)

向量減法

向量減法,等於 A 向量加上 B 向量的反轉向量。

A = (1, 2)
B = (-3, 5)
C = A - B = A + (-B) = (4, -3)

向量縮放

向量縮放,等於向量乘上縮放係數。若縮放係數為反向,則反轉向量方向。

A = (1, 2)
A x 2 = (2, 4)
A x (-3) = (-3, -6)

向量長度

向量長度,即兩點之間的最短距離,為一不具方向性的純量,可透過直角三角形的畢氏定理(兩股平方相加後開根號)求得。

A = (5, -12)
|A| = 13

單位向量

單位向量即該向量長度為 1 的向量,常用來換算不同尺度之間的比例。

A = (3, 4)
Aunit = A x 1 / |A| = A x 1 / 5 = (0.6, 0.8)

向量運算方式


建構向量類別

建構函式與基本運算

透過建構函式建立向量類別,並在其原型物件中加入常見的運算方法,包括加法、減法、縮放、長度、角度以及單位向量。

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
// Vector Constructor
const Vector = function(x, y) {
this.x = x;
this.y = y;
};

// Add the vector to the otehr and return the result (new a vector to keep the original one unchanged)
Vector.prototype.add = function(V) {
return new Vector(this.x + V.x, this.y + V.y);
};

// Subtract the vector from the otehr and return the result (new a vector to keep the original one unchanged)
Vector.prototype.sub = function(V) {
return new Vector(this.x - V.x, this.y - V.y);
};

// Multiply the vector by s and return the result (new a vector to keep the original one unchanged)
Vector.prototype.mul = function(s) {
return new Vector(this.x * s, this.y * s);
};

// Calculate the length of the vector
Vector.prototype.length = function() {
const dist = Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2));
return dist;
};

// Calculate the angle of the vector
Vector.prototype.angle = function() {
return Math.atan2(this.y, this.x);
};

// Calculate the unit vector
Vector.prototype.unit = function() {
const unit = this.mul(1 / this.length());
return unit;
};

其他方法

一些常用的方法,也可以加入原型物件中,包括字串輸出、向量移動、重新賦值、兩向量比較。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Print the vector as a string
Vector.prototype.toString = function() {
return `(${this.x}, ${this.y})`;
};

// Change the vector's x / y value
Vector.prototype.move = function(x, y) {
this.x += x;
this.y += y;
return this;
};

// Reset the vector's x / y value
Vector.prototype.set = function(x, y) {
this.x = x;
this.y = y;
return this;
};

// Compare two vectors to check whether they are equal
Vector.prototype.equal = function(V) {
return this.x === V.x && this.y === V.y;
};

複製:注意物件傳參考

當我們想要複製一個向量時,不能單純以 = 賦值,因為物件傳參考(傳址)的特性會將兩個變數指向同一個記憶體位址以存取相同物件,導致若有一方的值被改變,另外一個的值也會隨之變動。所以,必須透過創造新物件的方式來複製物件

1
2
3
4
// Clone the vector by creating a new object
Vector.prototype.clone = function() {
return new Vector(this.x, this.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
const A = new Vector(3, 4);
const B = new Vector(-3, -5);

const C = A.add(B);
console.log(`${A} + ${B} = ${C}`);

const D = A.sub(B);
console.log(`${A} - ${B} = ${D}`);

const E = B.mul(3).mul(-2);
console.log(`${B} * 3 * (-2) = ${E}`);

const F = A.length();
console.log(`| A | = ${F}`);

console.log(A.unit());

console.log(`${A}${B} 是否相同? ${A.equal(B)}`);

const newA = A.clone();
newA.move(1, 2);
console.log(`newA : ${newA} ; A : ${A}`);
/* const newA = A
newA.move(1, 2)
console.log(`newA : ${newA} , A: ${A};物件傳參考特性, newA 和 A 都會指向同一個記憶體位置,其中一個修改,另一個也會跟著改動。因此,我們使用 clone 來產生一個新的物件。`)
*/

Cascade 串用方法

這裡值得一提的是 Cascade 的概念。在向量類別的 addsubmul 方法中,我們回傳一個新的向量物件 return new Vector(); ,代表運算後的結果;而 setmove 方法則回傳了物件本身 return this;

無論是回傳新的向量物件,還是回傳物件本身,這兩者都是由向量類別所建構的實體,當然可以繼續取用其方法。因此,我們得以撰寫類似 jQuery 鏈式風格的語法,譬如上述測試範例中 const E = B.mul(3).mul(-2); 連續呼叫物件方法,如此簡潔的語法設計就稱之為 Cascade

Kuro 大大在〈重新認識 JavaScript: Day 21 函式的 Combo 技: Cascade〉這篇文章中詳細解釋了 Cascade 的設計原理,這裡一併附上紀錄。

在 Canvas 中繪製向量圖形

我們試著在 Canvas 中繪製兩個 DEMO ,第一個是 drawVectorPointAtMouse ,第二個是 drawThreeVectors ,請參考下方程式碼,可以先思考這兩個繪圖函式分別會畫出什麼樣的圖形?

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
63
64
65
66
67
68
69
70
// Init the canvas 
const canvas = document.getElementById('my-canvas');
const ctx = canvas.getContext('2d');
let ww = canvas.width = window.innerWidth;
let wh = canvas.height = window.innerHeight;

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

// Record the mouse position by creating a new vector while mouse moving
let mousePos = new Vector(0, 0);
canvas.addEventListener('mousemove', function(e) {
mousePos = new Vector(e.x, e.y);
// console.log(`mouse : ${mousePos}`)
});

// Draw a vector
function drawVector(V, trans) {
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.save();
ctx.rotate(V.angle());
ctx.fillText(V, V.length() / 2, 10)
// Draw the vector and its arrow
ctx.lineTo(V.length(), 0);
ctx.lineTo(V.length() - 5, -5);
ctx.lineTo(V.length() - 5, 5);
ctx.lineTo(V.length(), 0);
ctx.lineWidth = 5;
ctx.strokeStyle = 'black';
ctx.stroke();
ctx.restore();

// If trans is true, translate the canvas' center to the head point of the vector
if (trans) ctx.translate(V.x, V.y);
}

// Draw a 100px vector that always points at the mouse
function drawVectorPointAtMouse() {
ctx.clearRect(0, 0, ww, wh);
const c = new Vector(ww / 2, wh / 2);

ctx.save();
ctx.translate(c.x, c.y);
const md = mousePos.sub(c);
drawVector(md.unit().mul(100), false);
ctx.restore();
}

// Draw three vectors
function drawThreeVectors() {
const v1 = new Vector(250, 0);
const v2 = new Vector(0, 200);
const v3 = v1.add(v2).mul(-1);
const c = new Vector(ww / 2, wh / 2);

ctx.save();
ctx.translate(c.x + 150, c.y);
drawVector(v1, true);
drawVector(v2, true);
drawVector(v3, true);
ctx.restore();
}

// Draw every 30ms
setInterval(drawVectorPointAtMouse, 30);
setInterval(drawThreeVectors, 30);


利用向量改寫彈力球的語法

學會透過向量類別繪製圖形後,我們可以改寫上回實作的彈力球程式碼,利用向量物件計算球的速度、加速度、反彈以及摩擦力等物理變化,讓程式變得更簡潔。在使用之前,必須先將向量建構函式複製到先前的程式碼中。以下為使用向量物件後的程式碼:

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
// The function constructor of Ball
function Ball() {
this.p = new Vector(ww / 2, wh / 2)
this.v = new Vector(3, 3)
this.a = new Vector(0, 1)
this.r = 50
this.dragging = false
}

// Update the status of the ball
Ball.prototype.update = function() {
if(this.dragging === false) {
// Update the ball's position, velocity and friction
this.p = this.p.add(this.v)
this.v = this.v.add(this.a)
this.v = this.v.mul(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()
}
}

// Set the mouse position
let mousePos = new Vector(0, 0)

// Drag the ball when mousedown
canvas.addEventListener('mousedown', function(e) {
mousePos = new Vector(e.x, e.y)
const dist = mousePos.sub(ball.p).length()
if(dist < ball.r) ball.dragging = true
})

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

if(ball.dragging) {
let delta = nowPos.sub(ball.p)
ball.p = ball.p.add(delta)
ball.v = delta.clone()
}

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


參考資料

  1. 動畫互動網頁特效入門(JS/CANVAS):5-4 向量概念
把網頁當畫布揮灑的 Canvas 07:貪吃蛇 把網頁當畫布揮灑的 Canvas 05:物理基礎與dat-gui

評論

Your browser is out-of-date!

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

×