在 Web 应用开发中,抽奖功能是提升用户参与度的常用手段。使用 Vue3 结合 canvas 技术,我们可以轻松实现一个高度自定义的抽奖转盘组件,不仅能设定中奖概率,还能灵活配置奖项图标和名称。本文将详细介绍该组件的实现原理、步骤,并提供完整代码。
实现原理
抽奖转盘组件的核心在于通过 canvas 绘制转盘图形,并结合动画效果实现转盘的旋转。通过设定不同奖项的中奖概率,随机选择中奖奖项,并使用缓动函数控制转盘旋转的动画效果,使转盘最终停在对应的中奖区域。
实现步骤
1. 定义组件模板
在 Vue3 的单文件组件(.vue)中,定义抽奖转盘组件的模板。模板包含 canvas 画布用于绘制转盘,一个中心按钮用于触发抽奖,以及一个提示框用于展示中奖结果。
<template>
<div class="lottery-container">
<div class="wheel-container">
<canvas ref="canvasRef" width="350" height="350"></canvas>
<div class="wheel-center" @click="startLottery">
<div class="start-btn">{{ isRotating? '抽奖中...' : '开始抽奖' }}</div>
</div>
<div class="pointer"></div>
</div>
<div v-if="result" class="result-modal">
<h3>恭喜您获得: {{ result.name }}</h3>
<button @click="result = null">确定</button>
</div>
</div>
</template>
2. 定义组件属性和数据
使用defineProps定义组件接收的属性,包括奖项配置(prizes)、抽奖持续时间(duration)、转盘颜色(colors)。同时,使用ref定义组件内部状态,如是否正在旋转(isRotating)、旋转角度(rotationAngle)、选中的奖项索引(selectedPrizeIndex)、中奖结果(result)等。
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps({
prizes: {
type: Array,
required: true,
validator: (value) => {
return value.length >= 2 && value.every(item =>
item.name && typeof item.probability === 'number' && item.probability >= 0
)
}
},
duration: {
type: Number,
default: 5000
},
colors: {
type: Array,
default: () => [
'#FF5252', '#FF4081', '#E040FB', '#7C4DFF',
'#536DFE', '#448AFF', '#40C4FF', '#18FFFF',
'#64FFDA', '#69F0AE', '#B2FF59', '#EEFF41',
'#FFFF00', '#FFD740', '#FFAB40', '#FF6E40'
]
}
})
const emit = defineEmits(['start', 'end'])
const canvasRef = ref(null)
const isRotating = ref(false)
const rotationAngle = ref(0)
const selectedPrizeIndex = ref(-1)
const result = ref(null)
const loadedImages = ref({})
let animationFrameId = null
let startTime = 0
3. 预加载图片
如果奖项配置中有图标,需要提前加载图片,确保绘制转盘时图标能正常显示。通过Image对象加载图片,并在onload事件中触发转盘绘制。
const loadImages = () => {
props.prizes.forEach((prize, index) => {
if (prize.icon) {
const img = new Image()
img.src = prize.icon
img.onload = () => {
loadedImages.value[index] = img
drawWheel()
}
img.onerror = () => {
console.error(`图片加载失败: ${prize.icon}`)
}
}
})
}
4. 绘制转盘
获取 canvas 上下文,根据奖项数量计算每个扇形的角度,绘制转盘的扇形区域,并在每个扇形区域绘制奖项名称和图标。
const drawWheel = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const centerX = canvas.width / 2
const centerY = canvas.height / 2
const radius = Math.min(centerX, centerY) - 10
const arc = Math.PI * 2 / props.prizes.length
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制扇形
props.prizes.forEach((prize, index) => {
const startAngle = index * arc + rotationAngle.value
const endAngle = (index + 1) * arc + rotationAngle.value
ctx.beginPath()
ctx.fillStyle = props.colors[index % props.colors.length]
ctx.moveTo(centerX, centerY)
ctx.arc(centerX, centerY, radius, startAngle, endAngle)
ctx.closePath()
ctx.fill()
// 绘制文字和图标
ctx.save()
ctx.translate(centerX, centerY)
ctx.rotate(startAngle + arc / 2)
// 绘制文字
ctx.fillStyle = '#fff'
ctx.font = 'bold 14px Arial'
ctx.textAlign = 'center'
ctx.fillText(prize.name, radius * 0.7, 5)
// 绘制图标
if (loadedImages.value[index]) {
const img = loadedImages.value[index]
ctx.drawImage(img, radius * 0.5 - 15, -15, 30, 30)
} else if (prize.icon) {
ctx.fillStyle = 'rgba(255,255,255,0.7)'
ctx.fillRect(radius * 0.5 - 15, -15, 30, 30)
}
ctx.restore()
})
}
5. 开始抽奖和动画效果
实现startLottery方法用于开始抽奖,通过selectPrizeByProbability方法根据概率选择中奖奖项,然后使用animateRotation方法实现转盘旋转的动画效果。动画效果中使用缓动函数控制旋转速度,使转盘先快后慢最终停在中奖区域。
const startLottery = () => {
if (isRotating.value) return
emit('start')
isRotating.value = true
result.value = null
selectedPrizeIndex.value = selectPrizeByProbability()
startTime = performance.now()
animateRotation()
}
const animateRotation = () => {
const now = performance.now()
const elapsed = now - startTime
const progress = Math.min(elapsed / props.duration, 1)
// 缓动函数 - 先快后慢
const easeOut = 1 - Math.pow(1 - progress, 4)
// 计算旋转角度
const anglePerItem = Math.PI * 2 / props.prizes.length
const fullCircles = 5 // 完整旋转圈数
// 关键修正:减去Math.PI/2(90度)来校准初始位置
const targetAngle = fullCircles * Math.PI * 2 +
(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -
(anglePerItem / 2) -
Math.PI / 2
rotationAngle.value = easeOut * targetAngle
drawWheel()
if (progress < 1) {
animationFrameId = requestAnimationFrame(animateRotation)
} else {
// 确保最终角度精确对准奖项中心
rotationAngle.value = fullCircles * Math.PI * 2 +
(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -
(anglePerItem / 2) -
Math.PI / 2
drawWheel()
isRotating.value = false
emit('end', props.prizes[selectedPrizeIndex.value])
result.value = props.prizes[selectedPrizeIndex.value]
}
}
6. 根据概率选择奖项
实现selectPrizeByProbability方法,计算所有奖项的总概率,然后生成一个随机数,根据随机数落在哪个概率区间来确定中奖奖项。
const selectPrizeByProbability = () => {
const totalProbability = props.prizes.reduce((sum, prize) => sum + prize.probability, 0)
const random = Math.random() * totalProbability
let currentSum = 0
for (let i = 0; i < props.prizes.length; i++) {
currentSum += props.prizes[i].probability
if (random <= currentSum) {
return i
}
}
return 0
}
7. 生命周期钩子和监听器
在onMounted钩子中调用loadImages和drawWheel初始化转盘;在onBeforeUnmount钩子中取消动画帧,避免内存泄漏;使用watch监听props.prizes的变化,重新加载图片并绘制转盘。
onMounted(() => {
loadImages()
drawWheel()
})
onBeforeUnmount(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
})
watch(() => props.prizes, () => {
loadedImages.value = {}
loadImages()
drawWheel()
}, { deep: true })
8. 定义组件样式
通过 CSS 样式定义抽奖转盘组件的外观,包括转盘容器、中心按钮、指针和中奖结果提示框的样式。
.lottery-container {
display: flex;
flex-direction: column;
align-items: center;
}
.wheel-container {
position: relative;
width: 350px;
height: 350px;
margin: 0 auto;
}
canvas {
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
}
.wheel-center {
position: absolute;
width: 80px;
height: 80px;
background: white;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
border: 2px solid #e74c3c;
}
.start-btn {
font-size: 16px;
font-weight: bold;
color: #e74c3c;
text-align: center;
user-select: none;
}
.pointer {
position: absolute;
width: 40px;
height: 40px;
top: -20px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.pointer::before {
content: '';
position: absolute;
border-width: 15px;
border-style: solid;
border-color: transparent transparent #e74c3c transparent;
top: 0;
left:7px;
}
.result-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
z-index: 100;
text-align: center;
}
.result-modal h3 {
margin-top: 0;
color: #e74c3c;
}
.result-modal button {
margin-top: 20px;
padding: 10px 20px;
background: #e74c3c;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
完整代码
<template>
<div class="lottery-container">
<div class="wheel-container">
<canvas ref="canvasRef" width="350" height="350"></canvas>
<div class="wheel-center" @click="startLottery">
<div class="start-btn">{{ isRotating ? '抽奖中...' : '开始抽奖' }}</div>
</div>
<div class="pointer"></div>
</div>
<div v-if="result" class="result-modal">
<h3>恭喜您获得: {{ result.name }}</h3>
<button @click="result = null">确定</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps({
prizes: {
type: Array,
required: true,
validator: (value) => {
return value.length >= 2 && value.every(item =>
item.name && typeof item.probability === 'number' && item.probability >= 0
)
}
},
duration: {
type: Number,
default: 5000
},
colors: {
type: Array,
default: () => [
'#FF5252', '#FF4081', '#E040FB', '#7C4DFF',
'#536DFE', '#448AFF', '#40C4FF', '#18FFFF',
'#64FFDA', '#69F0AE', '#B2FF59', '#EEFF41',
'#FFFF00', '#FFD740', '#FFAB40', '#FF6E40'
]
}
})
const emit = defineEmits(['start', 'end'])
const canvasRef = ref(null)
const isRotating = ref(false)
const rotationAngle = ref(0)
const selectedPrizeIndex = ref(-1)
const result = ref(null)
const loadedImages = ref({})
let animationFrameId = null
let startTime = 0
// 预加载所有图片
const loadImages = () => {
props.prizes.forEach((prize, index) => {
if (prize.icon) {
const img = new Image()
img.src = prize.icon
img.onload = () => {
loadedImages.value[index] = img
drawWheel()
}
img.onerror = () => {
console.error(`图片加载失败: ${prize.icon}`)
}
}
})
}
// 绘制转盘
const drawWheel = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const centerX = canvas.width / 2
const centerY = canvas.height / 2
const radius = Math.min(centerX, centerY) - 10
const arc = Math.PI * 2 / props.prizes.length
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制扇形
props.prizes.forEach((prize, index) => {
const startAngle = index * arc + rotationAngle.value
const endAngle = (index + 1) * arc + rotationAngle.value
ctx.beginPath()
ctx.fillStyle = props.colors[index % props.colors.length]
ctx.moveTo(centerX, centerY)
ctx.arc(centerX, centerY, radius, startAngle, endAngle)
ctx.closePath()
ctx.fill()
// 绘制文字和图标
ctx.save()
ctx.translate(centerX, centerY)
ctx.rotate(startAngle + arc / 2)
// 绘制文字
ctx.fillStyle = '#fff'
ctx.font = 'bold 14px Arial'
ctx.textAlign = 'center'
ctx.fillText(prize.name, radius * 0.7, 5)
// 绘制图标
if (loadedImages.value[index]) {
const img = loadedImages.value[index]
ctx.drawImage(img, radius * 0.5 - 15, -15, 30, 30)
} else if (prize.icon) {
ctx.fillStyle = 'rgba(255,255,255,0.7)'
ctx.fillRect(radius * 0.5 - 15, -15, 30, 30)
}
ctx.restore()
})
}
// 开始抽奖
const startLottery = () => {
if (isRotating.value) return
emit('start')
isRotating.value = true
result.value = null
selectedPrizeIndex.value = selectPrizeByProbability()
startTime = performance.now()
animateRotation()
}
// 动画函数
const animateRotation = () => {
const now = performance.now()
const elapsed = now - startTime
const progress = Math.min(elapsed / props.duration, 1)
// 缓动函数 - 先快后慢
const easeOut = 1 - Math.pow(1 - progress, 4)
// 计算旋转角度
const anglePerItem = Math.PI * 2 / props.prizes.length
const fullCircles = 5 // 完整旋转圈数
// 关键修正:减去Math.PI/2(90度)来校准初始位置
const targetAngle = fullCircles * Math.PI * 2 +
(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -
(anglePerItem / 2) -
Math.PI / 2
rotationAngle.value = easeOut * targetAngle
drawWheel()
if (progress < 1) {
animationFrameId = requestAnimationFrame(animateRotation)
} else {
// 确保最终角度精确对准奖项中心
rotationAngle.value = fullCircles * Math.PI * 2 +
(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -
(anglePerItem / 2) -
Math.PI / 2
drawWheel()
isRotating.value = false
emit('end', props.prizes[selectedPrizeIndex.value])
result.value = props.prizes[selectedPrizeIndex.value]
}
}
// 根据概率选择奖项
const selectPrizeByProbability = () => {
const totalProbability = props.prizes.reduce((sum, prize) => sum + prize.probability, 0)
const random = Math.random() * totalProbability
let currentSum = 0
for (let i = 0; i < props.prizes.length; i++) {
currentSum += props.prizes[i].probability
if (random <= currentSum) {
return i
}
}
return 0
}
// 初始化
onMounted(() => {
loadImages()
drawWheel()
})
onBeforeUnmount(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
})
watch(() => props.prizes, () => {
loadedImages.value = {}
loadImages()
drawWheel()
}, { deep: true })
</script>
<style scoped>
.lottery-container {
display: flex;
flex-direction: column;
align-items: center;
}
.wheel-container {
position: relative;
width: 350px;
height: 350px;
margin: 0 auto;
}
canvas {
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
}
.wheel-center {
position: absolute;
width: 80px;
height: 80px;
background: white;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
border: 2px solid #e74c3c;
}
.start-btn {
font-size: 16px;
font-weight: bold;
color: #e74c3c;
text-align: center;
user-select: none;
}
.pointer {
position: absolute;
width: 40px;
height: 40px;
top: -20px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.pointer::before {
content: '';
position: absolute;
border-width: 15px;
border-style: solid;
border-color: transparent transparent #e74c3c transparent;
top: 0;
left:7px;
}
.result-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
z-index: 100;
text-align: center;
}
.result-modal h3 {
margin-top: 0;
color: #e74c3c;
}
.result-modal button {
margin-top: 20px;
padding: 10px 20px;
background: #e74c3c;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
</style>
在父组件中prizes的结构为:
[
{ name: '一等奖', probability: 5, icon: './public/icons/jiang.png' },
{ name: '二等奖', probability: 10, icon: './public/icons/jiang.png' },
{ name: '三等奖', probability: 15, icon: './public/icons/jiang.png' },
{ name: '四等奖', probability: 20, icon: './public/icons/jiang.png' },
{ name: '五等奖', probability: 25, icon: './public/icons/jiang.png' },
{ name: '谢谢参与', probability: 25 }
]