Vue3 中用 canvas 封装抽奖转盘组件:设定中奖概率及奖项图标和名称

在 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 }]

 

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/web/78658.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Linux 硬盘和光驱系统管理

一、硬盘与目录的容量 [rootwww ~]# df [-ahikHTm] [目录或档名] 选项与参数&#xff1a; -a &#xff1a;列出所有的档案系统&#xff0c;包括系统特有的 /proc 等档案系统&#xff1b; -k &#xff1a;以 KBytes 的容量显示各档案系统&#xff1b; -m &#xff1a;以 MByt…

2.Spring Boot中集成Guava Cache或者Caffeine

一、在Spring Boot(1.x版本)中集成Guava Cache 注意&#xff1a; Spring Boot 2.x用户&#xff1a;优先使用Caffeine&#xff0c;性能更优且维护活跃。 1. 添加依赖 在pom.xml中添加Guava依赖&#xff1a; <dependency><groupId>com.google.guava</groupId&…

黑马点评day02(缓存)

2、商户查询缓存 2.1 什么是缓存? 前言:什么是缓存? 就像自行车,越野车的避震器 举个例子:越野车,山地自行车,都拥有"避震器",防止车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样; 同样,实际开发中,系统也需要"避震…

头歌禁止复制怎么解除(简单版)

被头歌数据库作业禁止复制整神之后&#xff0c;主啵尝试网上各种解除方法&#xff0c;最后发现一个最简单且最快速的解除方法。 在浏览器中搜索万能复制插件 下载完成之后就可以随便复制粘贴啦 超简单 下载只需几秒

【无基础】小白解决Docker pull时报错:https://registry-1.docker.io/v2/

Docker Compose 启动失败问题解决方案 错误描述 执行 docker compose up -d 时出现以下错误&#xff1a; [] Running 9/9✘ api Error context canceled …

【数据结构】二叉树、堆

文章目录 二叉树的概念及结构定义特殊的二叉树核心性质存储方式 二叉树的链式存储前序遍历中序遍历后序遍历层序遍历 二叉树的顺序存储父子关系的推导堆&#xff08;heap&#xff09;堆的概念向上调整算法和向下调整算法向上调整算法向下调整算法 堆的创建堆的插入堆的删除 堆的…

Vue3响应式原理那些事

文章目录 1 响应式基础:Proxy 与 Reflect1.1 Proxy 代理拦截1.2 Reflect 确保 `this` 指向正确1.2.1 修正 `this` 指向问题1.2.2 统一的操作返回值1.3 与 Vue2 的对比2 依赖收集与触发机制2.1 全局依赖存储结构:WeakMap → Map → Set2.2 依赖收集触发时机2.3 依赖收集核心实…

精选10个好用的WordPress免费主题

10个好用的WordPress免费主题 1. Astra Astra 是全球最受欢迎的WordPress免费主题。它功能丰富&#xff0c;易于使用&#xff0c;SEO友好&#xff0c;是第一个安装量突破100万的非默认主题&#xff0c;并获得了5000多个五星好评。 它完美集成了Elementor、Beaver&#xff0c;…

【SaaS多租架构】数据隔离与性能平衡

SaaS多租户架构:数据隔离与性能平衡 一、技术背景及发展二、技术特点:数据隔离与性能优化的双核心三、技术细节:实现路径与关键技术四、实际案例分析五、未来发展趋势结语一、技术背景及发展 多租户架构是云计算与SaaS(软件即服务)模式的核心技术,其核心目标是通过共享基…

部署GM DC Monitor 一体化监控预警平台

1&#xff09;首先在官网下载镜像文件 广目&#xff08;北京&#xff09;软件有限公司广目&#xff08;北京&#xff09;软件有限公司https://www.gm-monitor.com/col.jsp?id1142&#xff09;其次进行部署安装&#xff0c;教程如下&#xff1a; 1. 基础环境要求 1) 系统&…

Webug4.0靶场通关笔记15- 第19关文件上传(畸形文件)

