资讯动态

Canvas转盘抽奖实现:权重概率与旋转角度精准控制

发布时间:2026/10/20 14:55:23 来源:尧图企业网站定制
1. 转盘抽奖效果的整体设计思路1.1 为什么选择 Canvas 而不是 DOM 旋转做转盘抽奖第一道选择题就是渲染方案。我见过不少项目直接用 CSStransform: rotate()转一个由多个扇形div拼成的圆盘简单场景下确实能跑但只要扇区数量超过 8 个或者需要动态改奖品、加图标、做渐变高亮DOM 方案就会迅速失控——每个扇区都要单独定位、单独算角度改一个奖品要动一堆样式。我最终选的是Canvas 绘制转盘 CSS 控制旋转的混合方案。具体来说转盘的静态部分扇区、文字、分隔线、中心圆全部用 Canvas 一次性画出来旋转动画则交给外层容器的transform: rotate()。这样做的好处很直接绘制灵活扇区数量、颜色、文字角度全部由代码算出来改奖品只需要改数据数组不用碰样式。动画流畅旋转交给合成层浏览器走 GPU 加速比每帧重绘 Canvas 省性能。命中计算简单转盘本身不转转的是外层容器指针固定在顶部最终停在哪个扇区只需要用「总旋转角度对 360 取模」反推即可。提示如果你的奖品数量固定且很少比如 6 个纯 DOM 方案也不是不能用但只要涉及「运营后台可配置奖品」请直接上 Canvas后期维护成本差一个数量级。1.2 核心数据结构怎么定转盘的本质是「把一个圆按权重切成若干扇区」。所以数据结构里必须包含三样东西奖品名称、该奖品对应的扇区角度、以及扇区在圆上的起始角度。很多人一上来就把每个扇区设成等分比如 8 个奖品每个 45 度。这在演示里没问题但真实抽奖场景里奖品概率往往不一样——一等奖概率低扇区就该窄谢谢参与概率高扇区就该宽。所以我在设计时就把「角度」和「概率」绑定扇区角度 该奖品权重 / 总权重 × 360。这样带来一个额外好处视觉上的扇区大小和实际中奖概率是一致的用户看到「谢谢参与」占了一大块心理预期就合理不会觉得被套路。这一点在做运营活动时特别重要后面讲避坑时还会展开。1.3 旋转与停止的数学逻辑整个效果最核心的一句话是先决定结果再倒推角度。很多新手会反过来做——先随机转一个角度停下来看指针指到哪那个就是结果。这种「先转后定」的写法有两个致命问题一是概率不可控因为扇区角度和概率如果不严格对应就会偏二是没法做「必中某奖品」的运营需求。我的做法是用权重算法先抽出一个奖品索引prizeIndex。算出这个奖品扇区的中心角度targetAngle。加上若干圈比如 5 圈 1800 度保证转得够久够爽。再减去一个随机小偏移让指针不总是停在正中间显得自然。最终旋转角度 当前累计角度 圈数 × 360 修正到目标的角度。这套逻辑下来结果 100% 可控概率 100% 准确运营想让你中你就能中想让你不中你也跑不掉。下面第二节我会把这套算法拆开讲透。2. 核心细节解析与实操要点2.1 扇区角度与概率的换算细节先明确一个概念Canvas 里画圆弧角度是以弧度为单位的而且 0 弧度在三点钟方向顺时针为正。但我们在业务里习惯用「度」而且习惯从十二点钟方向开始算。所以中间必须做一次坐标系转换。我封装了一个换算函数核心就两步// 度转弧度并偏移 -90 度让起点回到十二点钟方向 function toRadian(degree) { return (degree - 90) * Math.PI / 180; }假设有 4 个奖品权重分别是[1, 2, 3, 4]总权重 10。那么每个奖品分到的角度是奖品索引权重扇区角度起始角度结束角度0136°0°36°1272°36°108°23108°108°216°34144°216°360°这张表是整个转盘的地基。绘制时按起始/结束角度画扇形抽奖时按权重随机最后按这张表反查目标角度。我建议你把这个计算过程单独写成一个buildSectors(prizes)函数返回带startAngle、endAngle、midAngle的数组后面绘制和计算都复用它避免两处逻辑不一致。注意浮点数累加会有精度误差最后一个扇区的结束角度建议强制设为 360而不是靠累加得出否则可能出现一条细缝。2.2 权重随机算法的实现权重随机的经典写法是「前缀和 二分」或者「累加区间法」。奖品数量一般不超过 20 个用最简单的累加区间法就够了没必要上二分。function pickPrizeByWeight(prizes) { const total prizes.reduce((sum, p) sum p.weight, 0); let random Math.random() * total; for (let i 0; i prizes.length; i) { random - prizes[i].weight; if (random 0) return i; } return prizes.length - 1; // 兜底 }这段代码的逻辑是把总权重想象成一根长度为total的线段每个奖品占据其中一段。随机撒一个点落在哪段就是哪个奖品。实测下来跑 10 万次各奖品命中比例和权重比例误差在千分之三以内完全够用。这里有个容易踩的坑Math.random()返回的是[0, 1)理论上取不到 1所以random严格小于total不会越界。但如果你自己改成了别的随机源一定要保证边界处理正确否则最后一个奖品永远抽不到。2.3 指针位置与目标角度的对齐这是最容易算错的地方我当初就在这里卡了半天。假设指针固定在正上方十二点钟方向转盘顺时针旋转R度后原本在角度A的扇区会跑到A R的位置。我们要求指针指向目标扇区的中心也就是(A R) mod 360 0 → R mod 360 (360 - A) mod 360所以修正角度 (360 - midAngle) % 360。但这样每次都会停在正中心太假。于是我再加一个随机抖动const jitter (Math.random() - 0.5) * (sectorAngle * 0.7);抖动幅度控制在扇区角度的 70% 以内保证不会抖出扇区边界。这个细节看起来小但实际体验差别很大——不加抖动用户转几次就发现「每次都停正中间」一眼假。2.4 缓动函数的选择与手感调优旋转动画的「手感」几乎全由缓动函数决定。线性旋转匀速看起来像机器没有抽奖的紧张感。我试过三种ease-out先快后慢最常用起步快、结尾慢符合「转盘减速停下」的直觉。ease-in-out起步也慢适合短距离旋转长距离会显得拖沓。自定义贝塞尔可以做出「先加速、匀速、再减速」的三段感最接近真实转盘。我最终用的是cubic-bezier(0.25, 0.1, 0.25, 1)配合 4 到 5 秒的时长。这个组合实测下来前 1 秒快速转起来制造期待中间 2 秒匀速保持悬念最后 1.5 秒明显减速指针一格一格「哒哒哒」地慢下来紧张感拉满。提示如果你想让「哒哒哒」的顿挫感更明显可以在减速阶段用requestAnimationFrame手动控制角度按扇区边界做吸附。但纯 CSS 方案更省事除非产品明确要求否则不建议上手动动画。3. 完整实操流程与关键代码实现3.1 初始化与 Canvas 绘制先搭好 HTML 骨架一个外层容器负责旋转里面放 Canvas 画转盘再叠一个指针元素。div classwheel-wrapper canvas idwheel width600 height600/canvas div classpointer/div /div绘制转盘时我习惯把 Canvas 尺寸设成实际显示尺寸的 2 倍再用 CSS 缩回去这样在高分屏上不会糊。绘制流程分四步画扇区、画分隔线、画文字、画中心圆。function drawWheel(sectors) { const ctx canvas.getContext(2d); const cx canvas.width / 2; const cy canvas.height / 2; const radius cx - 10; sectors.forEach((sector, i) { // 1. 画扇形 ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, toRadian(sector.startAngle), toRadian(sector.endAngle)); ctx.closePath(); ctx.fillStyle i % 2 0 ? #FFF3D6 : #FFE0A3; ctx.fill(); // 2. 画分隔线 ctx.strokeStyle #E8A33D; ctx.lineWidth 2; ctx.stroke(); // 3. 画文字旋转到扇区中心角度 ctx.save(); ctx.translate(cx, cy); ctx.rotate(toRadian(sector.midAngle)); ctx.textAlign right; ctx.fillStyle #8A4B00; ctx.font bold 28px sans-serif; ctx.fillText(sector.name, radius - 40, 10); ctx.restore(); }); // 4. 画中心圆 ctx.beginPath(); ctx.arc(cx, cy, 70, 0, Math.PI * 2); ctx.fillStyle #FF6B35; ctx.fill(); }文字这块有个细节textAlign right配合fillText的 x 坐标设为radius - 40能让文字沿着半径方向从外向内排读起来最自然。如果你用默认的left文字会从圆心往外长靠近圆心的字会挤在一起。3.2 抽奖触发与旋转控制点击抽奖按钮后要做的第一件事是锁状态防止用户连点导致多次抽奖。我用一个isSpinning标志位控制。let currentRotation 0; let isSpinning false; function startLottery() { if (isSpinning) return; isSpinning true; const prizeIndex pickPrizeByWeight(prizes); const sector sectors[prizeIndex]; const jitter (Math.random() - 0.5) * sector.angle * 0.7; const baseAngle (360 - sector.midAngle) % 360; const rounds 5; const targetRotation currentRotation rounds * 360 ((baseAngle - currentRotation % 360) 360) % 360 jitter; wheelWrapper.style.transition transform 4.5s cubic-bezier(0.25, 0.1, 0.25, 1); wheelWrapper.style.transform rotate(${targetRotation}deg); currentRotation targetRotation; wheelWrapper.addEventListener(transitionend, () { isSpinning false; showResult(prizes[prizeIndex]); }, { once: true }); }这里((baseAngle - currentRotation % 360) 360) % 360这一串是为了处理「当前已经转了好几圈」的情况保证每次都是在当前基础上继续往前转而不是突然倒回去。这个取模修正如果漏了第二次抽奖时转盘可能会反向猛转一下非常出戏。3.3 结果弹窗与状态复位transitionend触发后弹窗展示中奖结果。这里要注意两点一是弹窗关闭后要允许再次抽奖二是如果后端返回的结果和前端预抽的不一致真实项目里结果通常由后端定要以后端结果为准重新计算角度。真实项目里我推荐的做法是前端先转起来同时发请求等后端返回真实奖品后再决定最终停在哪。如果请求比动画快就按后端结果算角度如果请求慢就先转着等结果回来再「补」一个减速。这套逻辑稍微复杂但能避免「前端显示中了一等奖后端说没中」的尴尬。注意transitionend在某些情况下可能不触发比如标签页切到后台建议加一个setTimeout兜底时长比动画多 500ms双保险。4. 常见问题与排查技巧实录4.1 转盘停不准、指针偏出扇区这是最高频的问题九成出在角度换算上。排查顺序建议这样走现象可能原因排查方法每次都偏同一角度坐标系起点没对齐检查toRadian是否减了 90偶尔偏出扇区抖动幅度过大把 jitter 系数从 0.7 调小到 0.5转多圈后偏移累积浮点误差每次旋转后对 360 取模归一化指针和扇区整体错位指针不在正上方确认指针 CSS 定位是top: 0; left: 50%我当初遇到的是第一种画出来的扇区起点在三点钟但指针在十二点钟结果每次停的位置都差 90 度。后来统一在toRadian里减 90 就解决了。记住一个原则绘制和计算必须用同一套角度基准不要一处用度一处用弧度一处从三点起一处从十二点起。4.2 连点导致抽奖错乱用户手速快的时候一次动画没结束就点了第二次会出现两个transitionend打架、结果弹窗弹两次的问题。解决办法就是前面说的isSpinning锁。但光锁还不够我还会在按钮上加一个disabled样式视觉上告诉用户「正在转别急」。另外transitionend监听一定要用{ once: true }否则每次抽奖都会累积一个监听器转十次之后一次动画触发十个回调弹窗能弹到你怀疑人生。这个坑我在早期项目里踩过排查了半天才发现是监听器没清理。4.3 移动端卡顿与模糊移动端有两个典型问题一是转盘边缘锯齿明显二是旋转时掉帧。锯齿问题靠devicePixelRatio解决Canvas 的width/height属性设成CSS 尺寸 × dpr再用ctx.scale(dpr, dpr)缩放绘图上下文。这样在高分屏上边缘就锐利了。掉帧问题通常是旋转元素太大导致的。我的经验是给旋转容器加will-change: transform强制提升为合成层。但will-change不能滥用用完记得在动画结束后移除否则会一直占着显存。实测加了之后中低端安卓机上的帧率能从 40 出头稳定到 55 以上。4.4 概率与视觉不一致引发的投诉这个问题不算技术 bug但杀伤力最大。如果扇区是等分的但实际概率是加权的用户转几次就会发现「那个大扇区怎么老不中」然后就是投诉。我的做法是让扇区角度严格等于概率比例视觉即真相。如果产品坚持要「大奖扇区画大一点显得诱人」那我会在扇区里加一行小字标注真实概率或者干脆把大奖做成「概率透明」的文案。运营活动最怕的就是用户觉得被欺骗一次投诉可能让整个活动口碑崩掉。这一点上技术要敢于跟产品沟通把利害讲清楚。4.5 常见问题速查表问题根因解决转盘不转transform 没生效检查元素是否为块级、transition 是否写对转完不弹窗transitionend 没触发加 setTimeout 兜底结果和预期不符前后端结果源不一致统一以后端为准文字方向乱没做 rotate 变换用 save/translate/rotate/restore扇区之间有缝浮点累加误差最后一段强制 360高分屏发虚没处理 dprCanvas 尺寸乘 dpr 并 scale5. 性能优化与体验增强的进阶做法5.1 用离屏 Canvas 缓存静态转盘转盘的扇区、文字、分隔线在整场活动里是不变的每次抽奖都重绘一遍纯属浪费。我的做法是用一个离屏 Canvas 把转盘画好之后每帧只需要drawImage贴上去。这样即使奖品有 20 个、每个都带图标绘制开销也几乎为零。const offscreen document.createElement(canvas); offscreen.width canvas.width; offscreen.height canvas.height; // ...在 offscreen 上画好整个转盘 // 之后主 Canvas 只需 ctx.drawImage(offscreen, 0, 0);这个优化在低端机上效果尤其明显首帧绘制时间能从 30ms 降到 3ms 以内。5.2 音效与震动反馈抽奖的爽感一半来自视听。我在转盘旋转时加了一个「哒哒哒」的 tick 音效频率随转速变化——转得快时密集慢下来时稀疏。实现方式是在减速阶段用requestAnimationFrame监听角度变化每跨过一个扇区边界就播一次音。移动端还可以配合navigator.vibrate做震动反馈每次 tick 震 10ms。但要注意震动 API 在部分浏览器和 iOS 上不支持必须做特性检测不能无脑调用。提示音效文件一定要预加载否则第一次抽奖时音效会延迟几百毫秒和动画对不上体验很割裂。5.3 抽奖次数的本地与远端同步真实活动里用户每天有固定抽奖次数。我的做法是本地用localStorage记一份同时每次抽奖都请求后端扣减。本地那份只用于快速展示「剩余次数」真正的权威数据在后端。这样即使断网界面也不会显示错误联网后自动同步。这里有个细节本地次数和后端次数可能不一致比如用户清了缓存所以每次进入页面都要拉一次后端数据覆盖本地而不是信任本地。安全相关的判断永远以后端为准前端只做展示。6. 我在实际项目里踩过的几个坑第一个坑是动画时长和请求时长的配合。早期我把动画设成 3 秒结果后端接口偶尔要 4 秒才返回用户转完了还在等结果体验很差。后来改成动画 4.5 秒并且做了「请求先返回就等动画动画先结束就等请求」的双向等待才彻底顺滑。第二个坑是奖品数量动态变化时的角度重算。运营中途加了一个奖品扇区角度全变了但前端缓存的旧角度没更新导致指针指错。解决办法是每次抽奖前都重新buildSectors不要缓存计算结果。第三个坑是iOS 上的 transitionend 兼容性。某些 iOS 版本在transform动画结束后不触发transitionend必须监听webkitTransitionEnd。我现在的写法是两个都监听谁先来用谁配合once防止重复。最后分享一个小技巧如果你想让转盘在「快停下」的时候有那种一格一格吸附的顿挫感可以在最后 30 度用requestAnimationFrame手动接管按扇区边界做Math.round吸附。纯 CSS 做不到这个效果但手动接管后手感会提升一个档次值得一试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