之前在公司專案中實現了一個可配置的轉盤抽獎,主要是用canvas進行繪製,寫這篇文章是為了通過一個大家可能會感興趣的點,來熟悉canvas的一些api,我自己也算是進行一些複習,當然文章不會像專案中實現的那樣複雜,只是一個簡易版本的,儘量做到通俗易懂,給感興趣的你指引一條路(其實是懶~)。?
一、實現一個轉盤抽獎
在實現之前我們要有一個整體的思路,回想一下,我們曾經在網上或者現實中見過的抽獎轉盤都有那些元素:
- 一個大的轉盤
- 轉盤上有一個個區間,表示不同的獎品
- 在轉盤的中間有一個按鈕,按鈕上有一個指標,來指向抽中的獎品
我們再想想抽獎的過程:當我們點選中間的按鈕,轉盤開始旋轉,當獲取到獎品後,旋轉的速度從快到慢,緩緩停下,最後指向獎品所在的那個區域,我們的實現步驟就可以這樣
- 繪製所有的靜態元素
- 給轉盤新增旋轉動畫
- 指標所指區域為我們指定的獎品
1.繪製所有靜態元素
1.1.開發環境搭建
全域性安裝create-react-app
,快速生成react
的開發環境,這個與我們要開發的轉盤沒有耦合關係,只是為了方便除錯,用vue
也是可以的。
// 全域性安裝
npm install create-react-app -g
// 生成開發環境,lottery為目錄名
create-react-app lottery
複製程式碼
安裝完成後修改成如下圖目錄結構
turntable.jsx
內容如下:
export default class Turntable {
}
複製程式碼
App.js
內容如下:
import React, { Component } from 'react'
import Turntable from './turntable/turntable'
class App extends Component {
constructor(props) {
super(props)
}
render() {
return <div>抽獎轉盤</div>
}
}
export default App
複製程式碼
完成以上工作後,在當前目錄開啟命令列工具,輸入npm start
啟動專案
1.2.繪製大轉盤
修改App.js
中的內容如下:
import React, { Component } from 'react'
import Turntable from './turntable/turntable'
class App extends Component {
constructor(props) {
super(props)
// react中獲取dom元素
this.canvas = React.createRef()
}
componentDidMount() {
// canvas元素儲存在this.canvas的current屬性中
const canvas = this.canvas.current
// 獲取canvas的上下文,context含有各種api用來操作canvas
const context = canvas.getContext('2d')
// 設定canvas的寬高
canvas.width = 300
canvas.height = 300
// 建立turntable物件,並將canvas元素和context傳入
const turntable = new Turntable({canvas: canvas, context: context})
turntable.render()
}
render() {
return <canvas
ref={this.canvas}
style={{
width: '300px',
height: '300px',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: 'auto'
}}>
</canvas>
}
}
export default App
複製程式碼
修改turntable.jsx
中的內容:
export default class Turntable {
constructor(options) {
// 獲取並儲存傳入的canvas,context
this.canvas = options.canvas
this.context = options.context
}
drawPanel() {
const context = this.context
// 儲存當前畫布的狀態,使用restore呼叫後,保證了當前
// 繪製不受之前繪製的影響
context.save()
// 新建一個路徑,畫筆的位置回到預設的座標(0,0)的位置
// 保證了當前的繪製不會影響到之前的繪製
context.beginPath()
// 設定填充轉盤用的顏色,fill是填充而不是繪製.
context.fillStyle = '#FD6961'
// 繪製一個圓,有六個引數,分別表示:圓心的x座標,圓心的
// y座標,圓的半徑,開始繪製的角度,結束的角度,繪製方向
// (false表示順時針)
context.arc(150, 150, 150, 0, Math.PI * 2, false)
// 將我們設定的顏色填充到圓中,這裡不用closePath是因
// 為closePath對fill無效.
context.fill()
// 將畫布的狀態恢復到我們上一次save()時的狀態
context.restore()
}
render() {
this.drawPanel()
}
}
複製程式碼
儲存後,瀏覽器中結果顯示如下圖:
1.2.繪製獎品塊
在turntable.jsx
檔案中的Turntable
類中新增和修改內容如下(為了避免重複內容導致篇幅過長,所有不變的部分都不會展示):
// add
drawPrizeBlock() {
const context = this.context
// 第一個獎品色塊開始繪製時開始的弧度及結束的弧度,因為我們這裡
// 暫時固定設定為6個獎品,所以以6為基數
let startRadian = 0, RadianGap = Math.PI * 2 / 6, endRadian = startRadian + RadianGap
for (let i = 0; i < 6; i++) {
context.save()
context.beginPath()
// 為了區分不同的色塊,我們使用隨機生成的顏色作為色塊的填充色
context.fillStyle = '#'+Math.floor(Math.random()*16777215).toString(16)
// 這裡需要使用moveTo方法將初始位置定位在圓點處,這樣繪製的圓
// 弧都會以圓點作為閉合點,下面有使用moveTo和不使用moveTo的對比圖
context.moveTo(150, 150)
// 畫圓弧時,每次都會自動呼叫moveTo,將畫筆移動到圓弧的起點,半
// 徑我們設定的比轉盤稍小一點
context.arc(150, 150, 140, startRadian, endRadian, false)
// 每個獎品色塊繪製完後,下個獎品的弧度會遞增
startRadian += RadianGap
endRadian += RadianGap
context.fill()
context.restore()
}
}
// modify
render() {
this.drawPanel()
this.drawPrizeBlock()
}
複製程式碼
儲存後,我們的轉盤變為如下圖樣式:
使用繪製獎品色塊使用context.moveTo(150, 150)
和不使用的區別:
使用了context.moveTo(150, 150)
:
沒有使用context.moveTo(150, 150)
:
獎品塊繪製好了,我們需要新增每個獎品的名稱,假定我們有三個獎品,另外三個就設定為未中獎
turntable.jsx
檔案中內容改變如下:
constructor(options) {
this.canvas = options.canvas
this.context = options.context
// add 初始化時新增了獎品的配置
this.awards = [
{ level: '特等獎', name: '我的親筆簽名', color: '#576c0a' },
{ level: '未中獎', name: '未中獎', color: '#ad4411' },
{ level: '一等獎', name: '瑪莎拉蒂超級經典限量跑車', color: '#43ed04' },
{ level: '未中獎', name: '未中獎', color: '#d5ed1d' },
{ level: '二等獎', name: '辣條一包', color: '#32acc6' },
{ level: '未中獎', name: '未中獎', color: '#e06510' },
]
}
// add
// 想一想,如我們一等獎那樣,文字特別長的,超出我們的獎品塊,而canvas
// 卻不是那麼智慧給你提供自動換行的機制,於是我們只有手動處理換行
/**
*
* @param {*} context 這個就不用解釋了~
* @param {*} text 這個是我們需要處理的長文字
* @param {*} maxLineWidth 這個是我們自己定義的一行文字最大的寬度
*/
// 整個思路就是將滿足我們定義的寬度的文字作為value單獨新增到陣列中
// 最後返回的陣列的每一項就是我們處理後的每一行了.
getLineTextList(context, text, maxLineWidth) {
let wordList = text.split(''), tempLine = '', lineList = []
for (let i = 0; i < wordList.length; i++) {
// measureText方法是測量文字的寬度的,這個寬度相當於我們設定的
// fontSize的大小,所以基於這個,我們將maxLineWidth設定為當前字型大小的倍數
if (context.measureText(tempLine).width >= maxLineWidth) {
lineList.push(tempLine)
maxLineWidth -= context.measureText(text[0]).width
tempLine = ''
}
tempLine += wordList[i]
}
lineList.push(tempLine)
return lineList
}
// modify
drawPrizeBlock() {
const context = this.context
const awards = this.awards
let startRadian = 0, RadianGap = Math.PI * 2 / 6, endRadian = startRadian + RadianGap
for (let i = 0; i < awards.length; i++) {
context.save()
context.beginPath()
context.fillStyle = awards[i].color
context.moveTo(150, 150)
context.arc(150, 150, 140, startRadian, endRadian, false)
context.fill()
context.restore()
// 開始繪製我們的文字
context.save();
// 設定文字顏色
context.fillStyle = '#FFFFFF';
// 設定文字樣式
context.font = "14px Arial";
// 改變canvas原點的位置,簡單來說,translate到哪個座標點,那麼那個座標點就將變為座標(0, 0)
context.translate(
150 + Math.cos(startRadian + RadianGap / 2) * 140,
150 + Math.sin(startRadian + RadianGap / 2) * 140
);
// 旋轉角度,這個旋轉是相對於原點進行旋轉的.
context.rotate(startRadian + RadianGap / 2 + Math.PI / 2);
// 這裡就是根據我們獲取的各行的文字進行繪製,maxLineWidth我們取70,相當與
// 一行我們最多展示5個文字
this.getLineTextList(context, awards[i].name, 70).forEach((line, index) => {
// 繪製文字的方法,三個引數分別帶別:要繪製的文字,開始繪製的x座標,開始繪製的y座標
context.fillText(line, -context.measureText(line).width / 2, ++index * 25);
})
context.restore();
startRadian += RadianGap
endRadian += RadianGap
}
}
複製程式碼
儲存後,我們轉盤變成如下模樣:
1.3.繪製按鈕與箭頭
Turntable
類新增和修改內容如下:
// add
// 繪製按鈕,以及按鈕上start的文字,這裡沒有新的點,不再贅述
drawButton() {
const context = this.context
context.save()
context.beginPath()
context.fillStyle = '#FF0000'
context.arc(150, 150, 30, 0, Math.PI * 2, false)
context.fill()
context.restore()
context.save()
context.beginPath()
context.fillStyle = '#FFF'
context.font = '20px Arial'
context.translate(150, 150)
context.fillText('Start', -context.measureText('Start').width / 2, 8)
context.restore()
}
// add
// 繪製箭頭,用來指向我們抽中的獎品
drawArrow() {
const context = this.context
context.save()
context.beginPath()
context.fillStyle = '#FF0000'
context.moveTo(140, 125)
context.lineTo(150, 100)
context.lineTo(160, 125)
context.closePath()
context.fill()
context.restore()
}
render() {
this.drawPanel()
this.drawPrizeBlock()
this.drawButton()
this.drawArrow()
}
複製程式碼
儲存後,轉盤如下圖所示:
1.4.點選按鈕,讓轉盤轉動起來
效果應該是:當我們點選按鈕時,按鈕和指標保持不動,而轉盤以及轉盤上的獎品塊會同時旋轉起來。
而如何讓轉盤旋轉起來呢?還記得我們是如何繪製轉盤和獎品塊的嗎,我們是以繪製弧的方式,從角度為0的位置開始繪製,如果我們將繪製的開始角度增加一點,那麼轉盤的位置就相當與轉動了一點,那麼我們當我們不停的改變開始角度時,轉盤看起來就像是旋轉了。
Turntable
類中內容修改如下:
// modify
constructor(options) {
this.canvas = options.canvas
this.context = options.context
// 新增了這個屬性,來記錄我們的初始角度
this.startRadian = 0
this.awards = [
{ level: '特等獎', name: '我的親筆簽名', color: '#576c0a' },
{ level: '未中獎', name: '未中獎', color: '#ad4411' },
{ level: '一等獎', name: '瑪莎拉蒂超級經典限量跑車', color: '#43ed04' },
{ level: '未中獎', name: '未中獎', color: '#d5ed1d' },
{ level: '二等獎', name: '辣條一包', color: '#32acc6' },
{ level: '未中獎', name: '未中獎', color: '#e06510' },
]
}
// modify
drawPanel() {
const context = this.context
const startRadian = this.startRadian
context.save()
context.beginPath()
context.fillStyle = '#FD6961'
// 根據我們設定的初始角度來繪製轉盤
context.arc(150, 150, 150, startRadian, Math.PI * 2 + startRadian, false)
context.fill()
context.restore()
}
// modify
drawPrizeBlock() {
const context = this.context
const awards = this.awards
// 根據初始角度來繪製獎品塊
let startRadian = this.startRadian, RadianGap = Math.PI * 2 / 6, endRadian = startRadian + RadianGap
for (let i = 0; i < awards.length; i++) {
context.save()
context.beginPath()
context.fillStyle = awards[i].color
context.moveTo(150, 150)
context.arc(150, 150, 140, startRadian, endRadian, false)
context.fill()
context.restore()
context.save()
context.fillStyle = '#FFF'
context.font = "14px Arial"
context.translate(
150 + Math.cos(startRadian + RadianGap / 2) * 140,
150 + Math.sin(startRadian + RadianGap / 2) * 140
)
context.rotate(startRadian + RadianGap / 2 + Math.PI / 2)
this.getLineTextList(context, awards[i].name, 70).forEach((line, index) => {
context.fillText(line, -context.measureText(line).width / 2, ++index * 25)
})
context.restore()
startRadian += RadianGap
endRadian += RadianGap
}
}
// add
// 這個方法是為了將canvas再window中的座標點轉化為canvas中的座標點
windowToCanvas(canvas, e) {
// getBoundingClientRect這個方法返回html元素的大小及其相對於視口的位置
const canvasPostion = canvas.getBoundingClientRect(), x = e.clientX, y = e.clientY
return {
x: x - canvasPostion.left,
y: y - canvasPostion.top
}
};
// add
// 這個方法將作為真正的初始化方法
startRotate() {
const canvas = this.canvas
const context = this.context
// getAttribute這個方法可以獲取到元素的屬性值,我們獲取了canvas的樣式將之儲存在canvasStyle變數中
const canvasStyle = canvas.getAttribute('style');
// 這裡繪製我們初始化時候的canvas元素
this.render()
// 新增一個點選事件,點選按鈕後,我們開始旋轉轉盤
canvas.addEventListener('mousedown', e => {
let postion = this.windowToCanvas(canvas, e)
context.beginPath()
// 這裡是在按鈕區域在次繪製了一個沒有顏色的圓,然後判斷我們點選的落點是否在這個圓內,相當於判斷是否點選我們的按鈕
context.arc(150, 150, 30, 0, Math.PI * 2, false)
if (context.isPointInPath(postion.x, postion.y)) {
// 點選按鈕後,我們會呼叫這個方法來改變我們的初始角度startRadian
this.rotatePanel()
}
})
// 新增滑鼠移動事件,僅僅是為了設定滑鼠指標的樣式
canvas.addEventListener('mousemove', e => {
let postion = this.windowToCanvas(canvas, e)
context.beginPath()
context.arc(150, 150, 30, 0, Math.PI * 2, false)
if (context.isPointInPath(postion.x, postion.y)) {
canvas.setAttribute('style', `cursor: pointer;${canvasStyle}`)
} else {
canvas.setAttribute('style', canvasStyle)
}
})
}
// add
// 處理旋轉的關鍵方法
rotatePanel() {
// 每次呼叫都將初始角度增加1度
this.startRadian += Math.PI / 180
// 初始角度改變後,我們需要重新繪製
this.render()
// 迴圈呼叫rotatePanel函式,使得轉盤的繪製連續,造成旋轉的視覺效果
window.requestAnimationFrame(this.rotatePanel.bind(this));
}
// modify
render() {
this.drawPanel()
this.drawPrizeBlock()
this.drawButton()
this.drawArrow()
}
複製程式碼
App.js
內容修改如下:
// modify
componentDidMount() {
const canvas = this.canvas.current
const context = canvas.getContext('2d')
canvas.width = 300
canvas.height = 300
const turntable = new Turntable({ canvas: canvas, context: context })
// 將render替換為呼叫startRotate
turntable.startRotate()
}
複製程式碼
儲存後,將如下圖操作效果:
可以看到,當我們點選點選按鈕後,轉盤開始緩緩的旋轉了。
1.5.讓轉盤緩緩停留在我們指定的獎品處
Turntable
類中內容修改如下:
// modify
constructor(options) {
this.canvas = options.canvas
this.context = options.context
this.startRadian = 0
// 我們新增了一個點選限制,這裡為了控制抽獎中不讓再抽獎
this.canBeClick = true
this.awards = [
{ level: '特等獎', name: '我的親筆簽名', color: '#576c0a' },
{ level: '未中獎', name: '未中獎', color: '#ad4411' },
{ level: '一等獎', name: '瑪莎拉蒂超級經典限量跑車', color: '#43ed04' },
{ level: '未中獎', name: '未中獎', color: '#d5ed1d' },
{ level: '二等獎', name: '辣條一包', color: '#32acc6' },
{ level: '未中獎', name: '未中獎', color: '#e06510' },
]
}
// modify
startRotate() {
const canvas = this.canvas
const context = this.context
const canvasStyle = canvas.getAttribute('style');
this.render()
canvas.addEventListener('mousedown', e => {
// 只要抽獎沒有結束,就不讓再次抽獎
if (!this.canBeClick) return
this.canBeClick = false
let loc = this.windowToCanvas(canvas, e)
context.beginPath()
context.arc(150, 150, 30, 0, Math.PI * 2, false)
if (context.isPointInPath(loc.x, loc.y)) {
// 每次點選抽獎,我們都將初始化角度重置
this.startRadian = 0
// distance是我們計算出的將指定獎品旋轉到指標處需要旋轉的角度距離,distanceToStop下面會又說明
const distance = this.distanceToStop()
this.rotatePanel(distance)
}
})
canvas.addEventListener('mousemove', e => {
let loc = this.windowToCanvas(canvas, e)
context.beginPath()
context.arc(150, 150, 30, 0, Math.PI * 2, false)
if (context.isPointInPath(loc.x, loc.y)) {
canvas.setAttribute('style', `cursor: pointer;${canvasStyle}`)
} else {
canvas.setAttribute('style', canvasStyle)
}
})
}
// modify
rotatePanel(distance) {
// 我們這裡用一個很簡單的緩動函式來計算每次繪製需要改變的角度,這樣可以達到一個轉盤從塊到慢的漸變的過程
let changeRadian = (distance - this.startRadian) / 10
this.startRadian += changeRadian
// 當最後我們的目標距離與startRadian之間的差距低於0.05時,我們就預設獎品抽完了,可以繼續抽下一個了。
if (distance - this.startRadian <= 0.05) {
this.canBeClick = true;
return
}
this.render()
window.requestAnimationFrame(this.rotatePanel.bind(this, distance))
}
// add
distanceToStop() {
// middleDegrees為獎品塊的中間角度(我們最終停留都是以中間角度進行計算的)距離初始的startRadian的距離,distance就是當前獎品跑到指標位置要轉動的距離。
let middleDegrees = 0, distance = 0
// 對映出每個獎品的middleDegrees
const awardsToDegreesList = this.awards.map((data, index) => {
let awardRadian = (Math.PI * 2) / this.awards.length
return awardRadian * index + (awardRadian * (index + 1) - awardRadian * index) / 2
});
// 隨機生成一個索引值,來表示我們此次抽獎應該中的獎品
const currentPrizeIndex = Math.floor(Math.random() * this.awards.length)
console.log('當前獎品應該中的獎品是:'+this.awards[currentPrizeIndex].name)
middleDegrees = awardsToDegreesList[currentPrizeIndex];
// 因為指標是垂直向上的,相當座標系的Math.PI/2,所以我們這裡要進行判斷來移動角度
distance = Math.PI * 3 / 2 - middleDegrees
distance = distance > 0 ? distance : Math.PI * 2 + distance
// 這裡額外加上後面的值,是為了讓轉盤多轉動幾圈,看上去更像是在抽獎
return distance + Math.PI * 10;
}
複製程式碼
儲存後,我們便可以開始抽獎了,如下圖:
比較幸運,第一次就中了兩瑪莎拉蒂?
文章寫到這裡就差不多結束,為了方便理解(偷懶),文章中很多引數都是寫死的,大家在工作中千萬別這麼作,要被罵的~~
這個只是一個非常非常簡易的轉盤,還有很多很多地方可以進行優化和擴充套件,比如為每個獎品新增圖片,轉盤顏色及每個獎品塊顏色可配置,指標轉動進行抽獎,美化轉盤(示例的轉盤顏色是隨機生成的六個顏色,感覺還不醜哈~~),移動端適配等等,考驗你們的時候到了。
最後的最後,安利兩部國漫,《星辰變》,細節感人,只是太短了,到現在才3集;另一個是《狐妖小紅娘》,蘇蘇的配音簡直萌到心都化了,bilibili看哦~