一步一步帶你實現一個canvas抽獎轉盤

以前在公司項目中實現了一個可配置的轉盤抽獎,主要是用canvas進行繪製,寫這篇文章是爲了經過一個你們可能會感興趣的點,來熟悉canvas的一些api,我本身也算是進行一些複習,固然文章不會像項目中實現的那樣複雜,只是一個簡易版本的,儘可能作到通俗易懂,給感興趣的你指引一條路(實際上是懶~)。😁javascript

1、實現一個轉盤抽獎

在實現以前咱們要有一個總體的思路,回想一下,咱們曾經在網上或者現實中見過的抽獎轉盤都有那些元素:html

  • 一個大的轉盤
  • 轉盤上有一個個區間,表示不一樣的獎品
  • 在轉盤的中間有一個按鈕,按鈕上有一個指針,來指向抽中的獎品

咱們再想一想抽獎的過程:當咱們點擊中間的按鈕,轉盤開始旋轉,當獲取到獎品後,旋轉的速度從快到慢,緩緩停下,最後指向獎品所在的那個區域,咱們的實現步驟就能夠這樣vue

  • 繪製全部的靜態元素
  • 給轉盤添加旋轉動畫
  • 指針所指區域爲咱們指定的獎品

1.繪製全部靜態元素

1.1.開發環境搭建

全局安裝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

1.2.繪製大轉盤

修改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()
	}
}
複製代碼

保存後,瀏覽器中結果顯示以下圖:

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看哦~

源碼地址

相關文章
相關標籤/搜索