转盘游戏这个东西,但凡做前端或者搞过活动页的同学,应该都不陌生。一个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像素,用右对齐才能在视觉上保证所有文字离外圈边缘的距离是一致的。如果改成textAlign='center',文字中心放在同一个半径位置,文字长短不一会导致左边缘参差不齐,很难看。
另外,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管理。这样运营改奖品、调颜色、换权重,都不需要动业务代码,改配置就能生效。我自己后期做活动页,凡是转盘类的需求,第一件事就是把配置和渲染逻辑彻底分开,后面省下来的时间远超写这个配置层所花的时间。