一步一步带你实现一个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看哦~

源码地址

相关文章
相关标签/搜索