上一章講解了如何使用 canvas 實現刮刮卡抽獎,以及 canvas 最基本最基本的一些 api 方法。點擊回顧
本章開始一步一步帶着讀者實現大轉盤抽獎;大轉盤是個很是簡單且實用的 web 特效,五臟俱全,其中涉及到的知識點有圓的繪製及非零環繞原則
,路徑的繪製
,canvas transform
,逐幀動畫 requestAnimationFrame 方法
;接下來帶你們一步一步的實現。javascript
項目預覽連接地址css
先貼出代碼,讀者能夠複製如下代碼,直接運行。
在代碼後面我會逐一解釋每一塊關鍵代碼的做用。
示例代碼版本爲 ES6 ,請在現代瀏覽器下運行如下代碼html
<!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>
複製代碼
當頁面加載時會執行 drawRouletteWheel()
方法,這個方法將經過starRadian, awardRadian, awards
等全局變量,完成轉盤的全部繪製操做,包括:圓盤,獎品選塊,指針;java
定義點擊事件,當點擊旋轉按鈕,執行rotateWheel()
方法,該方法將動態改變全局變量 starRadian
的值,並調用 window.requestAnimationFrame()
方法實現逐幀旋轉動畫。git
咱們進入
drawRouletteWheel()
方法,能夠看到,該方法分爲四步:github
- 清空頁面中全部的元素;
- 繪製圓盤
- 繪製文字
- 繪製指針
之因此在繪製最開始對畫布作清理,是爲了完成逐幀動畫。 咱們能夠想象一下。你們都知道,咱們能夠在不少頁紙上畫一個小人不一樣的行走狀態,而後經過快速翻閱這些紙張,小人就會神奇的‘動’起來,你翻的越快,小人就跑的越快。 在 canvas 中,或者說在 js 中實現動畫,一樣是這個道理,咱們就想像每一頁紙就是動畫裏的每一幀,咱們翻頁的操做,在電腦屏幕上,實際就是清空整個畫布了。web
咱們經過全局變量 awards
這個數組,指定了獎項的顯示文字; 經過全局變量 startRadian
指定了起始角的弧度,也就是 0°; 經過 awardRadian
指定了每個獎品選快所佔的弧度;該值是經過 360° 的弧度值除以 獎品 的個數計算來的。canvas
咱們知道了圓的起始角,以及每個獎品選塊所佔的弧度值,那麼咱們是否是就能夠經過循環 awards 數組的個數,來獲取每個獎品選塊的起始角,以及終止角,並繪製出每一個獎品選塊的路徑,將他們鏈接起來,就成了一個「大卸八塊」的圓盤了呢?api
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; 若是計數器的值不等於0,則該範圍區域會被填充; 若是計數器的值等於0,則該範圍區域不會被填充顯示;
瞭解了非零環繞原則,咱們將其實際運用,來解決咱們剛纔的問題
咱們在上述代碼中,建立的是若干個順時針圓弧路徑,那麼咱們想讓這些區塊獨自填充,是否是隻要在圓內,再建立若干個半徑爲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所示,圓盤的繪製便完成了。
咱們須要在每個選塊中,繪製相對應的文字,而且這些文字的角度與位置必須與圓弧一致。
這裏咱們須要用到三角函數
來求圓周上某點的座標來做爲文字的座標,用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所示,
若是咱們想要獲取該圖中圓周上的一個座標相對 canvas 畫布的位置,咱們須要將該點與圓心相鏈接,並從該點向下延伸與圓心的 x 軸相交後造成的一個直角三角形,並求出該直角的 a 與 b 兩條邊的長度,與圓心的 x y 軸座標值相加,就是該點相對 canvas 畫布 x y 軸的座標。
那麼如何獲得 a b 兩條邊的長度? 咱們已知的條件有:center_x/center_y, radius, θ; 咱們知道,正弦 sin 是三角形的 對邊比斜邊,正好 b 是對邊 餘弦 cos 是三角形的 鄰邊比斜邊,正好 a 是鄰邊; 那麼 b = Math.sin(θ) * radius
, a = Math.cos(θ) * radius
;
咱們能夠經過三角函數的公式,獲得每個獎品選塊,中間位置的圓周上的座標點,並使用 context.translate(x, y)
將文字元素移動到該點上;
將文字移動到中心點後,再經過 context.rotate(deg)
方法,將文字旋轉角度與圓弧度對齊;
canvas 的 transform 中的方法,使用上基本和 css 是同樣的,只不過 canvas 變換是相對於畫布的變換。若是不太理解,能夠參考這篇文章
context.moveTo(y):創建路徑的起點; context.lineTo(
y): 創建一個點,該點與其餘點以及起點相鏈接,造成一條路徑; context.closePath(): 將路徑最後一個點,與起點相鏈接,閉合路徑。
瞭解了這三個方法,剩下的就是計算點位,再繪製一個本身喜歡的指針樣式了。
點擊旋轉按鈕,初始化當前時間,並隨機指定一個旋轉時間總長,和隨機指定一個旋轉變化值的峯值,最後調用
rotateWheel()
方法,開啓旋轉;在
rotatWheel()
方法裏,咱們會將表明當前進行時間的變量spinningTime
累加,直到其大於時間總長spinTotalTime
後,便獲取當前獎品值,並退出旋轉;咱們會利用緩動函數
easeOut()
來獲取一個動態的緩動值,將這個值賦值給startRadian
全局變量,並執行drawRouletteWheel()
方法重繪轉盤,便實現了旋轉。
咱們一般使用 js 中的 setInterval() 或者 setTimeout()
方法,來實現動畫,就像下面這樣:
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.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;
};
複製代碼
該緩動函數會在單位時間內,從初始值,增長到變化值(峯值);
仍是拿剛纔移動的小方塊舉例,緩動函數接收四個值,
movingTime
;moveChange
;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所示,
惟一的區別就是須要在最後將變化值轉換爲弧度值,而且中止旋轉時採集獎品的信息而已。
在
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];
}
複製代碼
取值的運算方法看似有點複雜,實際上很簡單,咱們只須要記住如下幾點:
不管轉盤轉多少圈,任意角有多大,咱們均可以經過 startAngle % 360
求餘數,來計算出,轉盤在中止旋轉後,起始角在360°範圍內的角度;
假如,咱們有四個獎項,那麼每一個獎項對應的角度就是 90°;爲了方便計算,咱們將 pointerAngle
的值設置爲0,也就是 0°所在位置的獎項會被輸出;那麼當起始角變成了 10°,剩餘的角度總和就是 350°,用 350° 除以 每一個獎項的角度 90°,再將獲得的值向下取整,值爲3,咱們就得到了 0°指針,指向轉盤起始角爲10° 時的獎品數組下標了!
大轉盤裏涉及了一些基本的數學知識,三角函數,圓周率等。若是同窗以爲看着有些吃力,趕忙回去看看初中數學吧💥。 下期爲你們奉上九宮格抽獎,敬請期待🙃