尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

HTML5抽奖转盘实战:Canvas绘制、动画与概率控制全解析

HTML5抽奖转盘实战:Canvas绘制、动画与概率控制全解析 这一阵子好几个朋友问我前端作业或者活动页面里的抽奖转盘怎么做干脆把我自己的实现方案整理出来。这项目看起来就是个转转转的圆盘实际上涉及Canvas绘图、动画循环、概率控制、移动端适配好几个环节写清楚还是挺有内容的。文章里我会把一套可以直接跑起来的HTML5转盘从零讲到能上线重点讲为什么这么做而不是贴一堆代码让你自己猜。1. 项目拆解HTML5转盘到底在做什么1.1 核心需求解析转盘这东西使用的场景无非三类营销活动抽奖、线下大屏互动、教学演示或者前端作业。不管哪种场景用户看到的都是一个圆盘和一根指针点击按钮之后圆盘旋转最终停在某个扇区弹窗告诉你中了什么。代码层面拆开看核心要做四件事画一个圆盘把奖品、文案、配色画上去。让圆盘能转转的时候有加速、减速、回弹这种物理手感。控制最终停的位置让中奖结果符合预设概率。结束后给出反馈弹窗、音效、记录缺一不可。我见过很多人一开始就去找jQuery插件确实省事但往往碰到改样式、改概率就抓瞎。自己用原生HTML5实现整个逻辑掌握在手里后续换奖品、换风格、加后端都很从容。1.2 技术方案选型Canvas还是CSSDOM实现一张会转的圆盘主流方案有两种用Canvas画出来或者用CSS的transform动画旋转一张图片。如果只是做一个静态圆盘然后旋转CSS方案确实快。把圆盘切成一块块DOM设置边界半径或者干脆用一张设计好的PNG图片然后用transform: rotate()驱动旋转好处是代码量少、兼容性也不用操心。但问题也很明显动态数据不好处理奖品数量一变CSS切割跟着全改。精细的缓动效果控制不灵活。指针和圆盘的相对位置判断比较绕。Canvas方案把绘制交给浏览器底层API每帧重绘或者旋转画布内容。优点是灵活奖品数量、颜色、文字都可以动态生成旋转逻辑用requestAnimationFrame驱动缓动效果想怎么调就怎么调。缺点是逻辑更细初学可能需要一点图形学基础。从我的经验看做转盘项目直接学Canvas是值得的。HTML5这个标题本身就意味着要用新一代Web能力Canvas是其中绕不开的核心。题目如果是“Html5转盘”用Canvas完成不仅专业答辩或者交作业时也更有得聊。2. 动工前的准备设计、数据与概率2.1 奖品结构与概率策略动手写代码之前先把数据模型定下来。我习惯把转盘配置做成一个数组每个元素代表一个扇区const prizes [ { name: 谢谢参与, color: #f65b5b, probability: 0.3 }, { name: 5元红包, color: #ff9f43, probability: 0.15 }, { name: 10元红包, color: #ffd166, probability: 0.1 }, { name: 优惠券, color: #17c3b2, probability: 0.25 }, { name: 蓝牙耳机, color: #4cc9f0, probability: 0.05 }, { name: 再来一次, color: #9b5de5, probability: 0.15 } ];字段解释一下name是显示在扇区上的文案color是扇区填充颜色probability是这个奖品被抽中的概率所有值加一起等于1。概率这块是转盘最容易踩坑的地方看起来转盘是“随机”停下实际上完全是我们控制好的。实现思路是这样的先按概率随机选出一个中奖奖品这一步在计算上完成。根据中奖奖品反推转盘需要旋转到的角度。让转盘转到那个角度停下。这样做的原因是真实物理旋转虽然公平但营销场景往往需要后台控制不能让用户无限次抽中大奖。所以这里的“随机”是概率层面的随机不是几何位置层面的随机。反推角度的公式后面详细讲这里先记住核心结论中奖结果由概率数组决定和转盘停在哪个扇区是一一对应的。2.2 转盘视觉设计与配色画转盘之前先想清楚配色。转盘视觉上要鲜艳但不廉价扇区之间的颜色对比要强这样指针停下时用户一眼就能看出落在哪。我常用的配色套路是相邻扇区用反差大的颜色比如暖色和冷色交替或者同色系不同深浅。上面prizes数组里的颜色就是交替冷暖的效果比较稳。还有一个细节扇区文字的颜色要单独控制浅色背景用深色文字深色背景用白色或浅色文字。我建议直接在配置里加textColor字段不要全部统一{ name: 谢谢参与, color: #f65b5b, textColor: #ffffff, probability: 0.3 }字体大小也要考虑。扇区数量少就大一点数量多就调小保证文字不溢出扇区边界。后面绘制代码里会把文字沿半径方向渲染锚点对齐做对了就不会碰到“文字跑到盘子外面”的问题。3. 核心实现从画盘到旋转3.1 画布初始化与高清屏适配先建一个基础HTML结构一个canvas元素一个按钮一个展示结果的容器div classwheel-container canvas idwheelCanvas width500 height500/canvas div classpointer/div button idspinBtn抽奖/button /div p idresult/p然后初始化Canvas。这里有个特别重要但经常被忽视的问题高清屏Retina屏下Canvas会模糊。原因是一块物理像素是逻辑像素好几倍的屏幕如果Canvas的绘制尺寸直接等于CSS尺寸浏览器会把绘制内容拉伸放大自然就糊了。解决方法是把Canvas的像素尺寸设置为CSS尺寸乘以设备像素比然后调用ctx.scale()const canvas document.getElementById(wheelCanvas); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; const cssSize 500; canvas.width cssSize * dpr; canvas.height cssSize * dpr; canvas.style.width cssSize px; canvas.style.height cssSize px; ctx.scale(dpr, dpr);后面所有绘制逻辑都按照CSS尺寸500来计算坐标不用关心dpr。这个适配是基础不做好后面画出来的转盘在手机上一片模糊体验很差。3.2 用Canvas绘制圆盘扇区绘制转盘的核心API是ctx.beginPath()、ctx.moveTo()、ctx.arc()、ctx.fill()。我们要画等分的扇区难点在于计算每个扇区的起止角度。角度从-90度开始比较容易理解。我们让第一个扇区的起点在12点钟方向这样指针在顶部时停哪个扇区更直观。每块扇区的弧度大小是const arcAngle (Math.PI * 2) / prizes.length;假设有6个奖品每个扇区就是60度换算成弧度是Math.PI / 3。遍历奖品数组累加当前索引乘以arcAngle就得到每一块的起始和结束角度function drawWheel() { const centerX cssSize / 2; const centerY cssSize / 2; const radius cssSize / 2 - 10; prizes.forEach((prize, index) { const startAngle -Math.PI / 2 index * arcAngle; const endAngle startAngle arcAngle; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle prize.color; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke(); }); }这里每块扇区都是从圆心出发画一个圆弧再闭合形成一个饼状区域。注意beginPath必须每个扇区都调用一次否则路径会互相粘连填充的颜色会串。画完扇区再画文字。文字要放在扇区的中间位置沿半径方向排版才好看prizes.forEach((prize, index) { const midAngle -Math.PI / 2 (index 0.5) * arcAngle; ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(midAngle); ctx.textAlign right; ctx.fillStyle prize.textColor || #333333; ctx.font bold 16px PingFang SC, Microsoft YaHei, sans-serif; ctx.fillText(prize.name, radius - 20, 6); ctx.restore(); });这里的关键是把坐标系平移到圆心再旋转到当前扇区的中间角度然后从右边往左画文字。textAlign设为right配合radius - 20的坐标文字会自然落在扇区中间偏边缘的位置。save和restore保证旋转效果只作用于当前文本不会影响后面的绘制。3.3 旋转动画requestAnimationFrame与缓动圆盘画好以后接下来就是让它转起来。转得自然的核心在缓动函数也就是转速从快到慢还要有回弹效果才真实。我实现的方案是点击按钮后先随机出中奖索引再根据当前角度、目标角度、圈数和缓动速度计算最终旋转角然后通过requestAnimationFrame驱动每帧绘制。计算目标角度的公式是function getTargetAngle(prizeIndex) { // 每个扇区占的角度 const arcAngle (Math.PI * 2) / prizes.length; // 中奖扇区的中间角度12点钟方向为0度基准 const prizeMidAngle prizeIndex * arcAngle arcAngle / 2; // 加上若干整圈看起来转得够久 const extraRounds 5 * Math.PI * 2; // 当前角度取模确保从当前位置继续转而不是每次都从0度开始 const currentMod currentAngle % (Math.PI * 2); let target currentAngle extraRounds (Math.PI * 2 - currentMod prizeMidAngle); return target; }这个公式我第一次写的时候绕了很久拆开来解释currentAngle是转盘当前已旋转的总角度。currentMod是当前角度对2π取模后的余数表示现在转盘停在了哪一弧度上。我们要让转盘再多转5圈然后正好停在中奖扇区的中间位置。(Math.PI * 2 - currentMod prizeMidAngle) 这一项是从当前位置到目标扇区中间位置的最短正向距离。这样不管转盘之前转到哪点击之后都能平滑地转到目标扇区不跳转、不倒转。动画循环用requestAnimationFrame。每帧计算当前角度距离目标角度的差乘以一个缓动系数加上原本的位置let currentAngle 0; let targetAngle 0; let isSpinning false; function animate() { const diff targetAngle - currentAngle; if (Math.abs(diff) 0.01) { currentAngle targetAngle; isSpinning false; // 中奖结果展示 showResult(); return; } // 缓动系数0.12系数越小减速越慢这里用差值比例做缓动 currentAngle diff * 0.12; drawWheelWithRotation(); requestAnimationFrame(animate); }看到没有这段代码没有计算速度也没有复杂的加速度曲线。它的原理是每帧都向目标位置逼近12%的剩余距离。一开始离目标远每帧转过的角度就大越靠近目标每帧转过的角度越小看起来就是自然的减速效果。这就是所谓“指数缓动”简单、稳定效果却很好。绘制的时候要重新画一遍整个圆盘只不过整体坐标系旋转过了function drawWheelWithRotation() { ctx.clearRect(0, 0, cssSize, cssSize); ctx.save(); ctx.translate(cssSize / 2, cssSize / 2); ctx.rotate(currentAngle); ctx.translate(-cssSize / 2, -cssSize / 2); drawWheel(); ctx.restore(); }每次重绘整个画布性能上没问题。100多个扇区也扛得住常规的6到12个扇区更是随便转。3.4 指针设计与中奖判定转盘本身在转指针固定在顶部不动这样看起来才是正常的转盘。指针我用一个绝对定位的三角形元素放在Canvas上方div classpointer/div.pointer { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 15px solid transparent; border-right: 15px solid transparent; border-top: 35px solid #e63946; z-index: 10; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); }指针的位置决定了中奖判定。因为我们绘制扇区时第一个扇区起点在12点方向所以指针指向顶部就是基准0度也就是第一个扇区的中间位置。中奖结果在点击按钮时就已经确定了到了动画结束的那一帧直接把结果写进DOM就好function getRandomPrize() { const random Math.random(); let cumulative 0; for (let i 0; i prizes.length; i) { cumulative prizes[i].probability; if (random cumulative) { return i; } } return prizes.length - 1; } function showResult() { // 这里直接读取上一次点击时确定的中奖索引 document.getElementById(result).textContent 中奖 prizes[lastPrizeIndex].name; }要注意如果结果展示依赖动画结束后的扇区位置反推中间一旦有概率配置错误或者角度计算偏差就会导致结果和展示不一致。最好的方案是先把中奖索引存在一个变量里动画结束后直接读取根本不依赖几何位置的最终状态。这样就算角度公式出了问题用户看到的弹窗结果也永远和点击时抽中的一致这是线上项目必须保证的。4. 完整体验打磨防抖、音效与可配置项4.1 加锁防连点、防重复提交转盘最容易翻车的不是画图而是用户连点。按钮不锁的话用户连续点十次目标角度会被不断重置转盘永远停不下来概率控制也就彻底失效了。解决方案就是加一个isSpinning锁spinBtn.addEventListener(click, () { if (isSpinning) return; isSpinning true; lastPrizeIndex getRandomPrize(); targetAngle getTargetAngle(lastPrizeIndex); // 隐藏上一次的结果 document.getElementById(result).textContent ; requestAnimationFrame(animate); });只要动画没结束isSpinning就是true点击事件直接return。动画结束那一帧把它设回false用户才能发起下一次抽奖。对于有后端的场景更稳妥的做法是点击后立即请求后端接口拿到中奖结果返回之前按钮保持灰态。前端只在接口返回后才计算目标角度这样即使后端请求很慢用户也不会在同一时间发起多个抽奖请求。我见过一个活动页面前端做着玩没问题一上真实活动就被刷了原因就是没有加锁用户开连点器一秒请求几十次。这个问题在真实线上环境真的会出事故。4.2 抽奖结果弹窗与记录交互转盘停了不能干瞪眼要给用户明确的反馈。最简单的方案是做一个Modal弹窗展示中奖结果和一个确定按钮。我这里提供一个轻量做法不引入UI框架function showResult() { const prizeName prizes[lastPrizeIndex].name; const modal document.getElementById(modal); modal.querySelector(.prize-name).textContent prizeName; modal.classList.add(active); } document.getElementById(modalCloseBtn).addEventListener(click, () { document.getElementById(modal).classList.remove(active); });div idmodal classmodal div classmodal-content h3恭喜你/h3 p classprize-name/p button idmodalCloseBtn知道了/button /div /div弹窗样式用固定定位加半透明背景居中展示内容这里就不放完整CSS了逻辑很简单。有需要的话可以把中奖记录也写到本地抽奖页关掉再打开还能看到历史这个用localStorage实现很方便。4.3 奖品配置与样式的动态化如果转盘奖品不是固定的最好把prizes数组抽出来放到一个单独的配置文件里甚至从后端接口拉取。前端只负责把数组渲染成转盘这样运营改奖品就不用动代码。动态渲染的关键是prizes数组改了之后要重新调用drawWheel()并且重置currentAngle和targetAnglefunction initWheel(config) { prizes config; currentAngle 0; targetAngle 0; drawWheel(); }这里有一个细节扇区数量改变后arcAngle会跟着变getTargetAngle函数里的计算也自动适配不需要额外改代码。这也是用Canvas动态绘制的优势换成静态图的话重新切图就是一个不小的工程。我在实际项目中还会把字体、圆心装饰、按钮文案这些抽成配置项这样页面风格调整的时候不用翻代码。但不要过度设计两三个页面复用一个配置就够了没必要做成一整个配置框架。4.4 音效与动效的细节加分项转盘在转的时候如果“嗒嗒嗒”的拨动音效那种期待感会强很多。实现方式不复杂每经过一个扇区边界触发一次短音效。触发时机在动画循环里判断let lastPassedIndex -1; function animate() { // ... const normalizedAngle ((currentAngle % (Math.PI * 2)) Math.PI * 2) % (Math.PI * 2); const passIndex Math.floor(normalizedAngle / arcAngle); if (passIndex ! lastPassedIndex) { lastPassedIndex passIndex; playTickSound(); } // ... }音效文件用短促的“咔嗒”声可以用AudioContext生成一个方波也可以加载一个几十KB的MP3。AudioContext的代码也不复杂function playTickSound() { const audioCtx new (window.AudioContext || window.webkitAudioContext)(); const oscillator audioCtx.createOscillator(); const gainNode audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value 800; oscillator.type square; gainNode.gain.setValueAtTime(0.03, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime 0.05); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime 0.05); }实测下来这个音效不要连续触发控制在每次16ms左右一帧的循环里只有跨扇区才播放频率不会太高。对了有一点要注意大部分浏览器要求用户和页面交互之后才能播放声音所以AudioContext要等点击按钮之后再创建不要在页面加载时就初始化。不然控制台会有一堆警告部分浏览器直接不出声。5. 常见问题与排查技巧实录5.1 问题速查表写转盘这一路我把遇到过的问题整理成了一张表基本覆盖了新手九成会踩的坑问题现象原因解决方案Canvas画面模糊没有适配设备像素比设置canvas.width为CSS尺寸乘dpr并ctx.scale(dpr, dpr)转盘每次都从零度开始转currentAngle没有保留用currentAngle % 2π计算当前位置目标角度在当前位置基础上加圈数转盘停的位置和中奖结果不一致角度反推公式错误中奖结果不依赖最终位置点击时先随机出结果按钮连点导致转盘不停缺少状态锁加isSpinning锁动画期间点击直接return扇区边缘有白线或漏色相邻路径粘连每画一个扇区前调用beginPath文字方向不对倒着排没有做坐标系旋转使用ctx.save、translate、rotate、restore组合手机上看转盘变形没有适配视口设置viewpoint metacanvas尺寸用CSS控制宽度100%保持宽高比动画结束时轻微回弹不自然缓动系数太大或阈值太小把0.12调小到0.05或把停止阈值改成0.0055.2 指针抖动与偏差排查很多转盘做出来之后指针会停在扇区边缘附近看起来摇摇欲坠。这种情况通常是角度对齐的问题。我用的计算方式是让目标停在扇区的中间位置这个逻辑本身是自洽的。但如果你希望指针停在扇区的边缘就要把目标角度里的arcAngle / 2去掉改成arcAngle。改起来只是一行代码的区别但视觉效果差异很大。指针本身的CSS定位也要注意三角形的尖端就是实际指向位置它的中心线必须和Canvas的12点方向对齐。我习惯把pointer放在容器顶部、水平居中尖端朝下和Canvas中心在一条竖线上。调试的时候可以用辅助线验证开发工具里画一条垂直参考线一眼就能看出偏没偏。另一个容易忽略的问题是Canvas容器和指针的父容器可能存在内边距或定位偏移。给容器设置position: relativeCanvas和指针都相对这个容器定位就不会出现“画布转到位了但指针指偏了”的情况。5.3 奖品摆放与文字溢出问题扇区数量比较多的时候文字很容易挤在一起。我试过八个扇区放八个四字奖项最后两个字直接溢出扇区边界丑得没法看。有两个解决办法一是限制扇区数量建议最多8个再多就分两行或者用图标代替文字。二是动态调整字号。在绘制之前根据prizes.length计算合适的字号const fontSize prizes.length 6 ? 18 : prizes.length 8 ? 14 : 12; ctx.font bold fontSize px PingFang SC, Microsoft YaHei, sans-serif;文字对齐用textAlign right也有讲究我试过居中模式但文字在旋转后的坐标系里会跑到扇区外面所以还是靠半径端对齐最稳。如果想让文字整体往中心靠把radius - 20改成半径的一半就行。5.4 移动端触控与布局适配转盘项目在移动端要注意viewport和尺寸适配meta nameviewport contentwidthdevice-width, initial-scale1.0 /Canvas的CSS宽高不要写死成500像素改成max-width: 100%再用aspect-ratio保持正方形.wheel-container { position: relative; width: 90vw; max-width: 500px; aspect-ratio: 1; margin: 0 auto; } canvas { width: 100%; height: 100%; }注意这里的canvas宽高百分比只影响CSS布局绘制逻辑里的中心点坐标需要动态获取不要继续用cssSize常量function getCanvasSize() { const rect canvas.getBoundingClientRect(); return rect.width; }绘制之前先取得实际的CSS尺寸然后所有绘制坐标都以这个尺寸为基准。这样在大屏和小屏幕上转盘都是完美的正圆不会拉伸变形。我实际测试过iPhone SE这种小屏和27寸显示器上这套自适应方案效果都正常。5.5 抽奖概率Debug技巧概率相关的bug是最隐蔽的因为有时候看起来正常跑几百次才发现大奖概率不对。这里分享一个调试方法统计验证法。在开发阶段把抽奖循环跑一万次统计每个奖项被抽中的次数看是否接近配置的概率function debugProbability() { const stats {}; for (let i 0; i 10000; i) { const idx getRandomPrize(); stats[prizes[idx].name] (stats[prizes[idx].name] || 0) 1; } console.table(stats); }这个方法能快速发现概率数组加总不为1、权重顺序颠倒等问题。有一次我调试发现最低概率的蓝牙耳机被抽中概率异常高排查后发现是把cumulative和random的比较符号写反了小于写成了大于。不加这个统计验证估计上线了都发现不了。6. 进阶优化奖励后端化与防作弊思路前面做的转盘是纯前端实现演示、交作业、小范围互动完全够用。但如果这个转盘要放在真实的活动页面上还涉及钱或者实物奖品那纯前端方案就有作弊风险。用户打开控制台直接改prizes数组把probability改成全大奖再调用一次抽奖就可能被薅羊毛。一个稳妥的架构是抽奖结果由后端接口返回。前端只负责提交抽奖请求后端根据服务端配置的概率算出中奖结果返回给前端前端再根据结果转动转盘。实现思路是点击按钮后先请求接口等到后端返回中奖索引再计算目标角度并开始动画async function handleSpin() { if (isSpinning) return; isSpinning true; const res await fetch(/api/lottery, { method: POST }); const data await res.json(); lastPrizeIndex data.prizeIndex; targetAngle getTargetAngle(lastPrizeIndex); requestAnimationFrame(animate); }后端返回之前按钮处于锁定状态用户不能连续抽奖。这样即使前端被篡改也拿不到额外的好处。还有一点接口要做频率限制和登录校验。每个用户单日抽奖次数、两次抽奖之间的最小间隔都应该在后端约束。前端做的限制只是用户体验层面后端限制才是安全边界。从转盘这个小小的项目能延伸出来的东西其实很多Canvas绘图只是基础还有异步交互、权限控制、并发处理这些更深层的话题。如果只做前端展示那上面这段可以直接跳过但如果你想把这个项目发展成真正能上线的作品后端化是必然要迈过的坎。我自己的经验是先把这个纯前端版本跑通把绘制、动画、概率这些核心逻辑吃透再逐步加上后端。这样每一步出问题都能很快定位是前端还是后端的问题不会一堆bug混在一起根本查不出来。最后再分享一个我踩过几次坑后总结出来的小技巧转盘项目交作业或者上线之前一定要把Canvas画出来的效果截图发到手机上看看。很多问题在电脑上完全看不出来字体大小、扇区颜色、文字溢出一到手机上就原形毕露。提前适配别等到现场演示的时候才发现转盘在客户手机上歪了。
返回列表