目录 第19关 文件上传(畸形文件) 1.打开靶场 2.源码分析 &#xff08;1&#xff09;客户端源码 &#xff08;2&#xff09;服务器源码 3.渗透实战 &#xff08;1&#xff09;构造脚本 &#xff08;2&#xff09;双写绕过 &#xff08;3&#xff09;访问脚本 本文通过《…

架构思维:构建高并发读服务_热点数据查询的架构设计与性能调优

文章目录 一、引言二、热点查询定义与场景三、主从复制——垂直扩容四、应用内前置缓存4.1 容量上限与淘汰策略4.2 延迟刷新&#xff1a;定期 vs. 实时4.3 逃逸流量控制4.4 热点发现&#xff1a;被动 vs. 主动 五、降级与限流兜底六、前端&#xff0f;接入层其他应对七、模拟压…

宝塔面板运行docker的jenkins

1.在宝塔面板装docker&#xff0c;以及jenkins 2.ip:端口访问jenkins 3.获取密钥&#xff08;点击日志&#xff09; 4.配置容器内的jdk和maven环境&#xff08;直接把jdk和maven文件夹放到jenkins容器映射的data文件下&#xff09; 点击容器-->管理-->数据存储卷--.把相…

C语言 ——— 函数

目录 函数是什么 库函数 学习使用 strcpy 库函数 自定义函数 写一个函数能找出两个整数中的最大值 写一个函数交换两个整型变量的内容 牛刀小试 写一个函数判断一个整数是否是素数 写一个函数判断某一年是否是闰年 写一个函数&#xff0c;实现一个整型有序数组的二分…

笔记本电脑升级计划(2017———2025)

ThinkPad T470 (2017) vs ThinkBook 16 (2025) 完整性能对比报告 一、核心硬件性能对比 1. CPU性能对比&#xff08;i5-7200U vs Ultra9-285H&#xff09; 参数i5-7200U (2017)Ultra9-285H (2025)提升百分比核心架构2核4线程 (Skylake)16核16线程 (6P8E2LPE)700%核心数制程工…

具身系列——PPO算法实现CartPole游戏(强化学习)

完整代码参考&#xff1a; https://gitee.com/chencib/ailib/blob/master/rl/ppo_cartpole.py 执行结果&#xff1a; 部分训练得分&#xff1a; (sd) D:\Dev\traditional_nn\feiai\test\rl>python ppo_cartpole_v2_succeed.py Ep: 0 | Reward: 23.0 | Running: 2…

Python项目源码60:电影院选票系统1.0(tkinter)

1.功能特点&#xff1a;通常选票系统应该允许用户选择电影、场次、座位&#xff0c;然后显示总价和生成票据。好的&#xff0c;我得先规划一下界面布局。 首先&#xff0c;应该有一个电影选择的列表&#xff0c;可能用下拉菜单Combobox来实现。然后场次时间&#xff0c;可能用…

【全队项目】智能学术海报生成系统PosterGenius--图片布局生成模型LayoutPrompt(2)

&#x1f308; 个人主页&#xff1a;十二月的猫-CSDN博客 &#x1f525; 系列专栏&#xff1a; &#x1f3c0;大模型实战训练营_十二月的猫的博客-CSDN博客 &#x1f4aa;&#x1f3fb; 十二月的寒冬阻挡不了春天的脚步&#xff0c;十二点的黑夜遮蔽不住黎明的曙光 目录 1. 前…

Linux的时间同步服务器(附加详细实验案例)

一、计时方式的发展 1.古代计时方式​ 公元前约 2000 年&#xff1a;古埃及人利用光线留下的影子计时&#xff0c;他们修建高耸的大型方尖碑&#xff0c;通过追踪方尖碑影子的移动判断时间&#xff0c;这是早期利用自然现象计时的典型方式 。​商朝时期&#xff1a;人们开发并…

【无需docker】mac本地部署dify

环境安装准备 #安装 postgresql13 brew install postgresql13 #使用zsh的在全局添加postgresql命令集 echo export PATH"/usr/local/opt/postgresql13/bin:$PATH" >> ~/.zshrc # 使得zsh的配置修改生效 source ~/.zshrc # 启动postgresql brew services star…