前言
又来到一年一度的中秋节,大家最近收到月饼了吗?如果没有,那你要好好反思下:这么多年下来,有没有好好工作?好好谈个恋爱?别人的大宝贝都有月饼为啥你没有。心疼靓仔三秒之余,我熬夜为广大靓仔准备了各式月饼,各位靓仔们准备好了,接下来开始接月饼了哈 🐒🐴
在线抽月饼
代码实现
月饼吃完了,我们来聊一聊这月饼实现方式了
Web Component
月饼抽奖骨架如下:指定了月饼种类、权重和颜色
<lucky-wheel
sectors='["蛋黄月饼 🥚🌕",
"豆沙月饼 🥟😋",
"五仁月饼 🌰🌕",
"红豆沙月饼 🍓🌕",
"提子月饼 🍇🌕",
"绿茶月饼 🍵🌕",
"奶黄月饼 🥛🌕",
"火腿月饼 🍖🌕"]'
probabilities="[15, 18, 20, 25, 8, 10, 20, 25]"
start-angle="0"
width="400"
height="400"
colors='["rgb(232, 98, 113)", "rgb(255, 255, 255)", "rgb(232, 98, 113)", "rgb(255, 255, 255)", "rgb(232, 98, 113)", "rgb(255, 255, 255)", "rgb(232, 98, 113)", "rgb(255, 255, 255)"]'
>
</lucky-wheel>
在内部还监听了它们的属性变化
static get observedAttributes() {
return ['sectors', 'probabilities', 'start-angle', 'colors']
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
switch (name) {
case 'sectors':
this.sectors = JSON.parse(newValue)
break
case 'probabilities':
this.probabilities = JSON.parse(newValue)
break
case 'start-angle':
this.startAngle = Number.parseFloat(newValue)
break
case 'colors':
this.colors = JSON.parse(newValue)
break
}
this.render()
}
}
connectedCallback() {
this.render()
}
基本元素绘制
我们可以看到抽奖界面主要由 外环、主表盘、中心盘和指针组成,接下来一一分析它们的实现
外环
drawOuterRing(ctx, options = {}) {
// 外环
const {
ringColor = 'rgba(238, 169, 74, 1)',
numCircles = 18,
circleOddColor = 'rgba(255, 252, 187, 1)',
circleEvenColor = 'rgba(245, 212, 160, 1)',
} = options
const centerX = this.width / 2
const centerY = this.height / 2
ctx.save()
ctx.beginPath()
ctx.fillStyle = ringColor
ctx.arc(centerY, centerY, centerX, 0, 2 * Math.PI, false)
ctx.closePath()
ctx.fill()
// 环上圆点
const angleIncrement = (2 * Math.PI) / numCircles
const radius = (centerX - 10)
for (let i = 0; i < numCircles; i++) {
const angle = i * angleIncrement
const circleX = centerX + radius * Math.cos(angle)
const circleY = centerY + radius * Math.sin(angle)
ctx.beginPath()
ctx.arc(circleX, circleY, 5, 0, 2 * Math.PI)
if (i % 2 === 0)
ctx.fillStyle = circleOddColor
else
ctx.fillStyle = circleEvenColor
ctx.fill()
}
ctx.restore()
}
主表盘
主表盘由两部分组成:内环和一系列扇形块 扇形块根据权重进行绘制,同时绘制扇形片文字
// 绘制扇形文字
drawText(ctx, options = {}) {
const {
text = '',
angle = '',
textOffset = 85,
color = '#eee',
font = '20px Arial',
} = options
const centerX = this.width / 2
const centerY = this.height / 2
const textX = centerX + textOffset * Math.cos(angle)
const textY = centerY + textOffset * Math.sin(angle)
ctx.save()
ctx.translate(textX, textY)
ctx.rotate(angle)
ctx.font = font
ctx.fillStyle = color
ctx.textAlign = 'center'
ctx.textBaseline = 'middle' // 设置文字垂直居中对齐
ctx.fillText(text, 0, 0)
ctx.restore()
}
// 绘制每片扇形
drawPerSector(ctx, chunks, options = {}) {
const {
ringWidth = 10,
ringColor = 'rgba(0,0,0,0.1)',
} = options
const centerX = this.width / 2
const centerY = this.height / 2
const totalProbabilities = this.probabilities.reduce((a, b) => a + b, 0)
// 初始化角度
let accumulatedAngle = this.spinType === 'panel' ? this.startAngle : 0
ctx.save()
const radius = centerX - 20
chunks.forEach((sector, index) => {
const sectorAngle = ((360 * this.probabilities[index]) / totalProbabilities) * Math.PI / 180
const endAngle = accumulatedAngle + sectorAngle
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.arc(centerY, centerY, radius, accumulatedAngle, endAngle, false)
ctx.closePath()
ctx.fillStyle = this.colors[index] // 使用提供的颜色
ctx.fill()
if (index % 2 === 0) {
this.drawText(ctx, {
text: sector,
angle: (accumulatedAngle + endAngle) / 2,
color: 'rgb(255, 255, 255, 1)',
})
}
else {
this.drawText(ctx, {
text: sector,
angle: (accumulatedAngle + endAngle) / 2,
color: 'rgb(233, 97, 113)',
})
}
accumulatedAngle = endAngle
})
// 绘制内圈
ctx.beginPath()
ctx.arc(centerY, centerY, radius - ringWidth / 2, 0, 2 * Math.PI)
ctx.lineWidth = ringWidth
ctx.strokeStyle = ringColor
ctx.stroke()
ctx.restore()
}
中心盘和指针
drawPointer(ctx, options = {}) {
const {
color = 'rgba(255,255,255, 1)',
radius = 40,
ringWidth = 8,
ringColor = 'rgba(72, 72, 72, 1)',
text = '奖',
textColor = 'rgb(225, 101, 117)',
} = options
const centerX = this.width / 2
const centerY = this.height / 2
// 指针旋转角度
const rotateAngle = this.spinType === 'pointer' ? this.startAngle : 0
// 绘制外圆
ctx.save()
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, 360 * Math.PI / 180)
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
ctx.shadowBlur = 10
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 1
ctx.fill()
ctx.restore()
// 绘制内圆
ctx.beginPath()
ctx.arc(centerX, centerY, radius - 10, 0, 2 * Math.PI)
ctx.lineWidth = ringWidth
ctx.strokeStyle = ringColor
ctx.stroke()
// 绘制文字
ctx.save()
ctx.translate(centerX, centerY)
ctx.rotate(rotateAngle)
ctx.font = '28px Arial'
ctx.fillStyle = textColor
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, 0, 0)
ctx.restore()
// 绘制指针箭头
const arrowX = centerX + (radius + 12) * Math.cos(rotateAngle)
const arrowY = centerY + (radius + 12) * Math.sin(rotateAngle)
ctx.beginPath()
ctx.moveTo(arrowX, arrowY)
ctx.arc(arrowX, arrowY, 18, rotateAngle - 140 * (Math.PI / 180), rotateAngle - 220 * (Math.PI / 180), true)
ctx.fillStyle = color
ctx.fill()
}
添加交互
添加点击事件,触发开始抽奖,这里实现了几种常用的动画缓动函数,可以根据配置切换
this.addEventListener('click', this.spin)
spin(ctx, options = {}) {
if (this.isSpinning)
return
this.isSpinning = true
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
const {
time = 0,
begin = 0,
type = 'ease-in-out',
end = getRandomInt(4000 * Math.PI / 180, 40000 * Math.PI / 180),
duration = 3000,
callback = (value) => {
this.startAngle = value
this.render()
},
} = options
this.play({
time,
begin,
end,
duration,
type,
callback,
})
}
// 启动
play(options) {
return new Promise((resolve) => {
let { time, begin, end, duration, type, callback } = options
const durNums = Math.ceil(duration / 16.7)
if (!window.requestAnimationFrame) {
window.requestAnimationFrame = function (fn) {
setTimeout(fn, 16.7)
}
}
const step = () => {
const value = getAnimationByType(type)(time, begin, end, durNums)
callback(value)
time++
if (time <= durNums) {
window.requestAnimationFrame(step)
}
else {
switch (this.spinType) {
case 'pointer':
this.dispatchEvent(new CustomEvent('spinComplete', { detail: this.getPointerSelectedValue() }))
break
case 'panel':
this.dispatchEvent(new CustomEvent('spinComplete', { detail: this.getPanelSelectedValue() }))
break
}
this.isSpinning = false
resolve()
}
}
step()
})
}
抽奖结果
使用转动的总角度,取余得到 0~360 区间的角度,再计算每块扇形块的角度区间,转动角度落在哪个区间内,则是最终抽奖结果
getPanelSelectedValue() {
const totalProbabilities = this.probabilities.reduce((a, b) => a + b, 0)
let turnAngle = (this.startAngle / (Math.PI / 180)) % 360
let accumulatedAngle = 0
if (turnAngle < 270)
turnAngle = 270 - turnAngle
else
turnAngle = (360 - turnAngle) + 270
for (let i = 0; i < this.probabilities.length; i++) {
const sectorAngle = (360 * this.probabilities[i]) / totalProbabilities
const endAngle = (accumulatedAngle + sectorAngle)
if (accumulatedAngle <= turnAngle && endAngle >= turnAngle)
return this.sectors[i]
accumulatedAngle = endAngle
}
return this.sectors[0]
}
后记
靓仔们,月饼吃完了,再看看我的其他系列吧~