跳到內容

Ball Class

本教學解釋如何在 canvas 動畫中使用 JavaScript 類別,不適合初學者。我建議先閱讀我們的「認識 Canvas」章節並嘗試色彩循環教學。

也就是說,我不會在這裡使用嚴格的類別語法,因為它只是 JavaScript 的「語法糖」,底層概念保持不變。如果您有興趣了解更複雜的動畫,我建議查看此頁面以獲得全面的介紹。否則,讓我們堅持基礎。

首先,類別只是一個普通的 JavaScript 函式。它的工作方式相同,但我們添加了將值綁定到函式特定實例的能力。在此範例中,我建立了一個根據基本物理行為的球。它追蹤其位置和速度,以及作用於它的「重力」。

<head>
<title>Simple Ball Bounce</title>
<meta description="Ball bounce"/>
<meta publisher="WhirlwindFX" />
</head>
<body style="margin: 0; padding: 0;">
<canvas id="exCanvas" width="320" height="200"></canvas>
</body>
<script>
// 從 DOM 取得 canvas 元素
var c = document.getElementById("exCanvas");
var ctx = c.getContext("2d");
// Canvas 變數
var width = 320;
var height = 200;
// 球位置的 X 分量
let ballX = 160;
// 球位置的 Y 分量
let ballY = 100;
// ballY 隨時間的變化。正值向下移動,負值向上移動
let velocityY = 0;
// velocityY 隨時間的變化。
let accelerationY = 1;
// 特效陣列
let effects = [];
function update() {
// 1. 覆寫上一個影格
ctx.fillStyle = background;
ctx.fillRect(0, 0, 320,200);
// 2. 確保特效陣列中有一個 Ball 類別的實例
if (effects.length < 1){
effects.push(new Ball());
}
// 3. 迭代特效陣列並呼叫每個元素的 draw 函式
effects.forEach(ele => {
ele.draw();
})
window.requestAnimationFrame(update);
}
// 4. 宣告 Ball 類別
function Ball(){
// 5. 設定實例變數
this.bx = ballX;
this.by = ballY;
this.vy = velocityY;
this.ay = accelerationY;
this.radius = 15;
this.impact = false;
// 6. 定義 draw 函式
this.draw = function(){
// 7. 以 y 加速度改變 y 速度
this.vy += this.ay;
// 8. 如果偵測到與地面的碰撞,將速度設定為向上並將碰撞布林值設定為 false。此檢查在下一個函式之前發生,以便我們繪製碰撞影格而不是跳過它。
if (this.impact){
this.vy = -10;
this.impact = false;
}
// 9. 偵測未來的碰撞。如果球位置加其半徑加其速度會使其低於地板,我們執行幾個操作——
if (this.by + this.radius + this.vy > 200){
// 將速度設定為 0 使球停留在我們這個影格放它的位置。
this.vy = 0;
// 將 impact 設定為 true,使下一次執行繪製向上彈跳
this.impact = true;
// 下一個影格繪製向上彈跳。此影格偵測到未來的碰撞。如果我們想看到碰撞本身,我們必須強制它。當球的外半徑低於地板時,我們向上移動球。
while (this.by + this.radius < 200){
this.by++;
}
}
// 10. 以 y 速度改變 y 分量。
this.by += this.vy;
// 繪製形狀。
ctx.beginPath();
ctx.arc(this.bx, this.by, this.radius, 0, Math.PI * 2);
ctx.fillStyle = "black"
ctx.fill();
}
}
window.requestAnimationFrame(update);
</script>

一旦我們克服了初始的複雜性,就更容易添加更進階的行為了。在下一個範例中,我添加了牆壁碰撞,並在 x 方向引入了一些隨機變化以保持球的運動。

