canvas 基礎系列(二)之實現大轉盤抽獎

木子七發表於2017-08-15

上一章講解了如何使用 canvas 實現刮刮卡抽獎,以及 canvas 最基本最基本的一些 api 方法。點選回顧
本章開始一步一步帶著讀者實現大轉盤抽獎;大轉盤是個非常簡單且實用的 web 特效,五臟俱全,其中涉及到的知識點有 圓的繪製及非零環繞原則路徑的繪製canvas transform逐幀動畫 requestAnimationFrame 方法;接下來帶大家一步一步的實現。

專案預覽連結地址

掃描二維碼預覽
掃描二維碼預覽

先貼出程式碼,讀者可以複製以下程式碼,直接執行。
在程式碼後面我會逐一解釋每一塊關鍵程式碼的作用。
示例程式碼版本為 ES6 ,請在現代瀏覽器下執行以下程式碼

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>大轉盤</title>
</head>
<body>
    <div id="spin_button" style="position: absolute;left: 232px;top: 232px;width: 50px;height: 50px;line-height: 50px;text-align: center;background: yellow;border-radius: 100%;cursor: pointer">旋轉</div>
    <canvas id="canvas" width="500" height="500"></canvas>
</body>
<script>
    let canvas = document.getElementById('canvas'),
        context = canvas.getContext('2d'),

        OUTSIDE_RADIUAS = 200,   // 轉盤的半徑
        INSIDE_RADIUAS = 0,      // 用於非零環繞原則的內圓半徑
        TEXT_RADIUAS = 160,      // 轉盤內文字的半徑

        CENTER_X = canvas.width / 2,
        CENTER_Y = canvas.height / 2,

        awards = [               // 轉盤內的獎品個數以及內容
            '大保健', '話費10元', '話費20元', '話費30元', '保時捷911', '周大福土豪金項鍊', 'iphone 20', '火星7日遊'
        ],

        startRadian = 0,                             // 繪製獎項的起始角,改變該值實現旋轉效果
        awardRadian = (Math.PI * 2) / awards.length, // 每一個獎項所佔的弧度

        duration = 4000,     // 旋轉事件
        velocity = 10,       // 旋轉速率
        spinningTime = 0,    // 旋轉當前時間
        spinTotalTime,       // 旋轉時間總長
        spinningChange;      // 旋轉變化值的峰值


    /**
     * 緩動函式,由快到慢
     * @param {Num} t 當前時間
     * @param {Num} b 初始值
     * @param {Num} c 變化值
     * @param {Num} d 持續時間
     */
    function easeOut(t, b, c, d) {
        if ((t /= d / 2) < 1) return c / 2 * t * t + b;
        return -c / 2 * ((--t) * (t - 2) - 1) + b;
    };

    /**
     * 繪製轉盤
     */
    function drawRouletteWheel() {
        // ----- ① 清空頁面元素,用於逐幀動畫
        context.clearRect(0, 0, canvas.width, canvas.height);
        // -----

        for (let i = 0; i < awards.length; i ++) {
            let _startRadian = startRadian + awardRadian * i,  // 每一個獎項所佔的起始弧度
                _endRadian =   _startRadian + awardRadian;     // 每一個獎項的終止弧度

            // ----- ② 使用非零環繞原則,繪製圓盤
            context.save();
            if (i % 2 === 0) context.fillStyle = '#FF6766'
            else             context.fillStyle = '#FD5757';
            context.beginPath();
            context.arc(canvas.width / 2, canvas.height / 2, OUTSIDE_RADIUAS, _startRadian, _endRadian, false);
            context.arc(canvas.width / 2, canvas.height / 2, INSIDE_RADIUAS, _endRadian, _startRadian, true);
            context.fill();
            context.restore();
            // -----

            // ----- ③ 繪製文字
            context.save();
            context.font = 'bold 16px Helvetica, Arial';
            context.fillStyle = '#FFF';
            context.translate(
                CENTER_X + Math.cos(_startRadian + awardRadian / 2) * TEXT_RADIUAS,
                CENTER_Y + Math.sin(_startRadian + awardRadian / 2) * TEXT_RADIUAS
            );
            context.rotate(_startRadian + awardRadian / 2 + Math.PI / 2);
            context.fillText(awards[i], -context.measureText(awards[i]).width / 2, 0);
            context.restore();
            // -----
        }

        // ----- ④ 繪製指標
        context.save();
        context.beginPath();
        context.moveTo(CENTER_X, CENTER_Y - OUTSIDE_RADIUAS + 8);
        context.lineTo(CENTER_X - 10, CENTER_Y - OUTSIDE_RADIUAS);
        context.lineTo(CENTER_X - 4, CENTER_Y - OUTSIDE_RADIUAS);
        context.lineTo(CENTER_X - 4, CENTER_Y - OUTSIDE_RADIUAS - 10);
        context.lineTo(CENTER_X + 4, CENTER_Y - OUTSIDE_RADIUAS - 10);
        context.lineTo(CENTER_X + 4, CENTER_Y - OUTSIDE_RADIUAS);
        context.lineTo(CENTER_X + 10, CENTER_Y - OUTSIDE_RADIUAS);
        context.closePath();
        context.fill();
        context.restore();
        // -----
    }

    /**
     * 開始旋轉
     */
    function rotateWheel() {
        // 當 當前時間 大於 總時間,停止旋轉,並返回當前值
        spinningTime += 20;
        if (spinningTime >= spinTotalTime) {
            console.log(getValue()); return
        }

        let _spinningChange = (spinningChange - easeOut(spinningTime, 0, spinningChange, spinTotalTime)) * (Math.PI / 180);
        startRadian += _spinningChange

        drawRouletteWheel();
        window.requestAnimationFrame(rotateWheel);
    }

    /**
     * 旋轉結束,獲取值
     */
    function getValue() {
        let startAngle = startRadian * 180 / Math.PI,       // 弧度轉換為角度
            awardAngle = awardRadian * 180 / Math.PI,

            pointerAngle = 90,                              // 指標所指向區域的度數,該值控制選取哪個角度的值
            overAngle = (startAngle + pointerAngle) % 360,  // 無論轉盤旋轉了多少圈,產生了多大的任意角,我們只需要求到當前位置起始角在360°範圍內的角度值
            restAngle = 360 - overAngle,                    // 360°減去已旋轉的角度值,就是剩下的角度值

            index = Math.floor(restAngle / awardAngle);     // 剩下的角度值 除以 每一個獎品的角度值,就能得到這是第幾個獎品

        return awards[index];
    }


    window.onload = function(e) {
        drawRouletteWheel();
    }

    document.getElementById('spin_button').addEventListener('click', () => {
        spinningTime = 0;                                // 初始化當前時間
        spinTotalTime = Math.random() * 3 + duration;    // 隨機定義一個時間總量
        spinningChange = Math.random() * 10 + velocity;  // 隨機頂一個旋轉速率
        rotateWheel();
    })
