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

Yokoo發表於2018-10-19

之前在公司專案中實現了一個可配置的轉盤抽獎,主要是用canvas進行繪製,寫這篇文章是為了通過一個大家可能會感興趣的點,來熟悉canvas的一些api,我自己也算是進行一些複習,當然文章不會像專案中實現的那樣複雜,只是一個簡易版本的,儘量做到通俗易懂,給感興趣的你指引一條路(其實是懶~)。?

一、實現一個轉盤抽獎

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

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

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

  • 繪製所有的靜態元素
  • 給轉盤新增旋轉動畫
  • 指標所指區域為我們指定的獎品

1.繪製所有靜態元素

1.1.開發環境搭建

全域性安裝create-react-app,快速生成react的開發環境,這個與我們要開發的轉盤沒有耦合關係,只是為了方便除錯,用vue也是可以的。

// 全域性安裝
npm install create-react-app -g

// 生成開發環境,lottery為目錄名
create-react-app lottery
複製程式碼

安裝完成後修改成如下圖目錄結構

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

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()
	}
}
複製程式碼

儲存後,瀏覽器中結果顯示如下圖:

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

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()
}
複製程式碼

儲存後,我們的轉盤變為如下圖樣式:

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

使用繪製獎品色塊使用context.moveTo(150, 150)和不使用的區別:

使用了context.moveTo(150, 150):

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

沒有使用context.moveTo(150, 150):

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

獎品塊繪製好了,我們需要新增每個獎品的名稱,假定我們有三個獎品,另外三個就設定為未中獎

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
  }
}
複製程式碼

儲存後,我們轉盤變成如下模樣:

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

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()
}
複製程式碼

儲存後,轉盤如下圖所示:

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

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()
}
複製程式碼

儲存後,將如下圖操作效果:

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

可以看到,當我們點選點選按鈕後,轉盤開始緩緩的旋轉了。

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;
}
複製程式碼

儲存後,我們便可以開始抽獎了,如下圖:

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

比較幸運,第一次就中了兩瑪莎拉蒂?

文章寫到這裡就差不多結束,為了方便理解(偷懶),文章中很多引數都是寫死的,大家在工作中千萬別這麼作,要被罵的~~

這個只是一個非常非常簡易的轉盤,還有很多很多地方可以進行優化和擴充套件,比如為每個獎品新增圖片,轉盤顏色及每個獎品塊顏色可配置,指標轉動進行抽獎,美化轉盤(示例的轉盤顏色是隨機生成的六個顏色,感覺還不醜哈~~),移動端適配等等,考驗你們的時候到了。

最後的最後,安利兩部國漫,《星辰變》,細節感人,只是太短了,到現在才3集;另一個是《狐妖小紅娘》,蘇蘇的配音簡直萌到心都化了,bilibili看哦~

原始碼地址

相關文章