<head>
<title>Full Bounce</title>
<meta description="Snake-like climbing gradient effect"/>
<meta publisher="WhirlwindFX" />
</head>
<body style="margin: 0; padding: 0;">
<canvas id="exCanvas" width="320" height="200"></canvas>
</body>
<script>
// 從 DOM 取得 canvas 元素
var c = document.getElementById("exCanvas");
var ctx = c.getContext("2d");
var width = 320;
var height = 200;
var hue = 0;
let ballX = 160;
// 1. 添加了 velocityX
let velocityX = 0;
let ballY = 100;
let velocityY = 0;
let accelerationY = 1;
let effects = [];
function update() {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 320,200);
if (effects.length < 1){
effects.push(new Ball());
}
effects.forEach(ele => {
ele.draw();
})
window.requestAnimationFrame(update);
}
function Ball(){
this.bx = ballX;
// 2. 給 x 一個隨機初始速度
this.vx = 100 * Math.sin(Date.now());
this.by = ballY;
this.vy = velocityY;
this.ay = accelerationY;
this.radius = 15;
// 3. 為水平接觸的碰撞布林值添加 x 和 y 分量
this.impactX = false;
this.impactY = false;
this.draw = function(){
this.vy += this.ay;
// 4. 每個影格,在 x 方向添加少量隨機速度以保持動量
this.vx += Math.sin(Date.now());
if (this.impactY){
this.vy = -10;
this.impactY = false;
}
// 5. 如果偵測到水平碰撞,反轉 x 速度並減去少量力以防止球失控。在這裡鏡像或添加力可以產生有趣的結果。
if (this.impactX){
this.vx *= -.9;
this.impactX = false;
}
if (this.by + this.radius + this.vy > height){
this.vy = 0;
this.impactY = true;
while (this.by + this.radius < height){
this.by++;
}
}
// 6. 偵測左右水平碰撞並像我們處理 y 分量一樣調整。
if (this.bx + this.radius + this.vx > width){
this.vx = 0;
this.impactX = true;
while (this.bx + this.radius > width){
this.bx--;
}
} else if (this.bx - this.radius + this.vx < 0){
this.vx = 0;
this.impactX = true;
while (this.bx - this.radius < 0){
this.bx++;
}
}
// 7. 以速度改變 x 和 y 分量。
this.by += this.vy;
this.bx += this.vx;
ctx.beginPath();
ctx.arc(this.bx, this.by, this.radius, 0, Math.PI * 2);
ctx.fillStyle = "black"
ctx.fill();
}
}
window.requestAnimationFrame(update);
</script>

我坦白告訴您,這個範例複雜(C 大寫),它可能需要比大多數基本特效更多的努力。為了避免在程式碼中添加太多注釋,我們在這裡介紹一些棘手的概念。

首先,一個球如何知道它將與另一個碰撞?每個影格,Ball 類別的每個實例都必須檢查其他每個球實例的位置。透過比較它們的位置,我們可以確定兩個球是否在彼此的半徑內,然後相應地反轉它們的 x 和 y 速度。

有時,一個球可能獲得足夠的速度穿過另一個球,使它們佔據幾乎相同的空間。調整兩個球位置所需的數學過於複雜,所以我簡單地移除我們當前正在檢查的球實例。由於 update 函式確保了最小數量的球,將建立一個新的球實例,模擬將繼續。

最後,我們應該在什麼時候計算每個球的速度和位置變化?球是在變數更新之前還是之後繪製重要嗎?在程式設計中,這兩個問題都有多個答案。為了處理這個問題,我引入了一種略微不同的碰撞追蹤方法。您可以選擇您喜歡的方法,但兩種方法都可以且確實有效。