</script>
</html>複製程式碼

?思路:

  1. 當頁面載入時會執行 drawRouletteWheel()方法,這個方法將通過starRadian, awardRadian, awards等全域性變數,完成轉盤的所有繪製操作,包括:圓盤,獎品選塊,指標;

  2. 定義點選事件,當點選旋轉按鈕,執行rotateWheel() 方法,該方法將動態改變全域性變數 starRadian的值,並呼叫 window.requestAnimationFrame()方法實現逐幀旋轉動畫。


繪製大轉盤

我們進入 drawRouletteWheel()方法,可以看到,該方法分為四步:

  1. 清空頁面中所有的元素;
  2. 繪製圓盤
  3. 繪製文字
  4. 繪製指標


  • 清空頁面所有元素

之所以在繪製最開始對畫布做清理,是為了完成逐幀動畫。
我們可以想象一下。大家都知道,我們可以在很多頁紙上畫一個小人不同的行走狀態,然後通過快速翻閱這些紙張,小人就會神奇的‘動’起來,你翻的越快,小人就跑的越快。
在 canvas 中,或者說在 js 中實現動畫,同樣是這個道理,我們就想像每一頁紙就是動畫裡的每一幀,我們翻頁的操作,在電腦螢幕上,實際就是清空整個畫布了。


  • 繪製圓盤

