Настраиваемая вертушка-лотерея была реализована в проекте компании и раньше, в основном с использованием холста для рисования.Эту статью я написал для ознакомления с некоторыми API холста через точку, которая может быть интересна всем.Обзора,конечно,статья не будет. быть таким же сложным, как тот, который реализован в проекте, это просто упрощенная версия, постарайтесь сделать ее как можно более простой для понимания и направьте вас по пути, если вам интересно (на самом деле лень~). 😁
1. Проведите лотерею с поворотным столом
Прежде чем осознать это, у нас должна быть общая идея.Вспомните, что лотерейные спиннеры, которые мы видели в Интернете или в реальности, имеют следующие элементы:
- большой проигрыватель
- На проигрывателе есть секции, представляющие различные призы.
- В центре колеса есть кнопка с указателем, указывающим на разыгранный приз.
Давайте подумаем о процессе лотереи: когда мы нажимаем кнопку посередине, поворотный стол начинает вращаться.Когда приз получен, скорость вращения меняется от быстрой к медленной, затем медленно останавливается и, наконец, указывает на область, где находится приз. Наши действия по реализации точно так же, как это
- рисовать все статические элементы
- Добавить анимацию вращения на поворотный стол
- Область, указанная указателем, является нашим назначенным призом.
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;
}
После сохранения мы можем запустить лотерею, как показано ниже:
К счастью, я впервые выиграл два Maserati😀
Статья почти закончилась.Для облегчения понимания(лень)многие параметры в статье прописаны до смерти.Пожалуйста,не делайте этого на работе,будете ругаться~~
Это просто очень, очень простой поворотный стол, и есть много, много мест, которые можно оптимизировать и расширить, например, добавить изображения для каждого приза, настраиваемый цвет поворотного стола и цвет каждого призового блока, вращение указателя для лотереи, украшение проигрывателя (пример проигрывателя) Цвета генерируются случайным образом шестью цветами, это не кажется уродливым ~~), адаптация мобильного терминала и т. д., пришло время проверить вас.
В конце концов, два национальных комикса Amway, "Звезды меняются", подробности трогательны, но он слишком короткий, пока всего 3 серии; другой - "Лисиная фея, маленькая сваха", озвучка Сусу такая милая, что сердце тает. , посмотри на билибили~