<head>
<title>Multiple Balls</title>
<meta description="Snake-like climbing gradient effect"/>
<meta publisher="WhirlwindFX" />
</head>
<body style="margin: 0; padding: 0;">
<canvas id="exCanvas" width="320" height="200"></canvas>
</body>
<script>
// 從 DOM 取得 canvas 元素
var c = document.getElementById("exCanvas");
var ctx = c.getContext("2d");
var width = 320;
var height = 200;
var hue = 0;
let ballX = 160;
let velocityX = 0;
let ballY = 100;
let velocityY = 0;
let accelerationY = 1;
let effects = [];
function update() {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 320,200);
// 1. 增加特效陣列中存在的球的數量
if (effects.length < 3){
effects.push(new Ball());
}
effects.forEach(ele => {
ele.draw();
})
window.requestAnimationFrame(update);
}
function Ball(){
this.bx = ballX;
this.vx = 100 * Math.sin(Date.now());
this.by = ballY;
this.vy = velocityY;
this.ay = accelerationY;
this.radius = 15;
// 2. 為每個球添加唯一 ID,以便我們可以相互比較而不是與自身比較。我們也移除了「impactX」和「impactY」,改用更好的多球解決方案。
this.id = Math.random();
this.draw = function(){
this.vy += this.ay;
this.vx += Math.sin(Date.now());
if (this.by + this.radius + this.vy > height){
this.vy = -10;
} else if (this.by - this.radius + this.vy < 0){
// 3. 在 canvas 中添加「天花板」,以防球碰撞導致高度超過 0y。
this.vy = 1;
}
if (this.bx + this.radius + this.vx > width){
this.vx = -.9;
} else if (this.bx - this.radius + this.vx < 0){
this.vx = -.9;
}
// 4. 迭代特效陣列中的每個球以檢查碰撞
for (let i = 0; i < effects.length; i++){
let ele = effects[i];
// 如果我們正在檢查的球是這個球,跳過此循環
if (this.id == ele.id){
continue;
}
// 如果球重疊太多,從 canvas 中移除此球
if (Math.abs(this.bx - ele.bx) < this.radius && Math.abs(this.by - ele.by) < this.radius){
effects.splice(i, 1);
}
// 如果球將碰撞,反轉它們的速度並減去一些力以考慮彈性
if (Math.abs(this.bx + this.vx - ele.bx) < (this.radius * 2) && Math.abs(this.by + this.vy - ele.by) < (this.radius * 2)){
this.vx *= -.9;
this.vy *= -.9;
}
}
this.by += this.vy;
this.bx += this.vx;
// 5. 由於速度的複雜計算在此之前發生,透過確保 x 和 y 座標在 canvas 內來最終確定它們。
while (this.bx - this.radius < 0){
this.bx++
}
while (this.bx + this.radius > width){
this.bx--
}
while (this.by - this.radius < 0){
this.by++
}
while (this.by + this.radius > height){
this.by--
}
ctx.beginPath();
ctx.arc(this.bx, this.by, this.radius, 0, Math.PI * 2);
ctx.fillStyle = "black"
ctx.fill();
}
}
window.requestAnimationFrame(update);
</script>

最後,我們將添加一些基本的使用者控制項以使事情更有趣。我已經詳細討論了色相變化,所以為了防止程式碼變得更加複雜,我們將重點放在調整球的數量和半徑上。這只需要對現有函式進行幾處修改。

<head>
<title>User Controls</title>
<meta description="Snake-like climbing gradient effect"/>
<meta publisher="WhirlwindFX" />
<!-- 添加兩個數字滑桿:半徑和數量 --->
<meta property="radius" label="Size" type="number" min="5" max="100" default="20">
<meta property="amount" label="Amount" type="number" min="1" max="50" default="2">
</head>
function update() {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 320,200);
// 將最小特效數設定為使用者控制的數量。如果特效長度大於 amount,從末尾彈出一個元素。
if (effects.length < amount){
effects.push(new Ball());
} else if (effects.length > amount){
effects.pop();
}
effects.forEach(ele => {
ele.draw();
})
window.requestAnimationFrame(update);
}
this.draw = function(){
// 最後,在每個影格的 draw 函式內將半徑設定為使用者控制的半徑。
this.radius = radius;
this.vy += this.ay;
this.vx += Math.sin(Date.now());
// . . . 函式其餘部分 . . .
}

希望您喜歡我們的 JavaScript 類別動畫教學!即使在這種複雜程度下,此特效在 HTML canvas 能實現的內容方面也只是冰山一角。此外,我認為它還沒有完成。如果我們追求完美,您會注意到球碰撞在它們實際發生之前一個影格就被偵測到了,這意味著它們通常實際上不會碰撞。碰撞在能量傳遞方面也被簡化了,因為結果速度只考慮球的當前速度而不考慮另一個球的能量。最後,每次迴圈多次檢查每個球的位置在時間複雜度方面是低效的,可以用基本的記憶化進行優化。

最終,始終對您的特效保持批判性態度。它們通常可以以這種或那種方式得到改進,而平庸特效和出色特效之間的差異通常只是看著它並允許自己至少不喜歡它一次。

如需最後一個逐步教學,請查看我們的回呼函式章節,我將詳細介紹擷取儀錶資料以啟動和停用特效的過程。