我們通過全域性變數 awards 這個陣列,指定了獎項的顯示文字;
通過全域性變數 startRadian 指定了起始角的弧度,也就是 0°;
通過 awardRadian 指定了每一個獎品選快所佔的弧度;該值是通過 360° 的弧度值除以 獎品 的個數計算來的。


我們知道了圓的起始角,以及每一個獎品選塊所佔的弧度值,那麼我們是不是就可以通過迴圈 awards 陣列的個數,來獲取每一個獎品選塊的起始角,以及終止角,並繪製出每個獎品選塊的路徑,將他們連線起來,就成了一個“大卸八塊”的圓盤了呢?


for (let i = 0; i < awards.length; i++) {
    let _startRadian = startRadian + awardRadian * i,  // 每一個獎項所佔的起始弧度
      _endRadian =   _startRadian + awardRadian;     // 每一個獎項的終止弧度
    context.save();
    if (i % 2 === 0) context.fillStyle = '#FF6766'
    else             context.fillStyle = '#FD5757';
    context.beginPath();
    context.arc(canvas.width / 2, canvas.height / 2, OUTSIDE_RADIUAS, _startRadian, _endRadian, false);
    context.fill();
  context.restore()
}複製程式碼

以上程式碼執行後,你會發現是這個鬼樣子?

圖1
圖1

之所以會被渲染成這樣,是因為我們繪製了與獎品個數相同的圓弧,但這些圓弧之間彼此是沒有聯絡的,他們是一個個單獨的路徑,所以填充時,也只會填充路徑一端到另一端區間內的空間。


為解決這個問題,我們需要引入一個新的概念 非零環繞原則


什麼是非零環繞原則?
這篇文章講解的非常詳細,大家可以詳細參閱,總結一下,就是:
路徑中指定範圍區域,從該區域內部畫一條足夠長的線段,使此線段的完全落在路徑範圍之外。
該線段與逆時針路徑相交,計數器減1;
該線段與順時針路徑相交,計數器加1;
如果計數器的值不等於0,則該範圍區域會被填充;
如果計數器的值等於0,則該範圍區域不會被填充顯示;

圖2
圖2

瞭解了非零環繞原則,我們將其實際運用,來解決我們剛才的問題


我們在上述程式碼中,建立的是若干個順時針圓弧路徑,那麼我們想讓這些區塊獨自填充,是不是隻要在圓內,再建立若干個半徑為0,逆時針圓弧路徑呢?

for (let i = 0; i < awards.length; i++) {
    let _startRadian = startRadian + awardRadian * i,  // 每一個獎項所佔的起始弧度
        _endRadian =   _startRadian + awardRadian;     // 每一個獎項的終止弧度
    context.save();
    if (i % 2 === 0) context.fillStyle = '#FF6766'
    else             context.fillStyle = '#FD5757';
    context.beginPath();

    context.arc(canvas.width / 2, canvas.height / 2, OUTSIDE_RADIUAS, _startRadian, _endRadian, false);

    context.arc(canvas.width / 2, canvas.height/  2, OUTSIDE_RADIUAS, _endRadian, _startRadian, true);

    context.fill();
    context.restore()
}複製程式碼

如圖3所示,圓盤的繪製便完成了。

圖3
圖3


  • 繪製文字

    我們需要在每一個選塊中,繪製相對應的文字,並且這些文字的角度與位置必須與圓弧一致。
    這裡我們需要用到 三角函式 來求圓周上某點的座標來作為文字的座標,用 canvas transform 來對文字進行位移與旋轉。


使用三角函式獲取文字的座標位置

在原始碼中有一段程式碼如下:

context.translate(
                CENTER_X + Math.cos(_startRadian + awardRadian / 2) * TEXT_RADIUAS,
                CENTER_Y + Math.sin(_startRadian + awardRadian / 2) * TEXT_RADIUAS
            );複製程式碼

這段程式碼程式碼的意思是將元素移動到指定的x, y 軸位置。x, y 軸的計算公式看著複雜,但你只要有一點點三角函式的概念,就能很快理解它們是如何得出的。

如圖4所示,

圖4
圖4

