在 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 = nulllet startTime = 0
3. 预加载图片
如果奖项配置中有图标,需要提前加载图片,确保绘制转盘时图标能正常显示。通过Image对象加载图片,并在onload事件中触发转盘绘制。
const loadImages = () => {props.prizes.forEach((prize, index) => {if (prize.icon) {const img = new Image()img.src = prize.iconimg.onload = () => {loadedImages.value[index] = imgdrawWheel()}img.onerror = () => {console.error(`图片加载失败: ${prize.icon}`)}}})}
4. 绘制转盘
获取 canvas 上下文,根据奖项数量计算每个扇形的角度,绘制转盘的扇形区域,并在每个扇形区域绘制奖项名称和图标。
const drawWheel = () => {const canvas = canvasRef.valueif (!canvas) returnconst ctx = canvas.getContext('2d')const centerX = canvas.width / 2const centerY = canvas.height / 2const radius = Math.min(centerX, centerY) - 10const arc = Math.PI * 2 / props.prizes.lengthctx.clearRect(0, 0, canvas.width, canvas.height)// 绘制扇形props.prizes.forEach((prize, index) => {const startAngle = index * arc + rotationAngle.valueconst endAngle = (index + 1) * arc + rotationAngle.valuectx.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) returnemit('start')isRotating.value = trueresult.value = nullselectedPrizeIndex.value = selectPrizeByProbability()startTime = performance.now()animateRotation()}const animateRotation = () => {const now = performance.now()const elapsed = now - startTimeconst progress = Math.min(elapsed / props.duration, 1)// 缓动函数 - 先快后慢const easeOut = 1 - Math.pow(1 - progress, 4)// 计算旋转角度const anglePerItem = Math.PI * 2 / props.prizes.lengthconst fullCircles = 5 // 完整旋转圈数// 关键修正:减去Math.PI/2(90度)来校准初始位置const targetAngle = fullCircles * Math.PI * 2 +(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -(anglePerItem / 2) -Math.PI / 2rotationAngle.value = easeOut * targetAngledrawWheel()if (progress < 1) {animationFrameId = requestAnimationFrame(animateRotation)} else {// 确保最终角度精确对准奖项中心rotationAngle.value = fullCircles * Math.PI * 2 +(Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) -(anglePerItem / 2) -Math.PI / 2drawWheel()isRotating.value = falseemit('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() * totalProbabilitylet currentSum = 0for (let i = 0; i < props.prizes.length; i++) {currentSum += props.prizes[i].probabilityif (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.iconimg.onload = () => {loadedImages.value[index] = imgdrawWheel()}img.onerror = () => {console.error(`图片加载失败: ${prize.icon}`)}}})
}// 绘制转盘
const drawWheel = () => {const canvas = canvasRef.valueif (!canvas) returnconst ctx = canvas.getContext('2d')const centerX = canvas.width / 2const centerY = canvas.height / 2const radius = Math.min(centerX, centerY) - 10const arc = Math.PI * 2 / props.prizes.lengthctx.clearRect(0, 0, canvas.width, canvas.height)// 绘制扇形props.prizes.forEach((prize, index) => {const startAngle = index * arc + rotationAngle.valueconst endAngle = (index + 1) * arc + rotationAngle.valuectx.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) returnemit('start')isRotating.value = trueresult.value = nullselectedPrizeIndex.value = selectPrizeByProbability()startTime = performance.now()animateRotation()
}// 动画函数
const animateRotation = () => {const now = performance.now()const elapsed = now - startTimeconst progress = Math.min(elapsed / props.duration, 1)// 缓动函数 - 先快后慢const easeOut = 1 - Math.pow(1 - progress, 4)// 计算旋转角度const anglePerItem = Math.PI * 2 / props.prizes.lengthconst fullCircles = 5 // 完整旋转圈数// 关键修正:减去Math.PI/2(90度)来校准初始位置const targetAngle = fullCircles * Math.PI * 2 + (Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) - (anglePerItem / 2) - Math.PI / 2rotationAngle.value = easeOut * targetAngledrawWheel()if (progress < 1) {animationFrameId = requestAnimationFrame(animateRotation)} else {// 确保最终角度精确对准奖项中心rotationAngle.value = fullCircles * Math.PI * 2 + (Math.PI * 2 - anglePerItem * selectedPrizeIndex.value) - (anglePerItem / 2) - Math.PI / 2drawWheel()isRotating.value = falseemit('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() * totalProbabilitylet currentSum = 0for (let i = 0; i < props.prizes.length; i++) {currentSum += props.prizes[i].probabilityif (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 }]