ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Canvas转盘抽奖游戏实现:从扇区绘制到概率控制全解析

Canvas转盘抽奖游戏实现:从扇区绘制到概率控制全解析 转盘游戏这个东西但凡做前端或者搞过活动页的同学应该都不陌生。一个Canvas转盘往页面上一放用户点一下“抽奖”转盘哗啦啦转几圈最后稳稳停在一格奖品上整个页面的互动节奏立刻就起来了。今天这篇我就从零开始用原生JavaScript Canvas把转盘游戏完整实现一遍从绘制扇区、写文字、做动画到控制中奖概率、处理边界情况全部拆开聊。适合想做H5抽奖活动、营销玩法或者单纯想练一练Canvas动画的同学直接照着抄作业就行。1. 先想明白再做转盘游戏的整体设计思路1.1 需求拆解转盘要解决的几件事接到一个转盘需求别急着开编辑器写代码。先花几分钟把需求拆成模块这是我和很多新人共事时反复强调的一点。一个完整的转盘游戏表面上看就是一个圆盘加指针但真正落到代码上其实包含下面四块独立的东西静态界面部分转盘本体扇区、颜色、文字、外圈装饰、顶部固定指针、中心抽奖按钮、中奖结果展示。旋转逻辑部分用户点击之后转盘以什么样的速度曲线转起来、转动时长多久、最终停在哪个扇区。抽奖规则部分中奖结果是怎么决定的。是纯随机还是带权重是前端直接出结果还是要等后端接口返回。交互边界部分动画播放期间能不能重复点击、接口超时怎么处理、移动端高分屏模糊、低性能设备掉帧这些都是边界问题。这四个模块在代码层面应当互相独立。我见过不少新手把绘制和动画耦合在同一个函数里后面改一个奖品颜色都要顺着代码捋半天非常痛苦。你只要一开始按这个结构拆后续无论是加奖品、调样式还是换概率规则都只需要动对应模块这才是工程化的做法。1.2 技术选型Canvas为什么是最优解实现转盘无非三条路纯CSS加预先切好的图片、SVG、Canvas。我实测下来大部分场景都该选Canvas理由很现实。先说CSS方案。转盘上每个扇区要单独配色、单独写文字如果用图片每次改奖品都得重新出图设计师和运营来回沟通几次你就烦了。而且CSS transform的rotate虽然做旋转动画很流畅但要让某一格精准停在指针位置角度换算同样不轻松动态生成扇区就更别想了。SVG的优势是文本和图形都是DOM可控性强但对于转盘这种大量扇形路径DOM节点一多低端机上动画和交互都会出现卡顿性能天花板很低。选Canvas的核心原因在于绘制逻辑完全由JavaScript驱动数据变了重新画一遍就行动画阶段只需要整体rotate一个画布性能非常稳定。我这些年做H5互动场景凡是涉及转盘、刮刮卡、拼图这类图形化组件默认都是Canvas方案没有纠结过。2. Canvas绘制完整转盘从扇区到文字2.1 先画扇区角度计算是关键转盘的本质就是在一个圆上画若干个相邻扇形。先把数据结构定义好我用一个数组保存奖品配置label是显示文字color是扇区颜色probability是权重后面概率控制会用到const canvas document.getElementById(wheel); const ctx canvas.getContext(2d); const centerX canvas.width / 2; const centerY canvas.height / 2; const radius Math.min(canvas.width, canvas.height) / 2 - 12; const prizes [ { label: 谢谢参与, color: #f8b0a0, probability: 30 }, { label: 一等奖, color: #fe5f55, probability: 5 }, { label: 二等奖, color: #c9df8a, probability: 15 }, { label: 三等奖, color: #fff6d9, probability: 15 }, { label: 幸运奖, color: #f0a202, probability: 15 }, { label: 再来一次, color: #93a8ac, probability: 20 }, ]; const sliceAngle (2 * Math.PI) / prizes.length;下面这段是绘制扇区的核心代码。有一个角度约定需要先说明Canvas里arc方法画弧时0弧度是从3点钟方向开始的并且沿顺时针增长。但转盘交互里指针一般都放在12点方向也就是 -π/2 的位置。所以画第一个扇区时我会把起始角度整体减去 π/2让扇区从正上方开始排布。prizes.forEach((prize, i) { const startAngle i * sliceAngle - Math.PI / 2; const endAngle startAngle sliceAngle; 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(); });这里有个细节我特别想提醒moveTo(centerX, centerY)不是可有可无的。它的作用是把路径的起点先落到圆心之后arc加closePath才能让扇形完整闭合。如果漏掉这一行扇区边缘会出现一条从圆弧连回圆心的线视觉上整个转盘是破的颜色填充也会出现诡异的缝隙。关于扇区之间的分割线我的做法是给每个扇区单独描边但lineWidth别超过2像素。颜色上建议用白色或者尽量浅的颜色这样扇区之间会有一条清晰但不会喧宾夺主的分隔线转起来的时候用户能看清格子边界。2.2 文字定位一个rotate就解决的问题扇区画完之后下一步是往每个扇区里写文字。文字的布局是转盘视觉效果的灵魂放歪了、挤成一堆整个转盘看起来就廉价。我直接说结论要的文字效果是沿半径方向铺开、从圆心向外延伸处理方式其实就三步——先保存画布状态把坐标原点平移到圆心再旋转到当前扇区的中心角最后沿x轴方向写字。ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(startAngle sliceAngle / 2); ctx.textAlign right; ctx.textBaseline middle; ctx.fillStyle #333333; ctx.font bold 15px PingFang SC, Microsoft YaHei, sans-serif; ctx.fillText(prize.label, radius - 10, 0); ctx.restore();这段代码理解起来其实不复杂。rotate执行之后坐标系的x轴正方向恰好指向当前扇区中心这时候fillText(prize.label, radius - 10, 0)是把文字画在x轴上等于沿着半径放置。为什么textAlign用right而不是center因为radius - 10这个坐标意味着文字右端距离边缘10像素用右对齐才能在视觉上保证所有文字离外圈边缘的距离是一致的。如果改成textAligncenter文字中心放在同一个半径位置文字长短不一会导致左边缘参差不齐很难看。另外save和restore必须配对使用。它们保证旋转平移只影响当前扇区的文字绘制不会串到下一个扇区去。忘记restore的典型症状是第二个扇区的文字出现在错误位置而且越往后偏得越离谱。这种bug你盯半天代码也不一定能发现但一旦知道原因下次就再也不会犯了。字体上建议用bold和系统字体栈不要在Canvas里依赖网站的整体字体设置。还有一点如果某个奖品名字特别长比如“价值1888元超级大礼包”超过7个字的建议做截断或者换行处理否则文字会溢出扇区边界。简单粗暴的解决方法是判断label.length超过5个字的就缩小字号。2.3 指针与中心按钮分层设计的讲究一个能上线的转盘不能光有扇区通常还要有外圈装饰、中心按钮和顶部指针。这三样东西在实现上可以分成两个部分跟着转盘一起转的和不跟着转的。外圈装饰我建议和扇区画在一起比如沿周边画一圈小灯珠或者加一圈细描边。这样它们会跟着转盘整体旋转符合视觉逻辑。实现方法很简单在画完扇区之后用循环在radius 8的位置画一圈小圆颜色交替变化就行。指针和中心按钮则要单独处理因为它们不能跟着转盘转。指针我推荐写在单独的drawPointer函数里每次动画帧的最后调用一次保证它始终固定在顶部指向正上方function drawPointer() { ctx.save(); ctx.translate(centerX, centerY - radius 8); ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(-12, 0); ctx.lineTo(12, 0); ctx.closePath(); ctx.fillStyle #d81159; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke(); ctx.restore(); }这里有个常见的认知误区我见过不止一个同学在初期把指针也绑定到旋转角度里结果每次转完指针跟着扇区一起偏移用户根本看不清到底指在了哪里。记住转盘游戏的标准交互模型指针固定转盘本体旋转停稳之后指针所指的扇区就是中奖结果。这个模型一旦定下来后面所有的角度计算都围绕它展开能少走很多弯路。中心按钮的策略也类似。如果按钮不参与旋转它其实可以不用画在Canvas里直接用绝对定位的HTML元素叠在Canvas上方这样点击事件直接绑在DOM上不用自己判断坐标。如果希望中心按钮上有动态效果比如加载中状态HTML方案操作起来比Canvas重绘方便太多。3. 旋转动画与概率控制转盘的核心体验3.1 动画引擎requestAnimationFrame加缓动函数转盘动起来不要用setInterval或者setTimeout硬数帧现代浏览器请老老实实使用requestAnimationFrame。它的执行时机由浏览器控制在屏幕真正刷新之前帧率和显示器刷新率保持一致动画既流畅又省电。原理层面简单理解浏览器会尽量保证每16.6毫秒执行一次回调60Hz屏幕而setInterval做不到这种同步经常会出现跳帧或者撕裂。动画的“手感”取决于缓动函数。所谓缓动就是速度变化的规律。转盘不能匀速转真实物理感受应该是从静止猛然加速高速旋转到接近目标位置时逐渐减速最后稳稳停住。我用得最多的是easeOutQuart它的衰减非常明显有一种被磁铁吸住的感觉function easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }整个旋转的时序控制大概是设定一个动画总时长比如4000到6000毫秒在每个requestAnimationFrame回调里计算已经过的时间比例然后把比例套进缓动函数算出当前这一帧转盘应处的角度最后重绘。let currentAngle 0; let isSpinning false; function spinWheel(targetIndex, duration 5000) { if (isSpinning) return; isSpinning true; const targetBase -Math.PI / 2 - targetIndex * sliceAngle; const targetAngle targetBase Math.ceil((currentAngle 4 * Math.PI * 2 - targetBase) / (Math.PI * 2)) * (Math.PI * 2); const startAngle currentAngle; const startTime performance.now(); function frame(now) { const elapsed (now - startTime) / duration; const progress Math.min(elapsed, 1); const eased easeOutQuart(progress); currentAngle startAngle (targetAngle - startAngle) * eased; render(); if (progress 1) { requestAnimationFrame(frame); } else { isSpinning false; showResult(targetIndex); } } requestAnimationFrame(frame); }这段代码里targetBase的计算经常把初学者绕晕我展开讲一下。指针固定在顶部 -π/2 方向第targetIndex个扇区中心在初始画布上的角度是targetIndex * sliceAngle。要让这个扇区中心最终落在指针那里转盘整体需要旋转-Math.PI/2 - targetIndex * sliceAngle。这个值可能是负数。所以我们通过Math.ceil加上若干个完整圆周2π让最终目标角度不仅大于当前角度还要比当前角度多出4圈以上。这样视觉效果就是转盘哗啦哗啦转了好几圈才停下来而不是“扭了一下”就中奖了。3.2 概率控制权重随机怎么做才合理转盘如果每个奖品都是均匀随机那就完全没运营空间一等奖和谢谢参与概率一样促销活动还怎么做所以必须要做权重随机。权重随机的思想用一句话概括把每个奖品的权重按顺序放在一条数轴上数轴总长是所有权重之和随机生成一个数看它落在哪个区间里那个区间的奖品就是结果。function getPrizeIndexByWeight() { const totalWeight prizes.reduce((sum, p) sum p.probability, 0); let rand Math.random() * totalWeight; for (let i 0; i prizes.length; i) { rand - prizes[i].probability; if (rand 0) return i; } return prizes.length - 1; }我解释一下这段代码的执行过程。假设权重是[30, 5, 15, 15, 15, 20]总权重100随机数rand是0到100之间的某个小数。假设rand是63循环里先减去30变成33不减到0继续减去5变成28减去15变成13减去15变成-2这时候小于等于0所以返回索引3也就是三等奖。整个逻辑就是不断用随机数去“消耗”权重区间落在哪个区间就返回哪个索引。最后一行return prizes.length - 1是兜底用的。虽然理论上循环一定能命中但浮点数在累加和相减的过程中会有极其微小的精度误差可能导致最后一个权重区间没被命中。兜底返回最后一个奖品能保证函数不会返回undefined。这里必须强调一句也是老生常谈前端概率控制只能防不懂技术的普通用户。只要打开浏览器开发者工具看一眼请求再点几下按钮就能绕过前端概率自己刷接口。所以严谨的抽奖活动中奖结果一定得由服务端根据真实概率算出后返回前端只负责展示。我上面这套权重随机仅仅用于本地开发演示真正上线时要把getPrizeIndexByWeight替换成请求后端接口拿到后台下发的targetIndex再转动画。3.3 完整抽奖流程先定结果还是先转动画把模块串起来一次完整的抽奖动作流程应该是用户点击抽奖按钮。立刻锁定按钮进入loading状态防止重复点击。请求抽奖接口拿到中奖索引。根据中奖索引计算目标角度开始转盘动画。动画结束展示中奖结果释放按钮。第3步和第4步的先后顺序非常关键。很多新手喜欢先转动画等停了再请求结果结果就是转盘已经稳稳停在“一等奖”上接口却返回“谢谢参与”用户截图留证活动方百口莫辩。所以在启动动画之前中奖结果必须已经确定下来动画只是把这个结果“表演”出来。如果接口延迟建议加一层超时处理。比如设置3秒的请求超时超时后给用户弹一个“网络开小差请重试”的提示按钮恢复点击状态。不要让用户盯着一个卡住的转盘干等活动页的访客耐心非常有限服务端一抖用户体验直接崩掉。async function handleSpinClick() { if (isSpinning) return; setButtonLoading(true); try { const targetIndex await fetchPrizeResult(); spinWheel(targetIndex); } catch (e) { setButtonLoading(false); showToast(网络开小差请重试); } }4. 实战踩坑记录与优化技巧4.1 高频问题排查速查表我在真实项目里把转盘最容易出的问题整理成一张表照着查基本都能解决问题现象直接原因处理方式转盘模糊、锯齿明显画布尺寸没有适配高分屏canvas.width按devicePixelRatio等比例放大再ctx.scale扇形之间出现白色裂缝扇区之间使用stroke互相覆盖先给整圆铺底色再绘制填充扇区最后统一细描边动画中途卡顿掉帧每帧重复绘制大量静态内容用离屏Canvas缓存静态转盘动画里只drawImage连续点击导致多次抽奖没有加锁用isSpinning开关控制动画结束前直接return中奖结果和停的位置对不上目标角度计算错误扇区和索引没对齐按3.3的流程先定结果再算角度移动端高度错位或字体异常页面缺少viewport设置head里补上viewport meta字体用相对单位其中“扇形裂缝”是我早期做项目时被坑得最惨的一个。以前为了让格子边界清晰每个扇区都描了一圈2像素白边结果扇区与扇区之间总有一条隐约的底色透出来放大看像缝隙。后来我改成先给整个圆铺一层白色背景再画扇区本身的填充色最后用1像素细描边统一处理缝隙问题就消失了。这个经验说起来简单但不知道原理的话真的能调一整天。4.2 离屏Canvas才是性能关键转盘本身的绘制内容其实不多但如果你加了外圈小灯珠、动态光影、阴影这些特效每帧重新计算这些图形就很费性能了。标准做法是初始化时把静态部分全部画到一个隐藏的canvas节点上也就是离屏canvas动画循环里直接用drawImage把整张离屏图贴到主画布上再叠加指针。代码如下const offCanvas document.createElement(canvas); offCanvas.width canvas.width; offCanvas.height canvas.height; const offCtx offCanvas.getContext(2d); // 绘制扇区、文字、外圈装饰到 offCtx只执行一次 buildStaticWheel(offCtx); function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(currentAngle); ctx.drawImage(offCanvas, -centerX, -centerY); ctx.restore(); drawPointer(); }我把原来的静态绘制代码整体挪进buildStaticWheel之后render只承担旋转和合成的工作。这样做的好处是不管转盘视觉多复杂动画每帧只做一次图片拷贝和一次指针绘制。我在本地模拟过同等条件下不用离屏canvas的方案在低端安卓机上会出现明显的掉帧用了之后基本能稳定60帧。高分屏适配也是性能之外的另一个被忽视的问题。如果不处理devicePixelRatio在高分辨率手机上转盘边缘全是锯齿非常掉档次。处理方式是在初始化时把canvas的实际像素尺寸放大再用CSS尺寸约束显示大小const dpr window.devicePixelRatio || 1; const cssSize 320; canvas.style.width cssSize px; canvas.style.height cssSize px; canvas.width cssSize * dpr; canvas.height cssSize * dpr; ctx.scale(dpr, dpr);注意ctx.scale(dpr, dpr)之后所有绘制坐标依然按CSS像素来写不用单独换算。这是最省心的高分屏适配方式。4.3 交互体验细节让转盘不廉价转盘做出来容易做得好就是另一回事了。我用过很多转盘demo也复盘过自己做的项目体验好坏其实不在功能代码有多少而在下面几个细节速度曲线一定要非线性。匀速旋转会让整个转盘显得机械非常像“程序跑出来的”。easeOutQuart是我最常用的末尾有一个明显的减速刹车过程接近真人用力转一下转盘再自然停下的手感。动画停稳之后要给即时反馈。弹窗、高亮中奖扇区、播放提示音至少做一样。我最常用的做法是动画结束瞬间给中奖扇区加一圈高亮描边同时弹一个结果弹窗。高亮描边的实现很简单在showResult里重新画一遍中奖扇区的描边颜色用金色或者白色加粗用户视觉焦点一下就拉过去了。点击按钮要有防抖和loading态。除了锁住isSpinning最好让按钮文字变成“抽奖中...”并禁用样式不然用户在动画刚停的瞬间反应不过来习惯性地又点一下白白增加一次接口请求。跨端兼容上移动端的事件绑定我建议用pointerdown或者兼容性更好的click。如果用了touchstart记得处理可能出现的300毫秒延迟和触摸穿透。不是说一定要用哪种而是要明确知道它们有什么区别别在真机上出现“点了没反应”或者“点一下触发两次”这种低级问题。画布尺寸在多个canvas并存时要显式给每个canvas赋宽高。这个坑比较冷门两个相同尺寸的canvas如果没有显式设置width和height属性getContext(2d)返回的上下文可能会共用同一个默认画布大小导致绘制尺寸错乱。遇到这个诡异问题先检查是不是每个canvas都写了独立的宽高属性。最后再分享一个小技巧转盘抽奖这种组件一定要把奖品配置抽成独立的数据结构最好是从接口拉取或者一个集中的config.js管理。这样运营改奖品、调颜色、换权重都不需要动业务代码改配置就能生效。我自己后期做活动页凡是转盘类的需求第一件事就是把配置和渲染逻辑彻底分开后面省下来的时间远超写这个配置层所花的时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进