如果我們想要獲取該圖中圓周上的一個座標相對 canvas 畫布的位置,我們需要將該點與圓心相連線,並從該點向下延伸與圓心的 x 軸相交後形成的一個直角三角形,並求出該直角的 a 與 b 兩條邊的長度,與圓心的 x y 軸座標值相加,就是該點相對 canvas 畫布 x y 軸的座標。

那麼如何得到 a b 兩條邊的長度?
我們已知的條件有:center_x/center_y, radius, θ;
我們知道,正弦 sin 是三角形的 對邊比斜邊,正好 b 是對邊
餘弦 cos 是三角形的 鄰邊比斜邊,正好 a 是鄰邊;
那麼 b = Math.sin(θ) * radiusa = Math.cos(θ) * radius

我們可以通過三角函式的公式,得到每一個獎品選塊,中間位置的圓周上的座標點,並使用 context.translate(x, y) 將文字元素移動到該點上;

將文字移動到中心點後,再通過 context.rotate(deg) 方法,將文字旋轉角度與圓弧度對齊;

canvas 的 transform 中的方法,使用上基本和 css 是一樣的,只不過 canvas 變換是相對於畫布的變換。如果不太理解,可以參考這篇文章


  • 繪製指標
    指標的繪製非常簡單,其中涉及到三個新方法:

context.moveTo($x, $y):建立路徑的起點;
context.lineTo($x, $y): 建立一個點,該點與其他點以及起點相連線,形成一條路徑;
context.closePath(): 將路徑最後一個點,與起點相連線,閉合路徑。

瞭解了這三個方法,剩下的就是計算點位,再繪製一個自己喜歡的指標樣式了。


旋轉大轉盤

  1. 點選旋轉按鈕,初始化當前時間,並隨機指定一個旋轉時間總長,和隨機指定一個旋轉變化值的峰值,最後呼叫 rotateWheel() 方法,開啟旋轉;

  2. rotatWheel() 方法裡,我們會將代表當前進行時間的變數 spinningTime 累加,直到其大於時間總長 spinTotalTime 後,便獲取當前獎品值,並退出旋轉;

  3. 我們會利用緩動函式 easeOut() 來獲取一個動態的緩動值,將這個值賦值給 startRadian 全域性變數,並執行 drawRouletteWheel() 方法重繪轉盤,便實現了旋轉。


  • setInterval setTimeout 實現的簡單動畫

我們通常使用 js 中的 setInterval() 或者 setTimeout() 方法,來實現動畫,就像下面這樣:

圖5
圖5

let canvas = document.getElementById('canvas'),
    context = canvas.getContext('2d');

let [x, y] = [0, 0],
    movingTime = 0,
    moveTotalTime = 3000;

function drawRect(x, y) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
    context.rect(x, y, 100, 100);
    context.fill();
}

setInterval(() => {
    movingTime += 20;
    if (movingTime >= moveTotalTime) return;

    x += 1;
    drawRect(x, y)
}, 20)複製程式碼

但是這兩個方法並不能提供製作動畫所需精確計時機制。它們只是讓應用程式能在某個大致時間點上執行程式碼的通用方法而已。

我們不應當主動去告知瀏覽器繪製下一幀動畫的時間,而是應當讓瀏覽器在它覺得可以繪製下一幀時通知你,我們可以用 window.requestAnimationFrame() 方法來實現。


  • window.requestAnimationFrame()

該方法接收一個回撥函式引數,並返回一個控制程式碼,我們可以通過 window.cancleRequestAnimationFrame() 方法,指定一個控制程式碼,來取消動畫。

下面我們將使用 setInterval() 方法實現的動畫,改造成 window.requestAnimationFrame() 實現:

let canvas = document.getElementById('canvas'),
    context = canvas.getContext('2d');

let [x, y] = [0, 0],
    movingTime = 0,
    moveTotalTime = 3000;

function drawRect(x, y) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
    context.rect(x, y, 100, 100);
    context.fill();
}

function moveRect() {
    movingTime += 20;
    if (movingTime >= moveTotalTime) return;

    x += 1;
    drawRect(x, y);

    window.requestAnimationFrame(moveRect);
}

moveRect();複製程式碼

很簡單對吧!

