以前在公司項目中實現了一個可配置的轉盤抽獎,主要是用canvas進行繪製,寫這篇文章是爲了經過一個你們可能會感興趣的點,來熟悉canvas的一些api,我本身也算是進行一些複習,固然文章不會像項目中實現的那樣複雜,只是一個簡易版本的,儘可能作到通俗易懂,給感興趣的你指引一條路(實際上是懶~)。😁javascript
在實現以前咱們要有一個總體的思路,回想一下,咱們曾經在網上或者現實中見過的抽獎轉盤都有那些元素:html
咱們再想一想抽獎的過程:當咱們點擊中間的按鈕,轉盤開始旋轉,當獲取到獎品後,旋轉的速度從快到慢,緩緩停下,最後指向獎品所在的那個區域,咱們的實現步驟就能夠這樣vue
全局安裝create-react-app
,快速生成react
的開發環境,這個與咱們要開發的轉盤沒有耦合關係,只是爲了方便調試,用vue
也是能夠的。java
// 全局安裝
npm install create-react-app -g
// 生成開發環境,lottery爲目錄名
create-react-app lottery
複製代碼
安裝完成後修改爲以下圖目錄結構react
turntable.jsx
內容以下:git
export default class Turntable {
}
複製代碼
App.js
內容以下:github
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
啓動項目npm
修改App.js
中的內容以下:canvas
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
中的內容:api
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()
}
}
複製代碼
保存後,瀏覽器中結果顯示以下圖:
在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
}
}
複製代碼
保存後,咱們轉盤變成以下模樣:
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()
}
複製代碼
保存後,轉盤以下圖所示:
效果應該是:當咱們點擊按鈕時,按鈕和指針保持不動,而轉盤以及轉盤上的獎品塊會同時旋轉起來。
而如何讓轉盤旋轉起來呢?還記得咱們是如何繪製轉盤和獎品塊的嗎,咱們是以繪製弧的方式,從角度爲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()
}
複製代碼
保存後,將以下圖操做效果:
能夠看到,當咱們點擊點擊按鈕後,轉盤開始緩緩的旋轉了。
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看哦~