但是我們發現,這個方塊移動的很僵硬,我們需要加入緩動函式,來讓它“靈活”起來。


  • 緩動函式

本章中只使用了一種緩動函式,easeOut() ,現在我們不需要知道它是什麼原理,只要知道如何使用它就行了:

/**
 * 緩動函式,由快到慢
 * @param {Num} t 當前時間
 * @param {Num} b 初始值
 * @param {Num} c 變化值
 * @param {Num} d 持續時間
 */
function easeOut(t, b, c, d) {
    if ((t /= d / 2) < 1) return c / 2 * t * t + b;
    return -c / 2 * ((--t) * (t - 2) - 1) + b;
};複製程式碼

該緩動函式會在單位時間內,從初始值,增加到變化值(峰值);

還是拿剛才移動的小方塊舉例,緩動函式接收四個值,

  1. 當前時間,也就是 movingTime
  2. 初始值,一般設定為 0 ;
  3. 變化值(峰值) ,也就是 moveChange
  4. 持續時間,也就是 moveTotalTime

程式碼我們就這麼寫:

let canvas = document.getElementById('canvas'),
    context = canvas.getContext('2d');

let [x, y] = [0, 0],
    moveChange = 5,
    movingTime = 0,
    moveTotalTime = 3000;

function easeOut(t, b, c, d) {
    if ((t /= d / 2) < 1) return c / 2 * t * t + b;
    return -c / 2 * ((--t) * (t - 2) - 1) + b;
};

function drawRect(x, y) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
    context.rect(x, y, 100, 100);
    context.fill();
}

function moveRect() {
    movingTime += 20;
    if (movingTime >= moveTotalTime) return;

    let _moveChange = moveChange - (easeOut(movingTime, 0, moveChange, moveTotalTime));

    x += _moveChange;
    drawRect(x, y);

    window.requestAnimationFrame(moveRect);
}

moveRect();複製程式碼

效果如圖6所示,

圖6
圖6

  • 讓轉盤轉起來
    如果你理解了上面所講的小方塊的位移動畫,那麼大轉盤的動畫也是一樣一樣的!

唯一的區別就是需要在最後將變化值轉換為弧度值,並且停止旋轉時採集獎品的資訊而已。


旋轉結束,採集獎品資訊

rotateWheel() 方法中,當 當前時間 大於 時間總量 時,會停止旋轉,並觸發 getValue() 方法。

function getValue() {
    let startAngle = startRadian * 180 / Math.PI,       // 弧度轉換為角度
        awardAngle = awardRadian * 180 / Math.PI,

        pointerAngle = 90,                              // 指標所指向區域的度數,該值控制選取哪個角度的值
        overAngle = (startAngle + pointerAngle) % 360,  // 無論轉盤旋轉了多少圈,產生了多大的任意角,我們只需要求到當前位置起始角在360°範圍內的角度值
        restAngle = 360 - overAngle,                    // 360°減去已旋轉的角度值,就是剩下的角度值

        index = Math.floor(restAngle / awardAngle);     // 剩下的角度值 除以 每一個獎品的角度值,並向下取整,就能得到這是第幾個獎品

    return awards[index];
}複製程式碼

取值的運算方法看似有點複雜,實際上很簡單,我們只需要記住以下幾點:

  1. 無論轉盤轉多少圈,任意角有多大,我們都可以通過 startAngle % 360 求餘數,來計算出,轉盤在停止旋轉後,起始角在360°範圍內的角度;

  2. 假如,我們有四個獎項,那麼每個獎項對應的角度就是 90°;為了方便計算,我們將 pointerAngle 的值設定為0,也就是 0°所在位置的獎項會被輸出;那麼當起始角變成了 10°,剩餘的角度總和就是 350°,用 350° 除以 每個獎項的角度 90°,再將得到的值向下取整,值為3,我們就獲得了 0°指標,指向轉盤起始角為10° 時的獎品陣列下標了!


結語:

大轉盤裡涉及了一些基本的數學知識,三角函式,圓周率等。如果同學覺得看著有些吃力,趕緊回去看看初中數學吧?。
下期為大家奉上九宮格抽獎,敬請期待?

相關文章